diff --git a/compose-configs/egeria-freshstart/PyegeriaWebHandler/egeria-overview.html b/compose-configs/egeria-freshstart/PyegeriaWebHandler/egeria-overview.html
index 4591f259..e0c6943b 100644
--- a/compose-configs/egeria-freshstart/PyegeriaWebHandler/egeria-overview.html
+++ b/compose-configs/egeria-freshstart/PyegeriaWebHandler/egeria-overview.html
@@ -463,6 +463,11 @@
Data Stores by Kind
What the catalogued data stores are, by element type
+
+
Assets by Technology
+
The technology each asset records (deployedImplementationType); most assets record none
+
+
@@ -988,7 +993,7 @@ Share your feedback
hbars('byType',[],null,'No data yet');
hbars('byZone',[],null,'No data yet');
hbars('byConf',[],null,'No data yet');
-hbars('byKind',[],null,'No data yet');
+hbars('byKind',[],null,'No data yet'); hbars('byTech',[],null,'No data yet');
hbars('activity',[],null,'Not measured: no activity feed yet');
hbars('fbType',[],null,'No data yet');
@@ -1444,6 +1449,7 @@ Share your feedback
{ name: 'Assets by Type', status: 'live' }, // counts_by_type via /api/overview/summary
{ name: 'Elements by Governance Zone', status: 'live' }, // governed_coverage's topZones
{ name: 'Data Stores by Kind', status: 'live' }, // element types of the DataStore find the survey coverage already reads (no extra call)
+ { name: 'Assets by Technology', status: 'live' }, // paged find of Assets grouped by deployedImplementationType
{ name: 'Confidentiality Distribution', status: 'live' }, // one bounded find of elements carrying a Confidentiality classification, grouped by level
],
usage: [
@@ -1826,6 +1832,20 @@ Share your feedback
if (rest.length) rows.push(['Other kinds (' + rest.length + ')', rest.reduce(function(a, k){ return a + k[1]; }, 0), 'var(--c6)']);
hbars('byKind', rows);
}
+ // Assets by Technology: exclusive groups that add up to the asset total (each technology, assets that
+ // record none, template placeholders). null = the asset list could not be read in full (failed or capped).
+ function renderAssetsByTechnology(d){
+ if (typeof hbars !== 'function') return;
+ if (d.assetsByTechnology == null) { hbars('byTech', [], null, 'Not measured: the asset list could not be read in full'); return; }
+ var ts = Object.keys(d.assetsByTechnology).map(function(k){ return [k, d.assetsByTechnology[k]]; })
+ .sort(function(a, b){ return b[1] - a[1] || (a[0] < b[0] ? -1 : 1); });
+ var rows = ts.slice(0, 8).map(function(k){ return [k[0], k[1], 'var(--c3)']; });
+ var rest = ts.slice(8);
+ if (rest.length) rows.push(['Other technologies (' + rest.length + ')', rest.reduce(function(a, k){ return a + k[1]; }, 0), 'var(--c6)']);
+ if (d.technologyPlaceholders) rows.push(['Template placeholders', d.technologyPlaceholders, '#33415c']);
+ if (d.technologyUnrecorded) rows.push(['No technology recorded', d.technologyUnrecorded, '#33415c']);
+ hbars('byTech', rows, null, 'No assets catalogued');
+ }
function renderConfidentiality(d){
if (typeof hbars !== 'function') return;
if (d.confidentialityLevels == null) { hbars('byConf', [], null, 'Not measured: the confidentiality query did not return'); return; }
@@ -2025,7 +2045,7 @@ Share your feedback
store.latestSurveyTime = d.latestSurveyTime; store.surveysCompleted7d = d.surveysCompleted7d;
store.engineActionHealth = d.engineActionHealth;
// render only after every value above is stored (this used to run before the survey values were set)
- if (primary) { renderCertsSoon(d); renderSurveyCoverage(store); renderConfidentiality(d); renderDataStoreKinds(d); }
+ if (primary) { renderCertsSoon(d); renderSurveyCoverage(store); renderConfidentiality(d); renderDataStoreKinds(d); renderAssetsByTechnology(d); }
store.governedCount = d.governedCount;
if (d.assetTotal && d.governedCount != null) {
store.governedPctNum = Math.round(100 * d.governedCount / Math.max(d.assetTotal, d.governedCount));
diff --git a/compose-configs/egeria-freshstart/PyegeriaWebHandler/overview_handler.py b/compose-configs/egeria-freshstart/PyegeriaWebHandler/overview_handler.py
index 9c7a12bc..dcac5199 100644
--- a/compose-configs/egeria-freshstart/PyegeriaWebHandler/overview_handler.py
+++ b/compose-configs/egeria-freshstart/PyegeriaWebHandler/overview_handler.py
@@ -463,6 +463,7 @@ def get_summary(
schema = _schema_coverage(mgr, as_of_time, survey.get("schemaAnalysedGuids"))
engine_health = _engine_action_health(url, server, user_id, user_pwd, as_of_time)
confidentiality = _confidentiality_levels(mgr, as_of_time)
+ technology = _assets_by_technology(mgr, as_of_time)
payload = {
"asOfTime": as_of_time,
@@ -496,6 +497,10 @@ def get_summary(
"latestSurveyTime": survey["latestSurveyTime"],
"surveysCompleted7d": survey["surveysCompleted7d"],
"dataStoresByKind": survey["dataStoresByKind"],
+ "assetsByTechnology": technology["technologies"],
+ "technologyUnrecorded": technology["unrecorded"],
+ "technologyPlaceholders": technology["placeholders"],
+ "technologyAssetTotal": technology["total"],
"engineActionHealth": engine_health,
"surveysCompleted": survey["surveysCompleted"],
"surveysFailed": survey["surveysFailed"],
@@ -569,6 +574,46 @@ def _confidentiality_levels(mgr, as_of: Optional[str] = None) -> dict:
return out
+def _assets_by_technology(mgr, as_of: Optional[str] = None) -> dict:
+ """Assets grouped by the technology they record in `deployedImplementationType`. Most assets do not
+ record one, so the figures are exclusive and add up to the asset total: each technology, the assets
+ that record none (`unrecorded`), and template placeholders such as ``~{deployedImplementationType}~``
+ (`placeholders`: template elements carry the unfilled name, which is not a technology). Read in pages
+ until an empty one (Egeria's paging contract); every field is None when a page failed or
+ _REL_MAX_PAGES full pages were read without reaching the end (a capped read can only under-count)."""
+ out = {"technologies": None, "unrecorded": None, "placeholders": None, "total": None}
+ try:
+ counts: dict = {}
+ unrecorded = placeholders = total = 0
+ done = False
+ for page in range(_REL_MAX_PAGES):
+ body = {"class": "FindRequestBody", "metadataElementTypeName": "Asset",
+ "limitResultsByStatus": ["ACTIVE"], "graphQueryDepth": 0,
+ "startFrom": page * _SURVEY_LINK_CAP, "pageSize": _SURVEY_LINK_CAP}
+ if as_of:
+ body["asOfTime"] = as_of
+ chunk = mgr.find_metadata_elements(body)
+ chunk = [e for e in (chunk if isinstance(chunk, list) else []) if isinstance(e, dict)]
+ if not chunk:
+ done = True
+ break
+ for e in chunk:
+ total += 1
+ props = e.get("elementProperties") or {}
+ tech = str((props.get("propertiesAsStrings") or props).get("deployedImplementationType") or "").strip()
+ if not tech:
+ unrecorded += 1
+ elif tech.startswith("~{") and tech.endswith("}~"):
+ placeholders += 1
+ else:
+ counts[tech] = counts.get(tech, 0) + 1
+ if done:
+ out.update(technologies=counts, unrecorded=unrecorded, placeholders=placeholders, total=total)
+ except Exception as exc: # noqa: BLE001
+ logger.debug(f"overview assets by technology failed: {exc}")
+ return out
+
+
def _schema_coverage(mgr, as_of: Optional[str] = None, analysed=None) -> dict:
"""How many elements have a schema: the distinct anchors of SchemaType elements (a schema's anchor is
the asset it describes) UNION the assets a survey analysed the schema of (SchemaAnalysisAnnotation --
diff --git a/compose-configs/egeria-quickstart/PyegeriaWebHandler/egeria-overview.html b/compose-configs/egeria-quickstart/PyegeriaWebHandler/egeria-overview.html
index dc424e9c..96049c5c 100644
--- a/compose-configs/egeria-quickstart/PyegeriaWebHandler/egeria-overview.html
+++ b/compose-configs/egeria-quickstart/PyegeriaWebHandler/egeria-overview.html
@@ -463,6 +463,11 @@ Data Stores by Kind
What the catalogued data stores are, by element type
+
+
Assets by Technology
+
The technology each asset records (deployedImplementationType); most assets record none
+
+
@@ -985,7 +990,7 @@ Share your feedback
hbars('byType',[],null,'No data yet');
hbars('byZone',[],null,'No data yet');
hbars('byConf',[],null,'No data yet');
-hbars('byKind',[],null,'No data yet');
+hbars('byKind',[],null,'No data yet'); hbars('byTech',[],null,'No data yet');
hbars('activity',[],null,'Not measured: no activity feed yet');
hbars('fbType',[],null,'No data yet');
@@ -1441,6 +1446,7 @@ Share your feedback
{ name: 'Assets by Type', status: 'live' }, // counts_by_type via /api/overview/summary
{ name: 'Elements by Governance Zone', status: 'live' }, // governed_coverage's topZones
{ name: 'Data Stores by Kind', status: 'live' }, // element types of the DataStore find the survey coverage already reads (no extra call)
+ { name: 'Assets by Technology', status: 'live' }, // paged find of Assets grouped by deployedImplementationType
{ name: 'Confidentiality Distribution', status: 'live' }, // one bounded find of elements carrying a Confidentiality classification, grouped by level
],
usage: [
@@ -1814,6 +1820,20 @@ Share your feedback
if (rest.length) rows.push(['Other kinds (' + rest.length + ')', rest.reduce(function(a, k){ return a + k[1]; }, 0), 'var(--c6)']);
hbars('byKind', rows);
}
+ // Assets by Technology: exclusive groups that add up to the asset total (each technology, assets that
+ // record none, template placeholders). null = the asset list could not be read in full (failed or capped).
+ function renderAssetsByTechnology(d){
+ if (typeof hbars !== 'function') return;
+ if (d.assetsByTechnology == null) { hbars('byTech', [], null, 'Not measured: the asset list could not be read in full'); return; }
+ var ts = Object.keys(d.assetsByTechnology).map(function(k){ return [k, d.assetsByTechnology[k]]; })
+ .sort(function(a, b){ return b[1] - a[1] || (a[0] < b[0] ? -1 : 1); });
+ var rows = ts.slice(0, 8).map(function(k){ return [k[0], k[1], 'var(--c3)']; });
+ var rest = ts.slice(8);
+ if (rest.length) rows.push(['Other technologies (' + rest.length + ')', rest.reduce(function(a, k){ return a + k[1]; }, 0), 'var(--c6)']);
+ if (d.technologyPlaceholders) rows.push(['Template placeholders', d.technologyPlaceholders, '#33415c']);
+ if (d.technologyUnrecorded) rows.push(['No technology recorded', d.technologyUnrecorded, '#33415c']);
+ hbars('byTech', rows, null, 'No assets catalogued');
+ }
function renderConfidentiality(d){
if (typeof hbars !== 'function') return;
if (d.confidentialityLevels == null) { hbars('byConf', [], null, 'Not measured: the confidentiality query did not return'); return; }
@@ -2013,7 +2033,7 @@ Share your feedback
store.latestSurveyTime = d.latestSurveyTime; store.surveysCompleted7d = d.surveysCompleted7d;
store.engineActionHealth = d.engineActionHealth;
// render only after every value above is stored (this used to run before the survey values were set)
- if (primary) { renderCertsSoon(d); renderSurveyCoverage(store); renderConfidentiality(d); renderDataStoreKinds(d); }
+ if (primary) { renderCertsSoon(d); renderSurveyCoverage(store); renderConfidentiality(d); renderDataStoreKinds(d); renderAssetsByTechnology(d); }
store.governedCount = d.governedCount;
if (d.assetTotal && d.governedCount != null) {
store.governedPctNum = Math.round(100 * d.governedCount / Math.max(d.assetTotal, d.governedCount));
diff --git a/compose-configs/egeria-quickstart/PyegeriaWebHandler/overview_handler.py b/compose-configs/egeria-quickstart/PyegeriaWebHandler/overview_handler.py
index 9652af51..55cfe871 100644
--- a/compose-configs/egeria-quickstart/PyegeriaWebHandler/overview_handler.py
+++ b/compose-configs/egeria-quickstart/PyegeriaWebHandler/overview_handler.py
@@ -473,6 +473,7 @@ def get_summary(
schema = _schema_coverage(mgr, as_of_time, survey.get("schemaAnalysedGuids"))
engine_health = _engine_action_health(url, server, user_id, user_pwd, as_of_time)
confidentiality = _confidentiality_levels(mgr, as_of_time)
+ technology = _assets_by_technology(mgr, as_of_time)
payload = {
"asOfTime": as_of_time,
@@ -506,6 +507,10 @@ def get_summary(
"latestSurveyTime": survey["latestSurveyTime"],
"surveysCompleted7d": survey["surveysCompleted7d"],
"dataStoresByKind": survey["dataStoresByKind"],
+ "assetsByTechnology": technology["technologies"],
+ "technologyUnrecorded": technology["unrecorded"],
+ "technologyPlaceholders": technology["placeholders"],
+ "technologyAssetTotal": technology["total"],
"engineActionHealth": engine_health,
"surveysCompleted": survey["surveysCompleted"],
"surveysFailed": survey["surveysFailed"],
@@ -579,6 +584,46 @@ def _confidentiality_levels(mgr, as_of: Optional[str] = None) -> dict:
return out
+def _assets_by_technology(mgr, as_of: Optional[str] = None) -> dict:
+ """Assets grouped by the technology they record in `deployedImplementationType`. Most assets do not
+ record one, so the figures are exclusive and add up to the asset total: each technology, the assets
+ that record none (`unrecorded`), and template placeholders such as ``~{deployedImplementationType}~``
+ (`placeholders`: template elements carry the unfilled name, which is not a technology). Read in pages
+ until an empty one (Egeria's paging contract); every field is None when a page failed or
+ _REL_MAX_PAGES full pages were read without reaching the end (a capped read can only under-count)."""
+ out = {"technologies": None, "unrecorded": None, "placeholders": None, "total": None}
+ try:
+ counts: dict = {}
+ unrecorded = placeholders = total = 0
+ done = False
+ for page in range(_REL_MAX_PAGES):
+ body = {"class": "FindRequestBody", "metadataElementTypeName": "Asset",
+ "limitResultsByStatus": ["ACTIVE"], "graphQueryDepth": 0,
+ "startFrom": page * _SURVEY_LINK_CAP, "pageSize": _SURVEY_LINK_CAP}
+ if as_of:
+ body["asOfTime"] = as_of
+ chunk = mgr.find_metadata_elements(body)
+ chunk = [e for e in (chunk if isinstance(chunk, list) else []) if isinstance(e, dict)]
+ if not chunk:
+ done = True
+ break
+ for e in chunk:
+ total += 1
+ props = e.get("elementProperties") or {}
+ tech = str((props.get("propertiesAsStrings") or props).get("deployedImplementationType") or "").strip()
+ if not tech:
+ unrecorded += 1
+ elif tech.startswith("~{") and tech.endswith("}~"):
+ placeholders += 1
+ else:
+ counts[tech] = counts.get(tech, 0) + 1
+ if done:
+ out.update(technologies=counts, unrecorded=unrecorded, placeholders=placeholders, total=total)
+ except Exception as exc: # noqa: BLE001
+ logger.debug(f"overview assets by technology failed: {exc}")
+ return out
+
+
def _schema_coverage(mgr, as_of: Optional[str] = None, analysed=None) -> dict:
"""How many elements have a schema: the distinct anchors of SchemaType elements (a schema's anchor is
the asset it describes) UNION the assets a survey analysed the schema of (SchemaAnalysisAnnotation --
diff --git a/compose-configs/egeria-quickstart/PyegeriaWebHandler/tests/test_assets_by_technology.py b/compose-configs/egeria-quickstart/PyegeriaWebHandler/tests/test_assets_by_technology.py
new file mode 100644
index 00000000..2d64012e
--- /dev/null
+++ b/compose-configs/egeria-quickstart/PyegeriaWebHandler/tests/test_assets_by_technology.py
@@ -0,0 +1,93 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright Contributors to the ODPi Egeria project.
+"""_assets_by_technology() groups assets by deployedImplementationType into exclusive buckets.
+
+Most catalogued assets record no technology, so the result must add up to the asset total (each technology
++ assets that record none + template placeholders) and must never present a partial read as a total: it
+reads pages until an empty one and returns None everywhere if a page failed or the page limit was hit.
+
+The function is extracted from each handler file (quickstart and freshstart carry identical copies) and run
+against a fake manager, without importing pyegeria or FastAPI.
+"""
+import ast
+from pathlib import Path
+from typing import Optional
+
+import pytest
+
+_HERE = Path(__file__).resolve().parent
+_HANDLERS = {
+ "quickstart": _HERE.parent / "overview_handler.py",
+ "freshstart": _HERE.parents[2] / "egeria-freshstart" / "PyegeriaWebHandler" / "overview_handler.py",
+}
+_PAGE = 1000
+_NONE = {"technologies": None, "unrecorded": None, "placeholders": None, "total": None}
+
+
+def _load(path: Path):
+ if not path.exists():
+ pytest.skip(f"{path} not present in this checkout")
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ body = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "_assets_by_technology"]
+ assert len(body) == 1
+ ns = {"Optional": Optional, "_SURVEY_LINK_CAP": _PAGE, "_REL_MAX_PAGES": 10,
+ "logger": type("L", (), {"debug": staticmethod(lambda *a, **k: None)})()}
+ exec(compile(ast.Module(body=body, type_ignores=[]), str(path), "exec"), ns)
+ return ns["_assets_by_technology"]
+
+
+def _asset(tech=None):
+ props = {} if tech is None else {"deployedImplementationType": tech}
+ return {"elementGUID": "g", "elementProperties": {"propertiesAsStrings": props}}
+
+
+class _Mgr:
+ def __init__(self, assets, fail_at=None):
+ self.assets, self.fail_at, self.bodies = assets, fail_at, []
+
+ def find_metadata_elements(self, body):
+ self.bodies.append(body)
+ if self.fail_at is not None and body["startFrom"] >= self.fail_at:
+ raise RuntimeError("boom")
+ lo = body["startFrom"]
+ return self.assets[lo:lo + body["pageSize"]]
+
+
+@pytest.fixture(params=sorted(_HANDLERS))
+def by_tech(request):
+ return _load(_HANDLERS[request.param])
+
+
+def test_buckets_are_exclusive_and_add_up_to_the_total(by_tech):
+ assets = [_asset("CSV Data File")] * 3 + [_asset("Integration Connector")] + [_asset()] * 5 \
+ + [_asset("~{deployedImplementationType}~")] * 2 + [_asset(" ")]
+ out = by_tech(_Mgr(assets))
+ assert out["technologies"] == {"CSV Data File": 3, "Integration Connector": 1}
+ assert out["unrecorded"] == 6 and out["placeholders"] == 2 and out["total"] == 12
+ assert sum(out["technologies"].values()) + out["unrecorded"] + out["placeholders"] == out["total"]
+
+
+def test_reads_every_page_and_ends_on_an_empty_one(by_tech):
+ m = _Mgr([_asset("A")] * 2500)
+ out = by_tech(m)
+ assert out["technologies"] == {"A": 2500} and out["total"] == 2500
+ assert [b["startFrom"] for b in m.bodies] == [0, 1000, 2000, 3000]
+
+
+def test_as_of_time_is_sent_and_only_active_assets_are_read(by_tech):
+ m = _Mgr([_asset("A")])
+ by_tech(m, "2026-10-01T00:00:00")
+ assert m.bodies[0]["asOfTime"] == "2026-10-01T00:00:00"
+ assert m.bodies[0]["limitResultsByStatus"] == ["ACTIVE"] and m.bodies[0]["metadataElementTypeName"] == "Asset"
+
+
+def test_no_assets_is_a_real_zero(by_tech):
+ assert by_tech(_Mgr([])) == {"technologies": {}, "unrecorded": 0, "placeholders": 0, "total": 0}
+
+
+def test_hitting_the_page_limit_withholds_everything(by_tech):
+ assert by_tech(_Mgr([_asset("A")] * (10 * _PAGE + 1))) == _NONE
+
+
+def test_a_failed_page_discards_everything(by_tech):
+ assert by_tech(_Mgr([_asset("A")] * 5000, fail_at=2000)) == _NONE