diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 7b0cbda8c..4d167a4ed 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -7,6 +7,13 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v2
+ - uses: actions/setup-python@v4
+ with:
+ python-version: '3.11.4'
+ cache: 'pip'
+ - uses: actions/setup-node@v3
+ with:
+ cache: 'yarn'
- name: Install python dependencies
run: sudo apt-get update && sudo apt-get install -y python3-setuptools python3-pip chromium-browser libgbm1 && make install-deps
- name: Test-e2e
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 94bb1ca15..531b9b35b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -7,9 +7,14 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v2
+ - uses: actions/setup-python@v4
+ with:
+ python-version: '3.11.4'
+ cache: 'pip'
+ - uses: actions/setup-node@v3
+ with:
+ cache: 'yarn'
- name: Install python dependencies
run: sudo apt-get update && sudo apt-get install -y python3-setuptools python3-pip && make install-deps
- name: Test
run: make test
- - name: Test-e2e
- run: make e2e
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index cbf1dd8c1..a1f5606a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@ Vagrantfile
## act secrets
.secrets/
+.env
### conventions ###
venv/
@@ -30,4 +31,7 @@ yarn-error.log
coverage/
### Dev db
-standards_cache.sqlite
\ No newline at end of file
+standards_cache.sqlite
+
+### Neo4j
+neo4j/
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 1e9f86ac7..f78d2dce6 100644
--- a/Makefile
+++ b/Makefile
@@ -1,23 +1,38 @@
.ONESHELL:
-.PHONY: dev-run run test covers install-deps dev docker lint frontend clean all
+.PHONY: run test covers install-deps dev docker lint frontend clean all
prod-run:
cp cres/db.sqlite standards_cache.sqlite; gunicorn cre:app --log-file=-
-dev-run:
- . ./venv/bin/activate && FLASK_APP=cre.py FLASK_CONFIG=development flask run
+docker-neo4j:
+ docker start cre-neo4j 2>/dev/null || docker run -d --name cre-neo4j --env NEO4J_PLUGINS='["apoc"]' --env NEO4J_AUTH=neo4j/password --volume=`pwd`/.neo4j/data:/data --volume=`pwd`/.neo4j/logs:/logs --workdir=/var/lib/neo4j -p 7474:7474 -p 7687:7687 neo4j
+
+docker-redis:
+ docker start redis-stack 2>/dev/null || docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
+
+start-containers: docker-neo4j docker-redis
+
+start-worker:
+ . ./venv/bin/activate
+ FLASK_APP=`pwd`/cre.py python cre.py --start_worker
+
+dev-flask:
+ . ./venv/bin/activate
+ FLASK_APP=`pwd`/cre.py FLASK_CONFIG=development flask run
+
e2e:
yarn build
[ -d "./venv" ] && . ./venv/bin/activate
export FLASK_APP=$(CURDIR)/cre.py
export FLASK_CONFIG=development
- fFLASK_CONFIG=development flask run&
-
+ flask run&
+
yarn test:e2e
killall yarn
killall flask
+
test:
[ -d "./venv" ] && . ./venv/bin/activate
export FLASK_APP=$(CURDIR)/cre.py
@@ -79,4 +94,8 @@ import-all:
[ -d "./venv" ] && . ./venv/bin/activate
rm -rf standards_cache.sqlite && make migrate-upgrade && export FLASK_APP=$(CURDIR)/cre.py && python cre.py --add --from_spreadsheet https://docs.google.com/spreadsheets/d/1eZOEYgts7d_-Dr-1oAbogPfzBLh6511b58pX3b59kvg && python cre.py --generate_embeddings && python cre.py --zap_in --cheatsheets_in --github_tools_in --capec_in --owasp_secure_headers_in --pci_dss_4_in --juiceshop_in && python cre.py --generate_embeddings
+import-neo4j:
+ [ -d "./venv" ] && . ./venv/bin/activate
+ export FLASK_APP=$(CURDIR)/cre.py && python cre.py --populate_neo4j_db
+
all: clean lint test dev dev-run
diff --git a/Procfile b/Procfile
index 52d23bfd2..8537c1d95 100644
--- a/Procfile
+++ b/Procfile
@@ -1 +1,2 @@
-web: gunicorn cre:app --log-file=-
\ No newline at end of file
+web: gunicorn cre:app --log-file=-g
+worker: FLASK_APP=`pwd`/cre.py python cre.py --start_worker
\ No newline at end of file
diff --git a/README.md b/README.md
index e46c683a8..2b7dfd04a 100644
--- a/README.md
+++ b/README.md
@@ -60,11 +60,22 @@ To add a remote spreadsheet to your local database you can run
python cre.py --add --from_spreadsheet < google sheets url>
To run the web application for development you can run
-
make dev-run
+
+$ make start-containers
+$ make start-worker
+
+# in a seperate shell
+$ make dev-flask
+
Alternatively, you can use the dockerfile with
make docker && make docker-run
+Some features like Gap Analysis require a neo4j DB running, you can start this with
+
make docker-neo4j
+enviroment varaibles for app to connect to neo4jDB (default):
+- NEO4J_URL (neo4j//neo4j:password@localhost:7687)
+
To run the web application for production you need gunicorn and you can run from within the cre_sync dir
make prod-run
@@ -84,4 +95,4 @@ Please see [Contributing](CONTRIBUTING.md) for contributing instructions
Roadmap
---
-For a roadmap of what we would like to be done please see the [issues](https://github.com/OWASP/common-requirement-enumeration/issues).
+For a roadmap of what we would like to be done please see the [issues](https://github.com/OWASP/common-requirement-enumeration/issues).
\ No newline at end of file
diff --git a/application/cmd/cre_main.py b/application/cmd/cre_main.py
index d0bca16d8..d257389ec 100644
--- a/application/cmd/cre_main.py
+++ b/application/cmd/cre_main.py
@@ -17,7 +17,6 @@
from application.utils.external_project_parsers import (
capec_parser,
cwe,
- ccmv3,
ccmv4,
cheatsheets_parser,
misc_tools_parser,
@@ -375,14 +374,6 @@ def run(args: argparse.Namespace) -> None: # pragma: no cover
if args.export:
cache = db_connect(args.cache_file)
cache.export(args.export)
- if args.csa_ccm_v3_in:
- ccmv3.parse_ccm(
- ccmFile=sheet_utils.readSpreadsheet(
- alias="",
- url="https://docs.google.com/spreadsheets/d/1b5i8OV919aiqW2KcYWOQvkLorL1bRPqjthJxLH0QpD8",
- ),
- cache=db_connect(args.cache_file),
- )
if args.csa_ccm_v4_in:
ccmv4.parse_ccm(
ccmFile=sheet_utils.readSpreadsheet(
@@ -426,6 +417,12 @@ def run(args: argparse.Namespace) -> None: # pragma: no cover
generate_embeddings(args.cache_file)
if args.owasp_proj_meta:
owasp_metadata_to_cre(args.owasp_proj_meta)
+ if args.populate_neo4j_db:
+ populate_neo4j_db(args.cache_file)
+ if args.start_worker:
+ from application.worker import start_worker
+
+ start_worker(args.cache_file)
def db_connect(path: str):
@@ -530,3 +527,11 @@ def owasp_metadata_to_cre(meta_file: str):
},
"""
raise NotImplementedError("someone needs to work on this")
+
+
+def populate_neo4j_db(cache: str):
+ logger.info(f"Populating neo4j DB: Connecting to SQL DB")
+ database = db_connect(path=cache)
+ logger.info(f"Populating neo4j DB: Populating")
+ database.neo_db.populate_DB(database.session)
+ logger.info(f"Populating neo4j DB: Complete")
diff --git a/application/database/db.py b/application/database/db.py
index 1c3c1a3af..74f35f128 100644
--- a/application/database/db.py
+++ b/application/database/db.py
@@ -1,3 +1,14 @@
+from neomodel import (
+ config,
+ StructuredNode,
+ StringProperty,
+ UniqueIdProperty,
+ Relationship,
+ RelationshipTo,
+ ArrayProperty,
+ StructuredRel,
+ db,
+)
from sqlalchemy.orm import aliased
import os
import logging
@@ -5,16 +16,17 @@
from collections import Counter
from itertools import permutations
from typing import Any, Dict, List, Optional, Tuple, cast
-
+from itertools import chain
import networkx as nx
import yaml
from application.defs import cre_defs
from application.utils import file
from flask_sqlalchemy.model import DefaultMeta
from sqlalchemy import func
-from sqlalchemy.sql.expression import desc # type: ignore
import uuid
+from application.utils.gap_analysis import get_path_score
+
from .. import sqla # type: ignore
logging.basicConfig()
@@ -156,6 +168,350 @@ class Embeddings(BaseModel): # type: ignore
)
+class RelatedRel(StructuredRel):
+ pass
+
+
+class ContainsRel(StructuredRel):
+ pass
+
+
+class LinkedToRel(StructuredRel):
+ pass
+
+
+class SameRel(StructuredRel):
+ pass
+
+
+class NeoDocument(StructuredNode):
+ document_id = UniqueIdProperty()
+ name = StringProperty(required=True)
+ description = StringProperty(required=True)
+ tags = ArrayProperty(StringProperty())
+ doctype = StringProperty(required=True)
+ related = Relationship("NeoDocument", "RELATED", model=RelatedRel)
+
+ @classmethod
+ def to_cre_def(self, node):
+ raise Exception(f"Shouldn't be parsing a NeoDocument")
+
+
+class NeoNode(NeoDocument):
+ doctype = StringProperty()
+ version = StringProperty(required=True)
+ hyperlink = StringProperty()
+
+ @classmethod
+ def to_cre_def(self, node):
+ raise Exception(f"Shouldn't be parsing a NeoNode")
+
+
+class NeoStandard(NeoNode):
+ section = StringProperty()
+ subsection = StringProperty(required=True)
+ section_id = StringProperty()
+
+ @classmethod
+ def to_cre_def(self, node) -> cre_defs.Standard:
+ return cre_defs.Standard(
+ name=node.name,
+ id=node.document_id,
+ description=node.description,
+ tags=node.tags,
+ hyperlink=node.hyperlink,
+ version=node.version,
+ section=node.section,
+ sectionID=node.section_id,
+ subsection=node.subsection,
+ )
+
+
+class NeoTool(NeoStandard):
+ tooltype = StringProperty(required=True)
+
+ @classmethod
+ def to_cre_def(self, node) -> cre_defs.Tool:
+ return cre_defs.Tool(
+ name=node.name,
+ id=node.document_id,
+ description=node.description,
+ tags=node.tags,
+ hyperlink=node.hyperlink,
+ version=node.version,
+ section=node.section,
+ sectionID=node.section_id,
+ subsection=node.subsection,
+ )
+
+
+class NeoCode(NeoNode):
+ @classmethod
+ def to_cre_def(self, node) -> cre_defs.Code:
+ return cre_defs.Code(
+ name=node.name,
+ id=node.document_id,
+ description=node.description,
+ tags=node.tags,
+ hyperlink=node.hyperlink,
+ version=node.version,
+ )
+
+
+class NeoCRE(NeoDocument): # type: ignore
+ external_id = StringProperty()
+ contains = RelationshipTo("NeoCRE", "CONTAINS", model=ContainsRel)
+ linked = RelationshipTo("NeoStandard", "LINKED_TO", model=LinkedToRel)
+ same_as = RelationshipTo("NeoStandard", "SAME", model=SameRel)
+
+ @classmethod
+ def to_cre_def(self, node) -> cre_defs.CRE:
+ return cre_defs.CRE(
+ name=node.name,
+ id=node.document_id,
+ description=node.description,
+ tags=node.tags,
+ )
+
+
+class NEO_DB:
+ __instance = None
+
+ driver = None
+ connected = False
+
+ @classmethod
+ def instance(self):
+ if self.__instance is None:
+ self.__instance = self.__new__(self)
+
+ config.DATABASE_URL = (
+ os.getenv("NEO4J_URL") or "neo4j://neo4j:password@localhost:7687"
+ )
+ return self.__instance
+
+ def __init__(sel):
+ raise ValueError("NEO_DB is a singleton, please call instance() instead")
+
+ @classmethod
+ def add_gap_analysis(self, standard1: NeoNode, standard2: NeoNode):
+ """
+ Populates the DB with a precompute of the gap analysis between the two specific standards
+ """
+
+ @classmethod
+ def populate_DB(self, session):
+ for il in session.query(InternalLinks).all():
+ group = session.query(CRE).filter(CRE.id == il.group).first()
+ if not group:
+ logger.error(f"CRE {il.group} does not exist?")
+ self.add_cre(group)
+
+ cre = session.query(CRE).filter(CRE.id == il.cre).first()
+ if not cre:
+ logger.error(f"CRE {il.cre} does not exist?")
+ self.add_cre(cre)
+
+ self.link_CRE_to_CRE(il.group, il.cre, il.type)
+
+ for lnk in session.query(Links).all():
+ node = session.query(Node).filter(Node.id == lnk.node).first()
+ if not node:
+ logger.error(f"Node {lnk.node} does not exist?")
+ self.add_dbnode(node)
+
+ cre = session.query(CRE).filter(CRE.id == lnk.cre).first()
+ self.add_cre(cre)
+
+ self.link_CRE_to_Node(lnk.cre, lnk.node, lnk.type)
+
+ @classmethod
+ def add_cre(self, dbcre: CRE):
+ NeoCRE.create_or_update(
+ {
+ "name": dbcre.name,
+ "doctype": "CRE", # dbcre.ntype,
+ "document_id": dbcre.id,
+ "description": dbcre.description,
+ "links": [], # dbcre.links,
+ "tags": [dbcre.tags] if isinstance(dbcre.tags, str) else dbcre.tags,
+ }
+ )
+
+ @classmethod
+ def add_dbnode(self, dbnode: Node):
+ if dbnode.ntype == "Standard":
+ NeoStandard.create_or_update(
+ {
+ "name": dbnode.name,
+ "doctype": dbnode.ntype,
+ "document_id": dbnode.id,
+ "description": dbnode.description or "",
+ "tags": [dbnode.tags]
+ if isinstance(dbnode.tags, str)
+ else dbnode.tags,
+ "hyperlink": "", # dbnode.hyperlink or "",
+ "version": dbnode.version or "",
+ "section": dbnode.section or "",
+ "section_id": dbnode.section_id or "",
+ "subsection": dbnode.subsection or "",
+ }
+ )
+ return
+ if dbnode.ntype == "Tool":
+ NeoTool.create_or_update(
+ {
+ "name": dbnode.name,
+ "doctype": dbnode.ntype,
+ "document_id": dbnode.id,
+ "description": dbnode.description,
+ "links": [], # dbnode.links,
+ "tags": [dbnode.tags]
+ if isinstance(dbnode.tags, str)
+ else dbnode.tags,
+ "metadata": "{}", # dbnode.metadata,
+ "hyperlink": "", # dbnode.hyperlink or "",
+ "version": dbnode.version or "",
+ "section": dbnode.section,
+ "section_id": dbnode.section_id, # dbnode.sectionID,
+ "subsection": dbnode.subsection or "",
+ "tooltype": "", # dbnode.tooltype,
+ }
+ )
+ return
+ if dbnode.ntype == "Code":
+ NeoCode.create_or_update(
+ {
+ "name": dbnode.name,
+ "doctype": dbnode.ntype,
+ "document_id": dbnode.id,
+ "description": dbnode.description,
+ "links": [], # dbnode.links,
+ "tags": [dbnode.tags]
+ if isinstance(dbnode.tags, str)
+ else dbnode.tags,
+ "metadata": "{}", # dbnode.metadata,
+ "hyperlink": "", # dbnode.hyperlink or "",
+ "version": dbnode.version or "",
+ }
+ )
+ return
+ raise Exception(f"Unknown DB type: {dbnode.ntype}")
+
+ @classmethod
+ def link_CRE_to_CRE(self, id1, id2, link_type):
+ cre1 = NeoCRE.nodes.get(document_id=id1)
+ cre2 = NeoCRE.nodes.get(document_id=id2)
+
+ if link_type == "Contains":
+ cre1.contains.connect(cre2)
+ return
+ if link_type == "Related":
+ cre1.related.connect(cre2)
+ return
+ raise Exception(f"Unknown relation type {link_type}")
+
+ @classmethod
+ def link_CRE_to_Node(self, CRE_id, node_id, link_type):
+ cre = NeoCRE.nodes.get(document_id=CRE_id)
+ node = NeoNode.nodes.get(document_id=node_id)
+ if link_type == "Linked To":
+ cre.linked.connect(node)
+ return
+ if link_type == "SAME":
+ cre.same_as.connect(node)
+ return
+ raise Exception(f"Unknown relation type {link_type}")
+
+ @classmethod
+ def gap_analysis(self, name_1, name_2):
+ base_standard = NeoStandard.nodes.filter(name=name_1)
+ denylist = ["Cross-cutting concerns"]
+ from pprint import pprint
+ from datetime import datetime
+
+ t1 = datetime.now()
+ path_records_all, _ = db.cypher_query(
+ """
+ OPTIONAL MATCH (BaseStandard:NeoStandard {name: $name1})
+ OPTIONAL MATCH (CompareStandard:NeoStandard {name: $name2})
+ OPTIONAL MATCH p = allShortestPaths((BaseStandard)-[*..20]-(CompareStandard))
+ WITH p
+ WHERE length(p) > 1 AND ALL(n in NODES(p) WHERE (n:NeoCRE or n = BaseStandard or n = CompareStandard) AND NOT n.name in $denylist)
+ RETURN p
+ """,
+ {"name1": name_1, "name2": name_2, "denylist": denylist},
+ resolve_objects=True,
+ )
+ t2 = datetime.now()
+ pprint(f"path records all took {t2-t1}")
+ pprint(path_records_all.__len__())
+ # [ end= size=4>]]
+ path_records, _ = db.cypher_query(
+ """
+ OPTIONAL MATCH (BaseStandard:NeoStandard {name: $name1})
+ OPTIONAL MATCH (CompareStandard:NeoStandard {name: $name2})
+ OPTIONAL MATCH p = allShortestPaths((BaseStandard)-[:(LINKED_TO|CONTAINS)*..20]-(CompareStandard))
+ WITH p
+ WHERE length(p) > 1 AND ALL(n in NODES(p) WHERE (n:NeoCRE or n = BaseStandard or n = CompareStandard) AND NOT n.name in $denylist)
+ RETURN p
+ """,
+ {"name1": name_1, "name2": name_2, "denylist": denylist},
+ resolve_objects=True,
+ )
+ t3 = datetime.now()
+
+ def format_segment(seg: StructuredRel, nodes):
+ relation_map = {
+ RelatedRel: "RELATED",
+ ContainsRel: "CONTAINS",
+ LinkedToRel: "LINKED_TO",
+ SameRel: "SAME",
+ }
+ start_node = [
+ node for node in nodes if node.element_id == seg._start_node_element_id
+ ][0]
+ end_node = [
+ node for node in nodes if node.element_id == seg._end_node_element_id
+ ][0]
+
+ return {
+ "start": NEO_DB.parse_node(start_node),
+ "end": NEO_DB.parse_node(end_node),
+ "relationship": relation_map[type(seg)],
+ }
+
+ def format_path_record(rec):
+ return {
+ "start": NEO_DB.parse_node(rec.start_node),
+ "end": NEO_DB.parse_node(rec.end_node),
+ "path": [format_segment(seg, rec.nodes) for seg in rec.relationships],
+ }
+
+ pprint(
+ f"path records all took {t2-t1} path records took {t3 - t2}, total: {t3 - t1}"
+ )
+ return [NEO_DB.parse_node(rec) for rec in base_standard], [
+ format_path_record(rec[0]) for rec in (path_records + path_records_all)
+ ]
+
+ @classmethod
+ def standards(self) -> List[str]:
+ tools = []
+ for x in db.cypher_query("""MATCH (n:NeoTool) RETURN DISTINCT n.name""")[0]:
+ tools.extend(x)
+ standards = []
+ for x in db.cypher_query("""MATCH (n:NeoStandard) RETURN DISTINCT n.name""")[
+ 0
+ ]: # 0 is the results, 1 is the "n.name" param
+ standards.extend(x)
+ return list(set([x for x in tools] + [x for x in standards]))
+
+ @staticmethod
+ def parse_node(node: NeoDocument) -> cre_defs.Document:
+ return node.to_cre_def(node)
+
+
class CRE_Graph:
graph: nx.Graph = None
__instance = None
@@ -189,6 +545,8 @@ def add_cre(cls, dbcre: CRE, graph: nx.DiGraph) -> nx.DiGraph:
@classmethod
def add_dbnode(cls, dbnode: Node, graph: nx.DiGraph) -> nx.DiGraph:
if dbnode:
+ # coma separated tags
+
graph.add_node(
"Node: " + str(dbnode.id),
internal_id=dbnode.id,
@@ -231,11 +589,13 @@ def load_cre_graph(cls, session) -> nx.Graph:
class Node_collection:
graph: nx.Graph = None
+ neo_db: NEO_DB = None
session = sqla.session
def __init__(self) -> None:
if not os.environ.get("NO_LOAD_GRAPH"):
self.graph = CRE_Graph.instance(sqla.session)
+ self.neo_db = NEO_DB.instance()
self.session = sqla.session
def __get_external_links(self) -> List[Tuple[CRE, Node, str]]:
@@ -1059,30 +1419,8 @@ def find_path_between_nodes(
return res
- def gap_analysis(self, node_names: List[str]) -> List[cre_defs.Node]:
- """Since the CRE structure is a tree-like graph with
- leaves being nodes we can find the paths between nodes
- find_path_between_nodes() is a graph-path-finding method
- """
- processed_nodes = []
- dbnodes: List[Node] = []
- for name in node_names:
- dbnodes.extend(self.session.query(Node).filter(Node.name == name).all())
-
- for node in dbnodes:
- working_node = nodeFromDB(node)
- for other_node in dbnodes:
- if node.id == other_node.id:
- continue
- if self.find_path_between_nodes(node.id, other_node.id):
- working_node.add_link(
- cre_defs.Link(
- ltype=cre_defs.LinkTypes.LinkedTo,
- document=nodeFromDB(other_node),
- )
- )
- processed_nodes.append(working_node)
- return processed_nodes
+ def standards(self) -> List[str]:
+ return self.neo_db.standards()
def text_search(self, text: str) -> List[Optional[cre_defs.Document]]:
"""Given a piece of text, tries to find the best match
@@ -1427,3 +1765,26 @@ def dbCREfromCRE(cre: cre_defs.CRE) -> CRE:
external_id=cre.id,
tags=",".join(tags),
)
+
+
+def gap_analysis(neo_db, node_names: List[str]):
+ base_standard, paths = neo_db.gap_analysis(node_names[0], node_names[1])
+ if base_standard is None:
+ return None
+ grouped_paths = {}
+ for node in base_standard:
+ key = node.id
+ if key not in grouped_paths:
+ grouped_paths[key] = {"start": node, "paths": {}}
+
+ for path in paths:
+ key = path["start"].id
+ end_key = path["end"].id
+ path["score"] = get_path_score(path)
+ del path["start"]
+ if end_key in grouped_paths[key]["paths"]:
+ if grouped_paths[key]["paths"][end_key]["score"] > path["score"]:
+ grouped_paths[key]["paths"][end_key] = path
+ else:
+ grouped_paths[key]["paths"][end_key] = path
+ return (node_names, grouped_paths)
diff --git a/application/frontend/src/const.ts b/application/frontend/src/const.ts
index 231f78447..6f176723f 100644
--- a/application/frontend/src/const.ts
+++ b/application/frontend/src/const.ts
@@ -36,3 +36,4 @@ export const CRE = '/cre';
export const GRAPH = '/graph';
export const DEEPLINK = '/deeplink';
export const BROWSEROOT = '/root_cres';
+export const GAP_ANALYSIS = '/map_analysis';
diff --git a/application/frontend/src/pages/GapAnalysis/GapAnalysis.tsx b/application/frontend/src/pages/GapAnalysis/GapAnalysis.tsx
new file mode 100644
index 000000000..6da81b358
--- /dev/null
+++ b/application/frontend/src/pages/GapAnalysis/GapAnalysis.tsx
@@ -0,0 +1,337 @@
+import axios from 'axios';
+import React, { useEffect, useRef, useState } from 'react';
+import { useLocation } from 'react-router-dom';
+import {
+ Accordion,
+ Button,
+ Container,
+ Dropdown,
+ DropdownItemProps,
+ Grid,
+ Icon,
+ Label,
+ Popup,
+ Table,
+} from 'semantic-ui-react';
+
+import { LoadingAndErrorIndicator } from '../../components/LoadingAndErrorIndicator';
+import { useEnvironment } from '../../hooks';
+import { GapAnalysisPathStart } from '../../types';
+import { getDocumentDisplayName } from '../../utils';
+import { getInternalUrl } from '../../utils/document';
+
+const GetSegmentText = (segment, segmentID) => {
+ let textPart = segment.end;
+ let nextID = segment.end.id;
+ let arrow = ;
+ if (segmentID !== segment.start.id) {
+ textPart = segment.start;
+ nextID = segment.start.id;
+ arrow = ;
+ }
+ const text = (
+ <>
+
+ {arrow}{' '}
+
+ {segment.relationship.replace('_', ' ').toLowerCase()} {segment.score > 0 && <> (+{segment.score})>}
+
+ {getDocumentDisplayName(textPart, true)} {textPart.section ?? ''} {textPart.subsection ?? ''}{' '}
+ {textPart.description ?? ''}
+ >
+ );
+ return { text, nextID };
+};
+
+function useQuery() {
+ const { search } = useLocation();
+
+ return React.useMemo(() => new URLSearchParams(search), [search]);
+}
+
+const GetStrength = (score) => {
+ if (score == 0) return 'Direct';
+ if (score <= 2) return 'Strong';
+ if (score >= 20) return 'Weak';
+ return 'Average';
+};
+
+const GetStrengthColor = (score) => {
+ if (score === 0) return 'darkgreen';
+ if (score <= 2) return '#93C54B';
+ if (score >= 20) return 'Red';
+ return 'Orange';
+};
+
+const GetResultLine = (path, gapAnalysis, key) => {
+ let segmentID = gapAnalysis[key].start.id;
+ return (
+