diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 99656bb..a0ee908 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -18,12 +18,11 @@ jobs: matrix: os: [ubuntu-latest] python_version: - - "3.10" - "3.11" - "3.12" include: - os: windows-latest - python_version: "3.10" + python_version: "3.11" steps: - uses: actions/checkout@v2 - name: Set up Python ${{matrix.python_version}} @@ -56,7 +55,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: "3.10" + python-version: "3.11" - name: Install dependencies run: |- python -m pip install -U pip diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 47563ce..68e5d0a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: - python-version: "3.10" + python-version: "3.11" - name: Upgrade pip run: |- python -m pip install -U pip diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 36f00cf..5030375 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: hooks: - id: mypy additional_dependencies: - - capellambse==0.5.70 + - capellambse==0.5.72 - types-pyyaml==6.0.11 - repo: https://github.com/pylint-dev/pylint rev: v3.2.7 diff --git a/capella_model_explorer/backend/__main__.py b/capella_model_explorer/backend/__main__.py index b6a5ccf..dfacaa6 100644 --- a/capella_model_explorer/backend/__main__.py +++ b/capella_model_explorer/backend/__main__.py @@ -25,7 +25,12 @@ default=PATH_TO_TEMPLATES, ) def run(model: capellambse.MelodyModel, templates: Path): - backend = explorer.CapellaModelExplorerBackend(Path(templates), model) + + backend = explorer.CapellaModelExplorerBackend( + Path(templates), + model, + ) + uvicorn.run(backend.app, host=HOST, port=int(PORT)) diff --git a/capella_model_explorer/backend/explorer.py b/capella_model_explorer/backend/explorer.py index cc531a2..a51164c 100644 --- a/capella_model_explorer/backend/explorer.py +++ b/capella_model_explorer/backend/explorer.py @@ -16,7 +16,7 @@ import markupsafe import prometheus_client import yaml -from fastapi import APIRouter, FastAPI, Request +from fastapi import APIRouter, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles @@ -27,7 +27,9 @@ 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__ @@ -39,6 +41,15 @@ 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) @@ -80,6 +91,8 @@ def __post_init__(self): 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): @@ -139,7 +152,12 @@ 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) + 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( @@ -276,6 +294,38 @@ async def catch_all(request: Request, rest_of_path: str): async def version(): return {"version": self.app.version} + @self.app.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"]) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + @self.app.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.app.get("/api/commits") + async def get_commits(): + result = model_diff.populate_commits(self.model) + return result + + @self.app.get("/api/diff") + async def get_diff(): + if self.diff: + return self.diff + return { + "error": "No data available. Please compare two commits first." + } + def index_template(template, templates, templates_grouped, filename=None): idx = filename if filename else template["idx"] @@ -306,3 +356,24 @@ def index_templates( 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" in obj: + if obj["children"]: + create_diff_lookup(obj["children"], lookup) + except Exception: + pass + return lookup diff --git a/capella_model_explorer/backend/model_diff.py b/capella_model_explorer/backend/model_diff.py new file mode 100644 index 0000000..a569578 --- /dev/null +++ b/capella_model_explorer/backend/model_diff.py @@ -0,0 +1,484 @@ +# Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import datetime +import enum +import logging +import pathlib +import subprocess +import typing as t + +import capellambse +import capellambse.model as m +import capellambse.model.common as c +import diff_match_patch +import typing_extensions as te +from capellambse.filehandler import git, local + +logger = logging.getLogger(__name__) +NUM_COMMITS = "20" + + +class RevisionInfo(te.TypedDict, total=False): + hash: te.Required[str] + """The revision hash.""" + revision: str + """The original revision passed to the diff tool.""" + author: str + """The author of the revision, in "Name " format.""" + date: datetime.datetime + """The time and date of the revision.""" + description: str + """The description of the revision, i.e. the commit message.""" + subject: str + """The subject of the commit.""" + tag: str | None + """The tag of the commit.""" + + +class Metadata(te.TypedDict): + model: dict[str, t.Any] + """The 'modelinfo' used to load the models, sans the revision key.""" + new_revision: RevisionInfo + old_revision: RevisionInfo + + +class BaseObject(te.TypedDict): + uuid: str + display_name: str + """Name for displaying in the frontend. + + This is usually the ``name`` attribute of the "current" version of + the object. + """ + + +class FullObject(BaseObject, te.TypedDict): + attributes: dict[str, t.Any] + """All attributes that the object has (or had).""" + + +class ChangedAttribute(te.TypedDict): + previous: t.Any + """The old value of the attribute.""" + current: t.Any + """The new value of the attribute.""" + + +class ChangedObject(BaseObject, te.TypedDict): + attributes: dict[str, ChangedAttribute] + """The attributes that were changed.""" + + +class ObjectChange(te.TypedDict, total=False): + created: list[FullObject] + """Contains objects that were created.""" + deleted: list[FullObject] + """Contains objects that were deleted.""" + modified: list[ChangedObject] + + +ObjectLayer: te.TypeAlias = "dict[str, ObjectChange]" + + +class ObjectChanges(te.TypedDict, total=False): + oa: ObjectLayer + """Changes to objects from the OperationalAnalysis layer.""" + sa: ObjectLayer + """Changes to objects from the SystemAnalysis layer.""" + la: ObjectLayer + """Changes to objects from the LogicalAnalysis layer.""" + pa: ObjectLayer + """Changes to objects from the PhysicalAnalysis layer.""" + epbs: ObjectLayer + """Changes to objects from the EPBS layer.""" + + +class ChangeSummaryDocument(te.TypedDict): + metadata: Metadata + objects: ObjectChanges + + +def init_model(model: capellambse.MelodyModel) -> t.Optional[str]: + """Initialize the model and return the path if it's a git repository.""" + file_handler = model.resources["\x00"] + path = file_handler.path + + if isinstance(file_handler, git.GitFileHandler): + path = file_handler.cache_dir + elif ( + isinstance(file_handler, local.LocalFileHandler) + and file_handler.rootdir.joinpath(".git").is_dir() + ): + pass + else: + return None + return str(path) + + +def populate_commits(model: capellambse.MelodyModel): + path = init_model(model) + if not path: + return path + commits = get_commit_hashes(path) + return commits + + +def _serialize_obj(obj: t.Any) -> t.Any: + if isinstance(obj, c.GenericElement): + return {"uuid": obj.uuid, "display_name": _get_name(obj)} + elif isinstance(obj, c.ElementList): + return [{"uuid": i.uuid, "display_name": _get_name(i)} for i in obj] + elif isinstance(obj, (enum.Enum, enum.Flag)): + return obj.name + return obj + + +def get_diff_data(model: capellambse.MelodyModel, head: str, prev: str): + path = init_model(model) + if not path: + return None + path = str(pathlib.Path(path).resolve()) + old_model = capellambse.MelodyModel(path=f"git+{path}", revision=prev) + + metadata: Metadata = { + "model": {"path": path, "entrypoint": None}, + "old_revision": _get_revision_info(path, prev), + "new_revision": _get_revision_info(path, head), + } + + objects = compare_models(old_model, model) + diff_data: ChangeSummaryDocument = { + "metadata": metadata, + "objects": objects, + } + return _traverse_and_diff(diff_data) + + +def _get_revision_info( + repo_path: str, + revision: str, +) -> RevisionInfo: + """Return the revision info of the given model.""" + author, date_str, description = ( + subprocess.check_output( + ["git", "log", "-1", "--format=%aN%x00%aI%x00%B", revision], + cwd=repo_path, + encoding="utf-8", + ) + .strip() + .split("\x00") + ) + subject = description.splitlines()[0] + try: + tag = subprocess.check_output( + ["git", "tag", "--points-at", revision], + cwd=repo_path, + encoding="utf-8", + stderr=subprocess.DEVNULL, + ).strip() + except subprocess.CalledProcessError: + tag = None + + return { + "hash": revision, + "revision": revision, + "author": author, + "date": datetime.datetime.fromisoformat(date_str), + "description": description.rstrip(), + "subject": subject, + "tag": tag if tag else None, + } + + +def get_commit_hashes(path: str) -> list[RevisionInfo]: + """Return the commit hashes of the given model.""" + commit_hashes = subprocess.check_output( + ["git", "log", "-n", NUM_COMMITS, "--format=%H"], + cwd=path, + encoding="utf-8", + ).splitlines() + commits = [_get_revision_info(path, c) for c in commit_hashes] + return commits + + +def _get_name(obj: m.diagram.Diagram | c.ModelObject) -> str: + """Return the object's name. + + If the object doesn't own a name, its type is returned instead. + """ + return getattr(obj, "name", None) or f"[{type(obj).__name__}]" + + +def compare_models( + old: capellambse.MelodyModel, + new: capellambse.MelodyModel, +): + """Compare all elements in the given models.""" + changes = {} + for layer in ( + "oa", + "sa", + "la", + "pa", + ): + layer_old = getattr(old, layer) + layer_new = getattr(new, layer) + changes[layer] = compare_objects(layer_old, layer_new, old) + return changes + + +def compare_objects( + old_object: capellambse.ModelObject | None, + new_object: capellambse.ModelObject, + old_model: capellambse.MelodyModel, +): + + assert old_object is None or type(old_object) is type( + new_object + ), f"{type(old_object).__name__} != {type(new_object).__name__}" + + attributes: dict[str, ChangedAttribute] = {} + children: dict[str, t.Any] = {} + for attr in dir(type(new_object)): + acc = getattr(type(new_object), attr, None) + if isinstance(acc, c.AttributeProperty): + _handle_attribute_property( + attr, old_object, new_object, attributes + ) + elif isinstance( + acc, c.AttrProxyAccessor | c.LinkAccessor | c.ParentAccessor + ): + _handle_accessors(attr, old_object, new_object, attributes) + elif ( + # pylint: disable=unidiomatic-typecheck + type(acc) is c.RoleTagAccessor + or (type(acc) is c.DirectProxyAccessor and not acc.rootelem) + ): + _handle_direct_accessors( + attr, old_object, new_object, children, old_model + ) + + if attributes or children or old_object is None: + return { + "display_name": _get_name(new_object), + "uuid": getattr(new_object, "uuid", None), + "change": "created" if old_object is None else "modified", + "attributes": attributes, + "children": children, + } + return None + + +def _handle_attribute_property(attr, old_object, new_object, attributes): + if attr != "uuid": + try: + old_value = getattr(old_object, attr, None) + new_value = getattr(new_object, attr, None) + if old_value != new_value: + attributes[attr] = { + "previous": _serialize_obj(old_value), + "current": _serialize_obj(new_value), + } + except Exception as e: + print(f"Failed to process attribute '{attr}': {e}") + else: + pass + + +def _handle_accessors(attr, old_object, new_object, attributes): + old_value = getattr(old_object, attr, None) + new_value = getattr(new_object, attr, None) + if isinstance(old_value, c.GenericElement | type(None)) and isinstance( + new_value, c.GenericElement | type(None) + ): + if old_value is new_value is None: + pass + elif old_value is None: + attributes[attr] = { + "previous": None, + "current": _serialize_obj(new_value), + } + elif new_value is None: + attributes[attr] = { + "previous": _serialize_obj(old_value), + "current": None, + } + elif old_value.uuid != new_value.uuid: + attributes[attr] = { + "previous": _serialize_obj(old_value), + "current": _serialize_obj(new_value), + } + elif isinstance(old_value, c.ElementList | type(None)) and isinstance( + new_value, c.ElementList + ): + old_value = old_value or [] + if [i.uuid for i in old_value] != [i.uuid for i in new_value]: + attributes[attr] = { + "previous": _serialize_obj(old_value), + "current": _serialize_obj(new_value), + } + else: + raise RuntimeError( + f"Type mismatched between new value and old value:" + f" {type(old_value)} != {type(new_value)}" + ) + + +def _handle_direct_accessors( + attr, old_object, new_object, children, old_model +): + old_value = getattr(old_object, attr, None) + new_value = getattr(new_object, attr, None) + if isinstance(old_value, c.GenericElement | type(None)) and isinstance( + new_value, c.GenericElement | type(None) + ): + if old_value is new_value is None: + pass + elif old_value is None: + assert new_value is not None + children[new_value.uuid] = compare_objects( + None, new_value, old_model + ) + elif new_value is None: + children[old_value.uuid] = { + "display_name": _get_name(old_value), + "change": "deleted", + } + elif old_value.uuid == new_value.uuid: + result = compare_objects(old_value, new_value, old_model) + if result: + children[new_value.uuid] = result + else: + children[old_value.uuid] = { + "display_name": _get_name(old_value), + "change": "deleted", + } + children[new_value.uuid] = compare_objects( + None, new_value, old_model + ) + elif isinstance(old_value, c.ElementList | type(None)) and isinstance( + new_value, c.ElementList + ): + old_value = old_value or [] + for item in new_value: + try: + old_item = old_model.by_uuid(item.uuid) + except KeyError: + old_item = None + if old_item is None: + children[item.uuid] = compare_objects(None, item, old_model) + else: + result = compare_objects(old_item, item, old_model) + if result: + children[item.uuid] = result + + for item in old_value: + try: + new_object._model.by_uuid(item.uuid) + except KeyError: + children[item.uuid] = { + "display_name": _get_name(item), + "change": "deleted", + } + + +def _traverse_and_diff(data) -> dict[str, t.Any]: + """Traverse the data and perform diff on text fields. + + This function recursively traverses the data and performs an HTML + diff on every "name" and "description" field that has child keys + "previous" and "current". The result is stored in a new child key + "diff". + """ + updates = {} + for key, value in data.items(): + if ( + isinstance(value, dict) + and "previous" in value + and "current" in value + ): + curr_type = type(value["current"]) + if curr_type == str: + diff = _diff_text( + (value["previous"] or "").splitlines(), + value["current"].splitlines(), + ) + updates[key] = {"diff": diff} + elif curr_type == dict: + diff = _diff_objects(value["previous"], value["current"]) + updates[key] = {"diff": diff} + elif curr_type == list: + diff = _diff_lists(value["previous"], value["current"]) + updates[key] = {"diff": diff} + elif key == "description": + prev, curr = _diff_description( + (value["previous"] or "").splitlines(), + value["current"].splitlines(), + ) + if prev == curr == None: + continue + updates[key] = {"diff": ""} + value.update({"previous": prev, "current": curr}) + elif isinstance(value, list): + for item in value: + _traverse_and_diff(item) + elif isinstance(value, dict): + _traverse_and_diff(value) + for key, value in updates.items(): + data[key].update(value) + return data + + +def _diff_text(previous, current) -> str: + dmp = diff_match_patch.diff_match_patch() + diff = dmp.diff_main("\n".join(previous), "\n".join(current)) + dmp.diff_cleanupSemantic(diff) + return dmp.diff_prettyHtml(diff) + + +def _diff_objects(previous, current) -> str: + return ( + f"{previous['display_name']} → " if previous else "" + ) + f"{current['display_name']}" + + +def _diff_lists(previous, current): + out = {} + previous = {item["uuid"]: item for item in previous} + for item in current: + if item["uuid"] not in previous: + out[item["uuid"]] = f"{item['display_name']}" + elif item["uuid"] in previous: + if item["display_name"] != previous[item["uuid"]]["display_name"]: + out[item["uuid"]] = ( + f"{_diff_objects(previous[item['uuid']], item)}" + ) + else: + out[item["uuid"]] = f"{item['display_name']}" + current = {item["uuid"]: item for item in current} + for uuid in previous: + if uuid not in current: + out[uuid] = f"{previous[uuid]['display_name']}" + return out + + +def _diff_description( + previous, current +) -> t.Tuple[str, str] | t.Tuple[None, None]: + if previous == current == None: + return None, None + dmp = diff_match_patch.diff_match_patch() + diff = dmp.diff_main("\n".join(previous), "\n".join(current)) + dmp.diff_cleanupSemantic(diff) + previous_result = "" + current_result = "" + for operation, text in diff: + if operation == 0: + previous_result += text + current_result += text + elif operation == -1: + previous_result += f"{text}" + elif operation == 1: + current_result += f"{text}" + return previous_result, current_result diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ce8c1a8..456143c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,11 +1,12 @@ // Copyright DB InfraGO AG and contributors // SPDX-License-Identifier: Apache-2.0 -import { Route, BrowserRouter as Router, Routes } from "react-router-dom"; -import { API_BASE_URL, ROUTE_PREFIX } from "./APIConfig"; -import "./App.css"; -import { HomeView } from "./views/HomeView"; -import { TemplateView } from "./views/TemplateView"; +import { Route, BrowserRouter as Router, Routes } from 'react-router-dom'; +import { API_BASE_URL, ROUTE_PREFIX } from './APIConfig'; +import './App.css'; +import { HomeView } from './views/HomeView'; +import { TemplateView } from './views/TemplateView'; +import { ModelComparisonView } from './views/ModelComparisonView'; function App() { return ( @@ -20,6 +21,10 @@ function App() { path="/:templateName/:objectID" element={} /> + } + /> ); diff --git a/frontend/src/components/Breadcrumbs.jsx b/frontend/src/components/Breadcrumbs.jsx index 31cb7b2..414d63e 100644 --- a/frontend/src/components/Breadcrumbs.jsx +++ b/frontend/src/components/Breadcrumbs.jsx @@ -49,7 +49,6 @@ export const Breadcrumbs = () => { labels[to] = pathnames[i]; } } - console.log(labels); setBreadcrumbLabels(labels); }; @@ -81,9 +80,7 @@ export const Breadcrumbs = () => { const label = breadcrumbLabels[to] || value; return ( -
  • +
  • {!last && ( { + const [expandedNodes, setExpandedNodes] = useState({}); + + const getNodeClass = (changed, attributes) => { + const hasNonEmptyAttributes = + attributes && Object.keys(attributes).length > 0; + switch (changed) { + case 'created': + return 'text-green-500'; + case 'deleted': + return 'text-red-500 line-through cursor-not-allowed dark:text-custom-dark-error'; + case 'modified': + return hasNonEmptyAttributes + ? 'text-orange-600' + : 'text-gray-500 dark:text-gray-200'; + default: + return 'text-gray-900 dark:text-gray-100'; + } + }; + + const getIcon = (changed, attributes) => { + const hasNonEmptyAttributes = + attributes && Object.keys(attributes).length > 0; + switch (changed) { + case 'created': + return ; + case 'deleted': + return ( + + ); + case 'modified': + return hasNonEmptyAttributes ? ( + + ) : ( + + ); + default: + return null; + } + }; + + const handleToggle = (uuid) => { + setExpandedNodes((prev) => ({ + ...prev, + [uuid]: !prev[uuid] + })); + }; + + const handleLeafClick = (node) => { + setObjectID(node.uuid); + }; + + const flattenNodes = (node) => { + let nodes = []; + if (node.children) { + Object.values(node.children).forEach((child) => { + nodes = nodes.concat(flattenNodes(child)); + }); + } + nodes.push(node); + return nodes; + }; + + const filterNodes = (node) => { + const allNodes = flattenNodes(node); + return allNodes.filter((n) => { + if (filterStatus !== 'all' && n.change !== filterStatus) { + return false; + } + if (!searchTerm) return true; + return n.display_name.toLowerCase().includes(searchTerm.toLowerCase()); + }); + }; + + const renderNode = (node) => { + const hasChildren = node.children && Object.keys(node.children).length > 0; + const isExpanded = expandedNodes[node.uuid] || false; + return ( +
    +
    +
    + {hasChildren ? ( + + ) : ( + + )} + handleLeafClick(node)} + className={`flex-1 cursor-pointer truncate ${node.change === 'deleted' ? 'pointer-events-none' : ''}`} + title={node.display_name}> + {node.display_name} + +
    + + {getIcon(node.change, node.attributes)} + +
    + {isExpanded && hasChildren && ( +
    + {Object.keys(node.children).map((childId) => ( + + ))} +
    + )} +
    + ); + }; + + const filteredNodes = filterNodes(node); + return ( +
    + {searchTerm || filterStatus !== 'all' + ? filteredNodes.map((filteredNode) => renderNode(filteredNode)) + : renderNode(node)} +
    + ); +}; diff --git a/frontend/src/components/DiffView.jsx b/frontend/src/components/DiffView.jsx new file mode 100644 index 0000000..f75580e --- /dev/null +++ b/frontend/src/components/DiffView.jsx @@ -0,0 +1,120 @@ +// Copyright DB InfraGO AG and contributors +// SPDX-License-Identifier: Apache-2.0 +import { useEffect, useState } from 'react'; +import { SVGDisplay } from './SVGDisplay'; +import { API_BASE_URL } from '../APIConfig'; +import { Spinner } from './Spinner'; + +export const DiffView = ({ objectID, endpoint }) => { + const [details, setDetails] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const fetchTemplate = async () => { + setIsLoading(true); + + try { + const postResponse = await fetch(`${API_BASE_URL}/object-diff`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ uuid: objectID }) + }); + + if (!postResponse.ok) { + throw new Error( + `Failed to post diff data: ${postResponse.statusText}` + ); + } + + let url; + if (objectID) { + url = `${endpoint}object_comparison/${objectID}`; + } else { + throw new Error('Object ID is missing or invalid'); + } + + const getResponse = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'text/html' + } + }); + + if (!getResponse.ok) { + throw new Error( + `Failed to fetch object comparison: ${getResponse.statusText}` + ); + } + + const data = await getResponse.text(); + + const parser = new DOMParser(); + const doc = parser.parseFromString(data, 'text/html'); + const contentItems = []; + + doc.body.childNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.tagName === 'svg') { + contentItems.push({ + type: 'SVGDisplay', + content: node.outerHTML + }); + } else { + contentItems.push({ + type: 'HTML', + content: node.outerHTML + }); + } + } + }); + + setDetails(contentItems); + } catch (error) { + console.error('Error fetching template:', error); + setDetails([ + { type: 'Error', content: `Error fetching data: ${error.message}` } + ]); + } finally { + setIsLoading(false); + } + }; + + if (objectID) { + fetchTemplate(); + } + }, [objectID]); + + return ( +
    + {isLoading ? ( + + ) : ( +
    + {details.map((item, idx) => { + if (item.type === 'SVGDisplay') { + return ; + } else { + return ( +
    + ); + } + })} +
    + )} +
    + ); +}; diff --git a/frontend/src/components/ModelDiff.jsx b/frontend/src/components/ModelDiff.jsx new file mode 100644 index 0000000..f88e3c0 --- /dev/null +++ b/frontend/src/components/ModelDiff.jsx @@ -0,0 +1,227 @@ +// Copyright DB InfraGO AG and contributors +// SPDX-License-Identifier: Apache-2.0 + +import { API_BASE_URL, ROUTE_PREFIX } from '../APIConfig'; +import React, { useState } from 'react'; +import { Spinner } from './Spinner'; + +export const ModelDiff = ({ onRefetch, hasDiffed }) => { + const [isLoading, setIsLoading] = useState(false); + const [completeLoading, setCompleteLoading] = useState(false); + const [commitDetails, setCommitDetails] = useState({}); + const [selectionOptions, setSelectionOptions] = useState([]); + const [isPopupVisible, setIsPopupVisible] = useState(false); + const [selectedDetails, setSelectedDetails] = useState(''); + const [error, setError] = useState(''); + const [selectedOption, setSelectedOption] = useState(''); + + const handleSelectChange = (e) => { + const option = e.target.value; + setSelectedOption(option); + const selectedValue = JSON.parse(e.target.value); + setSelectedDetails(selectedValue); + }; + + const handleGenerateDiff = async () => { + if (!commitDetails[0].hash || !selectedDetails.hash) { + alert('Please select a version.'); + return; + } + setCompleteLoading(false); + setIsLoading(true); + try { + const url = API_BASE_URL + '/compare'; + const response = await postData(url, { + head: commitDetails[0].hash, + prev: selectedDetails.hash + }); + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + } catch (error) { + console.error('Error:', error); + } finally { + setIsLoading(false); + setCompleteLoading(true); + onRefetch(); + } + }; + + const postData = async (url = '', data = {}) => { + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response; + } catch (error) { + console.error('Error in postData:', error); + throw error; + } + }; + + async function openModelCompareDialog() { + try { + const response = await fetch(API_BASE_URL + '/commits'); + if (!response.ok) { + throw new Error( + 'Failed to fetch commits info: ' + response.statusText + ); + } + const data = await response.json(); + if (data === null) { + alert('No commits found.'); + throw new Error('No commits found.'); + } + setCommitDetails(data); + const options = data.map((commit) => ({ + value: JSON.stringify(commit), + label: `${commit.hash.substring(0, 7)} ${commit.tag ? `(${commit.tag})` : ''} - ${commit.subject} - Created on ${commit.date.substring(0, 10)}` + })); + + setSelectionOptions(options); + setIsPopupVisible(true); + } catch (err) { + setError(err.message); + } + } + + return ( +
    + + {isPopupVisible && ( +
    +
    { + if (!isLoading) { + setIsPopupVisible(false); + setCompleteLoading(false); + } + }}>
    +
    e.stopPropagation()}> +
    +
    + {error ? ( +
    +

    + Cannot generate model diff: {error} +

    +

    + Select 'Deep clone' before running the session or run + model within git repo. +

    +
    + ) : ( + <> + +
    +

    Hash: {commitDetails[0].hash}

    + {commitDetails[0].tag && ( +

    Tag: {commitDetails[0].tag}

    + )} +

    Author: {commitDetails[0].author}

    +

    Description: {commitDetails[0].description}

    +

    Date: {commitDetails[0].date}

    +
    + + {selectedDetails && ( +
    +

    Hash: {selectedDetails.hash}

    + {selectedDetails.tag && ( +

    Tag: {selectedDetails.tag}

    + )} +

    Author: {selectedDetails.author}

    +

    Description: {selectedDetails.description}

    +

    Date: {selectedDetails.date.substring(0, 10)}

    +
    + )} + {isLoading && ( +
    + +
    + )} +
    + + {completeLoading && ( + + )} +
    + + )} +
    +
    +
    +
    + )} +
    + ); +}; diff --git a/frontend/src/components/Spinner.jsx b/frontend/src/components/Spinner.jsx index 74596af..6bb3780 100644 --- a/frontend/src/components/Spinner.jsx +++ b/frontend/src/components/Spinner.jsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export const Spinner = () => ( -
    +
    ( -
    onClickCallback(idx)} - className={ - 'm-2 mt-6 max-w-sm cursor-pointer rounded-lg bg-gray-200 shadow-md ' + - 'hover:bg-custom-light dark:bg-custom-dark-2 dark:shadow-dark ' + - 'dark:hover:bg-custom-dark-4' - }> -
    -
    -
    - {name} -
    - {instanceCount === 1 && ( - - - - )} - {instanceCount > 1 && ( - - {' '} - {instanceCount} - - )} -
    -

    - {description} -

    -
    - {error && ( - - {' '} - {error} - - )} - {isStable && ( - - {' '} - Stable - - )} - {isExperimental && ( - - {' '} - Experimental - - )} - {isDocument && ( - - Document - - )} +}) => { + return ( +
    onClickCallback(idx)} + className={ + 'm-2 mt-6 max-w-sm cursor-pointer rounded-lg bg-gray-200 shadow-md ' + + 'hover:bg-custom-light dark:bg-custom-dark-2 dark:shadow-dark ' + + 'dark:hover:bg-custom-dark-4' + }> +
    +
    +
    + {name} +
    + {instanceCount === 1 && ( + + + + )} + {instanceCount > 1 && ( + + {' '} + {instanceCount} + + )} +
    +

    + {description} +

    +
    + {error && ( + + {' '} + {error} + + )} + {isStable && ( + + {' '} + Stable + + )} + {isExperimental && ( + + {' '} + Experimental + + )} + {isDocument && ( + + Document + + )} +
    -
    -); + ); +}; diff --git a/frontend/src/components/TemplateDetails.jsx b/frontend/src/components/TemplateDetails.jsx index cb11dd6..8c126bc 100644 --- a/frontend/src/components/TemplateDetails.jsx +++ b/frontend/src/components/TemplateDetails.jsx @@ -107,10 +107,12 @@ export const TemplateDetails = ({ endpoint, onSingleInstance }) => { ) : ( details.instanceList && details.instanceList - .filter((object) => - object.name && object.name - .toLowerCase() - .includes(filterText.toLowerCase()) + .filter( + (object) => + object.name && + object.name + .toLowerCase() + .includes(filterText.toLowerCase()) ) .sort((a, b) => a.name.localeCompare(b.name)) .map((object) => ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 4928021..8c3f54a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -257,3 +257,27 @@ input:checked + .slider:before { .dark .header-button:hover { background-color: var(--custom-light); } + +.deleted, +del { + color: red; +} + +.created, +ins { + color: #00aa00; +} + +.text-removed, +del > p { + background: #ffe6e6; + display: inline; + text-decoration: none; +} + +.text-added, +ins > p { + background: #e6ffe6; + text-decoration: none; + display: inline; +} diff --git a/frontend/src/views/HomeView.jsx b/frontend/src/views/HomeView.jsx index 4f73082..8a1aa13 100644 --- a/frontend/src/views/HomeView.jsx +++ b/frontend/src/views/HomeView.jsx @@ -1,31 +1,78 @@ // Copyright DB InfraGO AG and contributors // SPDX-License-Identifier: Apache-2.0 -import React, { useState, useEffect } from "react"; -import { WiredTemplatesList } from "../components/WiredTemplatesList"; -import { API_BASE_URL } from "../APIConfig"; -import { ThemeSwitcher } from "../components/ThemeSwitcher"; -import { SoftwareVersion } from "../components/SoftwareVersion"; +import React, { useState, useEffect } from 'react'; +import { WiredTemplatesList } from '../components/WiredTemplatesList'; +import { API_BASE_URL } from '../APIConfig'; +import { SoftwareVersion } from '../components/SoftwareVersion'; +import { ModelDiff } from '../components/ModelDiff'; export const HomeView = () => { const [modelInfo, setModelInfo] = useState(null); const [error, setError] = useState(null); + const [headDate, setHeadDate] = useState(null); + const [headTag, setHeadTag] = useState(null); + const [comparedVersionInfo, setComparedVersionInfo] = useState(null); + const [hasDiffed, setHasDiffed] = useState(false); useEffect(() => { const fetchModelInfo = async () => { try { - const response = await fetch(API_BASE_URL + "/model-info"); + const response = await fetch(API_BASE_URL + '/model-info'); const data = await response.json(); setModelInfo(data); } catch (err) { - setError("Failed to fetch model info: " + err.message); + setError('Failed to fetch model info: ' + err.message); } - document.body.style.height = "auto"; + document.body.style.height = 'auto'; }; fetchModelInfo(); }, []); + const fetchDiffInfo = async () => { + try { + const response = await fetch(API_BASE_URL + '/diff'); + const data = await response.json(); + if (data.error) { + console.log('Error fetching model diff:', data.error); + setComparedVersionInfo(null); + } else { + setComparedVersionInfo(data); + setHasDiffed(true); + } + } catch (err) { + console.log('Failed to fetch model diff: ' + err.message); + setComparedVersionInfo(null); + } + }; + + useEffect(() => { + fetchDiffInfo(); + }, []); + + useEffect(() => { + const fetchHeadDate = async () => { + try { + const response = await fetch(API_BASE_URL + '/commits'); + const data = await response.json(); + if (data && data.length > 0) { + setHeadDate(data[0].date.substring(0, 10)); + setHeadTag(data[0].tag); + } else { + console.log('No commits found'); + setHeadDate(''); + setHeadTag(''); + } + } catch (err) { + console.log('Failed to fetch head date: ' + err.message); + setHeadDate(''); + setHeadTag(''); + } + }; + fetchHeadDate(); + }, []); + if (error) { return (
    @@ -38,19 +85,42 @@ export const HomeView = () => {
    {modelInfo && ( -
    -

    {modelInfo.title}

    - {modelInfo.capella_version && ( -

    Capella Version: {modelInfo.capella_version}

    - )} - {modelInfo.revision &&

    Revision: {modelInfo.revision}

    } - {modelInfo.branch &&

    Branch: {modelInfo.branch}

    } - {modelInfo.hash &&

    Commit Hash: {modelInfo.hash}

    } -
    -
    + <> +
    +

    {modelInfo.title}

    + {modelInfo.capella_version && ( +

    Capella Version: {modelInfo.capella_version}

    + )} + {modelInfo.revision &&

    Revision: {modelInfo.revision}

    } + {modelInfo.branch &&

    Branch: {modelInfo.branch}

    } + {modelInfo.hash &&

    Current Commit Hash: {modelInfo.hash}

    } + {headDate &&

    Date Created: {headDate}

    } + {headTag &&

    Tag: {headTag}

    } + + {comparedVersionInfo && ( +
    +

    Compared to Version:

    + {comparedVersionInfo.metadata.old_revision.tag && ( +

    Tag: {comparedVersionInfo.metadata.old_revision.tag}

    + )} +

    + Commit Hash:{' '} + {comparedVersionInfo.metadata.old_revision.hash} +

    +

    + Date Created:{' '} + {comparedVersionInfo.metadata.old_revision.date.substring( + 0, + 10 + )} +

    +
    + )} +
    +
    + )}
    diff --git a/frontend/src/views/ModelComparisonView.jsx b/frontend/src/views/ModelComparisonView.jsx new file mode 100644 index 0000000..44515ca --- /dev/null +++ b/frontend/src/views/ModelComparisonView.jsx @@ -0,0 +1,137 @@ +// Copyright DB InfraGO AG and contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { useEffect, useState } from 'react'; +import { Header } from '../components/Header'; +import { DiffExplorer } from '../components/DiffExplorer'; +import { DiffView } from '../components/DiffView'; +import { API_BASE_URL } from '../APIConfig'; +import { useMediaQuery } from 'react-responsive'; + +export const ModelComparisonView = ({ endpoint }) => { + const [modelDiff, setModelDiff] = useState(null); + const [objectID, setObjectID] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [filterStatus, setFilterStatus] = useState('all'); + const isSmallScreen = useMediaQuery({ query: '(max-width: 1080px)' }); + const [isSidebarVisible, setIsSidebarVisible] = useState(!isSmallScreen); + + const handleSearchChange = (event) => { + setSearchTerm(event.target.value); + }; + const handleStatusChange = (event) => { + setFilterStatus(event.target.value); + }; + + const filterNodes = (node) => { + if (!searchTerm) return true; + if (node.display_name.toLowerCase().includes(searchTerm.toLowerCase())) + return true; + if (node.children) { + return Object.values(node.children).some(filterNodes); + } + return false; + }; + + useEffect(() => { + const fetchModelDiff = async () => { + try { + const response = await fetch(API_BASE_URL + '/diff'); + const data = await response.json(); + setModelDiff(data); + } catch (err) { + setModelDiff({}); + } + document.body.style.height = 'auto'; + }; + + fetchModelDiff(); + }, []); + + useEffect(() => {}, [objectID]); + useEffect(() => { + setIsSidebarVisible(!isSmallScreen); + }, [isSmallScreen]); + + const toggleSidebar = () => { + setIsSidebarVisible(!isSidebarVisible); + }; + + return ( +
    +
    + {isSmallScreen && ( + + )} +
    +
    +
    + + +
    + {modelDiff && + modelDiff.objects && + Object.keys(modelDiff.objects).map( + (layer) => + modelDiff.objects[layer] && ( + + ) + )} +
    +
    +
    + {objectID && } +
    +
    +
    +
    + ); +}; diff --git a/pyproject.toml b/pyproject.toml index afa81ab..a823cf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dynamic = ["version"] name = "capella-model-explorer" description = "A webapp for exploring Capella models" readme = "README.md" -requires-python = ">=3.10, <3.13" +requires-python = ">=3.11, <3.13" license = { text = "Apache-2.0" } authors = [{ name = "DB InfraGO AG" }] keywords = [] @@ -21,13 +21,14 @@ classifiers = [ "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] dependencies = [ - "capellambse>=0.5.70,<0.6", + "capellambse>=0.5.72,<0.6", "capellambse-context-diagrams", + "capella-diff-tools", + "diff-match-patch", "jinja2", "fastapi", "uvicorn", @@ -45,7 +46,7 @@ test = ["pytest", "pytest-cov"] [tool.black] line-length = 79 -target-version = ["py310"] +target-version = ["py311"] [tool.coverage.run] branch = true @@ -75,7 +76,7 @@ no_implicit_optional = true show_error_codes = true warn_redundant_casts = true warn_unreachable = true -python_version = "3.10" +python_version = "3.11" [[tool.mypy.overrides]] module = ["tests.*"] @@ -85,7 +86,7 @@ allow_untyped_defs = true [[tool.mypy.overrides]] # Untyped third party libraries module = [ - # ... + "diff_match_patch", ] ignore_missing_imports = true diff --git a/templates/common_macros.html.j2 b/templates/common_macros.html.j2 index a7d2314..73e2268 100644 --- a/templates/common_macros.html.j2 +++ b/templates/common_macros.html.j2 @@ -46,9 +46,19 @@ {% endif %} {% endmacro %} -{% macro show_other_attributes(object, excluded=[], hide_rules_check=False, hide_unset_attrs=False) %} +{% macro show_other_attributes(object, object_diff={}, display_modified_changes=None, excluded=[], hide_rules_check=False, hide_unset_attrs=False) %} {% set empty_attrs = [] %} + {% if object_diff %} + {% set attr_diff = object_diff["attributes"] %} + {% set attr_change = object_diff["change"] %} + {% set attr_lst = [] %} + {% if attr_diff is mapping %} + {%for key, val in attr_diff.items() %} + {{attr_lst.append(key)}} + {% endfor %} + {% endif %} + {%endif%} @@ -58,39 +68,65 @@ {%- set value = object[attr] -%} {% if value %} - - + {% if attr in attr_diff and attr_change == "modified" %} + + + {{ attr_lst.remove(attr) }} + {% else %} + + + {% endif %} + {% else %} {% set _none = empty_attrs.append(attr) %} {% endif %} {% endif %} {% endfor -%} + {% if attr_lst and attr_change == "modified" %} + {% for attr in attr_lst %} + + + {% endfor %} + {% endif %} +
    PropertyValue
    - {{ attr }} - - {%- if value.as_svg -%} - {{ value.as_svg|safe }} - {%- elif value is iterable and value is not string -%} -
      - {% for item in value %} -
    • {{ linked_name_with_icon(item) | safe }}
    • - {% endfor %} -
    - {%- elif value._short_html_ -%} -

    {{ linked_name_with_icon(value) | safe }}

    - {%- else -%} -

    {{ value }}

    - {%- endif -%} -
    + {{ attr }} + + {%- if value.as_svg -%} + {{ value.as_svg|safe }} + {%- else -%} + {{ display_modified_changes(attr_diff[attr]) | safe }} + {%- endif -%} + + {{ attr }} + + {%- if value.as_svg -%} + {{ value.as_svg | safe }} + {%- elif value is iterable and value is not string -%} +
      + {% for item in value %} +
    • {{ linked_name_with_icon(item) | safe }}
    • + {% endfor %} +
    + {%- elif value._short_html_ -%} +

    {{ linked_name_with_icon(value) | safe }}

    + {%- else -%} +

    {{ value }}

    + {%- endif -%} +
    + {{ attr }} + +

    {{ display_modified_changes(attr_diff[attr]) | safe }}

    +
    {% if empty_attrs %} - {% if not hide_unset_attrs %} -

    Unset attributes

    -

    The object has the following attributes in unset state (empty values): {{ ", ".join(empty_attrs) }}

    - {% endif %} - {% if not hide_rules_check %} - {{ show_compliance_to_modeling_rules(object) | safe }} - {% endif %} + {% if not hide_unset_attrs %} +

    Unset attributes

    +

    The object has the following attributes in unset state (empty values): {{ ", ".join(empty_attrs) }}

    + {% endif %} + {% if not hide_rules_check %} + {{ show_compliance_to_modeling_rules(object) | safe }} + {% endif %} {% endif %} {% endmacro %} diff --git a/templates/index.yaml b/templates/index.yaml index 79f38e6..0993ff8 100644 --- a/templates/index.yaml +++ b/templates/index.yaml @@ -17,3 +17,9 @@ categories: scope: name: object type: CapellaModule + - idx: object_comparison + template: object_comparison.html.j2 + name: Object Comparison + description: Template to visualize differences between two objects + isExperimental: true + single: true diff --git a/templates/logical-architecture/index.yaml b/templates/logical-architecture/index.yaml index 59d94d6..d0c231c 100644 --- a/templates/logical-architecture/index.yaml +++ b/templates/logical-architecture/index.yaml @@ -31,7 +31,7 @@ categories: below: la - template: logical-architecture/logical-state.html.j2 - idx: logical-state-mode + idx: logical-component-mode name: Logical Component Modes description: Defines states and modes of a system component or an actor isExperimental: true @@ -40,7 +40,7 @@ categories: below: la - template: logical-architecture/logical-state.html.j2 - idx: logical-state-mode + idx: logical-component-state name: Logical Component States description: Defines states and modes of a system component or an actor isExperimental: true diff --git a/templates/object_comparison.html.j2 b/templates/object_comparison.html.j2 new file mode 100644 index 0000000..0727aea --- /dev/null +++ b/templates/object_comparison.html.j2 @@ -0,0 +1,59 @@ +{# + Copyright DB InfraGO AG and contributors + SPDX-License-Identifier: Apache-2.0 +#} +{% from 'common_macros.html.j2' import linked_name_with_icon, show_other_attributes, draw_icon, show_compliance_to_modeling_rules %} + + +{% macro display_modified_changes(attr_diff) %} + {% set diff = attr_diff["diff"] %} + {% if diff is mapping %} + {% set seen = [] %} +
      + {% for i in attr_diff["current"] %} + {% if i["uuid"] in diff %} + {% set object = model.by_uuid(i["uuid"]) %} + {% set seen = seen.append(i["uuid"]) %} +
    • {{ linked_name_with_color_and_icon(object, diff[i["uuid"]]) | safe }}
    • + {% endif %} + {% endfor %} + {% for uuid, name in diff.items() %} + {% if uuid not in seen %} +
    • {{ name | safe}}
    • + {% endif %} + {% endfor %} +
    + {% elif diff %} +

    {{diff | safe}}

    + {% else %} +
      +
    • Previous: {{ attr_diff["previous"] | safe }}
    • +
    • Current: {{ attr_diff["current"] | safe }}
    • +
    + {% endif %} +{% endmacro %} + +{% macro linked_name_with_color_and_icon(obj, name) %} + {% if obj %} + + {% if obj.__class__.__name__ != "Part"%} + {{ draw_icon(obj, 15) | safe }} + {% endif %} + {{ name | safe}} + + {% else %} + {{ name | safe }} + {% endif %} +{% endmacro %} + + +{% if object %} + {% if object.name %} +

    {{ object.name }} ({{ object.__class__.__name__ }})

    + {% else %} +

    Unnamed ({{ object.__class__.__name__ }})

    + {% endif %} + {{show_other_attributes(object, object_diff, display_modified_changes=display_modified_changes) | safe}} +{% else %} +

    Object not found

    +{% endif %}