├── brick ├── __init__.py └── api │ ├── core │ ├── __init__.py │ ├── db.py │ ├── celery.py │ └── config.py │ ├── endpoints │ ├── __init__.py │ ├── rings.py │ ├── tasks.py │ └── files.py │ ├── __init__.py │ ├── main.py │ └── client.py ├── app ├── .npmrc ├── static │ └── favicon.png ├── src │ ├── lib │ │ ├── index.ts │ │ ├── stores │ │ │ ├── TabIndexStore.ts │ │ │ ├── ReferencePositionStore.ts │ │ │ ├── RingReferenceStore.ts │ │ │ ├── RequestInProgressStore.ts │ │ │ ├── TooltipStore.ts │ │ │ ├── FileDropzoneStateStore.ts │ │ │ ├── SessionFileStore.ts │ │ │ ├── PaletteStore.ts │ │ │ └── PlotConfigStore.ts │ │ ├── session │ │ │ ├── controls │ │ │ │ ├── panels │ │ │ │ │ ├── PaletteControlPanel.svelte │ │ │ │ │ └── AboutPanel.svelte │ │ │ │ ├── helpers │ │ │ │ │ ├── RingGenomadEdit.svelte │ │ │ │ │ ├── FontStyles.svelte │ │ │ │ │ ├── RingLabelNew.svelte │ │ │ │ │ ├── DeleteFile.svelte │ │ │ │ │ ├── DeleteRing.svelte │ │ │ │ │ ├── RingTitle.svelte │ │ │ │ │ ├── RingIndex.svelte │ │ │ │ │ ├── RingVisibility.svelte │ │ │ │ │ └── RingSettings.svelte │ │ │ │ ├── rings │ │ │ │ │ ├── NewReferenceRing.svelte │ │ │ │ │ ├── NewLabelRing.svelte │ │ │ │ │ ├── NewAnnotationRing.svelte │ │ │ │ │ ├── NewBlastRing.svelte │ │ │ │ │ └── NewGenomadRing.svelte │ │ │ │ └── upload │ │ │ │ │ └── FileUpload.svelte │ │ │ ├── palette │ │ │ │ ├── ColorPalette.svelte │ │ │ │ ├── ColorPicker.svelte │ │ │ │ ├── PaletteDisplay.svelte │ │ │ │ └── PalettePopup.svelte │ │ │ ├── modals │ │ │ │ ├── SaveSessionModal.svelte │ │ │ │ └── NewSessionModal.svelte │ │ │ └── LandingPage.svelte │ │ ├── brick │ │ │ └── helpers.ts │ │ └── helpers.ts │ ├── app.postcss │ ├── routes │ │ ├── session │ │ │ ├── +page.svelte │ │ │ ├── [session] │ │ │ │ └── +page.svelte │ │ │ └── +layout.svelte │ │ ├── +error.svelte │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ └── +layout.svelte │ ├── app.d.ts │ └── app.html ├── .prettierignore ├── postcss.config.cjs ├── .gitignore ├── .eslintignore ├── .prettierrc ├── vite.config.ts ├── tsconfig.json ├── .eslintrc.cjs ├── svelte.config.js ├── tailwind.config.ts ├── README.md ├── package.json └── themes │ └── dali.ts ├── docker ├── mongodb │ ├── brick_db_pwd.txt │ ├── brick_db_user.txt │ ├── mongo_root_pwd.txt │ ├── mongo_root_user.txt │ └── mongo-init.sh ├── traefik │ ├── localhost │ │ ├── dynamic.yml │ │ └── static.yml │ └── web │ │ ├── static.yml │ │ └── dynamic.yml ├── Dockerfile.app.dev ├── Dockerfile.dbs ├── docker-compose.traefik.web.yml ├── Dockerfile.app ├── docker-compose.traefik.localhost.yml ├── Dockerfile.server └── brick.env ├── .github └── ISSUE_TEMPLATES │ ├── config.yml │ ├── other_issue.md │ ├── question_user.md │ ├── question_dev.md │ ├── feature_request.md │ └── bug_report.md ├── docs └── brick.png ├── .pre-commit-config.yaml ├── conda.yml ├── requirements.txt ├── scripts ├── pre_bump.sh └── deploy.sh ├── cog.toml ├── .gitignore ├── LICENSE ├── tests ├── api_tests │ ├── test_core_db.py │ ├── test_endpoint_sessions.py │ ├── test_endpoint_tasks.py │ └── test_endpoint_rings.py └── core_tests │ └── test_utils.py ├── pyproject.toml └── CHANGELOG.md /brick/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /brick/api/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /brick/api/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker/mongodb/brick_db_pwd.txt: -------------------------------------------------------------------------------- 1 | admin -------------------------------------------------------------------------------- /docker/mongodb/brick_db_user.txt: -------------------------------------------------------------------------------- 1 | admin -------------------------------------------------------------------------------- /docker/mongodb/mongo_root_pwd.txt: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /docker/mongodb/mongo_root_user.txt: -------------------------------------------------------------------------------- 1 | root -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATES/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /docs/brick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esteinig/brick/HEAD/docs/brick.png -------------------------------------------------------------------------------- /app/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esteinig/brick/HEAD/app/static/favicon.png -------------------------------------------------------------------------------- /app/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /app/.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore files for PNPM, NPM and YARN 2 | pnpm-lock.yaml 3 | package-lock.json 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /app/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /docker/traefik/localhost/dynamic.yml: -------------------------------------------------------------------------------- 1 | http: 2 | routers: 3 | traefik: 4 | rule: "Host(`traefik.brick.localhost`)" 5 | service: "api@internal" -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 24.1.0 4 | hooks: 5 | - id: black 6 | language_version: python3 7 | -------------------------------------------------------------------------------- /conda.yml: -------------------------------------------------------------------------------- 1 | name: brick 2 | channels: 3 | - conda-forge 4 | - bioconda 5 | dependencies: 6 | - python=3.10 7 | - blast=2.15 8 | - genomad=1.7.6 9 | - pip 10 | -------------------------------------------------------------------------------- /app/src/app.postcss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @tailwind variants; 5 | 6 | html, 7 | body { 8 | @apply h-full overflow-hidden; 9 | } 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATES/other_issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other 3 | about: Any other issue you would like to raise 4 | title: '' 5 | labels: 'other' 6 | assignees: 'esteinig' 7 | 8 | --- 9 | 10 | -------------------------------------------------------------------------------- /app/src/routes/session/+page.svelte: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | .vscode -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATES/question_user.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: User Question 3 | about: Any questions about using `BRICK` 4 | title: '' 5 | labels: ['question', 'user'] 6 | assignees: 'esteinig' 7 | 8 | --- 9 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATES/question_dev.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Dev Question 3 | about: Any questions about developing `BRICK` 4 | title: '' 5 | labels: ['question', 'dev'] 6 | assignees: 'esteinig' 7 | 8 | --- 9 | 10 | -------------------------------------------------------------------------------- /app/.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /app/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /app/src/lib/stores/TabIndexStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | // Define the type for the store 4 | type TabIndexStore = number; 5 | 6 | export function resetTabs() { 7 | tabIndexStore.update(_ => 0) 8 | } 9 | 10 | // Create the store 11 | export const tabIndexStore = writable(0); 12 | -------------------------------------------------------------------------------- /brick/api/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | logging.basicConfig( 5 | level=logging.INFO, # (DEBUG, INFO, WARNING, ERROR, CRITICAL) 6 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 7 | filename="/tmp/brick.log" if Path("/tmp").exists() else None, 8 | filemode="w", 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/lib/stores/ReferencePositionStore.ts: -------------------------------------------------------------------------------- 1 | import { writable} from 'svelte/store'; 2 | 3 | // Define the type for the store 4 | type ReferencePositionStore = number | undefined; 5 | 6 | // Create the store 7 | const referencePosition = writable(undefined); 8 | 9 | 10 | // Export the store and functions 11 | export { referencePosition }; -------------------------------------------------------------------------------- /app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { purgeCss } from 'vite-plugin-tailwind-purgecss'; 2 | import { sveltekit } from '@sveltejs/kit/vite'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [sveltekit(), purgeCss({ 7 | safelist: { 8 | // any selectors that begin with "hljs-" will not be purged 9 | greedy: [/^hljs-/], 10 | }, 11 | }), 12 | ], 13 | }); -------------------------------------------------------------------------------- /app/src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |
7 |
8 |

Error

9 | {$page.error?.message || "Look, it's a mystery error!"} 10 |
11 |
-------------------------------------------------------------------------------- /app/src/lib/stores/RingReferenceStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import type { RingReference } from '$lib/types'; 3 | 4 | // Define the type for the store 5 | type RingReferenceStore = RingReference | null; 6 | 7 | export function clearRingReference() { 8 | ringReferenceStore.update(_ => null) 9 | } 10 | 11 | 12 | // Create the store 13 | export const ringReferenceStore = writable(null); 14 | -------------------------------------------------------------------------------- /app/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | 4 | import type { BrickInterfaceConfiguration } from "$lib/session/types"; 5 | 6 | // and what to do when importing types 7 | declare namespace App { 8 | // interface Locals {} 9 | interface PageData { 10 | userSettings: BrickInterfaceConfiguration 11 | } 12 | // interface Error {} 13 | // interface Platform {} 14 | } 15 | -------------------------------------------------------------------------------- /app/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /docker/traefik/localhost/static.yml: -------------------------------------------------------------------------------- 1 | global: 2 | sendAnonymousUsage: false 3 | 4 | api: 5 | dashboard: true 6 | insecure: false 7 | 8 | providers: 9 | docker: 10 | endpoint: "unix:///var/run/docker.sock" 11 | watch: true 12 | network: proxy 13 | exposedByDefault: false 14 | 15 | file: 16 | filename: "/etc/traefik/dynamic.yml" 17 | watch: true 18 | 19 | log: 20 | level: INFO 21 | format: common 22 | 23 | entryPoints: 24 | http: 25 | address: ":80" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Python dependencies for install via `mamba install --file requirements.txt` 2 | # in the Docker container, do not use to via `pip install` due to different 3 | # Redis database packages [redis==5.0.1] 4 | 5 | typer==0.9.0 6 | biopython==1.83 7 | pandas==2.1.4 8 | requests==2.31.0 9 | pre-commit==3.6.0 10 | fastapi==0.109.0 11 | redis-py==5.0.1 12 | celery[redis]==5.3.6 13 | uvicorn==0.25.0 14 | pydantic-settings==2.1.0 15 | python-multipart==0.0.6 16 | motor==3.6.0 17 | pytest==7.4.4 18 | pytest-asyncio==0.23.3 19 | httpx==0.26.0 20 | asgi-lifespan==2.1.0 21 | apscheduler==3.10.4 22 | strenum==0.4.15 23 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // 16 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 17 | // from the referenced tsconfig.json - TypeScript does not merge them in 18 | } 19 | -------------------------------------------------------------------------------- /app/src/lib/stores/RequestInProgressStore.ts: -------------------------------------------------------------------------------- 1 | import { derived, writable } from 'svelte/store'; 2 | 3 | // A store to keep track of the number of active requests 4 | const activeRequests = writable(0); 5 | 6 | // A derived store to indicate if any upload is in progress 7 | export const requestInProgress = derived( 8 | activeRequests, 9 | $activeRequests => $activeRequests > 0 10 | ); 11 | 12 | // Functions to increment and decrement the active uploads count 13 | export function startRequestState() { 14 | activeRequests.update(n => n + 1); 15 | } 16 | 17 | export function completeRequestState() { 18 | activeRequests.update(n => Math.max(0, n - 1)); // Prevents negative values 19 | } -------------------------------------------------------------------------------- /docker/Dockerfile.app.dev: -------------------------------------------------------------------------------- 1 | # Use an official Node runtime as a parent image 2 | FROM node:20.9.0 AS build 3 | 4 | USER node 5 | 6 | # Set the working directory in the container to /app 7 | WORKDIR /usr/src/app 8 | 9 | # Copy the package.json and package-lock.json from the /app directory 10 | COPY --chown=node:node app/package*.json ./ 11 | 12 | # Install any needed packages 13 | RUN npm install 14 | 15 | # Bundle app source inside the Docker image 16 | COPY --chown=node:node app/ . 17 | 18 | # Set the node env vars required 19 | ENV PORT=5174 20 | ENV PROTOCOL_HEADER=x-forwarded-proto 21 | ENV HOST_HEADER=x-forwarded-host 22 | 23 | # Command to run 24 | CMD ["npm", "run", "dev", "--", "--port", "5174", "--host"] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATES/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'feature' 6 | assignees: 'esteinig' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Example: I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /app/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type { import("eslint").Linter.FlatConfig } */ 2 | module.exports = { 3 | root: true, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:svelte/recommended', 8 | 'prettier' 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['@typescript-eslint'], 12 | parserOptions: { 13 | sourceType: 'module', 14 | ecmaVersion: 2020, 15 | extraFileExtensions: ['.svelte'] 16 | }, 17 | env: { 18 | browser: true, 19 | es2017: true, 20 | node: true 21 | }, 22 | overrides: [ 23 | { 24 | files: ['*.svelte'], 25 | parser: 'svelte-eslint-parser', 26 | parserOptions: { 27 | parser: '@typescript-eslint/parser' 28 | } 29 | } 30 | ] 31 | }; 32 | -------------------------------------------------------------------------------- /scripts/pre_bump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | LATEST=$1 5 | VERSION=$2 6 | 7 | 8 | # Check that all commits 9 | # comply with specifications 10 | cog check 11 | 12 | # Echo the version change 13 | echo "bumping $LATEST to $VERSION" 14 | 15 | # Replace docker-compose main script version 16 | sed -i 's/PUBLIC_BRICK_VERSION='"$LATEST"'/PUBLIC_BRICK_VERSION='"$VERSION"'/' docker/brick.env 17 | 18 | # Replace Python package version line 19 | sed -i 's/version = "'"${LATEST}"'"/version = "'"${VERSION}"'"/' pyproject.toml 20 | 21 | # Replace Sveltekit application version 22 | # `npm install` or `build` in the container will update the `package-lock.json` 23 | sed -i 's/"version": "'"${LATEST}"'"/"version": "'"${VERSION}"'"/' app/package.json 24 | -------------------------------------------------------------------------------- /app/src/lib/stores/TooltipStore.ts: -------------------------------------------------------------------------------- 1 | import { writable} from 'svelte/store'; 2 | import type { RingSegment } from '$lib/types'; 3 | 4 | // Define the type for the store 5 | type TooltipStore = RingSegment | undefined; 6 | 7 | // Create the store 8 | const tooltip = writable(undefined); 9 | 10 | // Function to add a new session file 11 | function setTooltip(ringSegment: RingSegment) { 12 | tooltip.update(_ => ringSegment); 13 | } 14 | 15 | // Function to remove a session file by id 16 | function removeTooltip() { 17 | tooltip.update(_ => undefined); 18 | } 19 | 20 | 21 | function clearTooltip() { 22 | tooltip.update(_ => undefined) 23 | } 24 | // Export the store and functions 25 | export { tooltip, setTooltip, removeTooltip}; -------------------------------------------------------------------------------- /app/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-node'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | extensions: ['.svelte'], 7 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 8 | // for more information about preprocessors 9 | preprocess: [vitePreprocess()], 10 | 11 | kit: { 12 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 13 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 14 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 15 | adapter: adapter() 16 | } 17 | }; 18 | export default config; -------------------------------------------------------------------------------- /app/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import type { Config } from 'tailwindcss' 3 | import forms from '@tailwindcss/forms'; 4 | import typography from '@tailwindcss/typography'; 5 | import { skeleton } from '@skeletonlabs/tw-plugin' 6 | 7 | import { momaDali } from './themes/dali'; 8 | 9 | export default { 10 | darkMode: 'class', 11 | content: ['./src/**/*.{html,js,svelte,ts}', join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')], 12 | theme: { 13 | extend: {}, 14 | }, 15 | plugins: [ 16 | forms, 17 | typography, 18 | skeleton({ 19 | themes: { 20 | custom: [ 21 | momaDali 22 | ], 23 | preset: [ 24 | { 25 | name: 'wintry', 26 | enhancements: true, 27 | }, 28 | ], 29 | }, 30 | }), 31 | ], 32 | } satisfies Config; 33 | -------------------------------------------------------------------------------- /cog.toml: -------------------------------------------------------------------------------- 1 | ignore_merge_commits = true 2 | branch_whitelist = [ 3 | "main", 4 | "release/**" 5 | ] 6 | 7 | skip_untracked = false 8 | pre_bump_hooks = [ 9 | """#!/bin/sh 10 | ./scripts/pre_bump.sh {{latest}} {{version}} 11 | """ 12 | ] 13 | post_bump_hooks = [] 14 | pre_package_bump_hooks = [] 15 | post_package_bump_hooks = [] 16 | 17 | [git_hooks] 18 | 19 | [commit_types] 20 | chore = { changelog_title = "", omit_from_changelog = true } 21 | ci = { changelog_title = "", omit_from_changelog = true } 22 | perf = { changelog_title = "", omit_from_changelog = true } 23 | hotfix = { changelog_title = "Hotfixes" } 24 | release = { changelog_title = "Releases" } 25 | 26 | [changelog] 27 | path = "CHANGELOG.md" 28 | authors = [ 29 | { username = "esteinig", signature = "Eike Steinig" } 30 | ] 31 | 32 | [bump_profiles] 33 | 34 | [packages] 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # Celery stuff 29 | celerybeat-schedule 30 | celerybeat.pid 31 | 32 | # Environments 33 | .env 34 | .venv 35 | venv/ 36 | ENV/ 37 | env.bak/ 38 | venv.bak/ 39 | 40 | # mkdocs documentation 41 | /site 42 | 43 | # Development 44 | /app/.vscode 45 | /app/.sveltekit 46 | 47 | # Deployment 48 | /docker/certs/* 49 | /.secrets/ 50 | /brick.env 51 | /docker-compose.web.yml 52 | /docker-compose.localhost.yml 53 | /docker-compose.traefik.web.yml 54 | /docker-compose.traefik.localhost.yml -------------------------------------------------------------------------------- /docker/mongodb/mongo-init.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | # Secrets must be read from secret database env file as they 4 | # can be exposed in Docker commands and configurations when 5 | # configured directly in the container deployment 6 | 7 | 8 | MONGO_INITDB_ROOT_USERNAME=$(cat /run/secrets/mongo_root_user | tr -d "\n") 9 | MONGO_INITDB_ROOT_PASSWORD=$(cat /run/secrets/mongo_root_pwd | tr -d "\n") 10 | BRICK_MONGODB_USERNAME=$(cat /run/secrets/brick_db_user | tr -d "\n") 11 | BRICK_MONGODB_PASSWORD=$(cat /run/secrets/brick_db_pwd | tr -d "\n") 12 | 13 | mongosh < To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Eike Steinig 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/src/lib/stores/FileDropzoneStateStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | // Define the type for the store 4 | type FileDropzoneStateStore = Map; 5 | 6 | // Create the store 7 | export const fileDropzoneStateStore = writable( 8 | new Map([ 9 | ['referenceDropzone', false], 10 | ['genbankDropzone', false], 11 | ['blastDropzone', false], 12 | ['customDropzone', false], 13 | ['sessionDropzone', false] 14 | ]) 15 | ); 16 | 17 | // Set a dropzone to loading state 18 | export function startDropzoneLoading(dropzoneId: string): void { 19 | fileDropzoneStateStore.update((state) => { 20 | if (state.has(dropzoneId)) { 21 | state.set(dropzoneId, true); 22 | } 23 | return state; 24 | }); 25 | } 26 | 27 | 28 | // Set a dropzone to completed state 29 | export function completeDropzoneLoading(dropzoneId: string): void { 30 | fileDropzoneStateStore.update((state) => { 31 | if (state.has(dropzoneId)) { 32 | state.set(dropzoneId, false); 33 | } 34 | return state; 35 | }); 36 | } -------------------------------------------------------------------------------- /docker/Dockerfile.dbs: -------------------------------------------------------------------------------- 1 | FROM condaforge/mambaforge:latest as builder 2 | 3 | # Set the working directory in the builder stage 4 | WORKDIR /app 5 | 6 | # Create a new environment for bioinformatics dependencies 7 | COPY conda.yml ./ 8 | RUN mamba env create -f conda.yml 9 | 10 | # Activate the new environment 11 | SHELL ["conda", "run", "-n", "brick", "/bin/bash", "-c"] 12 | 13 | # Use another stage for the runner 14 | FROM condaforge/mambaforge:latest as runner 15 | 16 | # Environment variable that determines API settings 17 | ARG UID=9234 18 | ARG GID=9234 19 | ARG USERNAME=brick-app 20 | 21 | # Create a non-root user and switch to it 22 | RUN groupadd -g ${GID} ${USERNAME} && useradd --no-log-init -u ${UID} -r -g ${USERNAME} ${USERNAME} 23 | USER ${USERNAME} 24 | 25 | WORKDIR /data 26 | 27 | # Copy the conda environment from the builder stage 28 | COPY --chown=${USERNAME}:${USERNAME} --from=builder /opt/conda /opt/conda 29 | 30 | # Set the environment variables to use the conda environment 31 | ENV PATH=/opt/conda/envs/brick/bin:$PATH 32 | 33 | # Download geNomad database to /data/genomad_db 34 | RUN genomad download-database . 35 | 36 | # Profiling => 18G RAM without --splits, 6GB with --split 4(geNomad v1.7) 37 | -------------------------------------------------------------------------------- /docker/docker-compose.traefik.web.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | reverse-proxy: 5 | image: traefik:v2.10 6 | restart: unless-stopped 7 | ports: 8 | # To be able to listen on port 80 (http) - check firewall settings 9 | - mode: host 10 | published: 80 11 | target: 80 12 | # To be able to listen on port 443 (https) - check firewall settings 13 | - mode: host 14 | published: 443 15 | target: 443 16 | volumes: 17 | # Set the container timezone by sharing the read-only localtime 18 | - /etc/localtime:/etc/localtime:ro 19 | # Give access to the UNIX Docker socket 20 | - /var/run/docker.sock:/var/run/docker.sock:ro 21 | # Map the static configuration into the container 22 | - ./docker/traefik/web/static.yml:/etc/traefik/traefik.yml:ro 23 | # Map the dynamic configuration into the container 24 | - ./docker/traefik/web/dynamic.yml:/etc/traefik/dynamic.yml:ro 25 | # Set the location where the local ACME certificate is saved 26 | - ./docker/certs:/etc/traefik/certs 27 | networks: 28 | - proxy 29 | security_opt: 30 | - no-new-privileges:true 31 | 32 | networks: 33 | proxy: 34 | external: true -------------------------------------------------------------------------------- /docker/Dockerfile.app: -------------------------------------------------------------------------------- 1 | # Use an official Node runtime as a parent image 2 | FROM node:20.9.0 AS build 3 | 4 | # Set the working directory in the container to /app 5 | WORKDIR /usr/src/app 6 | 7 | # Copy the package.json and package-lock.json from the /app directory 8 | COPY app/package*.json ./ 9 | 10 | # Install any needed packages 11 | RUN npm install 12 | 13 | # Bundle app source inside the Docker image 14 | COPY app/ . 15 | 16 | # Build the application 17 | RUN npm run build 18 | 19 | # Stage 2: Setup a lightweight production environment 20 | FROM node:20.9.0-alpine 21 | 22 | USER node 23 | 24 | # Set the working directory 25 | WORKDIR /app 26 | 27 | # Copy package.json and package-lock.json 28 | COPY --chown=node:node app/package*.json ./ 29 | 30 | # Install only production dependencies 31 | RUN npm install --production 32 | 33 | # Copy built assets from the build stage 34 | COPY --chown=node:node --from=build /usr/src/app/build build 35 | COPY --chown=node:node --from=build /usr/src/app/node_modules node_modules 36 | COPY --chown=node:node --from=build /usr/src/app/package.json package.json 37 | 38 | # Set the node env vars required 39 | ENV PORT=5173 40 | ENV PROTOCOL_HEADER=x-forwarded-proto 41 | ENV HOST_HEADER=x-forwarded-host 42 | 43 | 44 | # Command to run 45 | CMD ["node", "build"] -------------------------------------------------------------------------------- /docker/docker-compose.traefik.localhost.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | reverse-proxy: 5 | image: traefik:v2.10 6 | restart: unless-stopped 7 | ports: 8 | # To be able to listen on port 80 (http) - check firewall settings 9 | - mode: host 10 | published: 80 11 | target: 80 12 | # To be able to listen on port 443 (https) - check firewall settings 13 | - mode: host 14 | published: 443 15 | target: 443 16 | volumes: 17 | # Set the container timezone by sharing the read-only localtime 18 | - /etc/localtime:/etc/localtime:ro 19 | # Give access to the UNIX Docker socket 20 | - /var/run/docker.sock:/var/run/docker.sock:ro 21 | # Map the static configuration into the container 22 | - ./docker/traefik/localhost/static.yml:/etc/traefik/traefik.yml:ro 23 | # Map the dynamic configuration into the container 24 | - ./docker/traefik/localhost/dynamic.yml:/etc/traefik/dynamic.yml:ro 25 | # Set the location where the local ACME certificate is saved 26 | - ./docker/certs:/etc/traefik/certs 27 | networks: 28 | - proxy 29 | security_opt: 30 | - no-new-privileges:true 31 | 32 | networks: 33 | proxy: 34 | external: true -------------------------------------------------------------------------------- /app/src/lib/stores/SessionFileStore.ts: -------------------------------------------------------------------------------- 1 | import { writable, get} from 'svelte/store'; 2 | import type { FileType, SessionFile } from '$lib/types'; 3 | 4 | // Define the type for the store 5 | type SessionFileStore = SessionFile[]; 6 | 7 | // Create the store 8 | const sessionFiles = writable([]); 9 | 10 | // Function to add a new session file 11 | function addSessionFile(file: SessionFile) { 12 | sessionFiles.update(currentFiles => [...currentFiles, file]); 13 | } 14 | 15 | // Function to remove a session file by id 16 | function removeSessionFile(id: string) { 17 | sessionFiles.update(currentFiles => currentFiles.filter(file => file.id !== id)); 18 | } 19 | 20 | function sessionFileTypeAvailable(fileType: FileType): boolean { 21 | const currentFiles = get(sessionFiles); 22 | return currentFiles.some(file => file.type === fileType); 23 | } 24 | 25 | function clearSessionFiles() { 26 | sessionFiles.update(_ => []) 27 | } 28 | 29 | function getSessionFileById(id: string): SessionFile | undefined { 30 | const currentFiles = get(sessionFiles); 31 | return currentFiles.find(file => file.id === id); 32 | } 33 | 34 | // Export the store and functions 35 | export { sessionFiles, addSessionFile, removeSessionFile, sessionFileTypeAvailable, clearSessionFiles, getSessionFileById}; -------------------------------------------------------------------------------- /app/src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { SessionResponse } from '$lib/types'; 2 | import { env } from '$env/dynamic/private'; 3 | import type { PageServerLoad } from './$types'; 4 | import { FALLBACK_REFERENCE, FALLBACK_RINGS } from '$lib/data'; 5 | 6 | 7 | 8 | export const load: PageServerLoad = async ({ depends }) => { 9 | 10 | depends("app:landing") 11 | 12 | const response = await fetch(`${env.PRIVATE_DOCKER_API_URL}/sessions/default`); 13 | 14 | try { 15 | const sessionResponseData: SessionResponse = await response.json(); 16 | 17 | if (response.ok) { 18 | if (sessionResponseData.rings.length){ 19 | return { reference: sessionResponseData.rings[0].reference, rings: sessionResponseData.rings } 20 | } else { 21 | return { reference: FALLBACK_REFERENCE, rings: FALLBACK_RINGS } 22 | } 23 | 24 | } else { 25 | if (response.status === 404){ 26 | return { reference: FALLBACK_REFERENCE, rings: FALLBACK_RINGS } 27 | } else { 28 | return { reference: FALLBACK_REFERENCE, rings: FALLBACK_RINGS } 29 | } 30 | } 31 | } catch(error) { 32 | return { reference: FALLBACK_REFERENCE, rings: FALLBACK_RINGS } 33 | } 34 | }; -------------------------------------------------------------------------------- /tests/api_tests/test_core_db.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pymongo.collection import Collection 3 | from motor.motor_asyncio import AsyncIOMotorCollection 4 | from unittest.mock import patch, MagicMock, AsyncMock 5 | 6 | from brick.api.core.db import ( 7 | get_session_collection_motor, 8 | get_session_collection_pymongo, 9 | ) 10 | 11 | 12 | @pytest.fixture 13 | def mock_motor_client(): 14 | with patch( 15 | "motor.motor_asyncio.AsyncIOMotorClient", new_callable=AsyncMock 16 | ) as mock: 17 | mock.return_value.__getitem__.return_value.__getitem__.return_value = ( 18 | AsyncMock() 19 | ) 20 | yield mock 21 | 22 | 23 | @pytest.fixture 24 | def mock_pymongo_client(): 25 | with patch("pymongo.MongoClient", new_callable=MagicMock) as mock: 26 | mock.return_value.__getitem__.return_value.__getitem__.return_value = ( 27 | MagicMock() 28 | ) 29 | yield mock 30 | 31 | 32 | def test_get_session_collection_pymongo(mock_pymongo_client): 33 | collection = get_session_collection_pymongo() 34 | assert isinstance(collection, Collection) 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_get_session_collection_motor(mock_motor_client): 39 | collection = await get_session_collection_motor() 40 | assert isinstance(collection, AsyncIOMotorCollection) 41 | -------------------------------------------------------------------------------- /app/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 |
30 | 31 |
32 |
33 | 34 |
35 |
36 | 37 |
38 |
39 |
-------------------------------------------------------------------------------- /app/src/lib/stores/PaletteStore.ts: -------------------------------------------------------------------------------- 1 | import { MOMA_PALETTES, NZ_PALETTES, NATIONAL_PARK_PALETTES } from '$lib/data'; 2 | import type { Palette } from '$lib/types'; 3 | import { writable } from 'svelte/store'; 4 | 5 | 6 | function createPaletteStore() { 7 | const { subscribe, set, update } = writable([ 8 | NZ_PALETTES[1], MOMA_PALETTES[11], MOMA_PALETTES[2], MOMA_PALETTES[3], 9 | NATIONAL_PARK_PALETTES[0], NATIONAL_PARK_PALETTES[4], NATIONAL_PARK_PALETTES[10], NATIONAL_PARK_PALETTES[11] 10 | ]); 11 | 12 | return { 13 | subscribe, 14 | addPalette: (newPalette: Palette) => { 15 | update(palettes => { 16 | return [...palettes, newPalette]; 17 | }); 18 | }, 19 | removePalette: (paletteName: string) => { 20 | update(palettes => { 21 | return palettes.filter(p => p.name !== paletteName); 22 | }); 23 | }, 24 | checkPaletteExists: (paletteName: string): boolean => { 25 | let exists = false; 26 | update(palettes => { 27 | exists = palettes.some(p => p.name === paletteName); 28 | return palettes; // No change to the palettes 29 | }); 30 | return exists; 31 | }, 32 | reset: () => set([]) 33 | }; 34 | } 35 | 36 | export const paletteStore = createPaletteStore(); -------------------------------------------------------------------------------- /brick/api/core/celery.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | from .config import settings 3 | 4 | celery_app = Celery( 5 | "worker", 6 | broker=settings.CELERY_BROKER_URL, 7 | backend=settings.CELERY_RESULT_BACKEND, 8 | broker_connection_retry_on_startup=True, 9 | task_serializer="json", 10 | accept_content=["json"], # Ignore other content 11 | result_serializer="json", 12 | timezone="UTC", 13 | enable_utc=True, 14 | ) 15 | 16 | celery_app.autodiscover_tasks(["brick.api.tasks"]) 17 | 18 | celery_app.conf.worker_concurrency = settings.CELERY_THREADS_PER_WORKER 19 | celery_app.conf.worker_prefetch_multiplier = 1 # For memory-intensive tasks 20 | celery_app.conf.worker_max_tasks_per_child = 100 # Prevent memory leaks 21 | 22 | celery_app.conf.task_soft_time_limit = ( 23 | settings.CELERY_TASK_SOFT_TIMEOUT 24 | ) # Soft time limit in seconds (10m) 25 | celery_app.conf.task_time_limit = ( 26 | settings.CELERY_TASK_HARD_TIMEOUT 27 | ) # Hard time limit in seconds to kill the worker (20m) 28 | 29 | 30 | # Genomad can be resource intensive - until we have implemented resource-managing 31 | # workflows restrict task requests across all executions 32 | celery_app.conf.task_annotations = { 33 | "worker.tasks.process_genomad_ring": {"rate_limit": "5/m"}, 34 | "worker.tasks.process_blast_ring": { 35 | "rate_limit": "100/m" 36 | }, # Less strict, runs with minimal resources 37 | } 38 | -------------------------------------------------------------------------------- /app/src/lib/session/controls/panels/PaletteControlPanel.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 |
31 |
32 | Select any number of palettes for ring color selections 33 |
34 | 35 | {#each paletteItems as paletteItem} 36 | 37 | {/each} 38 | 39 |
-------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Navigate to your project directory 4 | cd $2 5 | 6 | # Depending on the environment, deploy the appropriate Docker Compose setup 7 | if [ "$1" = "prod" ]; then 8 | 9 | # Pull down the production stack 10 | docker compose -f docker-compose.web.yml --profile prod --profile server --project-name prod down 11 | 12 | # Deploy with commit SHA passed into the script through action 13 | git checkout $3 14 | 15 | # Rebuild to include latest changes and up the stack again 16 | docker compose -f docker-compose.web.yml --profile prod --profile server --project-name prod up --build -d 17 | 18 | # Log this action 19 | echo "$date Successfully deployed production application" >> ~/brick_deploy_action.log 20 | 21 | elif [ "$1" = "dev" ]; then 22 | 23 | # Pull down the development stack 24 | docker compose -f docker-compose.web.yml --profile dev --profile server-dev --project-name dev down 25 | 26 | # Deploy with commit SHA passed into the script through action 27 | git checkout $3 28 | 29 | # Rebuild to include latest changes and up the stack again 30 | docker compose -f docker-compose.web.yml --profile dev --profile server-dev --project-name dev up --build -d 31 | 32 | # Log this action 33 | echo "$date Successfully deployed development application" >> ~/brick_deploy_action.log 34 | 35 | else 36 | # Deployment script was run with invalid command-line input 37 | echo "$date Failed to run deploy script - $1 is not a valid option!" >> ~/brick_deploy_action.log 38 | fi -------------------------------------------------------------------------------- /app/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "brick" 7 | version = "0.4.0" 8 | authors = [{name="Eike Steinig", email="eike.steinig@unimelb.edu.au"}] 9 | description = "BRICK implements BRIG-like interactive figures in D3 for bacterial genome visualization, annotation, comparison and exploration using a Python scripting API, CLI, REST API or UI." 10 | readme = "README.md" 11 | requires-python = ">=3.10" 12 | dependencies = [ 13 | "typer==0.9.0", 14 | "biopython==1.83", 15 | "pandas==2.1.4", 16 | "requests==2.31.0", 17 | "pre-commit==3.6.0", 18 | "fastapi==0.109.0", 19 | "redis==5.0.1", 20 | "celery[redis]==5.3.6", 21 | "uvicorn==0.25.0", 22 | "pydantic-settings==2.1.0", 23 | "python-multipart==0.0.6", 24 | "motor==3.6.0", 25 | "pytest==7.4.4", 26 | "pytest-asyncio==0.23.3", 27 | "httpx==0.26.0", 28 | "asgi-lifespan==2.1.0", 29 | "apscheduler==3.10.4", 30 | "strenum==0.4.15" 31 | ] 32 | 33 | [tool.setuptools.packages.find] 34 | where = ["."] 35 | include = ["brick*"] 36 | namespaces = false 37 | 38 | [project.scripts] 39 | brick = "brick.terminal:app" 40 | 41 | [tool.pytest.ini_options] 42 | testpaths = [ 43 | "tests/api_tests", 44 | "tests/core_tests", 45 | ] 46 | addopts = "-v" 47 | 48 | [tool.black] 49 | include = '\.pyi?$' 50 | exclude = ''' 51 | /( 52 | \.git 53 | | \.hg 54 | | \.mypy_cache 55 | | \.tox 56 | | \.venv 57 | | \.github 58 | | \.secrets 59 | | _build 60 | | buck-out 61 | | build 62 | | dist 63 | | app 64 | | docker 65 | | scripts 66 | )/ 67 | ''' 68 | 69 | -------------------------------------------------------------------------------- /app/src/routes/session/[session]/+page.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 | 32 | 33 |
34 | 35 |
36 |
37 | 38 |
39 |
40 | 41 |
42 |
43 |
44 | -------------------------------------------------------------------------------- /app/src/lib/session/palette/ColorPalette.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 |

24 | {title} 25 | {subtitle} 26 | {#if hoverDisplay} 27 | {displayValue} 28 | {/if} 29 |

30 |
31 | {#each colors as color} 32 |
displayValue = color} 36 | on:mouseout={() => displayValue = ""} 37 | on:keypress={() => displayValue = color} 38 | on:blur={() => displayValue = ""} 39 | on:focus={() => displayValue = color} 40 | on:click={() => colorClickHandler(color)} 41 | role="presentation">
42 | {/each} 43 |
44 |
-------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brick", 3 | "version": "0.4.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "lint": "prettier --check . && eslint .", 12 | "format": "prettier --write ." 13 | }, 14 | "devDependencies": { 15 | "@skeletonlabs/skeleton": "2.6.1", 16 | "@skeletonlabs/tw-plugin": "0.3.0", 17 | "@sveltejs/adapter-auto": "^3.0.0", 18 | "@sveltejs/adapter-node": "^3.0.0", 19 | "@sveltejs/kit": "^2.0.0", 20 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 21 | "@tailwindcss/forms": "0.5.7", 22 | "@tailwindcss/typography": "0.5.10", 23 | "@types/node": "20.10.5", 24 | "@types/sanitize-html": "^2.9.5", 25 | "@typescript-eslint/eslint-plugin": "^6.0.0", 26 | "@typescript-eslint/parser": "^6.0.0", 27 | "autoprefixer": "10.4.16", 28 | "eslint": "^8.28.0", 29 | "eslint-config-prettier": "^9.1.0", 30 | "eslint-plugin-svelte": "^2.30.0", 31 | "postcss": "8.4.32", 32 | "prettier": "^3.1.1", 33 | "prettier-plugin-svelte": "^3.1.2", 34 | "svelte": "^4.2.7", 35 | "svelte-check": "^3.6.0", 36 | "tailwindcss": "3.4.0", 37 | "tslib": "^2.4.1", 38 | "typescript": "^5.0.0", 39 | "vite": "^5.0.3", 40 | "vite-plugin-tailwind-purgecss": "0.2.0" 41 | }, 42 | "type": "module", 43 | "dependencies": { 44 | "@floating-ui/dom": "1.5.3", 45 | "d3": "^7.8.5", 46 | "dotenv": "^16.3.1", 47 | "highlight.js": "11.9.0", 48 | "uuid": "^9.0.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docker/Dockerfile.server: -------------------------------------------------------------------------------- 1 | FROM condaforge/mambaforge:latest as builder 2 | 3 | # Set the working directory in the builder stage 4 | WORKDIR /app 5 | 6 | # Create a new environment for bioinformatics dependencies 7 | COPY conda.yml ./ 8 | RUN mamba env create -f conda.yml 9 | 10 | # Activate the new environment 11 | SHELL ["conda", "run", "-n", "brick", "/bin/bash", "-c"] 12 | 13 | # Copy the requirements file 14 | COPY requirements.txt ./ 15 | 16 | # Install dependencies from requirements.txt 17 | RUN mamba install --file requirements.txt --yes \ 18 | && mamba clean --all --yes 19 | 20 | # Use another stage for the runner 21 | FROM condaforge/mambaforge:latest as runner 22 | 23 | # Environment variable that determines API settings 24 | ARG UID=9234 25 | ARG GID=9234 26 | ARG USERNAME=brick-app 27 | 28 | # Create a non-root user and switch to it 29 | RUN groupadd -g ${GID} ${USERNAME} && useradd --no-log-init -u ${UID} -r -g ${USERNAME} ${USERNAME} 30 | USER ${USERNAME} 31 | 32 | WORKDIR /app 33 | 34 | # Copy the conda environment from the builder stage 35 | COPY --chown=${USERNAME}:${USERNAME} --from=builder /opt/conda /opt/conda 36 | 37 | # Set the environment variables to use the conda environment 38 | ENV PATH=/opt/conda/envs/brick/bin:$PATH 39 | 40 | # Copy only the necessary files for Brick 41 | COPY --chown=${USERNAME}:${USERNAME} pyproject.toml ./ 42 | COPY --chown=${USERNAME}:${USERNAME} brick/ ./brick/ 43 | 44 | # Install the brick package with the user dependencies from the builder stage 45 | RUN pip install --no-dependencies --no-cache . 46 | 47 | # Use other application code if necessary 48 | COPY --chown=${USERNAME}:${USERNAME} tests ./tests/ 49 | COPY --chown=${USERNAME}:${USERNAME} docker/data/default.json ./ -------------------------------------------------------------------------------- /app/src/lib/session/controls/helpers/RingGenomadEdit.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
19 | 20 |
21 |

Prediction class scores

22 | 23 | Plasmid 24 | Virus 25 | Both 26 | 27 |
28 |
29 | 33 |

Apply natural curve smoothing function (front-end visualisation only)

34 |
35 |
-------------------------------------------------------------------------------- /docker/traefik/web/static.yml: -------------------------------------------------------------------------------- 1 | global: 2 | sendAnonymousUsage: false 3 | 4 | providers: 5 | docker: 6 | endpoint: "unix:///var/run/docker.sock" # Listen to the UNIX Docker socket 7 | exposedByDefault: false # Only expose container that are explicitly enabled (using label traefik.enabled) 8 | network: "proxy" # Default network to use for connections to all containers. 9 | file: 10 | filename: "/etc/traefik/dynamic.yml" # Link to the dynamic configuration 11 | watch: true # Watch for modifications 12 | providersThrottleDuration: 10 # Configuration reload frequency 13 | 14 | api: 15 | dashboard: true # Enable the dashboard 16 | 17 | # Certificate Resolvers are responsible for retrieving certificates from an ACME server 18 | # See https://doc.traefik.io/traefik/https/acme/#certificate-resolvers 19 | 20 | certificatesResolvers: 21 | letsEncrypt: 22 | acme: 23 | email: "{{{ traefik.web.email }}}" # Email address used for registration 24 | storage: "/etc/traefik/certs/acme.json" # File or key used for certificates storage 25 | httpchallenge: 26 | entryPoint: http 27 | 28 | entryPoints: 29 | http: 30 | address: ":80" # Create the HTTP entrypoint on port 80 31 | http: 32 | redirections: # HTTPS redirection (80 to 443) 33 | entryPoint: 34 | to: "https" # The target element 35 | scheme: "https" # The redirection target scheme 36 | https: 37 | address: ":443" # Create the HTTPS entrypoint on port 443 38 | -------------------------------------------------------------------------------- /app/src/lib/stores/PlotConfigStore.ts: -------------------------------------------------------------------------------- 1 | // store.ts 2 | import { writable, get } from 'svelte/store'; 3 | import { type PlotConfig } from '$lib/types'; 4 | 5 | const DEFAULT_PLOTCONFIG: PlotConfig = { 6 | svg: { 7 | backgroundOpacity: 0, 8 | backgroundColor: "#d3d3d3", 9 | zoomEnabled: false, 10 | zoomLowerLimit: 0.5, 11 | zoomUpperLimit: 42, 12 | positionEnabled: true, 13 | tooltipEnabled: true 14 | }, 15 | transition: { 16 | enabled: true, 17 | fadeDelay: 0, 18 | fadeDuration: 800 19 | }, 20 | title: { 21 | text: "BRICK", 22 | color: "#d3d3d3", 23 | opacity: 80, 24 | style: { 25 | italic: false, 26 | bold: false, 27 | code: false 28 | }, 29 | size: 120 30 | }, 31 | subtitle: { 32 | text: "BRIG-like Interactive Circular Knowledgebase", 33 | color: "#d3d3d3", 34 | opacity: 80, 35 | style: { 36 | italic: false, 37 | bold: false, 38 | code: false 39 | }, 40 | size: 90, 41 | height: 120 42 | }, 43 | rings: { 44 | radius: 200, 45 | height: 20, 46 | outerHeight: 20, 47 | gap: 5, 48 | labelGap: 5, 49 | lineWidth: 1, 50 | lineSmoothing: false 51 | }, 52 | labels: { 53 | lineColor: "#d3d3d3", 54 | lineWidth: 7, 55 | lineOpacity: 80, 56 | lineLength: 50, 57 | textColor: "#d3d3d3", 58 | textSize: 90, 59 | textOpacity: 80, 60 | textGap: 5 61 | }, 62 | }; 63 | 64 | export const plotConfigStore = writable(DEFAULT_PLOTCONFIG); 65 | 66 | export function getTransitionDurationTotal() { 67 | const plotConfig = get(plotConfigStore); 68 | return plotConfig.transition.fadeDelay + plotConfig.transition.fadeDuration; 69 | } -------------------------------------------------------------------------------- /app/src/lib/session/modals/SaveSessionModal.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | 27 | {#if $modalStore[0]} 28 |
29 |
{$modalStore[0].title ?? defaultTitle}
30 |
31 |

Sessions and files are stored in our database for one week and are accessible on this page, with the unique identifier: 32 | {$page.params.session}

33 | If you want to revisit your session after its expiration, you can download the model data and upload the file into a new session (re-hydration).

34 | Please note that uploaded files that have expired will not be available in restored sessions. 35 |

36 |

37 | 38 |
39 | 40 | 41 |
42 |
43 | {/if} -------------------------------------------------------------------------------- /tests/api_tests/test_endpoint_sessions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from httpx import AsyncClient 4 | from brick.api.main import app 5 | from brick.api.schemas import Session 6 | from unittest.mock import AsyncMock, patch 7 | 8 | 9 | # Mock session data 10 | mock_session_data = Session(id="some_uuid", date="some_iso_date", files=[], rings=[]) 11 | 12 | 13 | @pytest.fixture 14 | def mock_motor_collection(): 15 | # This mock represents the collection returned by get_session_collection_motor function 16 | mock_collection = AsyncMock() 17 | mock_collection.find_one.return_value = ( 18 | mock_session_data.model_dump() 19 | ) # session query must be returned as dict 20 | return mock_collection 21 | 22 | 23 | @patch("brick.api.endpoints.sessions.get_session_collection_motor") 24 | @pytest.mark.asyncio 25 | async def test_get_session_success( 26 | mock_get_session_collection_motor, mock_motor_collection 27 | ): 28 | # Set the mock collection as the return value of the function 29 | mock_get_session_collection_motor.return_value = mock_motor_collection 30 | 31 | async with AsyncClient(app=app, base_url="http://test") as ac: 32 | response = await ac.get("/sessions/some_uuid") 33 | assert response.status_code == 200 34 | assert response.json() == mock_session_data.model_dump() 35 | 36 | 37 | @patch("brick.api.endpoints.sessions.get_session_collection_motor") 38 | @pytest.mark.asyncio 39 | async def test_get_session_failure( 40 | mock_get_session_collection_motor, mock_motor_collection 41 | ): 42 | # Set the mock collection as the return value of the function 43 | mock_get_session_collection_motor.return_value = mock_motor_collection 44 | # Set the return value to None to imitate no session model found 45 | mock_get_session_collection_motor.return_value.find_one.return_value = None 46 | 47 | async with AsyncClient(app=app, base_url="http://test") as ac: 48 | response = await ac.get("/sessions/none_existent_uuid") 49 | assert response.status_code == 404 50 | assert response.json()["detail"] == "Session not found" 51 | -------------------------------------------------------------------------------- /app/src/lib/session/controls/rings/NewReferenceRing.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 |
33 |

Reference Ring

34 |

35 | 36 |

37 |

38 | Reference rings are simple continuous segments representing the reference genome against which other sequences are compared. 39 | Reference rings are usually shown on the inner track and can be omitted entirely. 40 |

41 | 42 | {#if $ringReferenceStore} 43 |
44 | 45 |
46 | 51 |
52 |
53 | {/if} 54 |
-------------------------------------------------------------------------------- /app/src/lib/session/controls/helpers/FontStyles.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 | 33 | 43 | 53 | 54 |
-------------------------------------------------------------------------------- /app/src/lib/session/LandingPage.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
18 |
19 | 20 |
21 |

BRICK

22 |

BRIG* - like interactive data visualization for bacterial genome annotation and comparison

23 | 24 | 27 |
28 |
29 | 34 | Create Figure 35 | 36 | 42 |
43 |
44 | {#if published} 45 |
Please consider citing our publication in research outputs
46 | Steinig et al. (2024) - BRICK: bacterial genome annotation and identity visualisation - Microbial Genomics (or similar) 47 | {:else} 48 | https://github.com/esteinig/brick 49 | {/if} 50 |
51 |
52 |
-------------------------------------------------------------------------------- /app/src/lib/session/controls/helpers/RingLabelNew.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 |
31 |
32 | 36 |
37 |
38 | 42 |
43 | 44 |
45 | 50 |
51 |
-------------------------------------------------------------------------------- /docker/traefik/web/dynamic.yml: -------------------------------------------------------------------------------- 1 | http: 2 | middlewares: 3 | # A basic authentification middleware, to protect the Traefik dashboard 4 | # Use with traefik.http.routers.myRouter.middlewares: "traefikAuth@file" 5 | traefikAuth: 6 | basicAuth: 7 | users: 8 | - "{{{ traefik.web.username }}}:{{{ traefik.web.password_hashed }}}" 9 | 10 | # Recommended default middleware for most of the services, others can be added to the default chain 11 | # Use with traefik.http.routers.myRouter.middlewares: "default@file" 12 | # Equivalent of traefik.http.routers.myRouter.middlewares: "default-security-headers@file" 13 | default: 14 | chain: 15 | middlewares: 16 | - default-security-headers 17 | 18 | # Add some security headers 19 | # Use with traefik.http.routers.myRouter.middlewares: "default-security-headers@file" 20 | default-security-headers: 21 | headers: 22 | browserXssFilter: true # X-XSS-Protection=1; mode=block 23 | contentTypeNosniff: true # X-Content-Type-Options=nosniff 24 | forceSTSHeader: true # Add the Strict-Transport-Security header even when the connection is HTTP 25 | frameDeny: true # X-Frame-Options=deny 26 | referrerPolicy: "strict-origin-when-cross-origin" 27 | sslRedirect: true # Allow only https requests 28 | stsIncludeSubdomains: true # Add includeSubdomains to the Strict-Transport-Security header 29 | stsPreload: true # Add preload flag appended to the Strict-Transport-Security header 30 | stsSeconds: 63072000 # Set the max-age of the Strict-Transport-Security header (63072000 = 2 years) 31 | 32 | routers: 33 | # Traefik dashboard router 34 | traefik: 35 | rule: "Host(`traefik.{{{ traefik.web.domain }}}`)" 36 | service: "api@internal" 37 | entrypoints: 38 | - "https" 39 | middlewares: 40 | - "traefikAuth@file" 41 | - "default@file" 42 | tls: 43 | certresolver: "letsEncrypt" 44 | options: "modern@file" 45 | 46 | # See https://doc.traefik.io/traefik/https/tls/ 47 | tls: 48 | options: 49 | # To use with the label "traefik.http.routers.myrouter.tls.options=modern@file" 50 | modern: 51 | minVersion: "VersionTLS13" # Minimum TLS Version 52 | sniStrict: true # Strict SNI Checking 53 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. 3 | 4 | - - - 5 | ## 0.4.0 - 2024-02-10 6 | #### Bug Fixes 7 | - **(api)** subprocess str types - (5fb84dd) - esteinig 8 | - **(app)** annotation primary return ring type - (ed5c1ad) - esteinig 9 | - **(stack)** mamba env in runner stage - (32f7ccd) - esteinig 10 | #### Features 11 | - **(app)** edit label styles and annotations - (2a59fd4) - esteinig 12 | 13 | - - - 14 | 15 | ## 0.3.0 - 2024-01-26 16 | #### Bug Fixes 17 | - **(action)** revert ssh-agent action version - (327b9eb) - esteinig 18 | - **(action)** remove ubuntu image in action - (efe04ae) - esteinig 19 | - **(app)** remove .vscode - (63c7ee3) - esteinig 20 | - **(ci)** test action - (3237260) - esteinig 21 | - **(stack)** mod web deployment dev profile - (f58f929) - esteinig 22 | - **(stack)** deployment .gitignore and spelling - (e42bfc3) - esteinig 23 | - **(stack)** fix web secrets path - (a180845) - esteinig 24 | - **(stack)** agnostic domain templates - (e054d63) - esteinig 25 | - **(stack)** proxied docker compose secrets location - (07cf507) - esteinig 26 | - **(stack)** docker web data cleaner - (f49c5d7) - esteinig 27 | #### Documentation 28 | - **(readme)** production reminder, web app restrictions - (8103782) - esteinig 29 | - **(readme)** deployment and development - (19b4859) - esteinig 30 | - **(readme)** fix etymology section - (0bc1c2a) - *esteinig* 31 | - **(readme)** fix rebuild command - (5c395d4) - *esteinig* 32 | - **(readme)** fix params - (5712a4d) - *esteinig* 33 | - **(readme)** fix styles - (ce0d76b) - *esteinig* 34 | - **(readme)** fix browser address and overview - (119aa4d) - *esteinig* 35 | - **(readme)** local stack deployment and dependencies - (567c75e) - esteinig 36 | #### Features 37 | - **(ci)** ci/cd for production releases - (e600e15) - esteinig 38 | 39 | - - - 40 | 41 | ## 0.2.0 - 2024-01-24 42 | #### Features 43 | - **(changelog)** auto bump version config - (8448509) - esteinig 44 | 45 | - - - 46 | 47 | ## 0.1.0 - 2024-01-24 48 | #### Bug Fixes 49 | - **(app)** fix session plot config reset - (d737a40) - esteinig 50 | - **(cli)** sort of all working - (f6754d8) - esteinig 51 | - **(cli)** terminal input fixes - (7f4c6b9) - esteinig 52 | - **(stack)** data cleaner default settings - (530c33a) - esteinig 53 | #### Refactoring 54 | - **(app)** refactor async action requests - (35f6633) - esteinig 55 | - **(brick)** ring store and reference selection - (88668e8) - esteinig 56 | - **(brick)** validators to field_validators in pydantic v2; custom 'labels in label ring schema - (c8c60d3) - esteinig 57 | 58 | - - - 59 | 60 | Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto). -------------------------------------------------------------------------------- /app/src/lib/session/controls/upload/FileUpload.svelte: -------------------------------------------------------------------------------- 1 | 51 | 52 |
65 |
66 | formElement.requestSubmit()} disabled={disabled}> 67 | 68 |
69 |
70 | 71 | {config.message} 72 | 73 | 74 | {config.meta} 75 | 76 |
77 | {#if $fileDropzoneStateStore.get(config.id)} 78 |
79 | {:else} 80 |
81 | {/if} 82 |
83 |
84 | -------------------------------------------------------------------------------- /app/src/lib/session/controls/helpers/DeleteFile.svelte: -------------------------------------------------------------------------------- 1 | 46 | 47 |
48 |
49 | 50 |
51 | 56 | 57 |
58 |
59 |
-------------------------------------------------------------------------------- /tests/api_tests/test_endpoint_tasks.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, MagicMock 2 | from fastapi.testclient import TestClient 3 | from brick.api.main import app 4 | from brick.api.schemas import TaskStatus, FileType, FileFormat, Selections 5 | from brick.api.schemas import SessionFile 6 | 7 | client = TestClient(app) 8 | 9 | 10 | def mock_session_file(): 11 | return SessionFile( 12 | session_id="some_uuid", 13 | id="some_uuid", 14 | type=FileType.GENOME, 15 | format=FileFormat.FASTA, 16 | records=100, 17 | total_length=200, 18 | name="file_name.ext", 19 | name_original="original_file_name.ext", 20 | selections=Selections(), 21 | ) 22 | 23 | 24 | def create_mock_async_result( 25 | status: TaskStatus, ready: bool, success: bool, error_message: str 26 | ): 27 | mock_result = MagicMock() 28 | mock_result.status = status.value 29 | mock_result.ready.return_value = ready 30 | if success: 31 | mock_result.get.return_value = { 32 | "success": success, 33 | "result": mock_session_file().model_dump(), 34 | } 35 | else: 36 | mock_result.get.return_value = {"success": success, "error": error_message} 37 | return mock_result 38 | 39 | 40 | @patch("brick.api.endpoints.tasks.AsyncResult") 41 | def test_get_task_status(mock_async_result): 42 | mock_async_result.return_value = create_mock_async_result( 43 | TaskStatus.PENDING, True, True, "" 44 | ) 45 | response = client.get("/tasks/status/test_task_id") 46 | assert response.status_code == 200 47 | assert response.json() == { 48 | "task_id": "test_task_id", 49 | "status": TaskStatus.PENDING.value, 50 | } 51 | 52 | 53 | @patch("brick.api.endpoints.tasks.AsyncResult") 54 | def test_get_task_result_not_ready(mock_async_result): 55 | mock_async_result.return_value = create_mock_async_result( 56 | TaskStatus.PROCESSING, False, True, "" 57 | ) 58 | response = client.get("/tasks/result/test_task_id") 59 | assert response.status_code == 202 60 | assert response.json()["status"] == TaskStatus.PROCESSING.value 61 | 62 | 63 | @patch("brick.api.endpoints.tasks.AsyncResult") 64 | def test_get_task_result_success(mock_async_result): 65 | mock_async_result.return_value = create_mock_async_result( 66 | TaskStatus.SUCCESS, True, True, "" 67 | ) 68 | response = client.get("/tasks/result/test_task_id") 69 | assert response.status_code == 200 70 | assert response.json()["status"] == TaskStatus.SUCCESS.value 71 | 72 | 73 | @patch("brick.api.endpoints.tasks.AsyncResult") 74 | def test_get_task_result_failure(mock_async_result): 75 | error_message = "error_message" 76 | mock_async_result.return_value = create_mock_async_result( 77 | TaskStatus.FAILURE, True, False, error_message 78 | ) 79 | response = client.get("/tasks/result/test_task_id") 80 | assert response.status_code == 500 81 | assert response.json()["detail"] == error_message 82 | -------------------------------------------------------------------------------- /app/src/lib/session/controls/helpers/DeleteRing.svelte: -------------------------------------------------------------------------------- 1 | 60 | 61 |
62 |
63 | 64 |
65 | 70 |
71 |
72 |
-------------------------------------------------------------------------------- /app/src/lib/session/palette/ColorPicker.svelte: -------------------------------------------------------------------------------- 1 | 79 | 80 |
81 |
82 |
83 | selectColor(event)}/> 84 |
85 |
86 |
-------------------------------------------------------------------------------- /brick/api/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import FastAPI 4 | from fastapi.middleware.cors import CORSMiddleware 5 | 6 | from .core.config import lifespan, settings, Settings 7 | 8 | from .endpoints import files 9 | from .endpoints import sessions 10 | from .endpoints import tasks 11 | from .endpoints import rings 12 | 13 | from ..utils import enough_disk_space 14 | 15 | 16 | def init_working_directory(settings: Settings): 17 | 18 | logging.info("Initiating working directory") 19 | 20 | # Absolute working directory path 21 | try: 22 | settings.WORK_DIRECTORY = settings.WORK_DIRECTORY.resolve() 23 | except Exception as _: 24 | logging.error( 25 | f"Working directory path could not be resolved: {settings.WORK_DIRECTORY}" 26 | ) 27 | exit(1) 28 | 29 | # Create working directory 30 | if not settings.WORK_DIRECTORY.exists(): 31 | logging.warning(f"Working directory does not exist: {settings.WORK_DIRECTORY}") 32 | logging.warning( 33 | f"Attempting to create working directory path for server operations..." 34 | ) 35 | 36 | try: 37 | settings.WORK_DIRECTORY.mkdir(parents=True) 38 | logging.info(f"Working directory created at: {settings.WORK_DIRECTORY}") 39 | 40 | except Exception as _: 41 | logging.error( 42 | f"Working directory could not be created: {settings.WORK_DIRECTORY}" 43 | ) 44 | exit(1) 45 | 46 | # Disk space check 47 | if not enough_disk_space( 48 | path=settings.WORK_DIRECTORY, disk_space_limit_gb=settings.WORK_DISK_SPACE_GB 49 | ): 50 | # Print to docker log for immediate warning rather than logging error to file only 51 | print( 52 | f"Not enough disk space for working directory at {settings.WORK_DIRECTORY} (< {settings.WORK_DISK_SPACE_GB})", 53 | flush=True, 54 | ) 55 | 56 | logging.error("Not enough disk space for working directory") 57 | logging.error( 58 | f"Application requires at least {settings.WORK_DISK_SPACE_GB} gigabytes free disk space at {settings.WORK_DIRECTORY}" 59 | ) 60 | exit(1) 61 | else: 62 | logging.info( 63 | f"Sufficient disk space (>= {settings.WORK_DISK_SPACE_GB} GB) at working directory: {settings.WORK_DIRECTORY}" 64 | ) 65 | 66 | 67 | def init_api(): 68 | 69 | logging.info("Initiating FastAPI") 70 | 71 | init_working_directory(settings=settings) 72 | 73 | app = FastAPI(title="BRICK API", lifespan=lifespan) 74 | 75 | app.include_router(files.router) 76 | app.include_router(sessions.router) 77 | app.include_router(tasks.router) 78 | app.include_router(rings.router) 79 | 80 | # CORS 81 | app.add_middleware( 82 | CORSMiddleware, 83 | allow_origins=settings.CORS_ORIGINS, 84 | allow_credentials=False, 85 | allow_methods=["GET", "POST", "PUT", "DELETE"], 86 | allow_headers=[ 87 | "Content-Type", 88 | "Accept", 89 | "X-Requested-With", 90 | "User-Agent", 91 | "Cache-Control", 92 | "Expires", 93 | "Pragma", 94 | "X-CSRF-Token", 95 | "Access-Control-Allow-Headers", 96 | "Accept-Encoding", 97 | "Accept-Language", 98 | ], 99 | ) 100 | 101 | return app 102 | 103 | 104 | app = init_api() 105 | -------------------------------------------------------------------------------- /app/src/lib/session/controls/helpers/RingTitle.svelte: -------------------------------------------------------------------------------- 1 | 83 | 84 |
85 |
86 | {#if editing} 87 |
88 | 89 |
90 | {:else} 91 | {title} 92 | {/if} 93 |
94 |
-------------------------------------------------------------------------------- /brick/api/endpoints/rings.py: -------------------------------------------------------------------------------- 1 | from fastapi.responses import JSONResponse 2 | from fastapi import APIRouter, HTTPException 3 | 4 | from ..schemas import BlastRingSchema, BlastRingResponse 5 | from ..schemas import AnnotationRingSchema, AnnotationRingResponse 6 | from ..schemas import LabelRingSchema, LabelRingResponse 7 | from ..schemas import ReferenceRingSchema, ReferenceRingResponse 8 | from ..schemas import GenomadRingSchema, GenomadRingResponse 9 | from ..tasks import ( 10 | process_blast_ring, 11 | process_annotation_ring, 12 | process_label_ring, 13 | process_reference_ring, 14 | process_genomad_ring, 15 | ) 16 | 17 | 18 | router = APIRouter( 19 | prefix="/rings", 20 | tags=["ring"], 21 | ) 22 | 23 | 24 | @router.post("/reference", response_model=ReferenceRingResponse) 25 | def create_reference_ring(ring_config: ReferenceRingSchema): 26 | 27 | try: 28 | task = process_reference_ring.delay(ring_config.model_dump()) 29 | except Exception as e: 30 | raise HTTPException(status_code=500, detail=f"Error initiating task: {str(e)}") 31 | 32 | return JSONResponse( 33 | status_code=202, content=BlastRingResponse(task_id=task.id).model_dump() 34 | ) 35 | 36 | 37 | @router.post("/blast", response_model=BlastRingResponse) 38 | def create_blast_ring(ring_config: BlastRingSchema): 39 | 40 | _, reference_file, genome_file = ring_config.get_file_paths() 41 | 42 | try: 43 | task = process_blast_ring.delay( 44 | str(reference_file), str(genome_file), ring_config.model_dump() 45 | ) 46 | except Exception as e: 47 | raise HTTPException(status_code=500, detail=f"Error initiating task: {str(e)}") 48 | 49 | return JSONResponse( 50 | status_code=202, content=BlastRingResponse(task_id=task.id).model_dump() 51 | ) 52 | 53 | 54 | @router.post("/annotation", response_model=AnnotationRingResponse) 55 | def create_annotation_ring(ring_config: AnnotationRingSchema): 56 | 57 | _, genbank_file, tsv_file = ring_config.get_file_paths() 58 | 59 | try: 60 | task = process_annotation_ring.delay( 61 | str(genbank_file) if genbank_file else None, 62 | str(tsv_file) if tsv_file else None, 63 | ring_config.model_dump(), 64 | ) 65 | except Exception as e: 66 | raise HTTPException(status_code=500, detail=f"Error initiating task: {str(e)}") 67 | 68 | return JSONResponse( 69 | status_code=202, content=AnnotationRingResponse(task_id=task.id).model_dump() 70 | ) 71 | 72 | 73 | @router.post("/label", response_model=LabelRingResponse) 74 | def create_label_ring(ring_config: LabelRingSchema): 75 | 76 | _, tsv_file = ring_config.get_file_paths() 77 | 78 | try: 79 | task = process_label_ring.delay( 80 | str(tsv_file) if tsv_file else None, ring_config.model_dump() 81 | ) 82 | except Exception as e: 83 | raise HTTPException(status_code=500, detail=f"Error initiating task: {str(e)}") 84 | 85 | return JSONResponse( 86 | status_code=202, content=LabelRingResponse(task_id=task.id).model_dump() 87 | ) 88 | 89 | 90 | @router.post("/genomad", response_model=GenomadRingResponse) 91 | def create_label_ring(ring_config: GenomadRingSchema): 92 | 93 | _, reference_file = ring_config.get_file_paths() 94 | 95 | try: 96 | task = process_genomad_ring.delay(str(reference_file), ring_config.model_dump()) 97 | except Exception as e: 98 | raise HTTPException(status_code=500, detail=f"Error initiating task: {str(e)}") 99 | 100 | return JSONResponse( 101 | status_code=202, content=GenomadRingResponse(task_id=task.id).model_dump() 102 | ) 103 | -------------------------------------------------------------------------------- /docker/brick.env: -------------------------------------------------------------------------------- 1 | # Settings >>inside<< container environments >>not<< docker compose file! 2 | 3 | # These settings cannot be changed with command-line environment variables 4 | # set on-the-fly before running `docker compose` for example DOES NOT WORK: 5 | # `CELERY_THREAD_PER_WORKER=16 docker compose ...` - this is because the 6 | # stack deployment should be hard-configured in this file at the moment 7 | 8 | # ================== 9 | # Brick API Settings 10 | # ================== 11 | 12 | # Different working directory path if required 13 | # WORK_DIRECTORY=/data/brick-work 14 | 15 | # Minimum disk space required 16 | WORK_DISK_SPACE_GB=5 17 | 18 | # Maximum size of a session directory 19 | SESSION_MAX_SIZE_MB=200 20 | 21 | # Maximum number of files in a session directory 22 | SESSION_MAX_FILES=10000 23 | 24 | # ================== 25 | # Brick APP Settings 26 | # ================== 27 | 28 | # Sets the expected origin of requests 29 | # for the Sveltekit server (form actions) 30 | 31 | # https://kit.svelte.dev/docs/adapter-node#environment-variables 32 | 33 | # Localhost and port for development deployment, 34 | # domain for web deployment - note that DEV (5173) and 35 | # PROD (5174) are on different ports so they can be run 36 | # simultaneously as profiles in the docker-compose file 37 | 38 | # Precedence for docker-compose environment block 39 | # with localhost configuraton at the moment! 40 | 41 | # ORIGIN: http://localhost:5174 42 | 43 | PUBLIC_BRICK_VERSION=0.4.0 44 | 45 | # Request size limit to Sveltekit server 46 | # determines maximum file and other request 47 | # size (20MB) 48 | BODY_SIZE_LIMIT=20000000 49 | 50 | # This may need to be adjusted for longer 51 | # running tasks or slow connections. 52 | 53 | # Task results are checked with an exponential 54 | # random backoff function, which adjusts the 55 | # initial PRIVATE_CELERY_TASK_CHECK_INTERVAL 56 | 57 | # Timeout and interval defined in milliseconds 58 | 59 | # Celery task result check timeout (10m) 60 | PRIVATE_CELERY_TASK_CHECK_TIMEOUT=600000 61 | 62 | # Celery task result check interval (500ms) 63 | PRIVATE_CELERY_TASK_CHECK_INTERVAL=500 64 | 65 | 66 | # ===================== 67 | # Brick WORKER Settings 68 | # ===================== 69 | 70 | # Timeouts of task queue submitssions 71 | # may need to be adjusted depending on 72 | # long running task configurations, 73 | # soft timeout triggers first 74 | 75 | # Timeout defined in seconds 76 | 77 | CELERY_TASK_SOFT_TIMEOUT=12000 78 | CELERY_TASK_HARD_TIMEOUT=18000 79 | 80 | # Mostly relevant to set resource limits on production server, 81 | # can generally be ignored on local machine. 82 | 83 | # If not set, defaults to total number of cores on machine for threads 84 | # available to each worker process (CELERY_THREADS_PER_WORKER) and: 85 | 86 | # 1 thread per subprocess execution if CELERY_THREADS_PER_PROCESS <= 4 87 | # and all threads per process if CELERY_THREADS_PER_PROCESS > 4, 88 | 89 | # Fallback of 1 thread for CELERY_THREADS_PER_WORKER and CELERY_THREADS_PER_PROCESS 90 | # if number of cores on machine cannot be determined 91 | 92 | # Note that hard resource limits may be enforced by the deploy directive for containers, 93 | # and limits on memory are generally set depending on largest database size. If container 94 | # memory limits are exceeded, the worker will hard crash the container - a restart policy 95 | # is recommended. 96 | 97 | # CELERY_THREADS_PER_WORKER=8 98 | # CELERY_THREADS_PER_PROCESS=8 99 | 100 | # Memory control over `mmseqs` database in geNomad 101 | # may significantly increase runtime, check if the 102 | # timeout settings are appropriate: 103 | 104 | # - PRIVATE_CELERY_TASK_CHECK_TIMEOUT 105 | # - CELERY_TASK_SOFT_TIMEOUT 106 | # - CELERY_TASK_HARD_TIMEOUT 107 | 108 | # GENOMAD_SPLITS_ARG=4 -------------------------------------------------------------------------------- /app/src/lib/session/controls/rings/NewLabelRing.svelte: -------------------------------------------------------------------------------- 1 | 42 | 43 |
44 |

Label Ring

45 |

46 | 47 |

48 |

Label rings consist of text-annotations at the end of lines that point to the feature of interest. 49 | Labels are always added to the outer ring and can be added manually or using custom annotation files. If start and end values in the annotation 50 | file are different, their midpoint is used to draw the annotation line.

51 | 52 | {#if $ringReferenceStore} 53 |
54 |
55 | 56 | 66 | 67 | {#each ringConfig.labels as segment, idx} 68 | {ringConfig.labels.splice(idx, 1); ringConfig.labels = ringConfig.labels}}> 69 | {/each} 70 | 71 |
72 | 73 |
74 | 79 | 84 |
85 | 86 |
87 | {/if} 88 |
-------------------------------------------------------------------------------- /brick/api/endpoints/tasks.py: -------------------------------------------------------------------------------- 1 | from celery.result import AsyncResult 2 | from fastapi.responses import JSONResponse 3 | from fastapi import APIRouter, HTTPException 4 | 5 | from ..schemas import TaskStatus, TaskStatusResponse, TaskResultResponse 6 | from ..core.celery import celery_app 7 | from ..schemas import Session, SessionFile, FileFormat, TaskResultType 8 | from ...rings import ( 9 | ReferenceRing, 10 | BlastRing, 11 | AnnotationRing, 12 | LabelRing, 13 | GenomadRing, 14 | RingType, 15 | ) 16 | 17 | router = APIRouter( 18 | prefix="/tasks", 19 | tags=["task", "celery"], 20 | ) 21 | 22 | 23 | @router.get("/status/{task_id}", response_model=TaskStatusResponse) 24 | def get_task_status(task_id: str): 25 | """ 26 | Query file upload processing status 27 | """ 28 | task_result = AsyncResult(task_id, app=celery_app) 29 | 30 | return {"task_id": task_id, "status": task_result.status} 31 | 32 | 33 | @router.get("/result/{task_id}") 34 | def get_task_result(task_id: str): 35 | """ 36 | Query file upload processing result 37 | """ 38 | task_result = AsyncResult(task_id, app=celery_app) 39 | 40 | if not task_result.ready(): 41 | return JSONResponse( 42 | status_code=202, 43 | content=TaskResultResponse( 44 | task_id=task_id, 45 | status=TaskStatus.PROCESSING, 46 | result=None, 47 | result_type=None, 48 | ).model_dump(), 49 | ) 50 | 51 | result = task_result.get() 52 | 53 | if result["success"]: 54 | 55 | # Task result outputs are always dictionaries 56 | if isinstance(result["result"], dict): 57 | result_data = result["result"] 58 | else: 59 | raise TypeError("Output of the requested task was not a dictionary") 60 | 61 | try: 62 | result_model = get_result_model(result_data=result_data) 63 | except Exception as e: 64 | raise HTTPException(status_code=500, detail=str(e)) 65 | 66 | return JSONResponse( 67 | status_code=200, 68 | content=TaskResultResponse( 69 | task_id=task_id, 70 | status=TaskStatus.SUCCESS, 71 | result=result_model, 72 | result_type=TaskResultType.from_model( 73 | model=result_model 74 | ), # add types here if adding new tasks 75 | ).model_dump(), 76 | ) 77 | else: 78 | raise HTTPException(status_code=500, detail=result["error"]) 79 | 80 | 81 | def get_result_model( 82 | result_data: dict, 83 | ) -> ( 84 | Session 85 | | SessionFile 86 | | BlastRing 87 | | AnnotationRing 88 | | ReferenceRing 89 | | GenomadRing 90 | | LabelRing 91 | ): 92 | """ 93 | Identification of result models from a common result endpoint for tasks exceuted with Celery 94 | """ 95 | 96 | if "format" in result_data and any( 97 | result_data["format"] == item.value for item in FileFormat 98 | ): 99 | return SessionFile(**result_data) 100 | elif "type" in result_data and result_data["type"] == RingType.BLAST: 101 | return BlastRing(**result_data) 102 | elif "type" in result_data and result_data["type"] == RingType.ANNOTATION: 103 | return AnnotationRing(**result_data) 104 | elif "type" in result_data and result_data["type"] == RingType.LABEL: 105 | return LabelRing(**result_data) 106 | elif "type" in result_data and result_data["type"] == RingType.REFERENCE: 107 | return ReferenceRing(**result_data) 108 | elif "type" in result_data and result_data["type"] == RingType.GENOMAD: 109 | return GenomadRing(**result_data) 110 | elif "date" in result_data: 111 | return Session(**result_data) 112 | else: 113 | raise TypeError("Task result did not match a known model") 114 | -------------------------------------------------------------------------------- /app/src/lib/session/controls/helpers/RingIndex.svelte: -------------------------------------------------------------------------------- 1 | 71 | 72 |
73 |
74 |
75 | {#if direction === RingDirection.IN} 76 | 81 | {:else} 82 | 87 | {/if} 88 |
89 | 90 |
91 |
-------------------------------------------------------------------------------- /app/src/lib/session/controls/helpers/RingVisibility.svelte: -------------------------------------------------------------------------------- 1 | 65 | 66 |
67 | 68 |
69 | {#if visible} 70 | 76 | {:else} 77 | 82 | {/if} 83 |
84 |
-------------------------------------------------------------------------------- /app/src/lib/session/controls/helpers/RingSettings.svelte: -------------------------------------------------------------------------------- 1 | 52 | 53 |
54 |
55 | 56 | handleSubmitAction(event.detail) } on:selectColor={(event) => handleSelectColor(event.detail.color)}> 57 | 58 |
59 | handleSubmitAction(event.detail) } on:selectColor={(event) => handleSelectColor(event.detail.color)}> 60 |
61 | handleSubmitAction(event.detail) } on:toggleVisibility={handleToggleVisibility}> 62 | handleSubmitAction(event.detail) } on:updateTitle={(event) => handleUpdateTitle(event.detail.title)} /> 63 |
64 |
65 | {#if ring.type !== RingType.LABEL} 66 | {#if ring.index !== 0} 67 | handleSubmitAction(event.detail) } on:update={handleMoveRingInside} indexGroup={indexGroup}> 68 | {:else} 69 | 70 | {/if} 71 | {#if !((isRingTypePresent(RingType.LABEL) && ring.index === ringData.length-2) || ring.index === ringData.length-1)} 72 | handleSubmitAction(event.detail) } on:update={handleMoveRingOutside} indexGroup={indexGroup}> 73 | {:else} 74 | 75 | {/if} 76 | {/if} 77 | handleSubmitAction(event.detail)} on:delete={handleDeleteRing}> 78 |
79 |
-------------------------------------------------------------------------------- /app/src/routes/session/+layout.svelte: -------------------------------------------------------------------------------- 1 | 38 | 39 | 40 | 41 | 42 | 43 | BRICK {env.PUBLIC_BRICK_VERSION} 44 | {#if INFOBANNER} 45 |
{INFOBANNER}
46 | {/if} 47 |
48 | 49 | 55 | 59 | 65 | 66 |
67 | {#if $navigating || $requestInProgress} 68 | 69 | {:else} 70 | 71 |
72 | {/if} 73 |
74 | 75 | 76 |
77 | -------------------------------------------------------------------------------- /app/src/lib/session/modals/NewSessionModal.svelte: -------------------------------------------------------------------------------- 1 | 58 | 59 | 60 | {#if $modalStore[0]} 61 |
62 |
{$modalStore[0].title ?? defaultTitle}
63 |
64 |

Sessions and files are available on the current page for one week:

65 |
66 | 67 | {#if clipboardCopy} 68 | 69 | 74 | {:else} 75 | 80 | {/if} 81 |
82 |
83 | 84 |
85 | 86 | 87 |
88 |
89 | {/if} -------------------------------------------------------------------------------- /app/src/lib/session/palette/PaletteDisplay.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | 25 | 26 | 29 | 30 | 31 |

{palette.name}by {palette.author}

32 | 33 |
34 | 35 |
36 | {#each palette.palettes as subpalette, i } 37 |
handlePaletteClick(subpalette)} on:keydown={() => handlePaletteClick(subpalette) }> 38 | 39 | 40 | 41 | {#if $paletteStore.some(p => p.name === subpalette.name)} 42 |
43 | 48 |
49 | {/if} 50 |
51 | 52 | 53 | {/each} 54 |
55 |
56 |
-------------------------------------------------------------------------------- /app/themes/dali.ts: -------------------------------------------------------------------------------- 1 | import type { CustomThemeConfig } from '@skeletonlabs/tw-plugin'; 2 | 3 | export const momaDali: CustomThemeConfig = { 4 | name: 'dali', 5 | properties: { 6 | // =~= Theme Properties =~= 7 | "--theme-font-family-base": `system-ui`, 8 | "--theme-font-family-heading": `system-ui`, 9 | "--theme-font-color-base": "0 0 0", 10 | "--theme-font-color-dark": "255 255 255", 11 | "--theme-rounded-base": "24px", 12 | "--theme-rounded-container": "12px", 13 | "--theme-border-base": "1px", 14 | // =~= Theme On-X Colors =~= 15 | "--on-primary": "0 0 0", 16 | "--on-secondary": "0 0 0", 17 | "--on-tertiary": "255 255 255", 18 | "--on-success": "0 0 0", 19 | "--on-warning": "255 255 255", 20 | "--on-error": "255 255 255", 21 | "--on-surface": "255 255 255", 22 | // =~= Theme Colors =~= 23 | // primary | #b4b87f 24 | "--color-primary-50": "244 244 236", // #f4f4ec 25 | "--color-primary-100": "240 241 229", // #f0f1e5 26 | "--color-primary-200": "236 237 223", // #eceddf 27 | "--color-primary-300": "225 227 204", // #e1e3cc 28 | "--color-primary-400": "203 205 165", // #cbcda5 29 | "--color-primary-500": "180 184 127", // #b4b87f 30 | "--color-primary-600": "162 166 114", // #a2a672 31 | "--color-primary-700": "135 138 95", // #878a5f 32 | "--color-primary-800": "108 110 76", // #6c6e4c 33 | "--color-primary-900": "88 90 62", // #585a3e 34 | // secondary | #6ea8ab 35 | "--color-secondary-50": "233 242 242", // #e9f2f2 36 | "--color-secondary-100": "226 238 238", // #e2eeee 37 | "--color-secondary-200": "219 233 234", // #dbe9ea 38 | "--color-secondary-300": "197 220 221", // #c5dcdd 39 | "--color-secondary-400": "154 194 196", // #9ac2c4 40 | "--color-secondary-500": "110 168 171", // #6ea8ab 41 | "--color-secondary-600": "99 151 154", // #63979a 42 | "--color-secondary-700": "83 126 128", // #537e80 43 | "--color-secondary-800": "66 101 103", // #426567 44 | "--color-secondary-900": "54 82 84", // #365254 45 | // tertiary | #8f5715 46 | "--color-tertiary-50": "238 230 220", // #eee6dc 47 | "--color-tertiary-100": "233 221 208", // #e9ddd0 48 | "--color-tertiary-200": "227 213 197", // #e3d5c5 49 | "--color-tertiary-300": "210 188 161", // #d2bca1 50 | "--color-tertiary-400": "177 137 91", // #b1895b 51 | "--color-tertiary-500": "143 87 21", // #8f5715 52 | "--color-tertiary-600": "129 78 19", // #814e13 53 | "--color-tertiary-700": "107 65 16", // #6b4110 54 | "--color-tertiary-800": "86 52 13", // #56340d 55 | "--color-tertiary-900": "70 43 10", // #462b0a 56 | // success | #8B9C3E 57 | "--color-success-50": "238 240 226", // #eef0e2 58 | "--color-success-100": "232 235 216", // #e8ebd8 59 | "--color-success-200": "226 230 207", // #e2e6cf 60 | "--color-success-300": "209 215 178", // #d1d7b2 61 | "--color-success-400": "174 186 120", // #aeba78 62 | "--color-success-500": "139 156 62", // #8B9C3E 63 | "--color-success-600": "125 140 56", // #7d8c38 64 | "--color-success-700": "104 117 47", // #68752f 65 | "--color-success-800": "83 94 37", // #535e25 66 | "--color-success-900": "68 76 30", // #444c1e 67 | // warning | #397893 68 | "--color-warning-50": "225 235 239", // #e1ebef 69 | "--color-warning-100": "215 228 233", // #d7e4e9 70 | "--color-warning-200": "206 221 228", // #cedde4 71 | "--color-warning-300": "176 201 212", // #b0c9d4 72 | "--color-warning-400": "116 161 179", // #74a1b3 73 | "--color-warning-500": "57 120 147", // #397893 74 | "--color-warning-600": "51 108 132", // #336c84 75 | "--color-warning-700": "43 90 110", // #2b5a6e 76 | "--color-warning-800": "34 72 88", // #224858 77 | "--color-warning-900": "28 59 72", // #1c3b48 78 | // error | #C4541D 79 | "--color-error-50": "246 229 221", // #f6e5dd 80 | "--color-error-100": "243 221 210", // #f3ddd2 81 | "--color-error-200": "240 212 199", // #f0d4c7 82 | "--color-error-300": "231 187 165", // #e7bba5 83 | "--color-error-400": "214 135 97", // #d68761 84 | "--color-error-500": "196 84 29", // #C4541D 85 | "--color-error-600": "176 76 26", // #b04c1a 86 | "--color-error-700": "147 63 22", // #933f16 87 | "--color-error-800": "118 50 17", // #763211 88 | "--color-error-900": "96 41 14", // #60290e 89 | // surface | #495a8f 90 | "--color-surface-50": "228 230 238", // #e4e6ee 91 | "--color-surface-100": "219 222 233", // #dbdee9 92 | "--color-surface-200": "210 214 227", // #d2d6e3 93 | "--color-surface-300": "182 189 210", // #b6bdd2 94 | "--color-surface-400": "128 140 177", // #808cb1 95 | "--color-surface-500": "73 90 143", // #6ea8ab 96 | "--color-surface-600": "66 81 129", // #425181 97 | "--color-surface-700": "55 68 107", // #37446b 98 | "--color-surface-800": "44 54 86", // #2c3656 99 | "--color-surface-900": "36 44 70", // #242c46 100 | 101 | } 102 | } -------------------------------------------------------------------------------- /brick/api/client.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import logging 3 | 4 | from datetime import datetime, timedelta 5 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 6 | from apscheduler.triggers.cron import CronTrigger 7 | from typing import AsyncGenerator 8 | 9 | from .schemas import Session 10 | 11 | 12 | class ApiClient: 13 | def __init__(self, base_url: str): 14 | self.base_url = base_url 15 | self.client = httpx.AsyncClient() 16 | 17 | async def get_session_ids(self): 18 | try: 19 | response = await self.client.get(f"{self.base_url}/sessions/identifiers") 20 | if response.status_code == 200: 21 | return response.json() 22 | else: 23 | logging.error(f"Failed to fetch session identifiers: {response.text}") 24 | return [] 25 | except Exception as e: 26 | logging.error(f"{e}") 27 | return [] 28 | 29 | async def delete_session(self, session_id: str, session_data: bool = True): 30 | 31 | try: 32 | response = await self.client.delete( 33 | f"{self.base_url}/sessions/{session_id}", 34 | params={"session_data": session_data}, 35 | ) 36 | return response.status_code, response.text 37 | except Exception as e: 38 | logging.error(f"{e}") 39 | return None, str(e) 40 | 41 | async def get_session(self, session_id: str): 42 | try: 43 | response = await self.client.get(f"{self.base_url}/sessions/{session_id}") 44 | if response.status_code == 200: 45 | return response.json() # Assuming the response is JSON serializable 46 | else: 47 | logging.error(f"Failed to fetch session {session_id}: {response.text}") 48 | return None 49 | except Exception as e: 50 | logging.error(f"Error fetching session {session_id}: {e}") 51 | return None 52 | 53 | async def close(self): 54 | await self.client.aclose() 55 | 56 | 57 | # Database cleaner 58 | 59 | 60 | async def fetch_sessions_generator( 61 | api_client: ApiClient, logger: logging.Logger | None = None 62 | ) -> AsyncGenerator[Session, None]: 63 | if logger: 64 | logger.info(f"Requesting session identifiers from API") 65 | 66 | session_ids = await api_client.get_session_ids() 67 | 68 | if logger: 69 | logger.info(f"Obtained {len(session_ids)} session identifiers from API") 70 | 71 | for session_id in session_ids: 72 | session_data = await api_client.get_session(session_id=session_id) 73 | if session_data: 74 | yield Session(**session_data) # full session data 75 | 76 | 77 | async def check_and_delete_expired_sessions( 78 | api_client: ApiClient, expire_days: int, logger: logging.Logger | None = None 79 | ): 80 | 81 | current_datetime = datetime.now() 82 | expiration_datetime = current_datetime - timedelta(days=expire_days) 83 | 84 | async for session in fetch_sessions_generator(api_client=api_client, logger=logger): 85 | session_date = datetime.fromisoformat(session.date) 86 | if session_date < expiration_datetime: 87 | if logger: 88 | logger.info(f"Session {session.id} is expired and will be deleted") 89 | 90 | status_code, response_text = await api_client.delete_session( 91 | session_id=session.id, session_data=True 92 | ) # delete session directory 93 | 94 | if status_code == 200: 95 | if logger: 96 | logger.info(f"Session {session.id} deleted successfully") 97 | else: 98 | if logger: 99 | logger.error( 100 | f"Failed to delete session {session.id}: {response_text}" 101 | ) 102 | else: 103 | if logger: 104 | logger.info(f"Session {session.id} has not yet expired") 105 | 106 | 107 | def schedule_session_cleanup( 108 | api_client: ApiClient, 109 | logger: logging.Logger | None = None, 110 | expire_days: int = 7, 111 | day_of_week: str = "*", 112 | time_of_day: str = "04:00", 113 | ): 114 | """ 115 | Schedule the session cleanup and start the scheduler 116 | """ 117 | 118 | async def job(): 119 | await check_and_delete_expired_sessions( 120 | api_client=api_client, expire_days=expire_days, logger=logger 121 | ) 122 | await api_client.close() 123 | 124 | scheduler = AsyncIOScheduler() 125 | scheduler.add_job( 126 | job, 127 | trigger=CronTrigger( 128 | day_of_week=day_of_week, 129 | hour=time_of_day.split(":")[0], 130 | minute=time_of_day.split(":")[1], 131 | ), 132 | ) 133 | scheduler.start() 134 | -------------------------------------------------------------------------------- /app/src/lib/brick/helpers.ts: -------------------------------------------------------------------------------- 1 | import { browser } from "$app/environment"; 2 | import { getTransitionDurationTotal } from "$lib/stores/PlotConfigStore"; 3 | import type { Ring, Session } from "$lib/types"; 4 | 5 | export function downloadSVG(id: string) { 6 | const svg = document.querySelector(`#${id} svg`); 7 | 8 | if (!svg) { 9 | console.error(`SVG element not found: ${id} svg`); 10 | return; 11 | } 12 | 13 | const serializer = new XMLSerializer(); 14 | const source = serializer.serializeToString(svg); 15 | 16 | const a = document.createElement('a'); 17 | a.href = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source); 18 | a.download = 'brick.svg'; 19 | a.click(); 20 | 21 | } 22 | 23 | export async function downloadPNG(id: string) { 24 | 25 | // await waitForTransition(); 26 | 27 | const svgElement = document.getElementById(id)?.querySelector('svg'); 28 | if (!svgElement) { 29 | console.error(`SVG element not found: ${id} > svg`); 30 | return; 31 | } 32 | 33 | const viewBox = svgElement.getAttribute('viewBox'); 34 | const [x, y, width, height] = viewBox ? viewBox.split(' ').map(Number) : [0, 0, svgElement.clientWidth, svgElement.clientHeight]; 35 | 36 | const serializer = new XMLSerializer(); 37 | const svgString = serializer.serializeToString(svgElement); 38 | 39 | const img = new Image(); 40 | const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); 41 | const url = URL.createObjectURL(svgBlob); 42 | 43 | img.onload = () => { 44 | const canvas = document.createElement('canvas'); 45 | canvas.width = width; 46 | canvas.height = height; 47 | const ctx = canvas.getContext('2d'); 48 | 49 | if (!ctx) { 50 | console.error('2D context not found for canvas'); 51 | return; 52 | } 53 | 54 | ctx.drawImage(img, -x, -y, width, height); 55 | URL.revokeObjectURL(url); 56 | 57 | const pngData = canvas.toDataURL('image/png'); 58 | const downloadLink = document.createElement('a'); 59 | downloadLink.href = pngData; 60 | downloadLink.download = 'brick.png'; 61 | document.body.appendChild(downloadLink); 62 | downloadLink.click(); 63 | document.body.removeChild(downloadLink); 64 | }; 65 | 66 | img.src = url; 67 | } 68 | 69 | 70 | // Helper function to wait for transitions to complete - might not be needed 71 | function waitForTransition() { 72 | return new Promise(resolve => { 73 | // Assuming a fixed duration for the transition 74 | const transitionDuration = getTransitionDurationTotal(); // Adjust as needed 75 | setTimeout(resolve, transitionDuration); 76 | }); 77 | } 78 | 79 | export function downloadJSON(data: Ring[] | Session) { 80 | 81 | // Convert data to JSON string with 2-space indent 82 | const jsonString = JSON.stringify(data, null, 2); 83 | 84 | // Create a Blob from the JSON string 85 | const blob = new Blob([jsonString], { type: 'application/json' }); 86 | 87 | // Create a URL for the Blob 88 | const url = URL.createObjectURL(blob); 89 | 90 | // Create a temporary anchor element and trigger download 91 | const a = document.createElement('a'); 92 | a.href = url; 93 | a.download = 'brick.json'; // Name of the downloaded file 94 | document.body.appendChild(a); // Append the anchor to the document 95 | a.click(); // Trigger a click on the element to start download 96 | 97 | // Clean up: remove the anchor element and revoke the Blob URL 98 | document.body.removeChild(a); 99 | URL.revokeObjectURL(url); 100 | } 101 | 102 | export function getDefaultScaleFactor() { 103 | 104 | if (browser){ 105 | const windowWidth = window.innerWidth; 106 | 107 | // Tailwind breakpoints for window sizes 108 | const breakpoints = { 109 | xs: 480, // Extra small devices (portrait phones) 110 | sm: 640, // Small devices (landscape phones) 111 | md: 768, // Medium devices (tablets) 112 | lg: 1024, // Large devices (laptops/desktops) 113 | xl: 1280, // Extra large devices (large laptops and desktops) 114 | xxl: 1536, // Bigger desktops 115 | xxxl: 1920, // Full HD and larger screens 116 | uhd: 2560, // 2K, QHD, and some larger screens 117 | uhd4k: 3840 // 4K UHD screens 118 | }; 119 | 120 | // Determine scaleFactor based on breakpoints 121 | // used by Tailwind for standard devices 122 | if (windowWidth < breakpoints.md) { 123 | return 0.4; 124 | } else if (windowWidth < breakpoints.lg) { 125 | return 0.5; 126 | } else if (windowWidth < breakpoints.xl) { 127 | return 0.6; 128 | } else if (windowWidth < breakpoints.xxl) { 129 | return 0.7; 130 | } else if (windowWidth < breakpoints.xxxl) { 131 | return 0.8; 132 | } else if (windowWidth < breakpoints.uhd) { 133 | return 0.9; 134 | } else if (windowWidth < breakpoints.uhd4k) { 135 | return 1.0; 136 | } else { 137 | return 1.1; 138 | } 139 | } else { 140 | return 1.0 141 | } 142 | 143 | } -------------------------------------------------------------------------------- /app/src/lib/session/controls/rings/NewAnnotationRing.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 |
49 |

Annotation Ring

50 |

51 | 52 |

53 |

Annotation rings consist of segments representing features along the 54 | selected reference genome. Annotations can be extracted from Genbank or custom table files.

55 | 56 | {#if $ringReferenceStore} 57 |
58 |
59 |
60 | 70 |
71 |
72 | 82 |
83 |
84 | 94 | 95 |
96 |
97 | 98 |
99 | 104 | 105 | {#if !sessionFileTypeAvailable(FileType.ANNOTATION_GENBANK) && !sessionFileTypeAvailable(FileType.ANNOTATION_CUSTOM)} 106 |
Please upload a reference annotation file
107 | {/if} 108 |
109 |
110 | {/if} 111 |
-------------------------------------------------------------------------------- /brick/api/core/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from fastapi import FastAPI 5 | from typing import Dict 6 | from pathlib import Path 7 | from contextlib import asynccontextmanager 8 | from pydantic import AnyHttpUrl, field_validator, model_validator 9 | from pydantic_settings import BaseSettings 10 | from typing import List, Optional 11 | from pathlib import Path 12 | 13 | from ...utils import get_cpu_count, get_process_threads 14 | 15 | 16 | class Settings(BaseSettings): 17 | 18 | # Basic application configuration 19 | APP_NAME: str = "BRICK" 20 | APP_VERSION: str = "0.1.0" 21 | API_PREFIX: str = "/api" 22 | DEBUG_MODE: bool = False 23 | 24 | # Secret key for instance 25 | SECRET_KEY: str = "CURRENTLY_NOT_USED" 26 | 27 | # Working directory for session and tasks 28 | WORK_DIRECTORY: Path = Path(f"/tmp/brick-work") 29 | WORK_DISK_SPACE_GB: float = 5 30 | 31 | # Session directory limits 32 | SESSION_MAX_SIZE_MB: int = 200 33 | SESSION_MAX_FILES: int = 10000 34 | 35 | # Celery configuration 36 | CELERY_BROKER_URL: str = "redis://redis:6379/0" 37 | CELERY_RESULT_BACKEND: str = "redis://redis:6379/1" 38 | CELERY_THREADS_PER_WORKER: int | None = None 39 | CELERY_THREADS_PER_PROCESS: int | None = None 40 | CELERY_TASK_SOFT_TIMEOUT: int = 12000 41 | CELERY_TASK_HARD_TIMEOUT: int = 18000 42 | 43 | # CORS configuration 44 | CORS_ORIGINS: List[str] | str = ["http://app:5173"] 45 | 46 | # Database configuration 47 | MONGODB_URL: str = "" 48 | MONGODB_USERNAME: str = "" 49 | MONGODB_PASSWORD: str = "" 50 | MONGODB_DATABASE: str = "brick" 51 | MONGODB_SESSION_COLLECTION: str = "sessions" 52 | 53 | # Local database in the database:/data volume 54 | GENOMAD_DATABASE: Path = Path("/data/genomad_db") 55 | GENOMAD_SPLITS_ARG: int | None = None 56 | 57 | class ConfigDict: 58 | case_sensitive = True 59 | 60 | @field_validator("CORS_ORIGINS", mode="before") 61 | def assemble_cors_origins(cls, v: Optional[str]) -> List[AnyHttpUrl]: 62 | if isinstance(v, str) and not v.startswith("["): 63 | return [i.strip() for i in v.split(",")] 64 | elif isinstance(v, (list, str)): 65 | return v 66 | return [] 67 | 68 | @field_validator("MONGODB_USERNAME", mode="before") 69 | def get_mongodb_secret_username(cls, v: str): 70 | if v: 71 | path = Path(v) 72 | if path.is_file() and path.exists(): 73 | v = path.read_text().strip("\n") 74 | return v 75 | 76 | @field_validator("MONGODB_PASSWORD", mode="before") 77 | def get_mongodb_secret_pwd(cls, v: str): 78 | if v and Path(v).exists(): 79 | v = Path(v).read_text().strip("\n") 80 | return v 81 | 82 | @field_validator("GENOMAD_DATABASE", mode="after") 83 | def check_genomad_database(cls, v: Path): 84 | if v and not v.exists(): 85 | logging.warning( 86 | f"geNomad database directory not found! ({v})" 87 | ) # tests run on action runner must avoid pulling database 88 | logging.warning( 89 | f"Attempts to execute `genomad` will fail in the `process_genomad_ring` worker!" 90 | ) 91 | return v 92 | 93 | @model_validator(mode="after") 94 | def get_default_mongodb_url(self) -> "Settings": 95 | if not self.MONGODB_URL: 96 | self.MONGODB_URL = f"mongodb://{self.MONGODB_USERNAME}:{self.MONGODB_PASSWORD}@mongodb:27017?authSource=admin" 97 | return self 98 | 99 | @model_validator(mode="after") 100 | def get_resource_config(self) -> "Settings": 101 | if not self.CELERY_THREADS_PER_WORKER: 102 | self.CELERY_THREADS_PER_WORKER = get_cpu_count( 103 | fallback=1 104 | ) # if CPU count cannot be determined use 1 thread 105 | print( 106 | f"Set default threads per worker {self.CELERY_THREADS_PER_WORKER}", 107 | flush=True, 108 | ) 109 | if not self.CELERY_THREADS_PER_PROCESS: 110 | self.CELERY_THREADS_PER_PROCESS = get_process_threads( 111 | fraction=1, fallback=2 112 | ) # if CPU counts <= 4 use 1 thread, else use fraction of total (all available) 113 | print( 114 | f"Set default threads per process {self.CELERY_THREADS_PER_PROCESS}", 115 | flush=True, 116 | ) 117 | return self 118 | 119 | 120 | def get_settings(): 121 | logging.info("Initiating settings for FastAPI") 122 | return Settings() 123 | 124 | 125 | # Global settings intitiation note that this will initiate 126 | # settings for any execution of tasks in the CLI 127 | settings = get_settings() 128 | 129 | # Default session for session endpoint 130 | DEFAULT_SESSIONS: Dict[str, dict] = {} 131 | 132 | 133 | def read_default_session(path: Path) -> Dict | None: 134 | session_data = None 135 | if path.exists() and path.is_file(): 136 | with path.open() as default_session: 137 | session_data = json.load(default_session) 138 | logging.info("Loaded default session for session endpoint") 139 | else: 140 | logging.info(f"Could not find default session for session endpoint ({path})") 141 | 142 | return session_data 143 | 144 | 145 | @asynccontextmanager 146 | async def lifespan(_: FastAPI): 147 | DEFAULT_SESSIONS["default"] = read_default_session( 148 | path=Path("default.json") # /app in container 149 | ) 150 | yield 151 | DEFAULT_SESSIONS.clear() 152 | -------------------------------------------------------------------------------- /app/src/lib/session/palette/PalettePopup.svelte: -------------------------------------------------------------------------------- 1 | 91 | 92 |
93 | 94 | 99 | 100 |
101 | 102 |
103 |
104 |

Palette selections

105 | 106 | 111 | 112 |
113 |
114 | {#each $paletteStore as palette} 115 |
116 | 117 |
118 | {/each} 119 |
120 |
121 |
122 |
-------------------------------------------------------------------------------- /app/src/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | 2 | import { TaskStatus, type PydanticValidationError, type TaskStatusResponse } from './types'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | export const createUuid = (short: boolean = false) => { 6 | return short ? uuidv4().substring(0, 8) : uuidv4() 7 | } 8 | 9 | export const createSessionId = () => { return uuidv4() }; 10 | export const shortenSessionId = (sessionId: string): string => { return sessionId.substring(0,8) }; 11 | 12 | 13 | export async function checkCeleryResults( 14 | url: string, timeout: number | string = 600000, pollingInterval: number | string = 100, backoffTimeout: number = 15000 15 | ): Promise { 16 | 17 | if (typeof timeout === 'string'){ 18 | timeout = parseEnvInt(timeout, 600000, 'PRIVATE_CELERY_TASK_CHECK_TIMEOUT'); 19 | } 20 | 21 | let pollingIntervalNumber: number; 22 | if (typeof pollingInterval === 'string'){ 23 | pollingIntervalNumber = parseEnvInt(pollingInterval, 1000, 'PRIVATE_CELERY_TASK_CHECK_INTERVAL'); 24 | } 25 | 26 | const startTime = new Date().getTime(); 27 | let attempts = 0; 28 | 29 | const timeoutPromise = new Promise((_, reject) => 30 | setTimeout(() => reject(new Error('Task result requests timed out')), timeout) 31 | ); 32 | 33 | const statusCheck = async (): Promise => { 34 | while (true) { 35 | const currentTime = new Date().getTime(); 36 | if (currentTime - startTime > timeout) { 37 | throw new Error('Task result requests timed out'); 38 | } 39 | 40 | const response = await fetch(url); 41 | const data: TaskStatusResponse = await response.json(); 42 | 43 | if (!response.ok) { 44 | throw new Error(`${data.detail ? data.detail : `${response.status}`}`); 45 | } 46 | 47 | if (data.status === TaskStatus.SUCCESS) { 48 | return data; 49 | } 50 | 51 | await new Promise(resolve => setTimeout(resolve, calculateBackoff(attempts))); 52 | attempts++; 53 | } 54 | }; 55 | 56 | function calculateBackoff(attempts: number): number { 57 | const maxInterval = Math.min(backoffTimeout, (pollingIntervalNumber * (2 ** attempts)) + Math.floor(Math.random() * 1000)); 58 | return maxInterval; 59 | } 60 | 61 | return await Promise.race([statusCheck(), timeoutPromise]); 62 | } 63 | 64 | 65 | // Error handling from unknown type error response in try/catch statements 66 | 67 | type ErrorWithMessage = { 68 | message: string 69 | } 70 | 71 | function isErrorWithMessage(error: unknown): error is ErrorWithMessage { 72 | return ( 73 | typeof error === 'object' && 74 | error !== null && 75 | 'message' in error && 76 | typeof (error as Record).message === 'string' 77 | ) 78 | } 79 | 80 | function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { 81 | if (isErrorWithMessage(maybeError)) return maybeError 82 | 83 | try { 84 | return new Error(JSON.stringify(maybeError)) 85 | } catch { 86 | // Fallback in case there's an error stringifying the maybeError 87 | // like with circular references for example. 88 | return new Error(String(maybeError)) 89 | } 90 | } 91 | 92 | export function getErrorMessage(error: unknown): string { 93 | return toErrorWithMessage(error).message 94 | } 95 | 96 | 97 | // Notification toast helper 98 | 99 | export enum ToastType { 100 | ERROR = "error", 101 | SUCCESS = "success", 102 | WARNING = "warning", 103 | DEFAULT = "default" 104 | } 105 | 106 | export function triggerToast(message: string, toastType: ToastType, toastStore: any, backgroundDefault: string = 'variant-filled-primary', timeoutDefault: number = 5000) { 107 | 108 | if (toastType === ToastType.ERROR) { 109 | toastStore.trigger({ 110 | message: message, 111 | background: 'variant-filled-error', 112 | timeout: 5000, 113 | }) 114 | } else if (toastType === ToastType.SUCCESS) { 115 | toastStore.trigger({ 116 | message: message, 117 | background: 'variant-filled-success', 118 | timeout: 3000, 119 | }) 120 | } else if (toastType === ToastType.WARNING) { 121 | toastStore.trigger({ 122 | message: message, 123 | background: 'variant-filled-warning', 124 | timeout: 5000, 125 | }) 126 | } else { 127 | toastStore.trigger({ 128 | message: message, 129 | background: backgroundDefault, 130 | timeout: timeoutDefault, 131 | }) 132 | } 133 | } 134 | 135 | 136 | export const parseEnvInt = (envVar: string, defaultValue: number, varName: string): number => { 137 | 138 | const parsedValue = parseInt(envVar); 139 | if (isNaN(parsedValue)) { 140 | console.warn(`Failed to parse environment variable string ${varName}. Using default value: ${defaultValue}`); 141 | return defaultValue; 142 | } 143 | 144 | return parsedValue; 145 | }; 146 | 147 | export function isValidUUIDv4(uuid: string): boolean { 148 | const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; 149 | return regex.test(uuid); 150 | } 151 | 152 | 153 | export function handleEndpointErrorResponse(detail: string | PydanticValidationError[], toastStore: any) { 154 | if (typeof detail === 'string'){ 155 | toastStore.trigger({ 156 | message: detail, 157 | background: 'variant-filled-error', 158 | timeout: 5000, 159 | }) 160 | } else if (Array.isArray(detail) && detail.every(element => 161 | typeof element === 'object' && 162 | element !== null && 163 | 'ctx' in element && 164 | 'loc' in element && 165 | 'msg' in element && 166 | 'type' in element && 167 | 'url' in element 168 | )) { 169 | for (let pydanticError of detail) { 170 | toastStore.trigger({ 171 | message: pydanticError.msg, 172 | background: 'variant-filled-error', 173 | timeout: 5000, 174 | }) 175 | } 176 | } else { 177 | toastStore.trigger({ 178 | message: "Error: an unexpected response error occurred :(", 179 | background: 'variant-filled-error', 180 | timeout: 5000, 181 | }) 182 | } 183 | 184 | 185 | } 186 | 187 | export function capitalize(str: string): string { 188 | if (str.length === 0) return str; 189 | return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); 190 | } -------------------------------------------------------------------------------- /tests/api_tests/test_endpoint_rings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import shutil 3 | import uuid 4 | 5 | from pathlib import Path 6 | from httpx import AsyncClient 7 | from unittest.mock import AsyncMock, patch 8 | from brick.api.main import app, settings 9 | 10 | from brick.rings import RingReference 11 | from brick.api.schemas import BlastRingSchema, BlastRingResponse 12 | from brick.api.schemas import AnnotationRingSchema, AnnotationRingResponse 13 | from brick.api.schemas import LabelRingSchema, LabelRingResponse 14 | 15 | 16 | def create_mock_session_directory(session_id: str) -> Path: 17 | tmp = Path(settings.WORK_DIRECTORY / session_id) 18 | tmp.mkdir(parents=True) 19 | return tmp 20 | 21 | 22 | def create_mock_session_file(session_id: str) -> Path: 23 | file_path = settings.WORK_DIRECTORY / session_id / str(uuid.uuid4()) 24 | file_path.touch() 25 | return file_path 26 | 27 | 28 | @pytest.fixture() 29 | def mock_label_ring_data(): 30 | 31 | mock_label_ring_tmpdir = create_mock_session_directory(str(uuid.uuid4())) 32 | session_id = mock_label_ring_tmpdir.name 33 | 34 | mock_label_ring_schema = LabelRingSchema( 35 | reference=RingReference(session_id=session_id), 36 | tsv_id=create_mock_session_file(session_id=session_id).name, 37 | ) 38 | 39 | mock_label_ring_response = LabelRingResponse(task_id="some_task_id") 40 | 41 | yield mock_label_ring_schema, mock_label_ring_response 42 | 43 | # Cleanup after tests deleting temporary session directory tree 44 | shutil.rmtree(mock_label_ring_tmpdir) 45 | 46 | 47 | @pytest.mark.asyncio 48 | @patch("brick.api.endpoints.rings.process_label_ring") 49 | async def test_create_label_ring_success(mock_process_label_ring, mock_label_ring_data): 50 | mock_label_ring_schema, mock_label_ring_response = mock_label_ring_data 51 | mock_process_label_ring.delay.return_value = AsyncMock( 52 | id=mock_label_ring_response.task_id 53 | ) 54 | 55 | async with AsyncClient(app=app, base_url="http://test") as ac: 56 | response = await ac.post( 57 | "/rings/label", json=mock_label_ring_schema.model_dump() 58 | ) 59 | assert response.status_code == 202 60 | assert response.json() == mock_label_ring_response.model_dump() 61 | 62 | 63 | @pytest.fixture() 64 | def mock_annotation_ring_data(): 65 | 66 | mock_annotation_ring_tmpdir = create_mock_session_directory(str(uuid.uuid4())) 67 | session_id = mock_annotation_ring_tmpdir.name 68 | 69 | mock_annotation_ring_schema = AnnotationRingSchema( 70 | reference=RingReference(session_id=session_id), 71 | tsv_id=create_mock_session_file(session_id=session_id).name, 72 | ) 73 | 74 | mock_annotation_ring_response = AnnotationRingResponse(task_id="some_task_id") 75 | 76 | yield mock_annotation_ring_schema, mock_annotation_ring_response 77 | 78 | # Cleanup after tests deleting temporary session directory tree 79 | shutil.rmtree(mock_annotation_ring_tmpdir) 80 | 81 | 82 | @pytest.mark.asyncio 83 | @patch("brick.api.endpoints.rings.process_annotation_ring") 84 | async def test_create_annotation_ring_success( 85 | mock_process_annotation_ring, mock_annotation_ring_data 86 | ): 87 | mock_annotation_ring_schema, mock_annotation_ring_response = ( 88 | mock_annotation_ring_data 89 | ) 90 | 91 | mock_process_annotation_ring.delay.return_value = AsyncMock( 92 | id=mock_annotation_ring_response.task_id 93 | ) 94 | 95 | async with AsyncClient(app=app, base_url="http://test") as ac: 96 | response = await ac.post( 97 | "/rings/annotation", json=mock_annotation_ring_schema.model_dump() 98 | ) 99 | assert response.status_code == 202 100 | assert response.json() == mock_annotation_ring_response.model_dump() 101 | 102 | 103 | @pytest.mark.asyncio 104 | @patch("brick.api.endpoints.rings.process_annotation_ring") 105 | async def test_create_annotation_ring_failure( 106 | mock_process_annotation_ring, mock_annotation_ring_data 107 | ): 108 | mock_annotation_ring_schema, _ = mock_annotation_ring_data 109 | mock_process_annotation_ring.delay.side_effect = Exception("Some error") 110 | 111 | async with AsyncClient(app=app, base_url="http://test") as ac: 112 | response = await ac.post( 113 | "/rings/annotation", json=mock_annotation_ring_schema.model_dump() 114 | ) 115 | assert response.status_code == 500 116 | 117 | 118 | @pytest.fixture() 119 | def mock_blast_ring_data(): 120 | 121 | mock_blast_ring_tmpdir = create_mock_session_directory(str(uuid.uuid4())) 122 | session_id = mock_blast_ring_tmpdir.name 123 | 124 | mock_blast_ring_schema = BlastRingSchema( 125 | reference=RingReference( 126 | session_id=session_id, 127 | reference_id=create_mock_session_file(session_id=session_id).name, 128 | ), 129 | genome_id=create_mock_session_file(session_id=session_id).name, 130 | ) 131 | 132 | mock_blast_ring_response = BlastRingResponse(task_id="some_task_id") 133 | 134 | yield mock_blast_ring_schema, mock_blast_ring_response 135 | 136 | # Cleanup after tests deleting temporary session directory tree 137 | shutil.rmtree(mock_blast_ring_tmpdir) 138 | 139 | 140 | @pytest.mark.asyncio 141 | @patch("brick.api.endpoints.rings.process_blast_ring") 142 | async def test_create_blast_ring_success(mock_process_blast_ring, mock_blast_ring_data): 143 | mock_blast_ring_schema, mock_blast_ring_response = mock_blast_ring_data 144 | mock_process_blast_ring.delay.return_value = AsyncMock( 145 | id=mock_blast_ring_response.task_id 146 | ) 147 | 148 | async with AsyncClient(app=app, base_url="http://test") as ac: 149 | response = await ac.post( 150 | "/rings/blast", json=mock_blast_ring_schema.model_dump() 151 | ) 152 | assert response.status_code == 202 153 | assert response.json() == mock_blast_ring_response.model_dump() 154 | 155 | 156 | @pytest.mark.asyncio 157 | @patch("brick.api.endpoints.rings.process_blast_ring") 158 | async def test_create_blast_ring_failure(mock_process_blast_ring, mock_blast_ring_data): 159 | mock_blast_ring_schema, _ = mock_blast_ring_data 160 | mock_process_blast_ring.delay.side_effect = Exception("Some error") 161 | 162 | async with AsyncClient(app=app, base_url="http://test") as ac: 163 | response = await ac.post( 164 | "/rings/blast", json=mock_blast_ring_schema.model_dump() 165 | ) 166 | assert response.status_code == 500 167 | -------------------------------------------------------------------------------- /app/src/lib/session/controls/rings/NewBlastRing.svelte: -------------------------------------------------------------------------------- 1 | 74 | 75 |
76 |

BLAST Ring

77 |

78 | 79 |

80 |

BLAST rings consist of segments representing the alignment of a genome 81 | against the selected reference.

82 | 83 | {#if $ringReferenceStore} 84 | 85 |
86 |
87 |
88 | 92 |
93 |
94 | {#if sessionFileTypeAvailable(FileType.GENOME)} 95 | 105 | {:else} 106 |
Please upload a genome sequence file
107 | {/if} 108 |
109 |
110 |
111 | 115 | 119 | 123 |
124 | 125 | 126 |
127 | 132 |
133 |
134 | {/if} 135 |
-------------------------------------------------------------------------------- /tests/core_tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import shutil 3 | 4 | from pathlib import Path 5 | from brick.utils import sanitize_input 6 | from brick.utils import sanitize_for_mongodb 7 | from brick.utils import sanitize_svg_content 8 | from brick.utils import enough_disk_space 9 | from brick.utils import DANGEROUS_ATTRS, DANGEROUS_TAGS 10 | 11 | # Tests for `enough_space` 12 | 13 | EXISTENT_PATH = Path("/tmp") # exists in test container 14 | NONEXISTENT_PATH = Path("/path/that/does/not/exist") 15 | INVALID_PATH = 12345 # invalid path type 16 | 17 | # Helper function to get available space for a path 18 | 19 | 20 | def get_available_space(path): 21 | return shutil.disk_usage(path).free 22 | 23 | 24 | def test_with_enough_space(): 25 | disk_space_required_gb = ( 26 | get_available_space(EXISTENT_PATH) / 1024**3 - 1 27 | ) # slightly less than available space 28 | assert enough_disk_space(EXISTENT_PATH, disk_space_required_gb) 29 | 30 | 31 | def test_with_insufficient_space(): 32 | disk_space_required_gb = ( 33 | get_available_space(EXISTENT_PATH) / 1024**3 + 1 34 | ) # slightly more than available space 35 | assert not enough_disk_space(EXISTENT_PATH, disk_space_required_gb) 36 | 37 | 38 | def test_with_nonexistent_path(): 39 | with pytest.raises(FileNotFoundError): 40 | enough_disk_space(NONEXISTENT_PATH, 1) 41 | 42 | 43 | def test_with_invalid_path_type(): 44 | with pytest.raises(TypeError): 45 | enough_disk_space(INVALID_PATH, 1) 46 | 47 | 48 | def test_with_negative_space_limit(): 49 | with pytest.raises(ValueError): 50 | enough_disk_space(EXISTENT_PATH, -1) 51 | 52 | 53 | # Tests for `sanitize_input` 54 | 55 | 56 | def test_sanitize_input_basic_html(): 57 | input_string = "
Test & bold
" 58 | expected_output = "<div>Test & <b>bold</b></div>" 59 | assert sanitize_input(input_string) == expected_output 60 | 61 | 62 | def test_sanitize_input_svg_tag(): 63 | input_string = "" 64 | assert "{$ne: null}" 113 | svg_sanitized = sanitize_svg_content(input_string) 114 | both_sanitized = sanitize_input(input_string, is_for_svg=True, is_for_db=True) 115 | assert svg_sanitized != both_sanitized 116 | assert "" 131 | sanitized = sanitize_input(input_string, is_for_svg=True) 132 | assert sanitized == "" 133 | 134 | 135 | def test_sanitize_input_unicode_characters(): 136 | input_string = "测试 🚀" 137 | sanitized = sanitize_input(input_string, is_for_svg=True) 138 | assert "测试" in sanitized 139 | assert "🚀" in sanitized 140 | assert "" 146 | ) 147 | sanitized = sanitize_input(input_string, is_for_svg=True, is_for_db=True) 148 | assert "g>" 155 | sanitized = sanitize_input(input_string, is_for_svg=True) 156 | assert "" not in sanitized 158 | 159 | 160 | def test_sanitize_svg_content_valid_attributes(): 161 | input_string = '' 162 | sanitized = sanitize_svg_content(input_string) 163 | assert 'cx="50"' in sanitized 164 | assert 'stroke="black"' in sanitized 165 | 166 | 167 | # TODO: nested tag and multiple operator sanitation 168 | 169 | # def test_sanitize_svg_content_nested_dangerous_tags(): 170 | # input_string = "" 171 | # assert sanitize_svg_content(input_string) == "" 172 | 173 | # def test_sanitize_for_mongodb_normal_text(): 174 | # input_string = "This is a $text with curly {braces}" 175 | # sanitized = sanitize_for_mongodb(input_string) 176 | # assert sanitized == input_string 177 | 178 | # def test_sanitize_for_mongodb_multiple_operators(): 179 | # input_string = "{$set: {$ne: null}}" 180 | # sanitized = sanitize_for_mongodb(input_string) 181 | # assert sanitized.count("\uFF04") == 2 182 | 183 | # def test_sanitize_for_mongodb_various_positions(): 184 | # input_string = "normal {$set} text $end" 185 | # sanitized = sanitize_for_mongodb(input_string) 186 | # assert "\uFF04set" in sanitized 187 | # assert "\uFF04end" in sanitized 188 | -------------------------------------------------------------------------------- /brick/api/endpoints/files.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import uuid 3 | 4 | from pathlib import Path 5 | from typing import List 6 | 7 | from pydantic import ValidationError 8 | from fastapi.responses import JSONResponse 9 | from fastapi import APIRouter, UploadFile, File, Form, HTTPException 10 | 11 | from ..schemas import ( 12 | FileConfig, 13 | UploadFileResponse, 14 | FileFormat, 15 | FileType, 16 | Session, 17 | SessionFile, 18 | ) 19 | 20 | from ..core.config import settings 21 | 22 | from ..core.db import get_session_collection_motor 23 | from ...utils import sanitize_input 24 | from ..tasks import process_file, rehydrate_session 25 | 26 | router = APIRouter( 27 | prefix="/files", 28 | tags=["file", "upload"], 29 | ) 30 | 31 | 32 | @router.get("/{session_id}", response_model=List[SessionFile]) 33 | async def get_files(session_id: str): 34 | 35 | collection = await get_session_collection_motor() 36 | session_data = await collection.find_one({"id": session_id}) 37 | 38 | if not session_data: 39 | raise HTTPException(status_code=404, detail="Session not found") 40 | 41 | return Session(**session_data).files 42 | 43 | 44 | @router.post("/upload") 45 | async def upload_file(file: UploadFile = File(...), config: str = Form(...)): 46 | """ 47 | Upload a file for initial validation and processing 48 | """ 49 | 50 | # Upload validations 51 | try: 52 | config_data = FileConfig.model_validate_json(config) 53 | except ValidationError as e: 54 | raise HTTPException(status_code=400, detail=f"Invalid config data: {e}") 55 | 56 | if file.filename == "": 57 | raise HTTPException(status_code=400, detail="No file uploaded") 58 | 59 | # Session directory checks 60 | session_directory: Path = settings.WORK_DIRECTORY / config_data.session_id 61 | 62 | if not session_directory.exists(): 63 | session_directory.mkdir(parents=True) 64 | 65 | try: 66 | if not is_safe_to_add_files( 67 | directory_path=session_directory, 68 | max_files_allowed=settings.SESSION_MAX_FILES, 69 | max_dir_size_mb=settings.SESSION_MAX_SIZE_MB, 70 | ): 71 | raise HTTPException( 72 | status_code=500, 73 | detail=f"Error uploading file: session has reached capacity", 74 | ) 75 | except NotADirectoryError as e: 76 | raise HTTPException(status_code=500, detail=f"Error uploading file: {str(e)}") 77 | 78 | # Sanitize filename 79 | sanitized_filename = sanitize_input( 80 | input_string=file.filename, is_for_db=True, is_for_svg=False 81 | ) 82 | 83 | # Save file and initate processing 84 | file_path = session_directory / safe_filename() 85 | 86 | try: 87 | with file_path.open("wb") as buffer: 88 | shutil.copyfileobj(file.file, buffer) 89 | except Exception as e: 90 | raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}") 91 | 92 | # JSON re-hydration of session or processing of files 93 | 94 | try: 95 | if ( 96 | config_data.file_format == FileFormat.JSON 97 | and config_data.file_type == FileType.SESSION 98 | ): 99 | task = rehydrate_session.delay(str(file_path), config_data.model_dump()) 100 | else: 101 | task = process_file.delay( 102 | str(file_path), config_data.model_dump(), sanitized_filename 103 | ) 104 | except Exception as e: 105 | file_path.unlink() # delete the file since the task failed to initiate 106 | raise HTTPException(status_code=500, detail=f"Error initiating task: {str(e)}") 107 | 108 | return JSONResponse( 109 | status_code=202, content=UploadFileResponse(task_id=task.id).model_dump() 110 | ) 111 | 112 | 113 | @router.delete("/{session_id}/{file_id}") 114 | async def delete_file(session_id: str, file_id: str): 115 | 116 | try: 117 | uuid.UUID(session_id, version=4) 118 | except ValueError: 119 | raise HTTPException( 120 | status_code=400, detail="Invalid session identifier format - expected UUID4" 121 | ) 122 | 123 | try: 124 | uuid.UUID(file_id, version=4) 125 | except ValueError: 126 | raise HTTPException( 127 | status_code=400, detail="Invalid file identifier format - expected UUID4" 128 | ) 129 | 130 | # Get the collection from the database 131 | collection = await get_session_collection_motor() 132 | 133 | # Retrieve the session data 134 | session_data = await collection.find_one({"id": session_id}) 135 | if not session_data: 136 | raise HTTPException(status_code=404, detail="Session not found") 137 | 138 | # Convert to Session model for ease of handling 139 | session = Session(**session_data) 140 | 141 | # Find the file in the session 142 | file_to_delete = next((file for file in session.files if file.id == file_id), None) 143 | if not file_to_delete: 144 | raise HTTPException(status_code=404, detail="File not found in session") 145 | 146 | # Delete from database first - no consequences 147 | # on file path in case of failure 148 | 149 | await collection.update_one( 150 | {"id": session_id}, {"$pull": {"files": {"id": file_id}}} 151 | ) 152 | 153 | # Define the file path 154 | file_path: Path = settings.WORK_DIRECTORY / session_id / file_to_delete.id 155 | 156 | # Delete the file from the filesystem 157 | if file_path.exists(): 158 | file_path.unlink() 159 | else: 160 | raise HTTPException(status_code=404, detail="File not found on disk") 161 | 162 | return JSONResponse( 163 | status_code=200, 164 | content={ 165 | "message": "File deleted sucessfully", 166 | "session_id": session_id, 167 | "file_id": file_id, 168 | }, 169 | ) 170 | 171 | 172 | # Helpers 173 | 174 | 175 | def safe_filename() -> str: 176 | """ 177 | Generate a safe filename to prevent directory traversal attacks 178 | """ 179 | return str(uuid.uuid4()) 180 | 181 | 182 | def is_safe_to_add_files( 183 | directory_path: Path, max_files_allowed: int, max_dir_size_mb: int 184 | ) -> bool: 185 | """ 186 | Check if the number of files and the total size of a directory do not exceed specified limits 187 | """ 188 | directory = Path(directory_path) 189 | 190 | if not directory.is_dir(): 191 | raise NotADirectoryError(f"{directory_path} is not a directory.") 192 | 193 | file_count = sum(1 for file in directory.glob("**/*") if file.is_file()) 194 | total_size = sum( 195 | file.stat().st_size for file in directory.glob("**/*") if file.is_file() 196 | ) 197 | total_size_mb = total_size / (1024 * 1024) 198 | 199 | return file_count < max_files_allowed and total_size_mb < max_dir_size_mb 200 | -------------------------------------------------------------------------------- /app/src/lib/session/controls/panels/AboutPanel.svelte: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 25 | 26 | 27 |

28 | BRICK implements an interactive version of the iconic 29 | BLAST Ring Image Generator (BRIG). 30 | Support for development, maintenance and deployment comes from the following institutions: 31 |

32 | 33 | 34 |
35 | 41 | 47 | 53 |
54 | 📡 55 | 56 |
University of Melbourne Research Cloud (NECTAR)
57 |
58 |
59 |
60 | 61 |

62 | If you would like to acknowledge the authors of the original visualization, please cite: 63 |

64 | 65 |
66 | 72 |
73 | 74 |

75 | BRICK would not be possible without the amazing work of bioinformaticians and researchers who make their software available open-source. 76 | If you use their tools in the visualization, please cite the following publications: 77 |

78 | 79 |
80 | 86 | 92 | 98 |
99 | 100 | 101 |

102 | BRICK is maintained by Eike Steinig (@esteinig) and was built with 103 | Sveltekit + 104 | Skeleton UI + 105 | FastAPI + 106 | Celery + 107 | Redis + 108 | MongoDB. 109 |

110 | 111 |
-------------------------------------------------------------------------------- /app/src/lib/session/controls/rings/NewGenomadRing.svelte: -------------------------------------------------------------------------------- 1 | 79 | 80 |
81 |

geNomad Ring

82 |

83 | 84 |

85 |

86 | geNomad rings visualize prediction scores for horizontal gene transfer (plasmid signature) or integrated phage (viral signature) regions in contiguous segments of non-overlapping windows along a reference sequence. 87 |

88 |

89 | Scores between 0 and 1 are computed for each window (--relaxed). Contiguous windows above the length threshold with an average score above the score threshold can be added as a labels or segment annotations. Window scores 90 | can also be added as a line ring with optional smoothing (see ring specific edit menu). 91 |

92 |

93 | Labels are added to the midpoint of the contiguous segment identified, usually a combination of labels and annotation segments can be helpful to show this. 94 | Segment scores are averaged over the identified contiguous segment if the segment is above the minimum segment length threshold. 95 |

96 |

97 | Minimum window score may need to be relaxed to allow for longer contiguous segments. Note that the line ring will show a zero score if below the window score threshold. Changing window parameters will recompute the predictions. 98 |

99 | 100 | {#if $ringReferenceStore} 101 | 102 |
103 |
104 |
105 | 109 |
110 |
111 | 119 |
120 |
121 |
122 | 126 | 130 | 134 | 138 |
139 |
140 | 145 |
Initital computation may take a few minutes depending on server load ❤️
146 |
147 |
148 | {/if} 149 |
--------------------------------------------------------------------------------