From a5c227286767d6b9cfec3ac87500f93a8276f805 Mon Sep 17 00:00:00 2001 From: yoshifuminakamura Date: Wed, 12 Aug 2026 10:41:49 +0900 Subject: [PATCH] Add environment snapshot operations links Signed-off-by: yoshifuminakamura --- result_server/routes/results_detail_routes.py | 70 ++++++++++- ...usage_report_profile_overview_section.html | 10 +- .../environment_snapshot_results.html | 102 ++++++++++++++++ result_server/templates/result_detail.html | 5 + result_server/test_support.py | 4 + ...test_environment_snapshot_results_route.py | 114 ++++++++++++++++++ .../tests/test_environment_snapshots.py | 8 ++ .../tests/test_portal_list_templates.py | 71 +++++++++++ .../tests/test_profile_usage_overview.py | 19 +++ result_server/utils/environment_snapshots.py | 61 ++++++++++ result_server/utils/profile_usage_overview.py | 97 +++++++++++++-- result_server/utils/result_detail_view.py | 7 ++ 12 files changed, 556 insertions(+), 12 deletions(-) create mode 100644 result_server/templates/environment_snapshot_results.html create mode 100644 result_server/tests/test_environment_snapshot_results_route.py diff --git a/result_server/routes/results_detail_routes.py b/result_server/routes/results_detail_routes.py index 28d2fe1..7bd6e30 100644 --- a/result_server/routes/results_detail_routes.py +++ b/result_server/routes/results_detail_routes.py @@ -1,13 +1,23 @@ -from flask import abort, current_app, render_template, request +from flask import abort, current_app, render_template, request, url_for +from werkzeug.exceptions import Forbidden, NotFound +from utils.environment_snapshots import ( + get_environment_snapshot, + list_environment_snapshot_results, +) +from utils.node_hours import compute_node_hours from utils.result_compare_view import load_result_compare_context from utils.result_detail_view import build_result_detail_context from utils.result_file import ( load_permitted_result_json, serve_permitted_result_file, ) -from utils.result_records import summarize_result_quality -from utils.trigger_display import load_trigger_run_lookup +from utils.result_records import ( + format_numeric_value, + format_result_timestamp, + summarize_result_quality, +) +from utils.trigger_display import load_trigger_run_lookup, summarize_execution_trigger def register_results_detail_routes(results_bp): @@ -35,8 +45,62 @@ def result_detail(filename): quality, load_trigger_run_lookup(current_app.config.get("EXECUTION_PROFILE_DB_PATH")), ) + if detail_context.get("environment_snapshot_hash"): + detail_context["environment_snapshot_results_url"] = url_for( + "results.environment_snapshot_results", + snapshot_hash=detail_context["environment_snapshot_hash"], + ) return render_template("result_detail.html", result=result, quality=quality, **detail_context) + @results_bp.route("/environment-snapshots/") + def environment_snapshot_results(snapshot_hash): + db_path = current_app.config.get("EXECUTION_PROFILE_DB_PATH") + snapshot = get_environment_snapshot(db_path, snapshot_hash) + if snapshot is None: + abort(404, "Environment snapshot not found") + + trigger_run_lookup = load_trigger_run_lookup(db_path) + result_rows = [] + for link in list_environment_snapshot_results(db_path, snapshot_hash): + filename = link.get("json_file") or "" + try: + result = load_permitted_result_json( + filename, + current_app.config["RECEIVED_DIR"], + not_found_message="Result file not found", + ) + except (Forbidden, NotFound): + continue + trigger_summary = summarize_execution_trigger(result, trigger_run_lookup) + result_rows.append({ + "filename": filename, + "timestamp": format_result_timestamp(filename), + "code": result.get("code") or link.get("code") or "-", + "system": result.get("system") or link.get("system") or "-", + "exp": result.get("Exp") or link.get("exp") or "-", + "fom": format_numeric_value(result.get("FOM")), + "fom_unit": result.get("FOM_unit") or "", + "pipeline_id": result.get("pipeline_id") or link.get("pipeline_id") or "-", + "node_hours": compute_node_hours(result), + "trigger_headline": trigger_summary["headline"], + "trigger_subline": trigger_summary.get("subline") or "", + "trigger_title": trigger_summary.get("title") or "", + }) + + visible_node_hours = round(sum(row["node_hours"] for row in result_rows), 2) + result_summary = { + "visible_count": len(result_rows), + "linked_count": snapshot.get("result_count") or len(result_rows), + "node_hours": visible_node_hours, + "latest_timestamp": result_rows[0]["timestamp"] if result_rows else "-", + } + return render_template( + "environment_snapshot_results.html", + snapshot=snapshot, + result_rows=result_rows, + result_summary=result_summary, + ) + @results_bp.route("/") def show_result(filename): if filename.endswith(".tgz"): diff --git a/result_server/templates/_usage_report_profile_overview_section.html b/result_server/templates/_usage_report_profile_overview_section.html index 79fca12..637d5e2 100644 --- a/result_server/templates/_usage_report_profile_overview_section.html +++ b/result_server/templates/_usage_report_profile_overview_section.html @@ -63,15 +63,23 @@

Results

{{ row.result_count }} results {{ row.node_hours }} node-hours + {{ row.snapshot_count }} snapshots + + attribution trigger/manual/legacy: + {{ row.attribution_counts.trigger_id_match }}/{{ row.attribution_counts.manual_profile_match }}/{{ row.attribution_counts.legacy_scope_fallback }} + {% if row.latest_result %} {{ row.latest_result.timestamp }} {{ row.latest_result.code }} / {{ row.latest_result.system }} / {{ row.latest_result.exp }} {{ row.latest_result.trigger_headline }} / pipeline {{ row.latest_result.pipeline_id }} + {% if row.latest_result.attribution %} + attributed by {{ row.latest_result.attribution.label }} + {% endif %} {% if row.latest_result.environment_snapshot %} - snapshot {{ row.latest_result.environment_snapshot.short_hash }} + snapshot {{ row.latest_result.environment_snapshot.short_hash }} / {{ row.latest_result.environment_snapshot.allocation_project_id }} / {{ row.latest_result.environment_snapshot.scheduler }} diff --git a/result_server/templates/environment_snapshot_results.html b/result_server/templates/environment_snapshot_results.html new file mode 100644 index 0000000..e8196b8 --- /dev/null +++ b/result_server/templates/environment_snapshot_results.html @@ -0,0 +1,102 @@ +{% extends "_results_base.html" %} +{% from "_detail_tables.html" import render_titled_key_value_table %} + +{% block title %}Environment Snapshot Results{% endblock %} +{% block page_subtitle %}Results collected from the same indexed execution environment snapshot.{% endblock %} + +{% block content %} +{% include "_detail_page_styles.html" %} + + +← Back to Results + +{% set summary = snapshot.summary if snapshot.summary is mapping else {} %} +{% set payload = snapshot.payload if snapshot.payload is mapping else {} %} +{% set system_payload = payload.system if payload.system is mapping else {} %} +{% set scheduler_payload = payload.scheduler if payload.scheduler is mapping else {} %} +{% set runner_payload = payload.runner if payload.runner is mapping else {} %} +{% set benchkit_payload = payload.benchkit if payload.benchkit is mapping else {} %} +{% set snapshot_rows = [ + {"label": "Snapshot Hash", "value": snapshot.snapshot_hash, "value_class": "snapshot-hash snapshot-mono"}, + {"label": "System", "value": summary.system or system_payload.name or "N/A"}, + {"label": "Allocation Project ID", "value": summary.allocation_project_id or system_payload.allocation_project_id or "not specified"}, + {"label": "Scheduler", "value": summary.scheduler or scheduler_payload.kind or "N/A"}, + {"label": "Runner", "value": summary.runner or runner_payload.description or "N/A"}, + {"label": "BenchKit Commit", "value": summary.benchkit_commit or benchkit_payload.commit_hash or "N/A"}, + {"label": "Result Count", "value": snapshot.result_count}, + {"label": "First Seen", "value": snapshot.first_seen_at}, + {"label": "Last Seen", "value": snapshot.last_seen_at}, +] %} + +{{ render_titled_key_value_table("Environment Snapshot", snapshot_rows, "meta-table") }} + +
+
+

Visible Results

+

{{ result_summary.visible_count }} / {{ result_summary.linked_count }} linked

+
+
+

Node-hours

+

{{ result_summary.node_hours }}

+
+
+

Latest Result

+

{{ result_summary.latest_timestamp }}

+
+
+ +
+

Results

+ {% if result_rows %} +
+ + + + + + + + + + + + + {% for row in result_rows %} + + + + + + + + + {% endfor %} + +
TimeScopeFOMPipelineNode-hoursRun Cause
{{ row.timestamp }} + {{ row.code }} + / {{ row.system }} + exp {{ row.exp }} + {{ row.fom }}{% if row.fom_unit %} {{ row.fom_unit }}{% endif %}{{ row.pipeline_id }}{{ row.node_hours }} + {{ row.trigger_headline }} + {% if row.trigger_subline %} + {{ row.trigger_subline }} + {% endif %} +
+
+ {% else %} +

No permitted results are linked to this snapshot.

+ {% endif %} +
+{% endblock %} diff --git a/result_server/templates/result_detail.html b/result_server/templates/result_detail.html index 6cecc39..7972cef 100644 --- a/result_server/templates/result_detail.html +++ b/result_server/templates/result_detail.html @@ -76,6 +76,11 @@

Quality

{% if environment_rows %} {{ render_titled_key_value_table("Environment Snapshot", environment_rows, "meta-table") }} +{% if environment_snapshot_results_url %} + +{% endif %} {% endif %} {% if vector_metrics %} diff --git a/result_server/test_support.py b/result_server/test_support.py index c165156..c88f85b 100644 --- a/result_server/test_support.py +++ b/result_server/test_support.py @@ -49,6 +49,10 @@ def result_compare(): def result_detail(filename): return filename + @results_bp.route("/environment-snapshots/") + def environment_snapshot_results(snapshot_hash): + return snapshot_hash + @results_bp.route("/usage") def usage_report(): return "" diff --git a/result_server/tests/test_environment_snapshot_results_route.py b/result_server/tests/test_environment_snapshot_results_route.py new file mode 100644 index 0000000..fac1d93 --- /dev/null +++ b/result_server/tests/test_environment_snapshot_results_route.py @@ -0,0 +1,114 @@ +"""Route tests for environment snapshot result listings.""" + +from __future__ import annotations + +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from test_support import build_results_route_app, install_portal_test_stubs # noqa: E402 + +install_portal_test_stubs() + +from utils.environment_snapshots import index_environment_snapshot # noqa: E402 + + +def _add_navigation_routes(app): + app.add_url_rule("/", "home", lambda: "home") + app.add_url_rule("/systems", "systemlist", lambda: "systems") + app.add_url_rule("/login", "auth.login", lambda: "login") + app.add_url_rule("/logout", "auth.logout", lambda: "logout") + + +def _write_json(path, payload): + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + + +def _payload(uuid, snapshot_hash="sha256:routeabc"): + return { + "_server_uuid": uuid, + "code": "qws", + "system": "Fugaku", + "Exp": "CASE1", + "FOM": "0.423", + "FOM_unit": "s", + "node_count": "1", + "pipeline_id": 3270, + "pipeline_timing": {"run_time": 120}, + "execution_trigger": { + "id": "qws-fugaku-time", + "type": "scheduled", + "reason": "cron:0 14 * * *", + }, + "environment_snapshot": { + "schema_version": 1, + "hash": snapshot_hash, + "summary": { + "system": "Fugaku", + "allocation_project_id": "rkp00010", + "scheduler": "pbs", + "runner": "fugaku-runner", + "benchkit_commit": "abcdef", + }, + "payload": { + "schema_version": 1, + "system": { + "name": "Fugaku", + "allocation_project_id": "rkp00010", + }, + "scheduler": {"kind": "pbs"}, + }, + }, + } + + +def test_environment_snapshot_results_route_lists_linked_results(tmp_path): + received_dir = tmp_path / "received" + received_dir.mkdir() + db_path = tmp_path / "cx_portal.sqlite3" + filename = "result_20260810_160604_11111111-2222-3333-4444-555555555555.json" + payload = _payload("11111111-2222-3333-4444-555555555555") + _write_json(received_dir / filename, payload) + assert index_environment_snapshot( + db_path=str(db_path), + payload=payload, + json_file=filename, + ) + + app = build_results_route_app(received_dir=str(received_dir)) + _add_navigation_routes(app) + app.config["EXECUTION_PROFILE_DB_PATH"] = str(db_path) + response = app.test_client().get("/results/environment-snapshots/sha256:routeabc") + + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "Environment Snapshot" in html + assert "Visible Results" in html + assert "1 / 1 linked" in html + assert "Node-hours" in html + assert "sha256:routeabc" in html + assert "result_detail" not in html + assert "2026-08-10 16:06:04" in html + assert "qws" in html + assert "Fugaku" in html + assert "Scheduled / qws-fugaku-time" in html + assert "0.03" in html + + +def test_result_detail_links_to_environment_snapshot_results(tmp_path): + received_dir = tmp_path / "received" + received_dir.mkdir() + filename = "result_20260810_160604_11111111-2222-3333-4444-555555555555.json" + _write_json(received_dir / filename, _payload("11111111-2222-3333-4444-555555555555")) + + app = build_results_route_app(received_dir=str(received_dir)) + _add_navigation_routes(app) + app.config["EXECUTION_PROFILE_DB_PATH"] = str(tmp_path / "cx_portal.sqlite3") + response = app.test_client().get(f"/results/detail/{filename}") + + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "View results with this snapshot" in html + assert "/results/environment-snapshots/sha256:routeabc" in html diff --git a/result_server/tests/test_environment_snapshots.py b/result_server/tests/test_environment_snapshots.py index 3a64565..4f6d64e 100644 --- a/result_server/tests/test_environment_snapshots.py +++ b/result_server/tests/test_environment_snapshots.py @@ -10,7 +10,9 @@ from utils.environment_snapshots import ( # noqa: E402 extract_environment_snapshot_record, + get_environment_snapshot, index_environment_snapshot, + list_environment_snapshot_results, list_environment_snapshots, ) @@ -75,6 +77,12 @@ def test_index_environment_snapshot_deduplicates_payloads(tmp_path): assert len(rows) == 1 assert rows[0]["snapshot_hash"] == "sha256:abc123" assert rows[0]["result_count"] == 2 + snapshot = get_environment_snapshot(str(db_path), "sha256:abc123") + assert snapshot is not None + assert snapshot["summary"]["system"] == "Fugaku" + assert snapshot["payload"]["scheduler"]["kind"] == "pbs" + linked_results = list_environment_snapshot_results(str(db_path), "sha256:abc123") + assert [row["json_file"] for row in linked_results] == ["result-b.json", "result-a.json"] import sqlite3 diff --git a/result_server/tests/test_portal_list_templates.py b/result_server/tests/test_portal_list_templates.py index aaa1e84..82bf085 100644 --- a/result_server/tests/test_portal_list_templates.py +++ b/result_server/tests/test_portal_list_templates.py @@ -347,6 +347,77 @@ def test_usage_report_template_renders_search_box(): assert "UNKNOWN_SYSTEM" in html +def test_profile_usage_overview_template_shows_snapshot_count_and_link(): + app = build_portal_shell_app( + templates_dir=os.path.join(os.path.dirname(__file__), "..", "templates"), + ) + with app.test_request_context("/results/usage"): + from flask import render_template + + html = render_template( + "_usage_report_profile_overview_section.html", + profile_usage_overview={ + "available": True, + "summary": { + "profile_count": 1, + "profile_with_results_count": 1, + "trigger_count": 1, + "result_count": 2, + "node_hours": 0.5, + }, + "rows": [ + { + "profile_id": "qws-fugaku", + "status": "approved", + "enabled": True, + "code": "qws", + "system": "Fugaku", + "exp": "*", + "allocation_project_id": "rkp00010", + "enabled_trigger_count": 1, + "trigger_count": 1, + "trigger_labels": ["scheduled / qws-fugaku-time / on"], + "latest_trigger_run": None, + "result_count": 2, + "node_hours": 0.5, + "snapshot_count": 2, + "attribution_counts": { + "trigger_id_match": 1, + "manual_profile_match": 0, + "legacy_scope_fallback": 1, + }, + "latest_result": { + "filename": "result_20260810_160604_uuid.json", + "timestamp": "2026-08-10 16:06:04", + "code": "qws", + "system": "Fugaku", + "exp": "CASE0", + "trigger_headline": "Scheduled / qws-fugaku-time", + "pipeline_id": 3301, + "attribution": { + "label": "trigger id match", + "reason": "trigger_id_match", + }, + "environment_snapshot": { + "hash": "sha256:abcdef", + "short_hash": "sha256:abcdef", + "allocation_project_id": "rkp00010", + "scheduler": "pbs", + }, + }, + } + ], + }, + ) + + assert "2 snapshots" in html + assert "attribution trigger/manual/legacy:" in html + assert "1/0/1" in html + assert "attributed by trigger id match" in html + assert "/results/environment-snapshots/sha256:abcdef" in html + assert "snapshot sha256:abcdef" in html + + def test_usage_report_node_hours_table_uses_explicit_column_widths(): app = build_portal_shell_app( templates_dir=os.path.join(os.path.dirname(__file__), "..", "templates"), diff --git a/result_server/tests/test_profile_usage_overview.py b/result_server/tests/test_profile_usage_overview.py index 351624c..4bfbe88 100644 --- a/result_server/tests/test_profile_usage_overview.py +++ b/result_server/tests/test_profile_usage_overview.py @@ -106,10 +106,17 @@ def test_profile_usage_overview_links_profile_triggers_results_and_node_hours(tm assert row["allocation_project_id"] == "rkp00010" assert row["enabled_trigger_count"] == 1 assert row["result_count"] == 1 + assert row["snapshot_count"] == 1 assert row["node_hours"] == 2.0 + assert row["attribution_counts"] == { + "trigger_id_match": 1, + "manual_profile_match": 0, + "legacy_scope_fallback": 0, + } assert row["latest_trigger_run"]["status"] == "submitted" assert row["latest_result"]["filename"] == result_file assert row["latest_result"]["trigger_headline"] == "Scheduled / qws-fugaku-time" + assert row["latest_result"]["attribution"]["reason"] == "trigger_id_match" assert row["latest_result"]["environment_snapshot"]["short_hash"] == "sha256:54d4b0024f..." assert row["latest_result"]["environment_snapshot"]["allocation_project_id"] == "rkp00010" @@ -185,9 +192,21 @@ def test_profile_usage_overview_does_not_scope_match_triggered_results_to_other_ rows = {row["profile_id"]: row for row in overview["rows"]} assert rows["qws-fugaku"]["result_count"] == 3 assert rows["qws-fugaku"]["node_hours"] == 1.75 + assert rows["qws-fugaku"]["attribution_counts"] == { + "trigger_id_match": 1, + "manual_profile_match": 1, + "legacy_scope_fallback": 1, + } + assert rows["qws-fugaku"]["latest_result"]["attribution"]["reason"] == "manual_profile_match" assert rows["qws-test"]["result_count"] == 1 assert rows["qws-test"]["node_hours"] == 0.5 + assert rows["qws-test"]["attribution_counts"] == { + "trigger_id_match": 0, + "manual_profile_match": 0, + "legacy_scope_fallback": 1, + } assert rows["qws-test"]["latest_result"]["timestamp"] == "2026-08-09 17:00:00" + assert rows["qws-test"]["latest_result"]["attribution"]["reason"] == "legacy_scope_fallback" def test_profile_usage_overview_handles_missing_db(tmp_path): diff --git a/result_server/utils/environment_snapshots.py b/result_server/utils/environment_snapshots.py index d212dc1..09ead2a 100644 --- a/result_server/utils/environment_snapshots.py +++ b/result_server/utils/environment_snapshots.py @@ -23,6 +23,16 @@ def _json_dump(value: Any) -> str: return json.dumps(value or {}, ensure_ascii=False, sort_keys=True) +def _json_load(value: Any) -> dict[str, Any]: + if not value: + return {} + try: + loaded = json.loads(value) + except (TypeError, json.JSONDecodeError): + return {} + return loaded if isinstance(loaded, dict) else {} + + def extract_environment_snapshot_record(payload: dict[str, Any]) -> dict[str, Any] | None: """Return normalized snapshot fields from a Result JSON payload.""" snapshot = payload.get("environment_snapshot") @@ -183,3 +193,54 @@ def list_environment_snapshots(db_path: str, *, limit: int = 100) -> list[dict[s (limit,), ).fetchall() ] + + +def get_environment_snapshot(db_path: str, snapshot_hash: str) -> dict[str, Any] | None: + """Return one environment snapshot row with decoded summary and payload.""" + if not db_path or not snapshot_hash: + return None + + store = ExecutionProfileStore(db_path) + store.migrate() + with store.connect() as conn: + row = conn.execute( + """ + SELECT * FROM environment_snapshots + WHERE snapshot_hash = ? + """, + (snapshot_hash,), + ).fetchone() + if row is None: + return None + + snapshot = dict(row) + snapshot["summary"] = _json_load(snapshot.get("summary_json")) + snapshot["payload"] = _json_load(snapshot.get("payload_json")) + return snapshot + + +def list_environment_snapshot_results( + db_path: str, + snapshot_hash: str, + *, + limit: int = 200, +) -> list[dict[str, Any]]: + """Return result links for one environment snapshot, newest first.""" + if not db_path or not snapshot_hash: + return [] + + store = ExecutionProfileStore(db_path) + store.migrate() + with store.connect() as conn: + return [ + dict(row) + for row in conn.execute( + """ + SELECT * FROM environment_snapshot_results + WHERE snapshot_hash = ? + ORDER BY updated_at DESC, json_file DESC + LIMIT ? + """, + (snapshot_hash, limit), + ).fetchall() + ] diff --git a/result_server/utils/profile_usage_overview.py b/result_server/utils/profile_usage_overview.py index fd80751..39b9785 100644 --- a/result_server/utils/profile_usage_overview.py +++ b/result_server/utils/profile_usage_overview.py @@ -45,11 +45,7 @@ def build_profile_usage_overview(received_dir: str, db_path: str | None) -> dict for profile in profile_result.profiles: profile_triggers = triggers_by_profile.get(profile["id"], []) trigger_ids = {trigger["id"] for trigger in profile_triggers} - matched_results = [ - record - for record in result_records - if _result_matches_profile(record["data"], profile, trigger_ids) - ] + matched_results = _attributed_result_records(result_records, profile, trigger_ids) latest_result = matched_results[0] if matched_results else None latest_run = _latest_profile_run(profile_triggers, runs_by_trigger) node_hours = round(sum(record["node_hours"] for record in matched_results), 2) @@ -66,7 +62,9 @@ def build_profile_usage_overview(received_dir: str, db_path: str | None) -> dict "enabled_trigger_count": sum(1 for trigger in profile_triggers if trigger.get("enabled")), "trigger_labels": [_trigger_label(trigger) for trigger in profile_triggers[:3]], "result_count": len(matched_results), + "snapshot_count": _environment_snapshot_count(matched_results), "node_hours": node_hours, + "attribution_counts": _attribution_counts(matched_results), "latest_result": _latest_result_context(latest_result), "latest_trigger_run": _latest_run_context(latest_run), } @@ -154,14 +152,84 @@ def _result_matches_profile( profile: dict[str, Any], trigger_ids: set[str], ) -> bool: + return _profile_attribution(result, profile, trigger_ids)["matched"] + + +def _attributed_result_records( + result_records: list[dict[str, Any]], + profile: dict[str, Any], + trigger_ids: set[str], +) -> list[dict[str, Any]]: + records = [] + for record in result_records: + attribution = _profile_attribution(record["data"], profile, trigger_ids) + if not attribution["matched"]: + continue + records.append({**record, "attribution": attribution}) + return records + + +def _profile_attribution( + result: dict[str, Any], + profile: dict[str, Any], + trigger_ids: set[str], +) -> dict[str, Any]: trigger_id = extract_execution_trigger(result)["id"] if trigger_id: - return trigger_id == profile.get("id") or trigger_id in trigger_ids - return ( + if trigger_id in trigger_ids: + return { + "matched": True, + "reason": "trigger_id_match", + "label": "trigger id match", + "trigger_id": trigger_id, + } + if trigger_id == profile.get("id"): + return { + "matched": True, + "reason": "manual_profile_match", + "label": "manual profile match", + "trigger_id": trigger_id, + } + return { + "matched": False, + "reason": "unmatched_trigger_id", + "label": "unmatched trigger id", + "trigger_id": trigger_id, + } + + if ( _scope_matches(profile.get("code", []), result.get("code")) and _scope_matches(profile.get("system", []), result.get("system")) and _scope_matches(profile.get("exp", []), result.get("Exp")) - ) + ): + return { + "matched": True, + "reason": "legacy_scope_fallback", + "label": "legacy scope fallback", + "trigger_id": "", + } + + return { + "matched": False, + "reason": "unmatched_scope", + "label": "unmatched scope", + "trigger_id": "", + } + + +def _attribution_counts(records: list[dict[str, Any]]) -> dict[str, int]: + counts = { + "trigger_id_match": 0, + "manual_profile_match": 0, + "legacy_scope_fallback": 0, + } + for record in records: + attribution = record.get("attribution") or {} + reason = attribution.get("reason") + if reason in counts: + counts[reason] += 1 + return counts + def _scope_matches(scope: list[str], value: Any) -> bool: if not scope: @@ -193,10 +261,23 @@ def _latest_result_context(record: dict[str, Any] | None) -> dict[str, Any] | No "exp": record["data"].get("Exp") or "-", "pipeline_id": record["data"].get("pipeline_id") or "-", "trigger_headline": trigger_summary.get("headline") or "-", + "attribution": record.get("attribution") or {}, "environment_snapshot": snapshot, } +def _environment_snapshot_count(records: list[dict[str, Any]]) -> int: + hashes = set() + for record in records: + snapshot = record["data"].get("environment_snapshot") + if not isinstance(snapshot, dict): + continue + snapshot_hash = str(snapshot.get("hash") or "").strip() + if snapshot_hash: + hashes.add(snapshot_hash) + return len(hashes) + + def _latest_run_context(run: dict[str, Any] | None) -> dict[str, str] | None: if not run: return None diff --git a/result_server/utils/result_detail_view.py b/result_server/utils/result_detail_view.py index fe0c66a..a54e21f 100644 --- a/result_server/utils/result_detail_view.py +++ b/result_server/utils/result_detail_view.py @@ -13,6 +13,7 @@ def build_result_detail_context(result, quality, trigger_runs_by_pipeline=None): "profile_rows": _build_profile_rows(profile_data), "quality_rows": _build_quality_rows(quality), "environment_rows": _build_environment_rows(result.get("environment_snapshot")), + "environment_snapshot_hash": _environment_snapshot_hash(result.get("environment_snapshot")), "vector_metrics": vector_metrics, "scalar_rows": _build_scalar_rows(scalar_metrics), "build_rows": _build_build_rows(build_data), @@ -170,6 +171,12 @@ def _build_environment_rows(environment_snapshot): return rows +def _environment_snapshot_hash(environment_snapshot): + if not isinstance(environment_snapshot, dict): + return "" + return str(environment_snapshot.get("hash") or "").strip() + + def _build_scalar_rows(scalar_metrics): if len(scalar_metrics.keys()) < 2: return []