diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 2de1001..93783e5 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -56,6 +56,13 @@ jobs: run: python -m pip install -U pip - name: Install pre-commit run: python -m pip install pre-commit + - name: Install hadolint + run: |- + curl -Lo \ + /usr/local/bin/hadolint \ + https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64 + chmod +x /usr/local/bin/hadolint + hadolint --version - name: Run Pre-Commit run: pre-commit run --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75da3fa..28df639 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -107,7 +107,7 @@ repos: - repo: https://github.com/rhysd/actionlint rev: v1.7.6 hooks: - - id: actionlint-docker + - id: actionlint - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.14.1 hooks: @@ -129,7 +129,7 @@ repos: - repo: https://github.com/hadolint/hadolint rev: v2.13.1-beta hooks: - - id: hadolint-docker + - id: hadolint args: - --ignore=DL3008 - repo: local diff --git a/Dockerfile b/Dockerfile index ae619cb..c75ccbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Build frontend -FROM node:20 as build-frontend +FROM node:20 AS build-frontend WORKDIR /app COPY frontend/package*.json ./ RUN npm install diff --git a/Makefile b/Makefile index 2348701..50f4216 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,24 @@ # Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 -CAPELLA_MODEL_EXPLORER_HOST_IP ?= 127.0.0.1 +CAPELLA_MODEL_EXPLORER_HOST_IP ?= 0.0.0.0 CAPELLA_MODEL_EXPLORER_PORT ?= 8000 -MODEL ?= coffee-machine +export MODEL ?= coffee-machine +export TEMPLATES_DIR ?= templates # NOTE: Keep the 'help' target first, so that 'make' acts like 'make help' .PHONY: help help: @echo 'Available make targets:' @echo '' + @echo 'Note: `UV_ENV_FILE=.env uv run make ...` or `dotenv run make ...`' + @echo 'can be used to run make with environment variables from a .env file' + @echo '' @echo ' run MODEL=/some/model.aird' - @echo ' -- Run the backend with a model' + @echo ' -- Run the app in production mode with a model' + @echo ' dev-backend -- Run the backend in development mode' + @echo ' dev-frontend -- Run the frontend in development mode' @echo ' build-frontend -- (Re-)Build the prod frontend files' - @echo ' dev-frontend -- Run the frontend in dev mode' @echo ' storybook -- Run storybook for frontend development' @echo ' clean-frontend -- Clean out all built/installed frontend files' @@ -22,14 +27,30 @@ run: frontend/dist/static/env.js sed -i "s|__ROUTE_PREFIX__||g" frontend/dist/static/env.js MODE=production python frontend/fetch-version.py python -c "from capellambse_context_diagrams import install_elk; install_elk()" - MODE=production python -m capella_model_explorer.backend "$$MODEL" ./templates + MODEL="$$MODEL" TEMPLATES_DIR="$$TEMPLATES_DIR" uvicorn \ + --host=$(CAPELLA_MODEL_EXPLORER_HOST_IP) \ + --port=$(CAPELLA_MODEL_EXPLORER_PORT) \ + capella_model_explorer.backend.main:app .PHONY: build-frontend build-frontend: frontend/node_modules cd frontend && npm run build +.PHONY: dev-backend +dev-backend: + sed -i "s|__ROUTE_PREFIX__||g" frontend/dist/static/env.js + MODE=production python frontend/fetch-version.py + python -c "from capellambse_context_diagrams import install_elk; install_elk()" + MODEL="$$MODEL" TEMPLATES_DIR="$$TEMPLATES_DIR" uvicorn \ + --host=$(CAPELLA_MODEL_EXPLORER_HOST_IP) \ + --port=$(CAPELLA_MODEL_EXPLORER_PORT) \ + --reload \ + capella_model_explorer.backend.main:app + .PHONY: dev-frontend dev-frontend: frontend/node_modules + sed -i "s|__ROUTE_PREFIX__||g" frontend/dist/static/env.js + python frontend/fetch-version.py cd frontend && npm run dev .PHONY: storybook diff --git a/README.md b/README.md index dc9736c..7645eaa 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,9 @@ Clone, then build and run locally with Docker: ```bash docker build -t model-explorer:latest . -docker run -e ROUTE_PREFIX="" -v /absolute/path/to/your/model/folder/on/host:/model -v $(pwd)/templates:/views -p 8000:8000 model-explorer +docker run --name=cme -e ROUTE_PREFIX="" \ + -v /absolute/path/to/your/model/folder/on/host:/model \ + -v "$PWD/templates:/views" -p 8000:8000 model-explorer ``` Then open your browser at `http://localhost:8000/views` and start exploring @@ -46,16 +48,13 @@ and see the changes immediately in the browser. # Development (local) -To run the app in dev mode you'll need to first run `npm run build` - this is -needed by the backend to have some statics to serve. Then run `npm run dev` in -a terminal and -`python -m capella_model_explorer.backend path_to_model path_to_templates` in -another terminal. The backend and statically built frontend will be served at +To run the app in dev mode you'll need to first run `make build-frontend`. This +is needed by the backend to have some statics to serve. Then run `make +dev-backend` in one terminal and `make dev-frontend` in another terminal. The +backend and statically built frontend will be served at `http://localhost:8000`. The live frontend will be served by vite at -`http://localhost:5173`(or similar, it will be printed in the terminal where -you ran `npm run dev`). If you wish to display the Frontend Software Version, -it will initially show 'Fetch Failed'. To successfully fetch and display the -version, you need to run the command `python frontend/fetch_version.py`. +`http://localhost:5173` (or similar, it will be printed in the terminal where +you ran `make dev-frontend`). # Installation diff --git a/capella_model_explorer/backend/__main__.py b/capella_model_explorer/backend/__main__.py deleted file mode 100644 index 3818491..0000000 --- a/capella_model_explorer/backend/__main__.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - -import os -from pathlib import Path - -import capellambse -import click -import uvicorn - -from . import explorer - -HOST = os.getenv("CAPELLA_MODEL_EXPLORER_HOST_IP", "0.0.0.0") -PORT = os.getenv("CAPELLA_MODEL_EXPLORER_PORT", "8000") - -PATH_TO_TEMPLATES = Path("./templates") - - -@click.command() -@click.argument("model", type=capellambse.ModelCLI()) -@click.argument( - "templates", - type=click.Path(path_type=Path, exists=True), - required=False, - default=PATH_TO_TEMPLATES, -) -def run(model: capellambse.MelodyModel, templates: Path): - backend = explorer.CapellaModelExplorerBackend( - Path(templates), - model, - ) - - uvicorn.run(backend.app, host=HOST, port=int(PORT)) - - -if __name__ == "__main__": - run() diff --git a/capella_model_explorer/backend/explorer.py b/capella_model_explorer/backend/explorer.py deleted file mode 100644 index 8820efc..0000000 --- a/capella_model_explorer/backend/explorer.py +++ /dev/null @@ -1,372 +0,0 @@ -# Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - -import dataclasses -import logging -import os -import pathlib -import time -import traceback -import typing as t -import urllib.parse as urlparse -from pathlib import Path - -import capellambse -import capellambse.model as m -import fastapi -import markupsafe -import prometheus_client -import yaml -from fastapi import APIRouter, FastAPI, HTTPException, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from jinja2 import ( - Environment, - FileSystemLoader, - TemplateSyntaxError, - is_undefined, -) -from pydantic import BaseModel - -from capella_model_explorer.backend import model_diff -from capella_model_explorer.backend import templates as tl - -from . import __version__ - -esc = markupsafe.escape - -PATH_TO_FRONTEND = Path("./frontend/dist") -ROUTE_PREFIX = os.getenv("ROUTE_PREFIX", "") -LOGGER = logging.getLogger(__name__) - - -class CommitRange(BaseModel): - head: str - prev: str - - -class ObjectDiffID(BaseModel): - uuid: str - - -@dataclasses.dataclass -class CapellaModelExplorerBackend: - app: FastAPI = dataclasses.field(init=False) - env: Environment = dataclasses.field(init=False) - templates: dict[str, t.Any] = dataclasses.field( - init=False, default_factory=dict - ) - templates_loader: tl.TemplateLoader = dataclasses.field(init=False) - - templates_path: Path - model: capellambse.MelodyModel - - templates_index: tl.TemplateCategories | None = dataclasses.field( - init=False - ) - - def __post_init__(self): - self.app = FastAPI(version=__version__) - self.router = APIRouter(prefix=ROUTE_PREFIX) - self.app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - self.templates_loader = tl.TemplateLoader(self.model) - self.env = Environment(loader=FileSystemLoader(self.templates_path)) - self.env.finalize = self.__finalize - self.env.filters["make_href"] = self.__make_href_filter - self.app.state.templates = Jinja2Templates(directory=PATH_TO_FRONTEND) - self.configure_routes() - self.app.include_router(self.router) - self.idle_time_gauge = prometheus_client.Gauge( - "idletime_minutes", - "Time in minutes since the last user interaction", - ) - self.last_interaction = time.time() - self.templates_index = self.templates_loader.index_path( - self.templates_path - ) - self.diff = {} - self.object_diff = {} - - @self.app.middleware("http") - async def update_last_interaction_time(request: Request, call_next): - if request.url.path not in ("/metrics", "/favicon.ico"): - self.last_interaction = time.time() - return await call_next(request) - - def __finalize(self, markup: t.Any) -> object: - markup = markupsafe.escape(markup) - return capellambse.helpers.replace_hlinks( - markup, self.model, self.__make_href - ) - - def __make_href_filter(self, obj: object) -> str | None: - if is_undefined(obj) or obj is None: - return "#" - - if isinstance(obj, m.ElementList): - raise TypeError("Cannot make an href to a list of elements") - if not isinstance(obj, m.ModelElement | m.AbstractDiagram): - raise TypeError(f"Expected a model object, got {obj!r}") - - try: - self.model.by_uuid(obj.uuid) - except KeyError: - return "#" - - return self.__make_href(obj) - - def __make_href( - self, obj: m.ModelElement | m.AbstractDiagram - ) -> str | None: - if self.templates_index is None: - return None - - for idx, template in self.templates_index.flat.items(): - if "type" in dir(template.scope): - clsname = template.scope.type - if obj.xtype.rsplit(":", 1)[-1] == clsname: - return f"{ROUTE_PREFIX}/{idx}/{obj.uuid}" - return f"{ROUTE_PREFIX}/__generic__/{obj.uuid}" - - def render_instance_page(self, template_text, base, object=None): - try: - # render the template with the object - template = self.env.from_string(template_text) - rendered = template.render( - object=object, - model=self.model, - diff_data=self.diff, - object_diff=self.object_diff, - ) - return HTMLResponse(content=rendered, status_code=200) - except TemplateSyntaxError as e: - error_message = markupsafe.Markup( - "

Template syntax error: {}, line {}

" - ).format(e.message, e.lineno) - base.error = error_message - print(base) - return HTMLResponse(content=error_message) - except Exception as e: - LOGGER.exception("Error rendering template") - trace = markupsafe.escape(traceback.format_exc()) - error_message = markupsafe.Markup( - '

' - f"Unexpected error: {type(e).__name__}: {e}" - '

'
-                f"object={object!r}\nmodel={self.model!r}"
-                f"\n\n{trace}"
-                "
" - ) - return HTMLResponse(content=error_message) - - def configure_routes(self): # noqa: C901 - self.app.mount( - f"{ROUTE_PREFIX}/assets", - StaticFiles( - directory=PATH_TO_FRONTEND.joinpath("assets"), html=True - ), - ) - self.app.mount( - f"{ROUTE_PREFIX}/static", - StaticFiles( - directory=PATH_TO_FRONTEND.joinpath("static"), html=True - ), - ) - - @self.router.get("/api/views") - def read_templates(): - self.templates_index = self.templates_loader.index_path( - self.templates_path - ) - return self.templates_index.as_dict - - @self.router.get("/api/objects/{uuid}") - def read_object(uuid: str): - obj = self.model.by_uuid(uuid) - details = tl.simple_object(obj) - return { - "idx": details["idx"], - "name": details["name"], - "type": obj.xtype, - } - - @self.router.get("/api/views/{template_name}") - def read_template(template_name: str): - template_name = urlparse.unquote(template_name) - if ( - self.templates_index is None - or template_name not in self.templates_index.flat - ): - return { - "error": ( - f"Template {template_name} not found" - " or templates index not initialized" - ) - } - base = self.templates_index.flat[template_name] - base.compute_instance_list(self.model) - return base - - @self.router.get("/api/views/{template_name}/{object_id}") - def render_template(template_name: str, object_id: str): - content = None - object = None - try: - template_name = urlparse.unquote(template_name) - if ( - self.templates_index is None - or template_name not in self.templates_index.flat - ): - return {"error": f"Template {template_name} not found"} - base = self.templates_index.flat[template_name] - template_filename = base.template - # load the template file from the templates folder - content = (self.templates_path / template_filename).read_text( - encoding="utf8" - ) - except Exception as e: - error_message = markupsafe.Markup( - "

Template not found: {}

" - ).format(str(e)) - return HTMLResponse(content=error_message) - if object_id == "render": - object = None - else: - try: - object = self.model.by_uuid(object_id) - except Exception as e: - error_message = markupsafe.Markup( - "

" - "Requested object not found: {}

" - ).format(str(e)) - return HTMLResponse(content=error_message) - return self.render_instance_page(content, base, object) - - @self.router.get("/api/model-info") - def model_info(): - info = self.model.info - resinfo = info.resources["\x00"] - return { - "title": info.title, - "revision": resinfo.revision, - "hash": resinfo.rev_hash, - "capella_version": info.capella_version, - "branch": resinfo.branch, - "badge": self.model.description_badge, - } - - @self.app.get("/metrics") - def metrics(): - idle_time_minutes = (time.time() - self.last_interaction) / 60 - self.idle_time_gauge.set(idle_time_minutes) - return fastapi.Response( - content=prometheus_client.generate_latest(), - media_type="text/plain", - ) - - @self.router.get("/api/metadata") - async def version(): - return {"version": self.app.version} - - @self.router.post("/api/compare") - async def post_compare(commit_range: CommitRange): - try: - self.diff = model_diff.get_diff_data( - self.model, commit_range.head, commit_range.prev - ) - self.diff["lookup"] = create_diff_lookup(self.diff["objects"]) - if self.diff["lookup"]: - return {"success": True} - return {"success": False, "error": "No model changes to show"} - except Exception as e: - LOGGER.exception("Failed to compare versions") - return {"success": False, "error": str(e)} - - @self.router.post("/api/object-diff") - async def post_object_diff(object_id: ObjectDiffID): - if object_id.uuid not in self.diff["lookup"]: - raise HTTPException(status_code=404, detail="Object not found") - - self.object_diff = self.diff["lookup"][object_id.uuid] - return {"success": True} - - @self.router.get("/api/commits") - async def get_commits(): - try: - return model_diff.populate_commits(self.model) - except Exception as e: - return {"error": str(e)} - - @self.router.get("/api/diff") - async def get_diff(): - if self.diff: - return self.diff - return { - "error": "No data available. Please compare two commits first." - } - - @self.router.get("/{rest_of_path:path}") - async def catch_all(request: Request, rest_of_path: str): - del rest_of_path - return self.app.state.templates.TemplateResponse( - "index.html", {"request": request} - ) - - -def index_template(template, templates, templates_grouped, filename=None): - idx = filename if filename else template["idx"] - record = {"idx": idx, **template} - if "category" in template: - category = template["category"] - if category not in templates_grouped: - templates_grouped[category] = [] - templates_grouped[category].append(record) - else: - templates_grouped["other"].append(record) - templates[idx] = template - - -def index_templates( - path: pathlib.Path, -) -> tuple[dict[str, t.Any], dict[str, t.Any]]: - templates_grouped: dict[str, t.Any] = {"other": []} - templates: dict[str, t.Any] = {} - for template_file in path.glob("**/*.yaml"): - template = yaml.safe_load(template_file.read_text(encoding="utf8")) - if "templates" in template: - for template_def in template["templates"]: - index_template(template_def, templates, templates_grouped) - else: - idx = urlparse.quote(template_file.name.replace(".yaml", "")) - index_template( - template, templates, templates_grouped, filename=idx - ) - return templates_grouped, templates - - -def create_diff_lookup(data, lookup=None): - if lookup is None: - lookup = {} - try: - if isinstance(data, dict): - for _, obj in data.items(): - if "uuid" in obj: - lookup[obj["uuid"]] = { - "uuid": obj["uuid"], - "display_name": obj["display_name"], - "change": obj["change"], - "attributes": obj["attributes"], - } - if children := obj.get("children"): - create_diff_lookup(children, lookup) - except Exception: - LOGGER.exception("Cannot create diff lookup") - return lookup diff --git a/capella_model_explorer/backend/main.py b/capella_model_explorer/backend/main.py new file mode 100644 index 0000000..9cba147 --- /dev/null +++ b/capella_model_explorer/backend/main.py @@ -0,0 +1,325 @@ +# Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import logging +import os +import pathlib +import time +import traceback +import typing as t +import urllib.parse + +import capellambse +import capellambse.model as m +import fastapi +import fastapi.middleware.cors +import fastapi.responses +import fastapi.staticfiles +import fastapi.templating +import jinja2 +import markupsafe +import prometheus_client +import pydantic + +import capella_model_explorer +from capella_model_explorer.backend import model_diff, state +from capella_model_explorer.backend import templates as tl + +FRONTEND_DIR = pathlib.Path("./frontend/dist") +ROUTE_PREFIX = os.getenv("ROUTE_PREFIX", "") +logger = logging.getLogger(__name__) + + +class CommitRange(pydantic.BaseModel): + head: str + prev: str + + +class ObjectDiffID(pydantic.BaseModel): + uuid: str + + +def _create_diff_lookup(data, lookup=None): + if lookup is None: + lookup = {} + try: + if isinstance(data, dict): + for _, obj in data.items(): + if "uuid" in obj: + lookup[obj["uuid"]] = { + "uuid": obj["uuid"], + "display_name": obj["display_name"], + "change": obj["change"], + "attributes": obj["attributes"], + } + if children := obj.get("children"): + _create_diff_lookup(children, lookup) + except Exception: + logger.exception("Cannot create diff lookup") + return lookup + + +def _finalize(markup: t.Any) -> object: + markup = markupsafe.escape(markup) + return capellambse.helpers.replace_hlinks(markup, state.model, _make_href) + + +def _make_href( + obj: m.ModelElement | m.AbstractDiagram, +) -> str | None: + if state.templates_index is None: + return None + + for idx, template in state.templates_index.flat.items(): + if "type" in dir(template.scope): + clsname = template.scope.type + if obj.xtype.rsplit(":", 1)[-1] == clsname: + return f"{ROUTE_PREFIX}/{idx}/{obj.uuid}" + return f"{ROUTE_PREFIX}/__generic__/{obj.uuid}" + + +def _make_href_filter(obj: object) -> str | None: + if jinja2.is_undefined(obj) or obj is None: + return "#" + + if isinstance(obj, m.ElementList): + raise TypeError("Cannot make an href to a list of elements") + if not isinstance(obj, m.ModelElement | m.AbstractDiagram): + raise TypeError(f"Expected a model object, got {obj!r}") + + try: + state.model.by_uuid(obj.uuid) + except KeyError: + return "#" + + return _make_href(obj) + + +def _render_instance_page(template_text, base, object=None): + try: + # render the template with the object + template = state.jinja_env.from_string(template_text) + rendered = template.render( + object=object, + model=state.model, + diff_data=state.diff, + object_diff=state.object_diff, + ) + return fastapi.responses.HTMLResponse( + content=rendered, status_code=200 + ) + except jinja2.TemplateSyntaxError as e: + error_message = markupsafe.Markup( + "

Template syntax error: {}, line {}

" + ).format(e.message, e.lineno) + base.error = error_message + print(base) + return fastapi.responses.HTMLResponse(content=error_message) + except Exception as e: + logger.exception("Error rendering template") + trace = markupsafe.escape(traceback.format_exc()) + error_message = markupsafe.Markup( + '

' + f"Unexpected error: {type(e).__name__}: {e}" + '

'
+            f"object={object!r}\nmodel={state.model!r}"
+            f"\n\n{trace}"
+            "
" + ) + return fastapi.responses.HTMLResponse(content=error_message) + + +app = fastapi.FastAPI(version=capella_model_explorer.__version__) +app.add_middleware( + fastapi.middleware.cors.CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +router = fastapi.APIRouter(prefix=ROUTE_PREFIX) +app.mount( + f"{ROUTE_PREFIX}/assets", + fastapi.staticfiles.StaticFiles(directory=FRONTEND_DIR / "assets"), +) +app.mount( + f"{ROUTE_PREFIX}/static", + fastapi.staticfiles.StaticFiles(directory=FRONTEND_DIR / "static"), +) + +try: + MODEL_INFO = os.environ["MODEL"] +except KeyError as err: + raise SystemExit("MODEL environment variable is not set") from err + +try: + _ = state.model +except AttributeError: + state.model = capellambse.loadcli(MODEL_INFO) + +state.templates = fastapi.templating.Jinja2Templates(directory=FRONTEND_DIR) + +state.templates_loader = tl.TemplateLoader(state.model) +state.jinja_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(state.templates_path) +) +state.jinja_env.finalize = _finalize +state.jinja_env.filters["make_href"] = _make_href_filter +state.templates_index = state.templates_loader.index_path(state.templates_path) + + +@app.middleware("http") +async def update_last_interaction_time(request: fastapi.Request, call_next): + if request.url.path not in ("/metrics", "/favicon.ico"): + state.last_interaction = time.time() + return await call_next(request) + + +@app.get("/metrics") +def metrics(): + idle_time_minutes = (time.time() - state.last_interaction) / 60 + state.idle_time_gauge.set(idle_time_minutes) + return fastapi.Response( + content=prometheus_client.generate_latest(), + media_type="text/plain", + ) + + +@router.get("/api/commits") +async def get_commits(): + try: + return model_diff.populate_commits(state.model) + except Exception as e: + return {"error": str(e)} + + +@router.post("/api/compare") +async def post_compare(commit_range: CommitRange): + try: + state.diff = model_diff.get_diff_data( + state.model, commit_range.head, commit_range.prev + ) + state.diff["lookup"] = _create_diff_lookup(state.diff["objects"]) + if state.diff["lookup"]: + return {"success": True} + return {"success": False, "error": "No model changes to show"} + except Exception as e: + logger.exception("Failed to compare versions") + return {"success": False, "error": str(e)} + + +@router.get("/api/diff") +async def get_diff(): + if state.diff: + return state.diff + return {"error": "No data available. Please compare two commits first."} + + +@router.get("/api/metadata") +async def version(): + return {"version": app.version} + + +@router.get("/api/model-info") +def model_info(): + info = state.model.info + resinfo = info.resources["\x00"] + return { + "title": info.title, + "revision": resinfo.revision, + "hash": resinfo.rev_hash, + "capella_version": info.capella_version, + "branch": resinfo.branch, + "badge": state.model.description_badge, + } + + +@router.post("/api/object-diff") +async def post_object_diff(object_id: ObjectDiffID): + if object_id.uuid not in state.diff["lookup"]: + raise fastapi.HTTPException(status_code=404, detail="Object not found") + + state.object_diff = state.diff["lookup"][object_id.uuid] + return {"success": True} + + +@router.get("/api/objects/{uuid}") +def read_object(uuid: str): + obj = state.model.by_uuid(uuid) + details = tl.simple_object(obj) + return { + "idx": details["idx"], + "name": details["name"], + "type": obj.xtype, + } + + +@router.get("/api/views") +def read_templates(): + state.templates_index = state.templates_loader.index_path( + state.templates_path + ) + return state.templates_index.as_dict + + +@router.get("/api/views/{template_name}") +def read_template(template_name: str): + template_name = urllib.parse.unquote(template_name) + if ( + state.templates_index is None + or template_name not in state.templates_index.flat + ): + return { + "error": ( + f"Template {template_name} not found" + " or templates index not initialized" + ) + } + base = state.templates_index.flat[template_name] + base.compute_instance_list(state.model) + return base + + +@router.get("/api/views/{template_name}/{object_id}") +def render_template(template_name: str, object_id: str): + content = None + object = None + try: + template_name = urllib.parse.unquote(template_name) + if ( + state.templates_index is None + or template_name not in state.templates_index.flat + ): + return {"error": f"Template {template_name} not found"} + base = state.templates_index.flat[template_name] + template_filename = base.template + # load the template file from the templates folder + content = (state.templates_path / template_filename).read_text( + encoding="utf8" + ) + except Exception as e: + error_message = markupsafe.Markup( + "

Template not found: {}

" + ).format(str(e)) + return fastapi.responses.HTMLResponse(content=error_message) + if object_id == "render": + object = None + else: + try: + object = state.model.by_uuid(object_id) + except Exception as e: + error_message = markupsafe.Markup( + "

Requested object not found: {}

" + ).format(str(e)) + return fastapi.responses.HTMLResponse(content=error_message) + return _render_instance_page(content, base, object) + + +# NOTE: Next endpoint (catch-all route) must be located after all other routes! +@router.get("/{rest_of_path:path}") +async def catch_all(request: fastapi.Request, rest_of_path: str): + del rest_of_path + return state.templates.TemplateResponse("index.html", {"request": request}) + + +app.include_router(router) diff --git a/capella_model_explorer/backend/state.py b/capella_model_explorer/backend/state.py new file mode 100644 index 0000000..4bc2cb4 --- /dev/null +++ b/capella_model_explorer/backend/state.py @@ -0,0 +1,34 @@ +# Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import pathlib +import time +import typing as t + +import prometheus_client + +if t.TYPE_CHECKING: + import capellambse + import fastapi.templating + import jinja2 + + import capella_model_explorer.backend.templates + +diff: dict = {} +idle_time_gauge = prometheus_client.Gauge( + "idletime_minutes", + "Time in minutes since the last user interaction", +) +jinja_env: jinja2.Environment +last_interaction = time.time() +model: capellambse.MelodyModel +object_diff: dict = {} +templates: fastapi.templating.Jinja2Templates +templates_loader: capella_model_explorer.backend.templates.TemplateLoader +templates_index: ( + capella_model_explorer.backend.templates.TemplateCategories | None +) +templates_path = pathlib.Path(os.getenv("TEMPLATES_DIR", "templates")) diff --git a/entrypoint.sh b/entrypoint.sh index 3ea167d..dd5b719 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,4 +7,6 @@ sed -i "s|__ROUTE_PREFIX__|${ROUTE_PREFIX}|g" ./frontend/dist/static/env.js sed -i "s|href=\"/|href=\"${ROUTE_PREFIX}/|g" ./frontend/dist/index.html sed -i "s|src=\"/|src=\"${ROUTE_PREFIX}/|g" ./frontend/dist/index.html -exec python -m capella_model_explorer.backend ${MODEL_ENTRYPOINT} /views +export MODEL="${MODEL_ENTRYPOINT}" +export TEMPLATES_DIR="/views" +exec uvicorn --host=0.0.0.0 --port=8000 capella_model_explorer.backend.main:app diff --git a/tests/test_capella_model_explorer.py b/tests/test_capella_model_explorer.py index 0a0030b..11b00ed 100644 --- a/tests/test_capella_model_explorer.py +++ b/tests/test_capella_model_explorer.py @@ -29,7 +29,7 @@ def test_template_loading(): "description": "This is a test template", "scope": {"type": "System", "below": "System"}, } - Template(**template_raw) + Template.model_validate(template_raw) def test_category_loading(): @@ -44,7 +44,7 @@ def test_category_loading(): "idx": "test", "templates": [template_raw], } - TemplateCategory(**category_raw) + TemplateCategory.model_validate(category_raw) def test_index_templates():