diff --git a/.dockerignore b/.dockerignore index 97ece6b..6edc187 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,3 @@ -src/.pdm-python \ No newline at end of file +**/.pdm-python +**/node_modules +**/.venv \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 43f24ff..4317f7b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -76,16 +76,25 @@ jobs: working-directory: ./frontend - backend-build: - needs: backend-check + docker-build: + needs: [frontend-build, backend-check] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup PDM - uses: pdm-project/setup-pdm@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 - - name: Install dependencies - working-directory: ./backend - run: pdm install + # - name: Login to DockerHub + # uses: docker/login-action@v1 + # with: + # username: ${{ secrets.DOCKER_USERNAME }} + # password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + push: false \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f165150 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM node:20 as frontend-builder + +RUN corepack enable && yarn set version berry + +WORKDIR /app/frontend + +COPY ./frontend/package.json ./frontend/yarn.lock ./ +COPY ./frontend/.yarn ./.yarn +COPY ./frontend/.yarnrc.yml ./ + +RUN yarn install + +COPY ./frontend . + +ENV NODE_ENV=production + +RUN yarn build --mode production + +FROM python:3.11-slim as backend-builder + +RUN pip install pdm + +WORKDIR /app + +COPY ./backend/pyproject.toml ./backend/pdm.lock ./ + +RUN pdm install --prod + +COPY ./backend/src ./src +COPY --from=frontend-builder /app/frontend/dist ./src/static + +WORKDIR /app/src + +EXPOSE 8000 + +ENTRYPOINT ["pdm", "run", "uvicorn", "main:app", "--host=0.0.0.0", "--port=8000"] diff --git a/backend/src/core/config.py b/backend/src/core/config.py index c5cc015..a9d47f4 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -6,19 +6,24 @@ class Settings(BaseSettings): """Configuration settings for the application.""" - + debug: bool = False + + root_path: str = "/arq" + docs_url: str = "api/docs" + openapi_url: str = "api/openapi.json" + redoc_url: str = "api/redoc" + api_prefix: str = "api" + environment: str = "production" - docs_url: str = "/arq/api/docs" - openapi_url: str = "/arq/api/openapi.json" - redoc_url: str = "/arq/api/redoc" + title: str = "Arq UI API" version: str = "0.1.0" summary: str = "Interface for Arq background jobs." description: str = "" cors_allowed_hosts: list[str] | None = ["http://localhost:5173"] - api_prefix: str = "/arq/api" + timezone: str = "UTC" diff --git a/backend/src/core/helpers.py b/backend/src/core/helpers.py new file mode 100644 index 0000000..51597fe --- /dev/null +++ b/backend/src/core/helpers.py @@ -0,0 +1,24 @@ +def join_paths_safely(base_path: str, relative_path: str) -> str: + """ + Joins a base path and a relative path safely, ensuring only one slash between them. + + This function ensures that there's exactly one slash between the base and relative paths, + regardless of whether the base path ends with a slash or the relative path starts with one. + + Parameters: + base_path (str): The base path to be joined. + relative_path (str): The relative path to append to the base path. + + Returns: + str: The resulting path after safely joining the base and relative paths. + + Examples: + >>> join_paths_safely('/base/path/', '/relative/path') + '/base/path/relative/path' + + >>> join_paths_safely('/base/path', 'relative/path') + '/base/path/relative/path' + """ + # Remove the trailing slash from the base path if it exists, and add a leading slash to the relative path if it's missing + # Then concatenate the paths + return base_path.rstrip('/') + '/' + relative_path.lstrip('/') \ No newline at end of file diff --git a/backend/src/main.py b/backend/src/main.py index 55e56e9..fb713a2 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1,4 +1,7 @@ import logging +from urllib.parse import urljoin + +from fastapi.staticfiles import StaticFiles from core.config import Settings, get_app_settings from core.exception_handler import ( @@ -7,6 +10,7 @@ http_exception_handler, starlette_http_exception_handler, ) +from core.helpers import join_paths_safely from endpoints.api import routers from fastapi import FastAPI from fastapi.exceptions import HTTPException, RequestValidationError @@ -25,9 +29,9 @@ def get_application() -> FastAPI: title=settings.title, version=settings.version, description=settings.description, - redoc_url=settings.redoc_url, - docs_url=settings.docs_url, - openapi_url=settings.openapi_url, + redoc_url= join_paths_safely(settings.root_path, settings.redoc_url), + docs_url=join_paths_safely(settings.root_path, settings.docs_url), + openapi_url=join_paths_safely(settings.root_path, settings.openapi_url), summary=settings.summary, ) @@ -40,13 +44,15 @@ def get_application() -> FastAPI: allow_headers=["*"], ) - application.include_router(routers, prefix=settings.api_prefix) + application.include_router(routers, prefix=join_paths_safely(settings.root_path, settings.api_prefix)) application.add_exception_handler(RequestValidationError, custom_validation_exception_handler) # type: ignore application.add_exception_handler(HTTPException, http_exception_handler) # type: ignore application.add_exception_handler(Exception, all_exception_handler) # type: ignore application.add_exception_handler(StarletteHTTPException, starlette_http_exception_handler) # type: ignore + application.mount(join_paths_safely(settings.root_path, "ui"), StaticFiles(directory="static", html=True), name="static") + return application diff --git a/backend/src/static/.gitkeep b/backend/src/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6121ed0..0abbdae 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,10 +1,17 @@ import { IFetchJobsParams, IJob, IJobsInfo } from "./types"; +function joinPathsSafely(basePath: string, relativePath: string): string { + const trimmedBasePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; + const trimmedRelativePath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; + return `${trimmedBasePath}/${trimmedRelativePath}`; +} + /** * Fetches jobs from the API based on the specified parameters. */ export function fetchJobs(params: IFetchJobsParams = {}): Promise { - const jobsUrl = new URL("jobs", import.meta.env.VITE_API_HOST).toString(); + + const jobsUrl = joinPathsSafely(import.meta.env.VITE_API_HOST, "jobs"); const queryParams = new URLSearchParams(); @@ -40,7 +47,7 @@ export function fetchJobs(params: IFetchJobsParams = {}): Promise { } export function abortJob(jobId: string): Promise { - const jobsUrl = new URL("jobs", import.meta.env.VITE_API_HOST).toString(); + const jobsUrl = joinPathsSafely(import.meta.env.VITE_API_HOST, "jobs"); const url = `${jobsUrl}/${jobId}`; return fetch(url, { method: "DELETE" }).then(async (response) => { @@ -52,7 +59,7 @@ export function abortJob(jobId: string): Promise { } export function fetchJob(jobId: string): Promise { - const jobsUrl = new URL("jobs", import.meta.env.VITE_API_HOST).toString(); + const jobsUrl = joinPathsSafely(import.meta.env.VITE_API_HOST, "jobs"); const url = `${jobsUrl}/${jobId}`; return fetch(url).then(async (response) => {