diff --git a/docs/guides/portal-execution-profiles-handoff.md b/docs/guides/portal-execution-profiles-handoff.md index e5408dd..d5f1067 100644 --- a/docs/guides/portal-execution-profiles-handoff.md +++ b/docs/guides/portal-execution-profiles-handoff.md @@ -51,15 +51,16 @@ Completed: `BK_TRIGGER_REASON`. `scripts/result.sh` stores these values under `execution_trigger` in Result JSON, and the Portal shows them as the result `Run Cause`. +- Received benchmark and estimation JSON metadata is indexed into + `result_metadata_index` at ingest time. JSON/tgz artifacts remain the raw + records and existing result pages remain file-backed. Remaining follow-up: -1. Index received benchmark and estimation JSON metadata into SQLite while - keeping JSON/tgz artifacts as raw records. The first index should be an - auxiliary lookup table populated at ingest time; existing result and - estimate pages can remain file-backed until the indexed views are reviewed. -2. Add environment snapshot storage after deciding which host/runtime metadata +1. Add environment snapshot storage after deciding which host/runtime metadata should define an environment identity. +2. Review which result and estimate views should move from file-backed scans to + indexed lookup once the operational view requirements are stable. GitLab schedules should not be the primary governance point. The Portal should own periodic and event-triggered execution decisions, then trigger GitLab CI @@ -77,6 +78,11 @@ without such a scheduler requirement should leave the field empty. BenchPark bridge controls in this repository are legacy; active BenchPark CI/CD/CB result handling has moved to a separate project. +Node-hour accounting follows the Result JSON `execution_mode` value. `cross` +results count run time only, because build and run are separated. Systems that +build and run in one scheduler job are recorded as `native`; `native` counts +build time plus run time. + ## GitLab Pipeline Trigger Configuration Dry-run payload rendering requires: diff --git a/result_server/routes/results_usage_routes.py b/result_server/routes/results_usage_routes.py index 72d11ec..9fdf79e 100644 --- a/result_server/routes/results_usage_routes.py +++ b/result_server/routes/results_usage_routes.py @@ -15,5 +15,6 @@ def usage_report(): current_app.config["RECEIVED_DIR"], request.args, get_fiscal_year(datetime.now()), + current_app.config.get("EXECUTION_PROFILE_DB_PATH"), ) return render_template("usage_report.html", **usage_context) diff --git a/result_server/templates/_usage_report_profile_overview_section.html b/result_server/templates/_usage_report_profile_overview_section.html new file mode 100644 index 0000000..1c54891 --- /dev/null +++ b/result_server/templates/_usage_report_profile_overview_section.html @@ -0,0 +1,87 @@ +
+

Profile Operations Overview

+

Execution profile activity across allocation, triggers, collected results, and node-hour usage.

+ + {% if profile_usage_overview.available %} +
+
+

Profiles

+

{{ profile_usage_overview.summary.profile_count }} registered / {{ profile_usage_overview.summary.profile_with_results_count }} with results

+
+
+

Triggers

+

{{ profile_usage_overview.summary.trigger_count }} definitions

+
+
+

Results

+

{{ profile_usage_overview.summary.result_count }} records / {{ profile_usage_overview.summary.node_hours }} node-hours

+
+
+ + {% if profile_usage_overview.rows %} +
+ + + + + + + + + + + + + + {% for row in profile_usage_overview.rows %} + + + + + + + + + + {% endfor %} + +
ProfileScopeAllocationTriggersLatest Trigger RunResultsLatest Result
+ {{ row.profile_id }} + {{ row.status }}{% if not row.enabled %} / disabled{% endif %} + + {{ row.code }} + / {{ row.system }} + exp {{ row.exp }} + {{ row.allocation_project_id }} + {{ row.enabled_trigger_count }} / {{ row.trigger_count }} enabled + {% for label in row.trigger_labels %} + {{ label }} + {% endfor %} + + {% if row.latest_trigger_run %} + {{ row.latest_trigger_run.status }} + {{ row.latest_trigger_run.created_at }} + {{ row.latest_trigger_run.reason }} + {% else %} + - + {% endif %} + + {{ row.result_count }} results + {{ row.node_hours }} node-hours + + {% 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 }} + {% else %} + - + {% endif %} +
+
+ {% else %} +

No execution profiles are registered.

+ {% endif %} + {% else %} +

Execution profile database is not configured for this Portal.

+ {% endif %} +
diff --git a/result_server/templates/usage_report.html b/result_server/templates/usage_report.html index 0874261..970416c 100644 --- a/result_server/templates/usage_report.html +++ b/result_server/templates/usage_report.html @@ -203,8 +203,36 @@ min-width: 280px; flex: 1 1 320px; } + .profile-usage-table { + min-width: 1120px; + table-layout: fixed; + } + .profile-usage-table th:nth-child(1) { width: 170px; } + .profile-usage-table th:nth-child(2) { width: 190px; } + .profile-usage-table th:nth-child(3) { width: 120px; } + .profile-usage-table th:nth-child(4) { width: 180px; } + .profile-usage-table th:nth-child(5) { width: 230px; } + .profile-usage-table th:nth-child(6) { width: 120px; } + .profile-usage-table th:nth-child(7) { width: 230px; } + .profile-usage-table td { + vertical-align: top; + white-space: normal; + } + .profile-usage-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + } + .profile-usage-subline { + display: block; + margin-top: 2px; + color: #64748b; + font-size: 12px; + line-height: 1.3; + overflow-wrap: anywhere; + } + {% include "_usage_report_profile_overview_section.html" %} {% include "_usage_report_node_hours_section.html" %} {% include "_usage_report_configuration_checks.html" %} {% include "_usage_report_coverage_section.html" %} @@ -214,7 +242,7 @@ function applyUsageSearch() { const input = document.getElementById('usageSearchInput'); const needle = (input?.value || '').toLowerCase().trim(); - const targets = ['.coverage-table tbody tr', '.quality-table tbody tr']; + const targets = ['.profile-usage-table tbody tr', '.coverage-table tbody tr', '.quality-table tbody tr']; targets.forEach((selector) => { document.querySelectorAll(selector).forEach((row) => { diff --git a/result_server/tests/test_portal_list_templates.py b/result_server/tests/test_portal_list_templates.py index 348b715..aaa1e84 100644 --- a/result_server/tests/test_portal_list_templates.py +++ b/result_server/tests/test_portal_list_templates.py @@ -335,8 +335,10 @@ def test_usage_report_template_renders_search_box(): coverage_systems=[], app_support_rows=[], result_quality_rollup={"rows": []}, + profile_usage_overview={"available": False, "rows": []}, ) + assert "Profile Operations Overview" in html assert "Filter coverage and current-state tables" in html assert "applyUsageSearch" in html assert "Application Entry Points" in html @@ -392,6 +394,7 @@ def test_usage_report_node_hours_table_uses_explicit_column_widths(): coverage_systems=[], app_support_rows=[], result_quality_rollup={"rows": []}, + profile_usage_overview={"available": False, "rows": []}, ) assert 'class="usage-node-hours-app-col"' in html @@ -479,6 +482,7 @@ def test_usage_report_quality_section_renders_actions_and_improvement_candidates }, coverage_systems=[], app_support_rows=[], + profile_usage_overview={"available": False, "rows": []}, result_quality_rollup={ "rows": [ { diff --git a/result_server/tests/test_profile_usage_overview.py b/result_server/tests/test_profile_usage_overview.py new file mode 100644 index 0000000..3cc6bad --- /dev/null +++ b/result_server/tests/test_profile_usage_overview.py @@ -0,0 +1,110 @@ +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from utils.execution_profiles import ( # noqa: E402 + ExecutionProfileStore, + normalize_profile, + normalize_trigger_definition, +) +from utils.profile_usage_overview import build_profile_usage_overview # noqa: E402 + + +def _profile(**overrides): + data = { + "id": "qws-fugaku", + "enabled": True, + "status": "approved", + "code": "qws", + "system": "Fugaku", + "exp": [], + "allocation_project_id": "rkp00010", + } + data.update(overrides) + profile, errors = normalize_profile(data) + assert errors == [] + assert profile is not None + return profile + + +def _trigger(**overrides): + data = { + "id": "qws-fugaku-time", + "trigger_type": "scheduled", + "profile_id": "qws-fugaku", + "enabled": True, + "gitlab_target": "swc", + "target_ref": "develop", + "cron_expr": "0 14 * * *", + "timezone": "Asia/Tokyo", + } + data.update(overrides) + trigger, errors = normalize_trigger_definition(data) + assert errors == [] + assert trigger is not None + return trigger + + +def test_profile_usage_overview_links_profile_triggers_results_and_node_hours(tmp_path): + db_path = tmp_path / "cx_portal.sqlite3" + received_dir = tmp_path / "received" + received_dir.mkdir() + + store = ExecutionProfileStore(str(db_path)) + store.upsert_profile(_profile(), actor="admin@test.com") + store.upsert_trigger_definition(_trigger(), actor="admin@test.com") + store.create_trigger_run( + trigger_id="qws-fugaku-time", + trigger_type="scheduled", + status="submitted", + dry_run=False, + reason="cron:0 14 * * *@2026-08-10T14:00+09:00", + payload={"submit": {"response": {"id": 123}}}, + ) + result_file = "result_20260810_140500_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.json" + (received_dir / result_file).write_text( + json.dumps( + { + "code": "qws", + "system": "Fugaku", + "Exp": "CASE0", + "node_count": "2", + "execution_mode": "cross", + "pipeline_timing": {"run_time": 3600}, + "pipeline_id": 123, + "execution_trigger": { + "id": "qws-fugaku-time", + "type": "scheduled", + "reason": "cron:0 14 * * *@2026-08-10T14:00+09:00", + }, + } + ), + encoding="utf-8", + ) + + overview = build_profile_usage_overview(str(received_dir), str(db_path)) + + assert overview["available"] is True + assert overview["summary"]["profile_count"] == 1 + assert overview["summary"]["profile_with_results_count"] == 1 + assert overview["summary"]["trigger_count"] == 1 + assert overview["summary"]["result_count"] == 1 + assert overview["summary"]["node_hours"] == 2.0 + row = overview["rows"][0] + assert row["profile_id"] == "qws-fugaku" + assert row["allocation_project_id"] == "rkp00010" + assert row["enabled_trigger_count"] == 1 + assert row["result_count"] == 1 + assert row["node_hours"] == 2.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" + + +def test_profile_usage_overview_handles_missing_db(tmp_path): + overview = build_profile_usage_overview(str(tmp_path), None) + + assert overview["available"] is False + assert overview["rows"] == [] diff --git a/result_server/tests/test_usage_report_view.py b/result_server/tests/test_usage_report_view.py index b419ebb..8391d3d 100644 --- a/result_server/tests/test_usage_report_view.py +++ b/result_server/tests/test_usage_report_view.py @@ -44,11 +44,17 @@ def test_build_usage_report_context_builds_named_coverage_headers(monkeypatch): "build_result_quality_rollup", lambda directory: {"rows": []}, ) + monkeypatch.setattr( + usage_report_view, + "build_profile_usage_overview", + lambda directory, db_path: {"available": False, "rows": []}, + ) context = usage_report_view.build_usage_report_context( "received", MultiDict(), 2025, + "cx_portal.sqlite3", ) assert context["period_type"] == "fiscal_year" @@ -58,3 +64,4 @@ def test_build_usage_report_context_builds_named_coverage_headers(monkeypatch): assert context["app_support_rows"] == [{"app": "qws", "systems": {}}] assert context["site_diagnostics"] == {"registered_system_count": 1} assert context["result_quality_rollup"] == {"rows": []} + assert context["profile_usage_overview"] == {"available": False, "rows": []} diff --git a/result_server/tests/test_usage_route.py b/result_server/tests/test_usage_route.py index b546498..5684dd2 100644 --- a/result_server/tests/test_usage_route.py +++ b/result_server/tests/test_usage_route.py @@ -139,10 +139,11 @@ def test_usage_route_uses_default_parameters(self, app, client, monkeypatch): captured = {} - def fake_build_usage_report_context(directory, args, current_fiscal_year): + def fake_build_usage_report_context(directory, args, current_fiscal_year, db_path=None): captured["directory"] = directory captured["args"] = args captured["current_fiscal_year"] = current_fiscal_year + captured["db_path"] = db_path return { "result": { "apps": [], @@ -169,6 +170,7 @@ def fake_build_usage_report_context(directory, args, current_fiscal_year): "partial_support": [], }, "result_quality_rollup": {"rows": []}, + "profile_usage_overview": {"available": False, "rows": []}, } import routes.results_usage_routes as usage_routes_mod @@ -179,6 +181,7 @@ def fake_build_usage_report_context(directory, args, current_fiscal_year): resp = client.get("/results/usage") assert resp.status_code == 200 assert captured["directory"] == app.config["RECEIVED_DIR"] + assert captured["db_path"] == app.config.get("EXECUTION_PROFILE_DB_PATH") assert captured["current_fiscal_year"] == 2025 assert captured["args"].get("period_type") is None diff --git a/result_server/utils/profile_usage_overview.py b/result_server/utils/profile_usage_overview.py new file mode 100644 index 0000000..6d8d972 --- /dev/null +++ b/result_server/utils/profile_usage_overview.py @@ -0,0 +1,205 @@ +"""Profile-centered operational overview for Usage Report.""" + +from __future__ import annotations + +import os +from datetime import datetime +from typing import Any + +from utils.execution_profiles import ExecutionProfileStore, load_execution_profiles +from utils.node_hours import compute_node_hours, extract_timestamp_from_filename +from utils.result_records import format_result_timestamp, load_result_json +from utils.trigger_display import extract_execution_trigger, summarize_execution_trigger + + +def build_profile_usage_overview(received_dir: str, db_path: str | None) -> dict[str, Any]: + """Summarize profiles with their trigger, result, and node-hour activity.""" + if not db_path or not os.path.exists(db_path): + return { + "available": False, + "path": db_path or "", + "rows": [], + "summary": _empty_summary(), + "errors": [], + } + + profile_result = load_execution_profiles(db_path or "") + if not profile_result.exists: + return { + "available": False, + "path": profile_result.path, + "rows": [], + "summary": _empty_summary(), + "errors": profile_result.errors, + } + + store = ExecutionProfileStore(db_path or "") + triggers = store.list_trigger_definitions() + trigger_runs = store.list_trigger_runs(limit=500) + result_records = _load_result_records(received_dir) + + triggers_by_profile = _group_by(triggers, "profile_id") + runs_by_trigger = _latest_runs_by_trigger(trigger_runs) + + rows = [] + 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) + ] + 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) + rows.append( + { + "profile_id": profile["id"], + "status": profile.get("status") or "-", + "enabled": bool(profile.get("enabled")), + "code": _scope_label(profile.get("code", [])), + "system": _scope_label(profile.get("system", [])), + "exp": _scope_label(profile.get("exp", [])), + "allocation_project_id": profile.get("allocation_project_id") or "-", + "trigger_count": len(profile_triggers), + "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), + "node_hours": node_hours, + "latest_result": _latest_result_context(latest_result), + "latest_trigger_run": _latest_run_context(latest_run), + } + ) + + return { + "available": True, + "path": profile_result.path, + "rows": rows, + "summary": { + "profile_count": len(rows), + "profile_with_results_count": sum(1 for row in rows if row["result_count"]), + "trigger_count": len(triggers), + "result_count": len(result_records), + "node_hours": round(sum(record["node_hours"] for record in result_records), 2), + }, + "errors": profile_result.errors, + } + + +def _empty_summary() -> dict[str, int | float]: + return { + "profile_count": 0, + "profile_with_results_count": 0, + "trigger_count": 0, + "result_count": 0, + "node_hours": 0.0, + } + + +def _group_by(rows: list[dict[str, Any]], key: str) -> dict[str, list[dict[str, Any]]]: + grouped: dict[str, list[dict[str, Any]]] = {} + for row in rows: + grouped.setdefault(str(row.get(key) or ""), []).append(row) + return grouped + + +def _latest_runs_by_trigger(runs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + latest: dict[str, dict[str, Any]] = {} + for run in runs: + trigger_id = str(run.get("trigger_id") or "") + if trigger_id and trigger_id not in latest: + latest[trigger_id] = run + return latest + + +def _latest_profile_run( + triggers: list[dict[str, Any]], + runs_by_trigger: dict[str, dict[str, Any]], +) -> dict[str, Any] | None: + runs = [ + runs_by_trigger[trigger["id"]] + for trigger in triggers + if trigger.get("id") in runs_by_trigger + ] + return runs[0] if runs else None + + +def _load_result_records(received_dir: str) -> list[dict[str, Any]]: + try: + filenames = [name for name in os.listdir(received_dir) if name.endswith(".json")] + except OSError: + filenames = [] + + records = [] + for filename in filenames: + data = load_result_json(filename, received_dir) + if not isinstance(data, dict): + continue + records.append( + { + "filename": filename, + "timestamp": extract_timestamp_from_filename(filename), + "timestamp_label": format_result_timestamp(filename), + "data": data, + "node_hours": compute_node_hours(data), + } + ) + records.sort(key=lambda record: record["timestamp"] or datetime.min, reverse=True) + return records + + +def _result_matches_profile( + result: dict[str, Any], + profile: dict[str, Any], + trigger_ids: set[str], +) -> bool: + trigger_id = extract_execution_trigger(result)["id"] + if trigger_id and trigger_id in trigger_ids: + return True + return ( + _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")) + ) + +def _scope_matches(scope: list[str], value: Any) -> bool: + if not scope: + return True + text = str(value or "").strip() + return text in {str(item).strip() for item in scope} + + +def _scope_label(scope: list[str]) -> str: + values = [str(value).strip() for value in scope if str(value).strip()] + return ", ".join(values) if values else "*" + + +def _trigger_label(trigger: dict[str, Any]) -> str: + state = "on" if trigger.get("enabled") else "paused" + return f"{trigger.get('trigger_type') or '-'} / {trigger.get('id') or '-'} / {state}" + + +def _latest_result_context(record: dict[str, Any] | None) -> dict[str, Any] | None: + if not record: + return None + trigger_summary = summarize_execution_trigger(record["data"]) + return { + "filename": record["filename"], + "timestamp": record["timestamp_label"], + "code": record["data"].get("code") or "-", + "system": record["data"].get("system") or "-", + "exp": record["data"].get("Exp") or "-", + "pipeline_id": record["data"].get("pipeline_id") or "-", + "trigger_headline": trigger_summary.get("headline") or "-", + } + + +def _latest_run_context(run: dict[str, Any] | None) -> dict[str, str] | None: + if not run: + return None + return { + "status": str(run.get("status") or "-"), + "created_at": str(run.get("created_at") or "-"), + "reason": str(run.get("reason") or "-"), + } diff --git a/result_server/utils/usage_report_view.py b/result_server/utils/usage_report_view.py index 883523a..1b15ad2 100644 --- a/result_server/utils/usage_report_view.py +++ b/result_server/utils/usage_report_view.py @@ -1,12 +1,13 @@ from utils.app_support_matrix import load_app_system_support_matrix from utils.node_hours import aggregate_node_hours +from utils.profile_usage_overview import build_profile_usage_overview from utils.result_quality_rollup import build_result_quality_rollup from utils.site_diagnostics import build_site_diagnostics from utils.system_info import get_all_systems_info from utils.usage_query_params import parse_usage_query_params, select_usage_periods -def build_usage_report_context(received_dir, args, current_fiscal_year): +def build_usage_report_context(received_dir, args, current_fiscal_year, db_path=None): """Build the Usage report view-model from request args and collected results.""" params = parse_usage_query_params(args, current_fiscal_year) period_type = params["period_type"] @@ -33,4 +34,5 @@ def build_usage_report_context(received_dir, args, current_fiscal_year): "app_support_rows": app_support_rows, "site_diagnostics": build_site_diagnostics(), "result_quality_rollup": build_result_quality_rollup(received_dir), + "profile_usage_overview": build_profile_usage_overview(received_dir, db_path), }