From bc19bece42523280d7749a50b6551dbe0471270d Mon Sep 17 00:00:00 2001 From: freshavocado7 Date: Thu, 11 Jul 2024 17:34:54 +0200 Subject: [PATCH 01/16] feat: Display model diff on objects in sidebar --- capella_model_explorer/backend/__main__.py | 7 +- capella_model_explorer/backend/explorer.py | 5 + capella_model_explorer/backend/model_diff.py | 118 ++++++++++++++++ frontend/src/components/TemplateCard.jsx | 139 +++++++++++-------- frontend/src/components/TemplateDetails.jsx | 84 ++++++++--- 5 files changed, 272 insertions(+), 81 deletions(-) create mode 100644 capella_model_explorer/backend/model_diff.py diff --git a/capella_model_explorer/backend/__main__.py b/capella_model_explorer/backend/__main__.py index b6a5ccf..4949d4a 100644 --- a/capella_model_explorer/backend/__main__.py +++ b/capella_model_explorer/backend/__main__.py @@ -8,7 +8,7 @@ import click import uvicorn -from . import explorer +from . import explorer, model_diff HOST = os.getenv("CAPELLA_MODEL_EXPLORER_HOST_IP", "0.0.0.0") PORT = os.getenv("CAPELLA_MODEL_EXPLORER_PORT", "8000") @@ -25,7 +25,10 @@ default=PATH_TO_TEMPLATES, ) def run(model: capellambse.MelodyModel, templates: Path): - backend = explorer.CapellaModelExplorerBackend(Path(templates), model) + diff = model_diff.model_diff() + backend = explorer.CapellaModelExplorerBackend( + Path(templates), model, diff + ) 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 839f129..67741db 100644 --- a/capella_model_explorer/backend/explorer.py +++ b/capella_model_explorer/backend/explorer.py @@ -49,6 +49,7 @@ class CapellaModelExplorerBackend: templates_path: Path model: capellambse.MelodyModel + model_diff: str templates_index: t.Optional[tl.TemplateCategories] = dataclasses.field( init=False @@ -278,6 +279,10 @@ async def catch_all(request: Request, rest_of_path: str): async def version(): return {"version": self.app.version} + @self.app.get("/api/model-diff") + async def model_diff(): + return self.model_diff + def index_template(template, templates, templates_grouped, filename=None): idx = filename if filename else template["idx"] diff --git a/capella_model_explorer/backend/model_diff.py b/capella_model_explorer/backend/model_diff.py new file mode 100644 index 0000000..ad2687f --- /dev/null +++ b/capella_model_explorer/backend/model_diff.py @@ -0,0 +1,118 @@ +# Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import argparse +import subprocess +from pathlib import Path + +import capellambse +from capella_diff_tools import __main__ as capella_diff_tools + + +def model_diff(): + data: dict = { + "created": {}, + "modified": {}, + "deleted": {}, + } + parser = argparse.ArgumentParser() + parser.add_argument("file_path", type=Path) + p = parser.parse_args() + model_path = p.file_path + model_dict = {"path": capella_diff_tools._ensure_git(model_path)} + if model_dict["path"]: + print(f"The model at {model_path} is inside a Git repository.") + commit_hashes_result = subprocess.run( + ["git", "log", "--format=%H"], + cwd=model_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + commit_hashes = commit_hashes_result.stdout.strip().split("\n") + if len(commit_hashes) > 1: + + old_model = capellambse.MelodyModel( + **model_dict, revision=commit_hashes[7] + ) + new_model = capellambse.MelodyModel( + **model_dict, revision=commit_hashes[0] + ) + objects = capella_diff_tools.compare.compare_all_objects( + old_model, new_model + ) + diagrams = capella_diff_tools.compare.compare_all_diagrams( + old_model, new_model + ) + print(objects) + transform_object_dict(objects) + data = { + "Diagrams": transform_diagram_dict(diagrams), + "Objects": transform_object_dict(objects), + } + return data + else: + pass + + return data + + +def transform_diagram_dict(dict): + modified: list = [] + created: list = [] + deleted: list = [] + traverse_diagrams(dict, created, modified, deleted) + created_dict = [ + {"name": item["display_name"], "uuid": item["uuid"]} + for item in created + ] + modified_dict = [ + {"name": item["display_name"], "uuid": item["uuid"]} + for item in modified + ] + deleted_dict = [ + {"name": item["display_name"], "uuid": item["uuid"]} + for item in deleted + ] + diff_dict = { + "Created": created_dict, + "Modified": modified_dict, + "Deleted": deleted_dict, + } + return diff_dict + + +def traverse_diagrams(node, created, modified, deleted): + if isinstance(node, dict): + for key, value in node.items(): + if key == "modified": + modified.extend(value) + elif key == "created": + created.extend(value) + elif key == "deleted": + deleted.extend(value) + else: + traverse_diagrams(value, created, modified, deleted) + elif isinstance(node, list): + for item in node: + traverse_diagrams(item, created, modified, deleted) + + +def transform_object_dict(original_dict): + obj: dict = {} + for _, object in original_dict.items(): + for category, actions in object.items(): + if category not in obj: + obj[category] = { + "created": [], + "modified": [], + "deleted": [], + } + for action, items in actions.items(): + for item in items: + display_name = item["display_name"] + obj[category][action].append( + {"name": display_name, "uuid": item["uuid"]} + ) + return obj diff --git a/frontend/src/components/TemplateCard.jsx b/frontend/src/components/TemplateCard.jsx index 4d9b902..b03026b 100644 --- a/frontend/src/components/TemplateCard.jsx +++ b/frontend/src/components/TemplateCard.jsx @@ -1,6 +1,9 @@ // Copyright DB InfraGO AG and contributors // SPDX-License-Identifier: Apache-2.0 +import React, { useState, useEffect } from 'react'; +import { API_BASE_URL } from '../APIConfig'; + import { FlaskConical, TriangleAlert, @@ -29,63 +32,83 @@ export const TemplateCard = ({ isStable = true, instanceCount = 0, error = false -}) => ( -
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 - - )} +}) => { + const [modelDiff, setModelDiff] = useState(null); + const [errorTest, setError] = useState(null); + + useEffect(() => { + const fetchModelDiff = async () => { + try { + const response = await fetch(API_BASE_URL + '/model-diff'); + const data = await response.json(); + setModelDiff(data); + } catch (err) { + setError('Failed to fetch model info: ' + err.message); + } + document.body.style.height = 'auto'; + }; + + fetchModelDiff(); + }, []); + + 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..2e5fc5a 100644 --- a/frontend/src/components/TemplateDetails.jsx +++ b/frontend/src/components/TemplateDetails.jsx @@ -3,14 +3,56 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { API_BASE_URL } from '../APIConfig'; +import { Recycle } from 'lucide-react'; export const TemplateDetails = ({ endpoint, onSingleInstance }) => { + const [modelDiff, setModelDiff] = useState(null); let { templateName, objectID } = useParams(); const [error, setError] = useState(null); const [details, setDetails] = useState([]); const navigate = useNavigate(); const [filterText, setFilterText] = useState(''); + useEffect(() => { + const fetchModelDiff = async () => { + try { + const response = await fetch(API_BASE_URL + '/model-diff'); + const data = await response.json(); + setModelDiff(data); + } catch (err) { + setError('Failed to fetch model info: ' + err.message); + } + document.body.style.height = 'auto'; + }; + + fetchModelDiff(); + }, []); + + const modifiedItems = [ + ...Object.values(modelDiff?.Diagrams?.Modified || []).map( + (item) => item.uuid + ), + ...Object.values(modelDiff?.Objects || {}).reduce((acc, object) => { + const modifiedNames = object.modified || []; + const uuid = modifiedNames.map((item) => item.uuid); + return acc.concat(uuid); + }, []) + ]; + + const createdItems = [ + ...Object.values(modelDiff?.Diagrams?.Created || []).map( + (item) => item.uuid + ), + ...Object.values(modelDiff?.Objects || {}).reduce((acc, object) => { + const createdNames = object.created || []; + const uuid = createdNames.map((item) => item.uuid); + return acc.concat(uuid); + }, []) + ]; + + console.log(createdItems); + useEffect(() => { const fetchDetails = async () => { try { @@ -107,10 +149,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) => ( @@ -119,23 +163,21 @@ export const TemplateDetails = ({ endpoint, onSingleInstance }) => { onClick={() => { navigate(`/${templateName}/${object.idx}`); }} - className={ - (objectID && object.idx === objectID - ? 'w-full bg-custom-blue text-white ' + - 'dark:bg-custom-blue dark:text-gray-100' - : 'w-full bg-gray-200 text-gray-900 ' + - 'dark:bg-custom-dark-4') + - ' dark:bg-dark-quaternary m-2 min-w-0 cursor-pointer ' + - 'rounded-lg shadow-md hover:bg-custom-blue ' + - 'hover:text-white dark:border-gray-700 ' + - 'dark:shadow-dark dark:hover:bg-blue-500 ' - }> -
-
+ className={`${ + objectID && object.idx === objectID + ? 'w-full bg-custom-blue text-white dark:bg-custom-blue dark:text-gray-100' + : 'w-full bg-gray-200 text-gray-900 dark:bg-custom-dark-4' + } ${ + createdItems.includes(object.idx) + ? 'border-l-8 border-transparent border-l-green-500' + : modifiedItems.includes(object.idx) + ? 'border-l-8 border-transparent border-l-orange-500' + : 'border-transparent' + } dark:bg-dark-quaternary m-2 min-w-0 cursor-pointer rounded-lg + border-2 shadow-md hover:bg-custom-blue hover:text-white + dark:shadow-dark dark:hover:bg-blue-500`}> +
+
{object.name}
From 732f2dec1ccc37b68c8daf256ffadaf9aa337aab Mon Sep 17 00:00:00 2001 From: freshavocado7 Date: Fri, 19 Jul 2024 15:28:22 +0200 Subject: [PATCH 02/16] feat: Implement model diff template --- capella_model_explorer/backend/__main__.py | 16 +- capella_model_explorer/backend/explorer.py | 12 +- capella_model_explorer/backend/model_diff.py | 187 +++++++++---------- frontend/src/components/Breadcrumbs.jsx | 5 +- frontend/src/components/TemplateDetails.jsx | 51 ++--- templates/index.yaml | 6 + templates/model-diff.html.j2 | 153 +++++++++++++++ 7 files changed, 292 insertions(+), 138 deletions(-) create mode 100644 templates/model-diff.html.j2 diff --git a/capella_model_explorer/backend/__main__.py b/capella_model_explorer/backend/__main__.py index 4949d4a..a3308bb 100644 --- a/capella_model_explorer/backend/__main__.py +++ b/capella_model_explorer/backend/__main__.py @@ -24,11 +24,21 @@ required=False, default=PATH_TO_TEMPLATES, ) -def run(model: capellambse.MelodyModel, templates: Path): - diff = model_diff.model_diff() +@click.option( + "--diff", + is_flag=True, + help="Run model diff before starting the server.", +) +def run(model: capellambse.MelodyModel, templates: Path, diff: bool): + data = {} + + if diff: + data = model_diff.get_data(model) + backend = explorer.CapellaModelExplorerBackend( - Path(templates), model, diff + Path(templates), model, data ) + 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 67741db..5f33d3c 100644 --- a/capella_model_explorer/backend/explorer.py +++ b/capella_model_explorer/backend/explorer.py @@ -49,7 +49,7 @@ class CapellaModelExplorerBackend: templates_path: Path model: capellambse.MelodyModel - model_diff: str + data: dict[str, t.Any] templates_index: t.Optional[tl.TemplateCategories] = dataclasses.field( init=False @@ -139,7 +139,9 @@ 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, data=self.data + ) return HTMLResponse(content=rendered, status_code=200) except TemplateSyntaxError as e: error_message = markupsafe.Markup( @@ -279,9 +281,9 @@ async def catch_all(request: Request, rest_of_path: str): async def version(): return {"version": self.app.version} - @self.app.get("/api/model-diff") - async def model_diff(): - return self.model_diff + @self.app.get("/api/data") + async def data(): + return self.data def index_template(template, templates, templates_grouped, filename=None): diff --git a/capella_model_explorer/backend/model_diff.py b/capella_model_explorer/backend/model_diff.py index ad2687f..a5b3aca 100644 --- a/capella_model_explorer/backend/model_diff.py +++ b/capella_model_explorer/backend/model_diff.py @@ -1,118 +1,101 @@ # Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 -import argparse +import copy +import datetime +import logging import subprocess -from pathlib import Path import capellambse -from capella_diff_tools import __main__ as capella_diff_tools +from capella_diff_tools import __main__ as diff +from capella_diff_tools import compare, report, types +from capellambse.filehandler import git, local +logger = logging.getLogger(__name__) -def model_diff(): - data: dict = { - "created": {}, - "modified": {}, - "deleted": {}, + +def get_data(model: capellambse.MelodyModel): + file_handler = model.resources["\x00"] + path = str(file_handler.path) + model_data: dict = { + "metadata": {"model": {"path": path, "entrypoint": None}}, + "diagrams": { + "created": {}, + "modified": {}, + "deleted": {}, + }, + "objects": { + "created": {}, + "modified": {}, + "deleted": {}, + }, } - parser = argparse.ArgumentParser() - parser.add_argument("file_path", type=Path) - p = parser.parse_args() - model_path = p.file_path - model_dict = {"path": capella_diff_tools._ensure_git(model_path)} - if model_dict["path"]: - print(f"The model at {model_path} is inside a Git repository.") - commit_hashes_result = subprocess.run( - ["git", "log", "--format=%H"], - cwd=model_path, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=True, - ) - commit_hashes = commit_hashes_result.stdout.strip().split("\n") - if len(commit_hashes) > 1: - old_model = capellambse.MelodyModel( - **model_dict, revision=commit_hashes[7] - ) - new_model = capellambse.MelodyModel( - **model_dict, revision=commit_hashes[0] - ) - objects = capella_diff_tools.compare.compare_all_objects( - old_model, new_model - ) - diagrams = capella_diff_tools.compare.compare_all_diagrams( - old_model, new_model - ) - print(objects) - transform_object_dict(objects) - data = { - "Diagrams": transform_diagram_dict(diagrams), - "Objects": transform_object_dict(objects), - } - return data - else: - pass + if isinstance(file_handler, git.GitFileHandler): + path = str(file_handler.cache_dir) + elif ( + isinstance(file_handler, local.LocalFileHandler) + and file_handler.rootdir.joinpath(".git").is_dir() + ): + pass + else: + logger.warning("Cannot create a diff: Not a git repo") + return model_data - return data + commit_hashes = ( + subprocess.check_output( + ["git", "log", "-n", "7", "--format=%H"], + cwd=path, + encoding="utf-8", + ) + .strip() + .split("\n") + ) + if len(commit_hashes) > 1: + head = commit_hashes[0] + prev = commit_hashes[6] + old_model = capellambse.MelodyModel(path=f"git+{path}", revision=prev) -def transform_diagram_dict(dict): - modified: list = [] - created: list = [] - deleted: list = [] - traverse_diagrams(dict, created, modified, deleted) - created_dict = [ - {"name": item["display_name"], "uuid": item["uuid"]} - for item in created - ] - modified_dict = [ - {"name": item["display_name"], "uuid": item["uuid"]} - for item in modified - ] - deleted_dict = [ - {"name": item["display_name"], "uuid": item["uuid"]} - for item in deleted - ] - diff_dict = { - "Created": created_dict, - "Modified": modified_dict, - "Deleted": deleted_dict, - } - return diff_dict + metadata: types.Metadata = { + "model": {"path": path, "entrypoint": None}, + "old_revision": _get_revision_info(path, prev), + "new_revision": _get_revision_info(path, head), + } + diagrams = compare.compare_all_diagrams(old_model, model) + objects = compare.compare_all_objects(old_model, model) + data: types.ChangeSummaryDocument = { + "metadata": metadata, + "diagrams": diagrams, + "objects": objects, + } + data = copy.deepcopy(data) + report._compute_diff_stats(data) + model_data = report._traverse_and_diff(data) + return model_data + else: + raise ValueError("Not enought commits in the repository to compare") -def traverse_diagrams(node, created, modified, deleted): - if isinstance(node, dict): - for key, value in node.items(): - if key == "modified": - modified.extend(value) - elif key == "created": - created.extend(value) - elif key == "deleted": - deleted.extend(value) - else: - traverse_diagrams(value, created, modified, deleted) - elif isinstance(node, list): - for item in node: - traverse_diagrams(item, created, modified, deleted) - -def transform_object_dict(original_dict): - obj: dict = {} - for _, object in original_dict.items(): - for category, actions in object.items(): - if category not in obj: - obj[category] = { - "created": [], - "modified": [], - "deleted": [], - } - for action, items in actions.items(): - for item in items: - display_name = item["display_name"] - obj[category][action].append( - {"name": display_name, "uuid": item["uuid"]} - ) - return obj +def _get_revision_info( + repo_path: str, + revision: str, +) -> types.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") + ) + return { + "hash": revision, + "revision": revision, + "author": author, + "date": datetime.datetime.fromisoformat(date_str), + "description": description.rstrip(), + } 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 [details, setDetails] = useState([]); const navigate = useNavigate(); const [filterText, setFilterText] = useState(''); + const createdItems = []; + const modifiedItems = []; useEffect(() => { const fetchModelDiff = async () => { try { - const response = await fetch(API_BASE_URL + '/model-diff'); + const response = await fetch(API_BASE_URL + '/data'); const data = await response.json(); setModelDiff(data); } catch (err) { @@ -29,29 +31,30 @@ export const TemplateDetails = ({ endpoint, onSingleInstance }) => { fetchModelDiff(); }, []); - const modifiedItems = [ - ...Object.values(modelDiff?.Diagrams?.Modified || []).map( - (item) => item.uuid - ), - ...Object.values(modelDiff?.Objects || {}).reduce((acc, object) => { - const modifiedNames = object.modified || []; - const uuid = modifiedNames.map((item) => item.uuid); - return acc.concat(uuid); - }, []) - ]; - - const createdItems = [ - ...Object.values(modelDiff?.Diagrams?.Created || []).map( - (item) => item.uuid - ), - ...Object.values(modelDiff?.Objects || {}).reduce((acc, object) => { - const createdNames = object.created || []; - const uuid = createdNames.map((item) => item.uuid); - return acc.concat(uuid); - }, []) - ]; + function processModelDiffItems(items) { + Object.entries(items).forEach(([layerKey, layer]) => { + if (layerKey === 'stats') return; // Skip 'stats' key + Object.values(layer).forEach((item) => { + ['created', 'modified'].forEach((action) => { + if (Array.isArray(item[action])) { + item[action].forEach((detail) => { + const targetList = + action === 'created' ? createdItems : modifiedItems; + targetList.push(detail['uuid']); + }); + } + }); + }); + }); + } - console.log(createdItems); + if (modelDiff) { + ['diagrams', 'objects'].forEach((key) => { + if (modelDiff[key]) { + processModelDiffItems(modelDiff[key]); + } + }); + } useEffect(() => { const fetchDetails = async () => { @@ -172,7 +175,7 @@ export const TemplateDetails = ({ endpoint, onSingleInstance }) => { ? 'border-l-8 border-transparent border-l-green-500' : modifiedItems.includes(object.idx) ? 'border-l-8 border-transparent border-l-orange-500' - : 'border-transparent' + : 'border-l-8 border-transparent' } dark:bg-dark-quaternary m-2 min-w-0 cursor-pointer rounded-lg border-2 shadow-md hover:bg-custom-blue hover:text-white dark:shadow-dark dark:hover:bg-blue-500`}> diff --git a/templates/index.yaml b/templates/index.yaml index 79f38e6..6d47c70 100644 --- a/templates/index.yaml +++ b/templates/index.yaml @@ -17,3 +17,9 @@ categories: scope: name: object type: CapellaModule + - idx: model-diff + template: model-diff.html.j2 + name: Model Version Diff + description: Template to visualize differences between two model versions + isExperimental: true + single: true diff --git a/templates/model-diff.html.j2 b/templates/model-diff.html.j2 new file mode 100644 index 0000000..6999a7f --- /dev/null +++ b/templates/model-diff.html.j2 @@ -0,0 +1,153 @@ +{# + Copyright DB InfraGO AG and contributors + SPDX-License-Identifier: Apache-2.0 +#} + +{% from 'common_macros.html.j2' import show_other_attributes, description, render_reqs_by_type, linked_name, linked_name_with_icon, display_traceability %} + +

    Model Change Assessment Report

    +

    This report provides an analysis of changes to the following model repository:

    +

    + Repository: {{ data["metadata"]["model"]["path"] | e }}
    + Entry point: {{ data["metadata"]["model"]["entrypoint"] | e }} +

    +

    The review of changes covers the following commits:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PreviousCurrent
    Author{{ data["metadata"]["old_revision"]["author"] | e }}{{ data["metadata"]["new_revision"]["author"] | e }}
    Revision{{ data["metadata"]["old_revision"]["revision"] | e }}{{ data["metadata"]["new_revision"]["revision"] | e }}
    Date & time of commit{{ data["metadata"]["old_revision"]["date"] | e }}{{ data["metadata"]["new_revision"]["date"] | e }}
    Commit message{{ data["metadata"]["old_revision"]["description"] | e }}{{ data["metadata"]["new_revision"]["description"] | e }}
    Commit ID (hash){{ data["metadata"]["old_revision"]["hash"] | e }}{{ data["metadata"]["new_revision"]["hash"] | e }}
    + +{% macro pretty_stats(stats) %} +( + {% if stats.created %}+{{stats["created"]}} / {% endif %} + {% if stats.deleted %}-{{stats["deleted"]}} / {% endif %} + {% if stats.modified %}Δ{{stats["modified"]}}{% endif %} +) +{% endmacro %} + + +{% macro display_basic_changes(key, objects, color) %} + {% if key in objects %} +

    {{key | upper}} ({{ objects[key] | length }})

    +
    +
      + {% if key == "deleted" %} + {% for obj in objects[key] %} +
    • {{ obj.display_name }}
    • + {% endfor %} + {% else %} + {% for obj in objects[key] %} + {% set object = model.by_uuid(obj.get("uuid", "default_value")) %} +
    • {{ linked_name_with_icon(object) | safe}}
    • + {% endfor %} + {% endif %} +
    +
    + {% endif %} +{% endmacro %} + + +{% macro spell_changes_out(changes) %} +
    + {{ display_basic_changes("created", changes, "#009900") | safe}} + {% if "modified" in changes %} +

    MODIFIED ({{ changes["modified"] | length }})

    +
    + {% for obj in changes["modified"] %} + {% set object = model.by_uuid(obj["uuid"]) %} +

    {{ linked_name_with_icon(object) | safe }}

    +
    +
      + {% for change in obj["attributes"] %} +
    • {{ change }}: + {% if "diff" in obj["attributes"][change] %} + {{ obj["attributes"][change]["diff"] | safe}} + {% else %} + {{ obj["attributes"][change]["previous"] | e }} -> {{ obj["attributes"][change]["current"] | e }} + {% endif %} +
    • + {% endfor %} +
    + {{ display_basic_changes("introduced", obj, "#009900") | safe }} + {{ display_basic_changes("removed", obj, "red") | safe}} +
    + {% endfor %} +
    + {% endif %} + {{ display_basic_changes("deleted", changes, "red") | safe }} +
    +{% endmacro %} + +{% set LAYER = {"oa": "Operational Analysis", "sa": "System Analysis", "la": "Logical Architecture", "pa": "Physical Architecture"}%} + +

    Diagram changes

    +
    + {% for layer in ["oa", "sa", "la", "pa"] %} + {% set layer_data = data["diagrams"][layer] %} + {% if layer_data and layer_data.stats %} +

    {{LAYER[layer]}} {{ pretty_stats(layer_data.stats) | safe }}

    +
    + {% for diag_type, diags in data.diagrams[layer].items() %} + {% if diags.stats %} +

    {{diag_type}} {{pretty_stats(diags.stats) | safe }}

    + {{ spell_changes_out(diags) | safe }} + {% endif %} + {% endfor %} +
    + {% endif %} + {% endfor %} +
    + +

    Object Changes {{ pretty_stats(data["objects"].stats) | safe }}

    +
    +{% for layer in ["oa", "sa", "la", "pa"] %} + {% set layer_data = data["objects"][layer] %} + {% if layer_data and layer_data.stats %} +

    {{LAYER[layer]}} {{ pretty_stats(layer_data.stats) | safe }}

    + {% macro render_section(data, layer) %} +
    + {% for obj_type in data["objects"][layer] if obj_type != "stats" %} + {% set obj_type_items = data["objects"][layer][obj_type] %} + {% if obj_type_items.stats %} +

    {{ obj_type }} {{ pretty_stats(obj_type_items.stats) | safe }}

    + {{ spell_changes_out(obj_type_items) | safe }} + {% endif %} + {% endfor %} +
    + {% endmacro %} + {{ render_section(data, layer) | safe }} + {% endif %} +{% endfor %} +
    From c7f9c6f81b572fbffa64c180f26c313fdc23662a Mon Sep 17 00:00:00 2001 From: freshavocado7 Date: Tue, 23 Jul 2024 09:58:01 +0200 Subject: [PATCH 03/16] feat: Show compared model versions --- frontend/src/components/TemplateDetails.jsx | 5 +- frontend/src/views/HomeView.jsx | 60 +++++++++++++++++---- pyproject.toml | 1 + templates/model-diff.html.j2 | 7 ++- 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/TemplateDetails.jsx b/frontend/src/components/TemplateDetails.jsx index 21084a4..99f624d 100644 --- a/frontend/src/components/TemplateDetails.jsx +++ b/frontend/src/components/TemplateDetails.jsx @@ -4,7 +4,6 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { API_BASE_URL } from '../APIConfig'; -import { Recycle } from 'lucide-react'; export const TemplateDetails = ({ endpoint, onSingleInstance }) => { const [modelDiff, setModelDiff] = useState(null); @@ -33,7 +32,7 @@ export const TemplateDetails = ({ endpoint, onSingleInstance }) => { function processModelDiffItems(items) { Object.entries(items).forEach(([layerKey, layer]) => { - if (layerKey === 'stats') return; // Skip 'stats' key + if (layerKey === 'stats') return; Object.values(layer).forEach((item) => { ['created', 'modified'].forEach((action) => { if (Array.isArray(item[action])) { @@ -169,7 +168,7 @@ export const TemplateDetails = ({ endpoint, onSingleInstance }) => { className={`${ objectID && object.idx === objectID ? 'w-full bg-custom-blue text-white dark:bg-custom-blue dark:text-gray-100' - : 'w-full bg-gray-200 text-gray-900 dark:bg-custom-dark-4' + : 'borer-r-8 w-full border-transparent bg-gray-200 text-gray-900 dark:bg-custom-dark-4' } ${ createdItems.includes(object.idx) ? 'border-l-8 border-transparent border-l-green-500' diff --git a/frontend/src/views/HomeView.jsx b/frontend/src/views/HomeView.jsx index 4f73082..be1c944 100644 --- a/frontend/src/views/HomeView.jsx +++ b/frontend/src/views/HomeView.jsx @@ -1,31 +1,46 @@ // 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'; export const HomeView = () => { const [modelInfo, setModelInfo] = useState(null); const [error, setError] = useState(null); + const [modelDiff, setModelDiff] = useState(null); 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(); }, []); + useEffect(() => { + const fetchModelDiff = async () => { + try { + const response = await fetch(API_BASE_URL + '/data'); + const data = await response.json(); + setModelDiff(data); + } catch (err) { + setError('Failed to fetch model info: ' + err.message); + } + document.body.style.height = 'auto'; + }; + + fetchModelDiff(); + }, []); + if (error) { return (
    @@ -45,11 +60,34 @@ export const HomeView = () => { )} {modelInfo.revision &&

    Revision: {modelInfo.revision}

    } {modelInfo.branch &&

    Branch: {modelInfo.branch}

    } - {modelInfo.hash &&

    Commit Hash: {modelInfo.hash}

    } + {modelInfo.hash &&

    Current Commit Hash: {modelInfo.hash}

    } + {modelDiff?.metadata?.new_revision && ( + <> +

    + Created on:{' '} + {new Date( + modelDiff.metadata.new_revision.date + ).toLocaleString()} +

    + + )}
    + dangerouslySetInnerHTML={{ __html: modelInfo.badge }}>
    + {modelDiff?.metadata?.old_revision && ( + <> +

    + Comparing with commit hash:{' '} + {modelDiff.metadata.old_revision.hash} +

    +

    + Created on:{' '} + {new Date( + modelDiff.metadata.old_revision.date + ).toLocaleString()} +

    + + )}
  • )}
    diff --git a/pyproject.toml b/pyproject.toml index bd72f91..788a566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ dependencies = [ "capellambse", "capellambse-context-diagrams", + "capella-diff-tools", "jinja2", "fastapi", "uvicorn", diff --git a/templates/model-diff.html.j2 b/templates/model-diff.html.j2 index 6999a7f..e977b14 100644 --- a/templates/model-diff.html.j2 +++ b/templates/model-diff.html.j2 @@ -3,8 +3,9 @@ SPDX-License-Identifier: Apache-2.0 #} -{% from 'common_macros.html.j2' import show_other_attributes, description, render_reqs_by_type, linked_name, linked_name_with_icon, display_traceability %} +{% from 'common_macros.html.j2' import linked_name, linked_name_with_icon %} +{% if data["metadata"].get("old_revision") %}

    Model Change Assessment Report

    This report provides an analysis of changes to the following model repository:

    @@ -151,3 +152,7 @@ {% endif %} {% endfor %}

    +{% else %} +

    Could not generate model diff report.

    +

    Select 'Deep clone' before running the session or --diff for developer mode

    +{% endif %} From d2f409e25d69dedf221cd2aeccac099091927fcd Mon Sep 17 00:00:00 2001 From: freshavocado7 Date: Thu, 25 Jul 2024 15:22:29 +0200 Subject: [PATCH 04/16] feat: Allow version selection for model comparison --- capella_model_explorer/backend/__main__.py | 16 +- capella_model_explorer/backend/explorer.py | 34 +++- capella_model_explorer/backend/model_diff.py | 84 ++++---- frontend/src/components/ModelDiff.jsx | 195 +++++++++++++++++++ frontend/src/components/Spinner.jsx | 2 +- frontend/src/components/TemplateDetails.jsx | 2 +- frontend/src/views/HomeView.jsx | 68 ++----- templates/model-diff.html.j2 | 6 +- 8 files changed, 300 insertions(+), 107 deletions(-) create mode 100644 frontend/src/components/ModelDiff.jsx diff --git a/capella_model_explorer/backend/__main__.py b/capella_model_explorer/backend/__main__.py index a3308bb..dfacaa6 100644 --- a/capella_model_explorer/backend/__main__.py +++ b/capella_model_explorer/backend/__main__.py @@ -8,7 +8,7 @@ import click import uvicorn -from . import explorer, model_diff +from . import explorer HOST = os.getenv("CAPELLA_MODEL_EXPLORER_HOST_IP", "0.0.0.0") PORT = os.getenv("CAPELLA_MODEL_EXPLORER_PORT", "8000") @@ -24,19 +24,11 @@ required=False, default=PATH_TO_TEMPLATES, ) -@click.option( - "--diff", - is_flag=True, - help="Run model diff before starting the server.", -) -def run(model: capellambse.MelodyModel, templates: Path, diff: bool): - data = {} - - if diff: - data = model_diff.get_data(model) +def run(model: capellambse.MelodyModel, templates: Path): backend = explorer.CapellaModelExplorerBackend( - Path(templates), model, data + 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 5f33d3c..65ccc76 100644 --- a/capella_model_explorer/backend/explorer.py +++ b/capella_model_explorer/backend/explorer.py @@ -26,7 +26,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__ @@ -38,6 +40,11 @@ LOGGER = logging.getLogger(__name__) +class DataPayload(BaseModel): + head: str + prev: str + + @dataclasses.dataclass class CapellaModelExplorerBackend: app: FastAPI = dataclasses.field(init=False) @@ -49,7 +56,6 @@ class CapellaModelExplorerBackend: templates_path: Path model: capellambse.MelodyModel - data: dict[str, t.Any] templates_index: t.Optional[tl.TemplateCategories] = dataclasses.field( init=False @@ -80,6 +86,7 @@ def __post_init__(self): self.templates_index = self.templates_loader.index_path( self.templates_path ) + self.data = {} @self.app.middleware("http") async def update_last_interaction_time(request: Request, call_next): @@ -281,8 +288,31 @@ async def catch_all(request: Request, rest_of_path: str): async def version(): return {"version": self.app.version} + @self.app.post("/api/data") + async def post_data(payload: DataPayload): + try: + self.data = {"head": payload.head, "prev": payload.prev} + return self.data + except Exception: + return {} + + @self.app.get("/api/commits") + async def commits(): + result = model_diff.populate_commits(self.model) + return result + @self.app.get("/api/data") - async def data(): + async def retrieve_data(): + if "metadata" not in self.data: + try: + self.data = model_diff.get_data( + self.model, + self.data["head"], + self.data["prev"], + ) + return {"data": self.data} + except Exception: + return {"error": "Couldn't retrieve model comparison data"} return self.data diff --git a/capella_model_explorer/backend/model_diff.py b/capella_model_explorer/backend/model_diff.py index a5b3aca..bd91ee8 100644 --- a/capella_model_explorer/backend/model_diff.py +++ b/capella_model_explorer/backend/model_diff.py @@ -7,14 +7,13 @@ import subprocess import capellambse -from capella_diff_tools import __main__ as diff from capella_diff_tools import compare, report, types from capellambse.filehandler import git, local logger = logging.getLogger(__name__) -def get_data(model: capellambse.MelodyModel): +def init_model(model: capellambse.MelodyModel): file_handler = model.resources["\x00"] path = str(file_handler.path) model_data: dict = { @@ -39,43 +38,40 @@ def get_data(model: capellambse.MelodyModel): ): pass else: - logger.warning("Cannot create a diff: Not a git repo") - return model_data + return {"error": "Not a git repo"}, model_data + return path, model_data - commit_hashes = ( - subprocess.check_output( - ["git", "log", "-n", "7", "--format=%H"], - cwd=path, - encoding="utf-8", - ) - .strip() - .split("\n") - ) - if len(commit_hashes) > 1: - head = commit_hashes[0] - prev = commit_hashes[6] - old_model = capellambse.MelodyModel(path=f"git+{path}", revision=prev) - - metadata: types.Metadata = { - "model": {"path": path, "entrypoint": None}, - "old_revision": _get_revision_info(path, prev), - "new_revision": _get_revision_info(path, head), - } - - diagrams = compare.compare_all_diagrams(old_model, model) - objects = compare.compare_all_objects(old_model, model) - data: types.ChangeSummaryDocument = { - "metadata": metadata, - "diagrams": diagrams, - "objects": objects, - } - data = copy.deepcopy(data) - report._compute_diff_stats(data) - model_data = report._traverse_and_diff(data) - return model_data - else: - raise ValueError("Not enought commits in the repository to compare") +def populate_commits(model: capellambse.MelodyModel): + result, _ = init_model(model) + if "error" in result: + return result + commits = get_commit_hashes(result) + return commits + + +def get_data(model: capellambse.MelodyModel, head: str, prev: str): + path, model_data = init_model(model) + + old_model = capellambse.MelodyModel(path=f"git+{path}", revision=prev) + + metadata: types.Metadata = { + "model": {"path": path, "entrypoint": None}, + "old_revision": _get_revision_info(path, prev), + "new_revision": _get_revision_info(path, head), + } + + diagrams = compare.compare_all_diagrams(old_model, model) + objects = compare.compare_all_objects(old_model, model) + data: types.ChangeSummaryDocument = { + "metadata": metadata, + "diagrams": diagrams, + "objects": objects, + } + data = copy.deepcopy(data) + report._compute_diff_stats(data) + model_data = report._traverse_and_diff(data) + return model_data def _get_revision_info( @@ -99,3 +95,17 @@ def _get_revision_info( "date": datetime.datetime.fromisoformat(date_str), "description": description.rstrip(), } + + +def get_commit_hashes(path: str): + commit_hashes = ( + subprocess.check_output( + ["git", "log", "-n", "7", "--format=%H"], + cwd=path, + encoding="utf-8", + ) + .strip() + .split("\n") + ) + commits = [_get_revision_info(path, c) for c in commit_hashes] + return commits diff --git a/frontend/src/components/ModelDiff.jsx b/frontend/src/components/ModelDiff.jsx new file mode 100644 index 0000000..3096d89 --- /dev/null +++ b/frontend/src/components/ModelDiff.jsx @@ -0,0 +1,195 @@ +// Copyright DB InfraGO AG and contributors +// SPDX-License-Identifier: Apache-2.0 + +import { API_BASE_URL } from '../APIConfig'; +import React, { useState, useEffect } from 'react'; +import { Spinner } from './Spinner'; + +export const ModelDiff = () => { + const [isLoading, setIsLoading] = useState(false); + const [completeLoading, setCompleteLoading] = useState(false); + const [prevSelection, setPrevSelection] = useState(''); + const [commitDetails, setCommitDetails] = useState({}); + const [selectionOptions, setSelectionOptions] = useState([]); + const [isPopupVisible, setIsPopupVisible] = useState(false); + const [selectedDetails, setSelectedDetails] = useState(''); + const [error, setError] = useState(''); + + const handleSelectChange = (e) => { + const selectedValue = e.target.value; + setPrevSelection(selectedValue); + + const selectedDetails = commitDetails.find( + (commit) => commit.hash === selectedValue + ); + setSelectedDetails(selectedDetails || {}); + }; + + useEffect(() => { + const fetchedOptions = async () => { + 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.error) { + throw new Error(data.error); + } + setCommitDetails(data); + const options = data.map((commit) => ({ + value: commit.hash, + label: `${commit.hash.substring(0, 7)} - Created on ${commit.date.substring(0, 10)}` + })); + + setSelectionOptions(options); + } catch (err) { + setError(err.message); + } + }; + fetchedOptions(); + }, []); + + const handleGenerateDiff = async () => { + if (!commitDetails[0].hash || !prevSelection) { + alert('Please select a version.'); + return; + } + setCompleteLoading(false); + setIsLoading(true); + try { + const url = new URL(API_BASE_URL + '/data'); + const postDataResponse = await postData(url, { + head: commitDetails[0].hash, + prev: prevSelection + }); + const response = await fetch(API_BASE_URL + '/data'); + const data = await response.json(); + } catch (error) { + console.error('Error:', error); + } finally { + setIsLoading(false); + setCompleteLoading(true); + } + }; + + const postData = async (url = '', data = {}) => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + return response.json(); + }; + + return ( +
    + + {isPopupVisible && ( + <> +
    { + 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}

    +

    Author: {commitDetails[0].author}

    +

    Description: {commitDetails[0].description}

    +

    Date: {commitDetails[0].date}

    +
    + + {selectedDetails && ( +
    +

    Hash: {selectedDetails.hash}

    +

    Author: {selectedDetails.author}

    +

    Description: {selectedDetails.description}

    +

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

    +
    + )} + {isLoading && ( +
    + +
    + )} + + + )} +
    +
    +
    + + )} +
    + ); +}; 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 = () => ( -
    +
    { const data = await response.json(); setModelDiff(data); } catch (err) { - setError('Failed to fetch model info: ' + err.message); + setModelDiff({}); } document.body.style.height = 'auto'; }; diff --git a/frontend/src/views/HomeView.jsx b/frontend/src/views/HomeView.jsx index be1c944..2077ff1 100644 --- a/frontend/src/views/HomeView.jsx +++ b/frontend/src/views/HomeView.jsx @@ -5,11 +5,11 @@ 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 [modelDiff, setModelDiff] = useState(null); useEffect(() => { const fetchModelInfo = async () => { @@ -26,21 +26,6 @@ export const HomeView = () => { fetchModelInfo(); }, []); - useEffect(() => { - const fetchModelDiff = async () => { - try { - const response = await fetch(API_BASE_URL + '/data'); - const data = await response.json(); - setModelDiff(data); - } catch (err) { - setError('Failed to fetch model info: ' + err.message); - } - document.body.style.height = 'auto'; - }; - - fetchModelDiff(); - }, []); - if (error) { return (
    @@ -53,42 +38,21 @@ 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 &&

    Current Commit Hash: {modelInfo.hash}

    } - {modelDiff?.metadata?.new_revision && ( - <> -

    - Created on:{' '} - {new Date( - modelDiff.metadata.new_revision.date - ).toLocaleString()} -

    - - )} -
    - {modelDiff?.metadata?.old_revision && ( - <> -

    - Comparing with commit hash:{' '} - {modelDiff.metadata.old_revision.hash} -

    -

    - Created on:{' '} - {new Date( - modelDiff.metadata.old_revision.date - ).toLocaleString()} -

    - - )} -
    + <> +
    +

    {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}

    } + +
    +
    + )}
    diff --git a/templates/model-diff.html.j2 b/templates/model-diff.html.j2 index e977b14..d224535 100644 --- a/templates/model-diff.html.j2 +++ b/templates/model-diff.html.j2 @@ -5,6 +5,7 @@ {% from 'common_macros.html.j2' import linked_name, linked_name_with_icon %} +{%if data.get("metadata")%} {% if data["metadata"].get("old_revision") %}

    Model Change Assessment Report

    This report provides an analysis of changes to the following model repository:

    @@ -151,8 +152,9 @@ {{ render_section(data, layer) | safe }} {% endif %} {% endfor %} +{% endif %}
    {% else %} -

    Could not generate model diff report.

    -

    Select 'Deep clone' before running the session or --diff for developer mode

    +

    Could not generate model comparison report.

    +

    Select 'Deep clone' before running the session or select a model that belongs to a git repo for developer mode.

    {% endif %} From 17b57a2e1086bc5ca0bb94210c51fd004f5f9d50 Mon Sep 17 00:00:00 2001 From: freshavocado7 Date: Thu, 25 Jul 2024 16:01:05 +0200 Subject: [PATCH 05/16] chore: Drop support for Python 3.10 --- .github/workflows/build-test-publish.yml | 5 ++--- .github/workflows/lint.yml | 2 +- pyproject.toml | 5 ++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index d2f1ec6..45b4d65 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/pyproject.toml b/pyproject.toml index 788a566..e8d92a9 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,7 +21,6 @@ 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", ] @@ -76,7 +75,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.*"] From 49c2453cfe59a12ede0d1fd32b753127515cace7 Mon Sep 17 00:00:00 2001 From: freshavocado7 Date: Mon, 29 Jul 2024 11:19:09 +0200 Subject: [PATCH 06/16] chore: Bump Python target version for black to 3.11 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e8d92a9..80d831a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ test = ["pytest", "pytest-cov"] [tool.black] line-length = 79 -target-version = ["py310"] +target-version = ["py311"] [tool.coverage.run] branch = true From e72ae5009986dcad8867a58ce9d717cc90c03ab7 Mon Sep 17 00:00:00 2001 From: vik378 Date: Wed, 31 Jul 2024 11:29:44 +0200 Subject: [PATCH 07/16] refactor: Diff endpoints & stuff --- capella_model_explorer/backend/explorer.py | 38 +++++++++----------- capella_model_explorer/backend/model_diff.py | 5 +-- frontend/src/components/ModelDiff.jsx | 17 ++++++--- frontend/src/components/TemplateDetails.jsx | 2 +- 4 files changed, 33 insertions(+), 29 deletions(-) diff --git a/capella_model_explorer/backend/explorer.py b/capella_model_explorer/backend/explorer.py index 65ccc76..c886816 100644 --- a/capella_model_explorer/backend/explorer.py +++ b/capella_model_explorer/backend/explorer.py @@ -40,7 +40,7 @@ LOGGER = logging.getLogger(__name__) -class DataPayload(BaseModel): +class CommitRange(BaseModel): head: str prev: str @@ -86,7 +86,7 @@ def __post_init__(self): self.templates_index = self.templates_loader.index_path( self.templates_path ) - self.data = {} + self.diff = {} @self.app.middleware("http") async def update_last_interaction_time(request: Request, call_next): @@ -147,7 +147,7 @@ def render_instance_page(self, template_text, base, object=None): # render the template with the object template = self.env.from_string(template_text) rendered = template.render( - object=object, model=self.model, data=self.data + object=object, model=self.model, data=self.diff ) return HTMLResponse(content=rendered, status_code=200) except TemplateSyntaxError as e: @@ -288,32 +288,28 @@ async def catch_all(request: Request, rest_of_path: str): async def version(): return {"version": self.app.version} - @self.app.post("/api/data") - async def post_data(payload: DataPayload): + @self.app.post("/api/compare") + async def post_data(commit_range: CommitRange): try: - self.data = {"head": payload.head, "prev": payload.prev} - return self.data - except Exception: - return {} + self.diff = model_diff.get_data( + self.model, commit_range.head, commit_range.prev + ) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} @self.app.get("/api/commits") async def commits(): result = model_diff.populate_commits(self.model) return result - @self.app.get("/api/data") + @self.app.get("/api/diff") async def retrieve_data(): - if "metadata" not in self.data: - try: - self.data = model_diff.get_data( - self.model, - self.data["head"], - self.data["prev"], - ) - return {"data": self.data} - except Exception: - return {"error": "Couldn't retrieve model comparison data"} - return self.data + 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): diff --git a/capella_model_explorer/backend/model_diff.py b/capella_model_explorer/backend/model_diff.py index bd91ee8..4aa6ee9 100644 --- a/capella_model_explorer/backend/model_diff.py +++ b/capella_model_explorer/backend/model_diff.py @@ -4,6 +4,7 @@ import copy import datetime import logging +import pathlib import subprocess import capellambse @@ -52,7 +53,7 @@ def populate_commits(model: capellambse.MelodyModel): def get_data(model: capellambse.MelodyModel, head: str, prev: str): path, model_data = init_model(model) - + path = pathlib.Path(path).resolve() old_model = capellambse.MelodyModel(path=f"git+{path}", revision=prev) metadata: types.Metadata = { @@ -100,7 +101,7 @@ def _get_revision_info( def get_commit_hashes(path: str): commit_hashes = ( subprocess.check_output( - ["git", "log", "-n", "7", "--format=%H"], + ["git", "log", "-n", "20", "--format=%H"], cwd=path, encoding="utf-8", ) diff --git a/frontend/src/components/ModelDiff.jsx b/frontend/src/components/ModelDiff.jsx index 3096d89..8aa398f 100644 --- a/frontend/src/components/ModelDiff.jsx +++ b/frontend/src/components/ModelDiff.jsx @@ -60,13 +60,16 @@ export const ModelDiff = () => { setCompleteLoading(false); setIsLoading(true); try { - const url = new URL(API_BASE_URL + '/data'); - const postDataResponse = await postData(url, { + const url = new URL(API_BASE_URL + '/compare'); + const response = await postData(url, { head: commitDetails[0].hash, prev: prevSelection }); - const response = await fetch(API_BASE_URL + '/data'); + //const response = await fetch(API_BASE_URL + '/data'); const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } } catch (error) { console.error('Error:', error); } finally { @@ -103,7 +106,7 @@ export const ModelDiff = () => { setCompleteLoading(false); }}>
    e.stopPropagation()}> @@ -151,9 +154,13 @@ export const ModelDiff = () => { ))} {selectedDetails && ( -
    +

    Hash: {selectedDetails.hash}

    Author: {selectedDetails.author}

    +
    +

    Description

    +

    {selectedDetails.description}

    +

    Description: {selectedDetails.description}

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

    diff --git a/frontend/src/components/TemplateDetails.jsx b/frontend/src/components/TemplateDetails.jsx index a6887b8..af4dde4 100644 --- a/frontend/src/components/TemplateDetails.jsx +++ b/frontend/src/components/TemplateDetails.jsx @@ -18,7 +18,7 @@ export const TemplateDetails = ({ endpoint, onSingleInstance }) => { useEffect(() => { const fetchModelDiff = async () => { try { - const response = await fetch(API_BASE_URL + '/data'); + const response = await fetch(API_BASE_URL + '/diff'); const data = await response.json(); setModelDiff(data); } catch (err) { From 6d343c36c176e51b82588416c29456821e8e382b Mon Sep 17 00:00:00 2001 From: vik378 Date: Wed, 31 Jul 2024 12:05:00 +0200 Subject: [PATCH 08/16] fix: Commits list loads only when compare requested --- frontend/src/components/ModelDiff.jsx | 58 ++++++++++++------------ frontend/src/components/TemplateCard.jsx | 26 +++++------ 2 files changed, 41 insertions(+), 43 deletions(-) diff --git a/frontend/src/components/ModelDiff.jsx b/frontend/src/components/ModelDiff.jsx index 8aa398f..5b85132 100644 --- a/frontend/src/components/ModelDiff.jsx +++ b/frontend/src/components/ModelDiff.jsx @@ -25,33 +25,6 @@ export const ModelDiff = () => { setSelectedDetails(selectedDetails || {}); }; - useEffect(() => { - const fetchedOptions = async () => { - 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.error) { - throw new Error(data.error); - } - setCommitDetails(data); - const options = data.map((commit) => ({ - value: commit.hash, - label: `${commit.hash.substring(0, 7)} - Created on ${commit.date.substring(0, 10)}` - })); - - setSelectionOptions(options); - } catch (err) { - setError(err.message); - } - }; - fetchedOptions(); - }, []); - const handleGenerateDiff = async () => { if (!commitDetails[0].hash || !prevSelection) { alert('Please select a version.'); @@ -89,16 +62,41 @@ export const ModelDiff = () => { return response.json(); }; + 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.error) { + throw new Error(data.error); + } + setCommitDetails(data); + const options = data.map((commit) => ({ + value: commit.hash, + label: `${commit.hash.substring(0, 7)} - Created on ${commit.date.substring(0, 10)}` + })); + + setSelectionOptions(options); + setIsPopupVisible(true); + } catch (err) { + setError(err.message); + } + }; + return (
    {isPopupVisible && ( - <> +
    { @@ -195,7 +193,7 @@ export const ModelDiff = () => {
    - +
    )}
    ); diff --git a/frontend/src/components/TemplateCard.jsx b/frontend/src/components/TemplateCard.jsx index b03026b..0ec0157 100644 --- a/frontend/src/components/TemplateCard.jsx +++ b/frontend/src/components/TemplateCard.jsx @@ -36,20 +36,20 @@ export const TemplateCard = ({ const [modelDiff, setModelDiff] = useState(null); const [errorTest, setError] = useState(null); - useEffect(() => { - const fetchModelDiff = async () => { - try { - const response = await fetch(API_BASE_URL + '/model-diff'); - const data = await response.json(); - setModelDiff(data); - } catch (err) { - setError('Failed to fetch model info: ' + err.message); - } - document.body.style.height = 'auto'; - }; + //useEffect(() => { + // const fetchModelDiff = async () => { + // try { + // const response = await fetch(API_BASE_URL + '/model-diff'); + // const data = await response.json(); + // setModelDiff(data); + // } catch (err) { + // setError('Failed to fetch model info: ' + err.message); + // } + // document.body.style.height = 'auto'; + // }; - fetchModelDiff(); - }, []); + // fetchModelDiff(); + // }, []); return (
    Date: Thu, 1 Aug 2024 14:51:30 +0200 Subject: [PATCH 09/16] fix: Display compared version information --- capella_model_explorer/backend/model_diff.py | 9 +++ frontend/src/components/ModelDiff.jsx | 72 ++++++++++---------- frontend/src/views/HomeView.jsx | 69 ++++++++++++++++++- 3 files changed, 113 insertions(+), 37 deletions(-) diff --git a/capella_model_explorer/backend/model_diff.py b/capella_model_explorer/backend/model_diff.py index 4aa6ee9..ac08b7e 100644 --- a/capella_model_explorer/backend/model_diff.py +++ b/capella_model_explorer/backend/model_diff.py @@ -89,12 +89,21 @@ def _get_revision_info( .strip() .split("\x00") ) + try: + tag = subprocess.check_output( + ["git", "describe", "--tags", revision], + cwd=repo_path, + encoding="utf-8", + ).strip() + except subprocess.CalledProcessError: + tag = None return { "hash": revision, "revision": revision, "author": author, "date": datetime.datetime.fromisoformat(date_str), "description": description.rstrip(), + "tag": tag, } diff --git a/frontend/src/components/ModelDiff.jsx b/frontend/src/components/ModelDiff.jsx index 5b85132..6548934 100644 --- a/frontend/src/components/ModelDiff.jsx +++ b/frontend/src/components/ModelDiff.jsx @@ -5,28 +5,25 @@ import { API_BASE_URL } from '../APIConfig'; import React, { useState, useEffect } from 'react'; import { Spinner } from './Spinner'; -export const ModelDiff = () => { +export const ModelDiff = ({ onRefetch, hasDiffed }) => { const [isLoading, setIsLoading] = useState(false); const [completeLoading, setCompleteLoading] = useState(false); - const [prevSelection, setPrevSelection] = useState(''); 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 selectedValue = e.target.value; - setPrevSelection(selectedValue); - - const selectedDetails = commitDetails.find( - (commit) => commit.hash === selectedValue - ); - setSelectedDetails(selectedDetails || {}); + const option = e.target.value; + setSelectedOption(option); + const selectedValue = JSON.parse(e.target.value); + setSelectedDetails(selectedValue); }; const handleGenerateDiff = async () => { - if (!commitDetails[0].hash || !prevSelection) { + if (!commitDetails[0].hash || !selectedDetails.hash) { alert('Please select a version.'); return; } @@ -36,9 +33,8 @@ export const ModelDiff = () => { const url = new URL(API_BASE_URL + '/compare'); const response = await postData(url, { head: commitDetails[0].hash, - prev: prevSelection + prev: selectedDetails.hash }); - //const response = await fetch(API_BASE_URL + '/data'); const data = await response.json(); if (data.error) { throw new Error(data.error); @@ -48,6 +44,7 @@ export const ModelDiff = () => { } finally { setIsLoading(false); setCompleteLoading(true); + onRefetch(); } }; @@ -62,7 +59,7 @@ export const ModelDiff = () => { return response.json(); }; - async function openModelCompareDialog(){ + async function openModelCompareDialog() { try { const response = await fetch(API_BASE_URL + '/commits'); if (!response.ok) { @@ -76,8 +73,8 @@ export const ModelDiff = () => { } setCommitDetails(data); const options = data.map((commit) => ({ - value: commit.hash, - label: `${commit.hash.substring(0, 7)} - Created on ${commit.date.substring(0, 10)}` + value: JSON.stringify(commit), + label: `${commit.tag} - ${commit.hash.substring(0, 7)} - Created on ${commit.date.substring(0, 10)}` })); setSelectionOptions(options); @@ -85,7 +82,7 @@ export const ModelDiff = () => { } catch (err) { setError(err.message); } - }; + } return (
    @@ -93,26 +90,30 @@ export const ModelDiff = () => { className="rounded border border-black bg-gray-200 px-4 py-2 text-gray-700 hover:bg-custom-light dark:bg-custom-dark-2 dark:text-gray-100 dark:hover:bg-custom-dark-4" onClick={openModelCompareDialog}> - Compare with previous version + {hasDiffed + ? 'Compare with another version' + : 'Compare with previous version'} {isPopupVisible && (
    { - setIsPopupVisible(false); - setCompleteLoading(false); + if (!isLoading) { + setIsPopupVisible(false); + setCompleteLoading(false); + } }}>
    e.stopPropagation()}> -
    +
    {error ? (
    -

    +

    Cannot generate model diff: {error}

    @@ -126,24 +127,24 @@ export const ModelDiff = () => { className="mb-2 w-full cursor-not-allowed bg-gray-300 text-gray-500 dark:bg-custom-dark-3 dark:text-gray-100" disabled> - {selectionOptions.map((option) => ( - - ))} +

    Hash: {commitDetails[0].hash}

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

    Tag: {commitDetails[0].tag}

    + )}

    Author: {commitDetails[0].author}

    Description: {commitDetails[0].description}

    Date: {commitDetails[0].date}

    + +
    + {modelDiff && + modelDiff.objects && + Object.keys(modelDiff.objects).map( + (layer) => + modelDiff.objects[layer] && ( + + ) + )} +
    +
    +
    + {objectID && ( + + )} +
    +
    +
    +
    + ); +}; diff --git a/pyproject.toml b/pyproject.toml index 80d831a..f2f191d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "capellambse", "capellambse-context-diagrams", "capella-diff-tools", + "diff-match-patch", "jinja2", "fastapi", "uvicorn", diff --git a/templates/index.yaml b/templates/index.yaml index 6d47c70..331b5d5 100644 --- a/templates/index.yaml +++ b/templates/index.yaml @@ -23,3 +23,9 @@ categories: description: Template to visualize differences between two model versions isExperimental: true single: true + - idx: object_diff + template: object_diff.html.j2 + name: Object Diff + 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_diff.html.j2 b/templates/object_diff.html.j2 new file mode 100644 index 0000000..adedf5a --- /dev/null +++ b/templates/object_diff.html.j2 @@ -0,0 +1,64 @@ +{# + Copyright DB InfraGO AG and contributors + SPDX-License-Identifier: Apache-2.0 +#} +{% from 'common_macros.html.j2' import show_other_attributes, linked_name, linked_name_with_icon %} + + +{% macro display_modified_changes(object_diff) %} + {% for key, value in object_diff["attributes"].items()%} + {% if "diff" in value %} +

    {{key}}

    + {% if "uuid" in value["current"] %} + {% set object = model.by_uuid(value["current"]["uuid"]) %} +

    {{ linked_name_with_color(object, value["current"][display_name]) | safe }}

    + {% endif %} +

    {{value["diff"] | safe}}

    + {% else %} +

    {{key}}

    +
      +
    • Previous: {{value["previous"] | safe}}
    • +
    • Current: {{value["current"] | safe}}
    • +
    + {% endif %} + {% endfor %} +{% endmacro %} + +{%- macro linked_name_with_color(object, name) -%}{{ name | trim }}{%- endmacro -%} + + +{% if object %} + {% if object.name %} +

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

    + {% else %} +

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

    + {% endif %} + {{show_other_attributes(object) | safe}} +{% else %} +

    deleted

    +{% endif %} + + +{% if object_diff %} +

    Changes

    +

    {{object_diff["change"] | capitalize}}

    + {% if object_diff["change"] == "modified" %} + {{ display_modified_changes(object_diff) | safe}} + {% endif %} +{% endif %} From dc83ae16cef721b120839247841f6025868c88ea Mon Sep 17 00:00:00 2001 From: freshavocado7 Date: Wed, 11 Sep 2024 09:56:47 +0200 Subject: [PATCH 12/16] feat: Improve tree explorer UI & object diff template --- .pre-commit-config.yaml | 1 + capella_model_explorer/backend/model_diff.py | 19 +-- frontend/src/components/DiffExplorer.jsx | 124 +++++++++++----- frontend/src/components/ModelDiff.jsx | 47 ++++--- frontend/src/index.css | 3 + frontend/src/views/ModelComparisonView.jsx | 53 +++++-- pyproject.toml | 2 +- templates/object_diff.html.j2 | 141 +++++++++++++++---- 8 files changed, 290 insertions(+), 100 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a0261c..fb76081 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,6 +51,7 @@ repos: hooks: - id: mypy additional_dependencies: + - capellambse==0.5.72 - types-pyyaml==6.0.11 - repo: https://github.com/pylint-dev/pylint rev: v3.1.0 diff --git a/capella_model_explorer/backend/model_diff.py b/capella_model_explorer/backend/model_diff.py index 9ae5cb6..e933363 100644 --- a/capella_model_explorer/backend/model_diff.py +++ b/capella_model_explorer/backend/model_diff.py @@ -265,6 +265,7 @@ def compare_objects( 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, @@ -446,23 +447,23 @@ def _diff_objects(previous, current): def _diff_lists(previous, current): - out = [] + out = {} previous = {item["uuid"]: item for item in previous} for item in current: if item["uuid"] not in previous: - out.append(f"
  • {item['display_name']}
  • ") + out[item["uuid"]] = f"{item['display_name']}" elif item["uuid"] in previous: if item["display_name"] != previous[item["uuid"]]["display_name"]: - out.append( - f"
  • {_diff_objects(previous[item['uuid']], item)}
  • " + out[item["uuid"]] = ( + f"{_diff_objects(previous[item['uuid']], item)}" ) else: - out.append(f"
  • {item['display_name']}
  • ") + out[item["uuid"]] = f"{item['display_name']}" current = {item["uuid"]: item for item in current} - for item in previous: - if item not in current: - out.append(f"
  • {previous[item]['display_name']}
  • ") - return "
      " + "".join(out) + "
    " + for uuid in previous: + if uuid not in current: + out[uuid] = f"{previous[uuid]['display_name']}" + return out def _diff_description(previous, current): diff --git a/frontend/src/components/DiffExplorer.jsx b/frontend/src/components/DiffExplorer.jsx index bc2ac24..e23cd34 100644 --- a/frontend/src/components/DiffExplorer.jsx +++ b/frontend/src/components/DiffExplorer.jsx @@ -2,17 +2,23 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useState } from 'react'; +import { + CirclePlus, + CircleX, + SquareAsterisk, + FolderOpen, + SquareChevronDown, + SquareChevronRightIcon +} from 'lucide-react'; export const DiffExplorer = ({ node, - nodeId, setObjectID, setDiffData, searchTerm, filterStatus }) => { - const [expanded, setExpanded] = useState(false); - const hasChildren = node.children && Object.keys(node.children).length > 0; + const [expandedNodes, setExpandedNodes] = useState({}); const getNodeClass = (changed, attributes) => { const hasNonEmptyAttributes = @@ -21,66 +27,107 @@ export const DiffExplorer = ({ case 'created': return 'text-green-500'; case 'deleted': - return 'text-red-500 line-through cursor-not-allowed'; + return 'text-red-500 line-through cursor-not-allowed dark:text-custom-dark-error'; case 'modified': - return hasNonEmptyAttributes ? 'text-orange-500' : 'text-gray-400'; + return hasNonEmptyAttributes + ? 'text-orange-500' + : 'text-gray-500 dark:text-gray-200'; default: - return 'text-gray-900'; + return 'text-gray-900 dark:text-gray-100'; } }; - const handleToggle = () => { - setExpanded(!expanded); + 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 = () => { - setObjectID(nodeId); + const handleLeafClick = (node) => { + setObjectID(node.uuid); setDiffData(node); }; - const filterNodes = (node) => { - if (filterStatus !== 'all' && node.change !== filterStatus) { - if (node.children) { - return Object.values(node.children).some(filterNodes); - } - return false; - } - if (!searchTerm) return true; - if (node.display_name.toLowerCase().includes(searchTerm.toLowerCase())) - return true; + const flattenNodes = (node) => { + let nodes = []; if (node.children) { - return Object.values(node.children).some(filterNodes); + Object.values(node.children).forEach((child) => { + nodes = nodes.concat(flattenNodes(child)); + }); } - return false; + nodes.push(node); + return nodes; }; - if (!filterNodes(node)) return null; + 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()); + }); + }; - return ( -
    + const renderNode = (node) => { + const hasChildren = node.children && Object.keys(node.children).length > 0; + const isExpanded = expandedNodes[node.uuid] || false; + return (
    -
    + key={node.uuid} + className={`text-start ${getNodeClass(node.change, node.attributes)}`}> +
    {hasChildren && ( - - {expanded ? '▼' : '▶'} + handleToggle(node.uuid)} + className="mr-2 cursor-pointer"> + {isExpanded ? ( + + ) : ( + + )} )} + {getIcon(node.change, node.attributes)} + + handleLeafClick(node)} + className={`flex-1 cursor-pointer truncate ${node.change === 'deleted' ? 'pointer-events-none' : ''}`} title={node.display_name}> {node.display_name}
    - {expanded && hasChildren && ( + {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/ModelDiff.jsx b/frontend/src/components/ModelDiff.jsx index af4ee56..2d14b3d 100644 --- a/frontend/src/components/ModelDiff.jsx +++ b/frontend/src/components/ModelDiff.jsx @@ -114,8 +114,9 @@ export const ModelDiff = ({ onRefetch, hasDiffed }) => { }}>
    e.stopPropagation()}>
    @@ -176,8 +177,9 @@ export const ModelDiff = ({ onRefetch, hasDiffed }) => {
    )} - - {completeLoading && ( - - )} + {completeLoading && ( + + )} +
    )}
    diff --git a/frontend/src/index.css b/frontend/src/index.css index 84a9e74..8c3f54a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -262,16 +262,19 @@ input:checked + .slider:before { del { color: red; } + .created, ins { color: #00aa00; } + .text-removed, del > p { background: #ffe6e6; display: inline; text-decoration: none; } + .text-added, ins > p { background: #e6ffe6; diff --git a/frontend/src/views/ModelComparisonView.jsx b/frontend/src/views/ModelComparisonView.jsx index ecfb0e5..72fa295 100644 --- a/frontend/src/views/ModelComparisonView.jsx +++ b/frontend/src/views/ModelComparisonView.jsx @@ -7,6 +7,7 @@ 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 = () => { const [modelDiff, setModelDiff] = useState(null); @@ -15,6 +16,8 @@ export const ModelComparisonView = () => { const [diffData, setDiffData] = 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); @@ -50,24 +53,57 @@ export const ModelComparisonView = () => { useEffect(() => {}, [objectID]); useEffect(() => {}, [diffData]); + useEffect(() => { + setIsSidebarVisible(!isSmallScreen); + }, [isSmallScreen]); + + const toggleSidebar = () => { + setIsSidebarVisible(!isSidebarVisible); + }; return (
    -
    -
    + {isSmallScreen && ( + + )} +
    +