diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9d797a46..fbfb6e82 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -22,7 +22,7 @@ "name": "edge-ocp-ci", "source": "./plugins/edge-ocp-ci", "description": "Edge OCP Payload Monitor — monitor OpenShift nightly payloads for edge topology (SNO/TNA/TNF) failures with AI-enriched analysis", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "edge-scrum", diff --git a/payload-monitor/payload_monitor/__main__.py b/payload-monitor/payload_monitor/__main__.py index b840e70c..985f2265 100644 --- a/payload-monitor/payload_monitor/__main__.py +++ b/payload-monitor/payload_monitor/__main__.py @@ -15,7 +15,7 @@ from .collectors import component_readiness, prow, sippy, timing from .collectors.release_controller import ( collect as collect_payloads, - discover_streams, + configured_stream_names, version_from_stream, ) from .config import Config @@ -154,9 +154,9 @@ def main( logger.info("Starting Edge OCP Payload Monitor") - # Step 1: Discover versions and resolve stream names - logger.info("Step 1: Discovering active versions...") - stream_names = discover_streams(config) + # Step 1: Resolve configured release streams + logger.info("Step 1: Resolving configured release streams...") + stream_names = configured_stream_names(config) active_versions = [version_from_stream(s) for s in stream_names] logger.info(f" Versions: {active_versions}") diff --git a/payload-monitor/payload_monitor/analyzer.py b/payload-monitor/payload_monitor/analyzer.py index eb9a5176..cebaa7e9 100644 --- a/payload-monitor/payload_monitor/analyzer.py +++ b/payload-monitor/payload_monitor/analyzer.py @@ -17,6 +17,7 @@ ) from .collectors import jira as jira_collector from .collectors.jira import has_auth as jira_has_auth +from .collectors.sippy import job_analysis_url logger = logging.getLogger(__name__) @@ -80,6 +81,9 @@ def _find_escalation_risks( for job_name, topology in informing_jobs.items(): consecutive = 0 + latest_prow_url = "" + latest_failure_seen = False + streak_runs: list[dict] = [] for payload in reversed_payloads: job_in_payload = None for job in payload.jobs: @@ -87,21 +91,28 @@ def _find_escalation_risks( job_in_payload = job break if job_in_payload is None: - # Job absent from this payload breaks the streak break if job_in_payload.result == JobResult.FAILURE: consecutive += 1 + streak_runs.append({ + "payload_tag": payload.tag, + "prow_url": job_in_payload.prow_url, + }) + if not latest_failure_seen: + latest_prow_url = job_in_payload.prow_url + latest_failure_seen = True else: break if consecutive >= config.escalation_threshold: - sippy_url = f"https://sippy.dptools.openshift.org/sippy-ng/jobs/{job_name}" risks.append(EscalationRisk( job_name=job_name, topology=topology, version=stream.version, consecutive_failures=consecutive, - sippy_url=sippy_url, + prow_url=latest_prow_url, + triage_url=job_analysis_url(stream.version, job_name), + failing_runs=streak_runs, )) return risks diff --git a/payload-monitor/payload_monitor/collectors/release_controller.py b/payload-monitor/payload_monitor/collectors/release_controller.py index 553edc76..b7c8080e 100644 --- a/payload-monitor/payload_monitor/collectors/release_controller.py +++ b/payload-monitor/payload_monitor/collectors/release_controller.py @@ -90,8 +90,12 @@ def _parse_jobs( -def discover_streams(config: Config) -> list[str]: - """Return list of nightly stream names to monitor.""" +def configured_stream_names(config: Config) -> list[str]: + """Map ``config.versions`` to release-controller stream names. + + This is a static mapping, not a live discovery against the + release-controller index. + """ return [_stream_name(v) for v in config.versions] @@ -204,7 +208,7 @@ def _collect_stream(stream: str, config: Config) -> StreamReport: def collect(config: Config, streams: Optional[list[str]] = None) -> list[StreamReport]: """Collect payload data for all configured streams in parallel.""" if streams is None: - streams = discover_streams(config) + streams = configured_stream_names(config) with ThreadPoolExecutor(max_workers=min(len(streams) or 1, 10)) as pool: futures = { diff --git a/payload-monitor/payload_monitor/collectors/sippy.py b/payload-monitor/payload_monitor/collectors/sippy.py index 277b0489..61366959 100644 --- a/payload-monitor/payload_monitor/collectors/sippy.py +++ b/payload-monitor/payload_monitor/collectors/sippy.py @@ -2,8 +2,10 @@ from __future__ import annotations +import json import logging from concurrent.futures import ThreadPoolExecutor, as_completed +from urllib.parse import quote import requests @@ -48,8 +50,17 @@ def fetch_edge_jobs( return edge_jobs +def job_analysis_url(version: str, name: str) -> str: + """Build the Sippy UI URL for a job's analysis page, filtered by job name.""" + filters = json.dumps({ + "items": [{"columnField": "name", "operatorValue": "equals", "value": name}] + }) + return f"{BASE_URL}/sippy-ng/jobs/{version}/analysis?filters={quote(filters)}" + + def identify_regressions( edge_jobs: list[dict], + version: str = "", min_runs: int = 3, ) -> list[Regression]: """Identify jobs that are getting worse over time. @@ -69,9 +80,9 @@ def identify_regressions( name = job.get("name", "") topology = job.get("_topology", "") jira_component = job.get("jira_component", "") - triage_url = f"{BASE_URL}/sippy-ng/jobs/{name}" + triage_url = job_analysis_url(version, name) - # Skip jobs with too few runs — insufficient data to confirm regression + # Skip jobs with too few runs - insufficient data to confirm regression if current_runs < min_runs: continue @@ -99,7 +110,7 @@ def _collect_version(version: str, config: Config) -> tuple[str, list[Regression session = create_session() try: edge_jobs = fetch_edge_jobs(version, config, session=session) - regressions = identify_regressions(edge_jobs) + regressions = identify_regressions(edge_jobs, version=version) if regressions: logger.info( f" {version}: {len(regressions)} edge job regressions detected" diff --git a/payload-monitor/payload_monitor/collectors/timing.py b/payload-monitor/payload_monitor/collectors/timing.py index 4bd0b936..db5737cc 100644 --- a/payload-monitor/payload_monitor/collectors/timing.py +++ b/payload-monitor/payload_monitor/collectors/timing.py @@ -324,29 +324,14 @@ def compute_stats(runs: list[TimingRun]) -> dict: # --------------------------------------------------------------------------- def fetch_edge_jobs(release: str, config: Config) -> list[dict]: - """Fetch all jobs from Sippy for a release, filter for edge topologies.""" - logger.info(f"Fetching edge topology jobs for release {release}") - try: - resp = _session.get(JOBS_URL, params={"release": release}, timeout=30) - resp.raise_for_status() - all_jobs = resp.json() - except requests_lib.RequestException as e: - logger.error(f"Failed to fetch Sippy jobs for {release}: {e}") - return [] + """Fetch all jobs from Sippy for a release, filter for edge topologies. - if not isinstance(all_jobs, list): - return [] - - edge_jobs = [] - for job in all_jobs: - name = job.get("name", "") - topology = config.classify_topology(name) - if topology in ("SNO", "TNA", "TNF"): - job["_topology"] = topology - edge_jobs.append(job) + Delegates to collectors.sippy.fetch_edge_jobs so topology filtering stays + config-driven and isn't duplicated across collectors. + """ + from . import sippy as _sippy - logger.info(f" Found {len(edge_jobs)} SNO/TNA/TNF jobs for {release}") - return edge_jobs + return _sippy.fetch_edge_jobs(release, config, session=_session) def fetch_job_runs(job_name: str, release: str) -> list[dict]: diff --git a/payload-monitor/payload_monitor/models.py b/payload-monitor/payload_monitor/models.py index fb4e75f2..442e2a23 100644 --- a/payload-monitor/payload_monitor/models.py +++ b/payload-monitor/payload_monitor/models.py @@ -205,7 +205,9 @@ class EscalationRisk: topology: str version: str consecutive_failures: int - sippy_url: str = "" + prow_url: str = "" + triage_url: str = "" + failing_runs: list[dict] = field(default_factory=list) @dataclass @@ -247,17 +249,6 @@ class TimingRun: def is_success(self) -> bool: return self.result == "S" - @property - def duration_minutes(self) -> float: - return self.duration_seconds / 60.0 - - @property - def install_duration_seconds(self) -> float: - """Return install step duration if available, else 0.""" - for key in ("install", "setup"): - if key in self.step_durations: - return self.step_durations[key] - return 0.0 @dataclass diff --git a/payload-monitor/payload_monitor/report/generator.py b/payload-monitor/payload_monitor/report/generator.py index 539014a5..98f19522 100644 --- a/payload-monitor/payload_monitor/report/generator.py +++ b/payload-monitor/payload_monitor/report/generator.py @@ -5,6 +5,7 @@ from typing import Optional +import html as html_mod import json import logging import re @@ -13,6 +14,7 @@ from urllib.parse import urlparse from jinja2 import Environment, FileSystemLoader +from markupsafe import Markup, escape from ..models import ( AttemptAnalysis, @@ -38,14 +40,34 @@ logger = logging.getLogger(__name__) TEMPLATES_DIR = Path(__file__).parent / "templates" +def _js_attr(value): + """Escape a value for use inside an HTML-attribute JavaScript context. + + Produces a JSON-quoted string (JS-safe) with double quotes HTML-escaped + to " so it nests safely inside onclick="..." attributes. + """ + return escape(json.dumps(str(value))) + _JINJA_ENV = Environment( loader=FileSystemLoader(str(TEMPLATES_DIR)), autoescape=True, ) +_JINJA_ENV.filters["js_attr"] = _js_attr _TAG_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})-(\d{2})(\d{2})(\d{2})$") +def _load_json(path: Path, description: str) -> dict: + """Load JSON from *path*, raising a clear, path-aware error on failure.""" + try: + with open(path) as f: + return json.load(f) + except FileNotFoundError: + raise FileNotFoundError(f"{description} not found: {path}") from None + except json.JSONDecodeError as e: + raise ValueError(f"{description} is not valid JSON ({path}): {e}") from e + + def _extract_date(tag: str) -> str: """Extract date and time from a payload tag like '4.19.0-0.nightly-2026-03-25-085944'.""" m = _TAG_DATE_RE.search(tag) @@ -85,13 +107,21 @@ def _fail_sort_key(x): all_failing.sort(key=_fail_sort_key) - # Build blocking job summaries: versions, test names, topology per unique job + # Build blocking job summaries and findings-summary counts in one pass blocking_job_versions: dict[str, list[str]] = {} blocking_job_tests: dict[str, list[str]] = {} + blocking_failures = 0 + informing_failures = 0 + blocking_jobs_seen: set[str] = set() + blocking_jobs_list: list[dict] = [] for item in all_failing: if item["job"].job_type == JobType.BLOCKING: + blocking_failures += 1 name = item["job"].name ver = item["version"] + if name not in blocking_jobs_seen: + blocking_jobs_seen.add(name) + blocking_jobs_list.append(item) if name not in blocking_job_versions: blocking_job_versions[name] = [] blocking_job_tests[name] = [] @@ -100,6 +130,14 @@ def _fail_sort_key(x): for ft in item["job"].failing_tests: if ft.name not in blocking_job_tests[name]: blocking_job_tests[name].append(ft.name) + elif item["job"].job_type == JobType.INFORMING: + informing_failures += 1 + + total_payloads = sum(len(stream.payloads) for stream in report.streams) + rejected_payloads = sum( + 1 for stream in report.streams for p in stream.payloads + if p.status == PayloadStatus.REJECTED + ) # Collect blocking jobs that succeeded only after retry (flaky passes) retried_successes = [] @@ -127,6 +165,28 @@ def _fail_sort_key(x): r.version = stream.version all_regressions.append(r) + # Per-stream views: affected topologies + trend + stream_views = [] + for stream in report.streams: + affected = sorted({ + j.topology for p in stream.payloads for j in p.failing_edge_jobs if j.topology + }) + mid = len(stream.payloads) // 2 + recent_fails = sum( + len(p.blocking_edge_failures) + len(p.informing_edge_failures) + for p in stream.payloads[:mid] + ) + older_fails = sum( + len(p.blocking_edge_failures) + len(p.informing_edge_failures) + for p in stream.payloads[mid:] + ) + stream_views.append({ + "stream": stream, + "affected_topologies": affected, + "recent_fails": recent_fails, + "older_fails": older_fails, + }) + # Unique topology names and versions for filters topologies = sorted(set( job["job"].topology for job in all_failing if job["job"].topology @@ -140,13 +200,6 @@ def _fail_sort_key(x): timing_unavailable = not report.skip_timing and not timing_html timing_errors = [e for e in report.data_errors if e.startswith("Timing:")] if timing_unavailable else [] - # Map blocking job name -> first index in all_failing for stable detail links - blocking_job_first_idx = {} - for idx, item in enumerate(all_failing, 1): - if item["job"].job_type == JobType.BLOCKING: - if item["job"].name not in blocking_job_first_idx: - blocking_job_first_idx[item["job"].name] = idx - return { "report": report, "all_failing": all_failing, @@ -187,13 +240,19 @@ def _fail_sort_key(x): "persistent_threshold": report.persistent_threshold, "escalation_risks": report.escalation_risks, "escalation_risk_jobs": set(er.job_name for er in report.escalation_risks), + "escalation_risk_by_job": {er.job_name: er for er in report.escalation_risks}, "cross_topology": report.cross_topology, "jira_matches_by_job": report.jira_matches, "suggested_bugs_by_job": {b.job_name: b for b in report.suggested_bugs}, "blocking_job_versions": blocking_job_versions, "blocking_job_tests": blocking_job_tests, - "blocking_job_first_idx": blocking_job_first_idx, "retried_successes": retried_successes, + "stream_views": stream_views, + "blocking_failures": blocking_failures, + "informing_failures": informing_failures, + "blocking_jobs_list": blocking_jobs_list, + "total_payloads": total_payloads, + "rejected_payloads": rejected_payloads, } @@ -204,8 +263,14 @@ def generate_html(report: MonitorReport, output_path: Optional[Path] = None) -> also writes to that file. """ # Load CSS and JS to inline - css = (TEMPLATES_DIR / "styles.css").read_text() - js = (TEMPLATES_DIR / "scripts.js").read_text() + try: + css = (TEMPLATES_DIR / "styles.css").read_text() + js = (TEMPLATES_DIR / "scripts.js").read_text() + except FileNotFoundError as e: + raise FileNotFoundError( + f"Report template asset missing ({e.filename}) - " + "reinstall payload_monitor or check TEMPLATES_DIR" + ) from e template = _JINJA_ENV.get_template("dashboard.html") context = _build_template_context(report) @@ -303,7 +368,7 @@ def _safe_urls(urls: list[str]) -> list[str]: return safe -def _render_analysis_card(da: dict, attempt_count: int = 1) -> str: +def _render_analysis_card(da: dict, attempt_count: int = 1, job_name: str = "") -> str: """Render an AI analysis card as an HTML string.""" template = _JINJA_ENV.get_template("_analysis_card.html") return template.render( @@ -315,6 +380,7 @@ def _render_analysis_card(da: dict, attempt_count: int = 1) -> str: same_root_cause=da.get("same_root_cause", True), attempt_analyses=da.get("attempt_analyses", []), attempt_count=attempt_count, + job_name=job_name, ) @@ -327,8 +393,7 @@ def patch_analysis_html(html_path: Path, analysis_path: Path) -> None: - ``data-prow-url`` on ``
`` elements identifies where to add badges - ``data-enrichment-status="data-only"`` marks the header status div """ - with open(analysis_path) as f: - data = json.load(f) + data = _load_json(analysis_path, "Analysis JSON") by_url = data.get("by_prow_url", {}) if not by_url: @@ -339,38 +404,80 @@ def patch_analysis_html(html_path: Path, analysis_path: Path) -> None: patched = 0 badge_html = 'AI Analyzed' - def _insert_badge(m): - summary_content = m.group(2) - if "ai-analyzed" in summary_content: - return m.group(0) - return f'{m.group(1)}\n {summary_content} {badge_html}{m.group(3)}' - - for prow_url, da in by_url.items(): - escaped_url = re.escape(prow_url) - - # Find the claude-suggestion div with matching data-prow-url - suggestion_pattern = ( - rf'
' - r'.*?
\s*' - ) - m = re.search(suggestion_pattern, content, flags=re.DOTALL) - if not m: - continue + _suggestion_re = re.compile( + r'
' + r'.*?', + re.DOTALL, + ) + _details_re = re.compile( + r'(
]*data-prow-url="([^"]*)"[^>]*>)\s*' + r'(.*?)' + r'()', + re.DOTALL, + ) - attempt_count = int(m.group(1)) - card_html = _render_analysis_card(da, attempt_count=attempt_count) + patched_urls: set[str] = set() - content = content[:m.start()] + card_html + content[m.end():] + def _replace_suggestion(m: re.Match) -> str: + nonlocal patched + prow_url = html_mod.unescape(m.group(1)) + da = by_url.get(prow_url) + if da is None: + return m.group(0) + attempt_count = int(m.group(2)) patched += 1 + patched_urls.add(prow_url) + return _render_analysis_card(da, attempt_count=attempt_count, job_name=da.get("job_name", "")) - # Add "AI Analyzed" badge to the
element for this job - details_pattern = ( - rf'(
]*data-prow-url="{escaped_url}"[^>]*>)\s*' - r'(.*?)' - r'()' - ) - content = re.sub(details_pattern, _insert_badge, content, count=1, flags=re.DOTALL) + content = _suggestion_re.sub(_replace_suggestion, content) + + def _insert_badge(m: re.Match) -> str: + if html_mod.unescape(m.group(2)) not in patched_urls: + return m.group(0) + summary_content = m.group(3) + if "ai-analyzed" in summary_content: + return m.group(0) + # Grid layout: insert badge inside the job cell before its closing + job_anchor = 'class="detail-cell-job"' + job_pos = summary_content.find(job_anchor) + if job_pos >= 0: + open_gt = summary_content.find('>', job_pos) + if open_gt >= 0: + close_pos = summary_content.find('', open_gt) + if close_pos >= 0: + summary_content = ( + summary_content[:close_pos] + + badge_html + + summary_content[close_pos:] + ) + return f'{m.group(1)}\n {summary_content}{m.group(4)}' + # Legacy flat layout fallback + for anchor in ('class="detail-date"', 'class="detail-version"'): + anchor_pos = summary_content.find(anchor) + if anchor_pos < 0: + continue + span_start = summary_content.rfind('', 0, span_start) + if last_close >= 0: + ins = last_close + 1 + while ins < span_start and summary_content[ins] in ' \t\n': + ins += 1 + summary_content = ( + summary_content[:ins] + + badge_html + '\n ' + + summary_content[ins:] + ) + else: + summary_content = badge_html + ' ' + summary_content + break + else: + summary_content = summary_content + ' ' + badge_html + return f'{m.group(1)}\n {summary_content}{m.group(4)}' + + content = _details_re.sub(_insert_badge, content) # Update header: replace "Data only" status using data attribute if patched > 0: @@ -407,8 +514,7 @@ def merge_analysis(report: MonitorReport, analysis_path: Path) -> None: } } """ - with open(analysis_path) as f: - data = json.load(f) + data = _load_json(analysis_path, "Analysis JSON") by_url = data.get("by_prow_url", {}) merged = 0 @@ -452,8 +558,7 @@ def _safe_dataclass_init(cls, data: dict): def load_json(json_path: Path) -> MonitorReport: """Load a MonitorReport from a JSON file (potentially enriched with deep analysis).""" - with open(json_path) as f: - data = json.load(f) + data = _load_json(json_path, "Report JSON") streams = [] for s in data.get("streams", []): diff --git a/payload-monitor/payload_monitor/report/templates/_analysis_card.html b/payload-monitor/payload_monitor/report/templates/_analysis_card.html index 0d5fcc7d..349d1aef 100644 --- a/payload-monitor/payload_monitor/report/templates/_analysis_card.html +++ b/payload-monitor/payload_monitor/report/templates/_analysis_card.html @@ -7,16 +7,19 @@ {% endif %} {% endif %}

AI Root Cause Analysis

+ {% if job_name %} +
{{ job_name }}
+ {% endif %}
- Root Cause: + Root Cause: {{ root_cause }}
- Failure Type: + Failure Type: {{ failure_type }}
- Impact: + Impact: {{ impact }}
{% if suspect_prs %} diff --git a/payload-monitor/payload_monitor/report/templates/dashboard.html b/payload-monitor/payload_monitor/report/templates/dashboard.html index f4329be3..69d9c674 100644 --- a/payload-monitor/payload_monitor/report/templates/dashboard.html +++ b/payload-monitor/payload_monitor/report/templates/dashboard.html @@ -12,6 +12,7 @@

Edge OCP Payload Monitor

SNO / TNA / TNF topology health across OpenShift nightly payloads
+
@@ -24,7 +25,7 @@

Edge OCP Payload Monitor

{% elif has_blocking %}
○ Data only - — run /edge-ocp-ci:generate-dashboard for AI analysis + - run /edge-ocp-ci:generate-dashboard for AI analysis
{% else %}
@@ -49,13 +50,13 @@

Edge OCP Payload Monitor

Note: This report was generated with limited data. {% if report.skip_prow %} - --skip-prow Prow artifact fetching was skipped — Error column, Failing Tests, and Failure Details will be empty. + --skip-prow Prow artifact fetching was skipped - Error column, Failing Tests, and Failure Details will be empty. {% endif %} {% if report.skip_sippy %} - --skip-sippy Sippy regression check was skipped — Sippy Job Regressions section is not available. + --skip-sippy Sippy regression check was skipped - Sippy Job Regressions section is not available. {% endif %} {% if report.skip_jira %} - JIRA JIRA_TOKEN not set — bug matching and suggested bugs are unavailable. Set JIRA_TOKEN to enable (optionally JIRA_USERNAME for Basic auth). + JIRA JIRA_TOKEN not set - bug matching and suggested bugs are unavailable. Set JIRA_TOKEN to enable (optionally JIRA_USERNAME for Basic auth). {% endif %} {% if report.skip_timing %} Insights Timing insights are disabled by default. Use --with-timing to include install/upgrade timing data. @@ -93,7 +94,7 @@

Edge OCP Payload Monitor

{% if report.payloads_per_stream < report.persistent_threshold %}
- Note: Analyzing only {{ report.payloads_per_stream }} payload{{ 's' if report.payloads_per_stream != 1 else '' }} per stream — + Note: Analyzing only {{ report.payloads_per_stream }} payload{{ 's' if report.payloads_per_stream != 1 else '' }} per stream - {% if report.payloads_per_stream < report.recurring_threshold %}Recurring, {% endif %}Persistent{% if report.payloads_per_stream < 3 %} and Unstable{% endif %} failure labels require more payloads to appear.
{% endif %} @@ -128,30 +129,11 @@

Edge OCP Jobs Health Overview

Payload History older → newer
- {% for stream in report.streams %} + {% for view in stream_views %} + {% set stream = view.stream %} {% set latest = stream.latest_payload %} {% set blocking_count = stream.total_blocking_edge_failures %} {% set informing_count = stream.total_informing_edge_failures %} - {# Collect affected topologies #} - {% set affected_topos = [] %} - {% for p in stream.payloads %} - {% for j in p.failing_edge_jobs %} - {% if j.topology and j.topology not in affected_topos %} - {% if affected_topos.append(j.topology) %}{% endif %} - {% endif %} - {% endfor %} - {% endfor %} - {# Compute trend: compare recent half vs older half #} - {% set mid = (stream.payloads | length // 2) | int %} - {% set recent_fails = namespace(count=0) %} - {% set older_fails = namespace(count=0) %} - {% for p in stream.payloads %} - {% if loop.index0 < mid %} - {% set recent_fails.count = recent_fails.count + (p.blocking_edge_failures | length) + (p.informing_edge_failures | length) %} - {% else %} - {% set older_fails.count = older_fails.count + (p.blocking_edge_failures | length) + (p.informing_edge_failures | length) %} - {% endif %} - {% endfor %}
{{ stream.version }}
Edge OCP Jobs Health Overview {% endif %}
- {% for t in affected_topos | sort %} + {% for t in view.affected_topologies %} {{ t }} {% endfor %} - {% if not affected_topos %} + {% if not view.affected_topologies %} {% endif %}
{% if blocking_count == 0 and informing_count == 0 %} - {% elif recent_fails.count > older_fails.count %} - ▼ worsening - {% elif recent_fails.count < older_fails.count %} - ▲ improving + {% elif view.recent_fails > view.older_fails %} + ▼ worsening + {% elif view.recent_fails < view.older_fails %} + ▲ improving {% else %} - ▬ stable + ▬ stable {% endif %}
@@ -240,40 +222,15 @@

Edge OCP Jobs Health Overview

{% set ns = namespace( total_failures=all_failing|length, - blocking_failures=0, - informing_failures=0, - rejected_payloads=0, - total_payloads=0, + blocking_failures=blocking_failures, + informing_failures=informing_failures, + rejected_payloads=rejected_payloads, + total_payloads=total_payloads, regression_count=all_regressions|length, component_regression_count=component_regressions|length, bugs_found=report.jira_bugs|length, bugs_suggested=report.suggested_bugs|length ) %} -{% for item in all_failing %} - {% if item.job.job_type.value == 'blocking' %}{% set ns.blocking_failures = ns.blocking_failures + 1 %}{% endif %} - {% if item.job.job_type.value == 'informing' %}{% set ns.informing_failures = ns.informing_failures + 1 %}{% endif %} -{% endfor %} -{% for stream in report.streams %} - {% for p in stream.payloads %} - {% set ns.total_payloads = ns.total_payloads + 1 %} - {% if p.status.value == 'Rejected' %}{% set ns.rejected_payloads = ns.rejected_payloads + 1 %}{% endif %} - {% endfor %} -{% endfor %} -{% set fs_affected_topos = [] %} -{% for item in all_failing %} - {% if item.job.topology and item.job.topology not in fs_affected_topos %} - {% if fs_affected_topos.append(item.job.topology) %}{% endif %} - {% endif %} -{% endfor %} -{# Collect unique blocking jobs (deduplicated by job name) #} -{% set blocking_jobs_seen = [] %} -{% set blocking_jobs_list = [] %} -{% for item in all_failing %} - {% if item.job.job_type.value == 'blocking' and item.job.name not in blocking_jobs_seen %} - {% if blocking_jobs_seen.append(item.job.name) %}{% endif %} - {% if blocking_jobs_list.append(item) %}{% endif %} - {% endif %} -{% endfor %}

Findings Summary

@@ -311,10 +268,10 @@

Findings Summary

- {% for t in fs_affected_topos | sort %} + {% for t in topologies %} {{ t }} {% endfor %} - {% if not fs_affected_topos %} + {% if not topologies %} {% endif %}
@@ -322,29 +279,28 @@

Findings Summary

- {# CRITICAL: Blocking failures — show the actual jobs inline #} + {# CRITICAL: Blocking failures - show the actual jobs inline #} {% if ns.blocking_failures > 0 %}
-
!
+
!
-
Critical — Immediate Action Required
+
Critical - Immediate Action Required
{{ ns.blocking_failures }} Blocking Failure{{ 's' if ns.blocking_failures != 1 else '' }}{% if blocking_jobs_list|length < ns.blocking_failures %} ({{ blocking_jobs_list|length }} Unique Job{{ 's' if blocking_jobs_list|length != 1 else '' }}){% endif %}
prevents payload acceptance
Investigate All →
- {# Compact blocking job summary — unique jobs with versions and top failing test #} + {# Compact blocking job summary - unique jobs with versions and top failing test #}
{% for item in blocking_jobs_list %} {% set fc = failure_counts.get(item.job.name, 0) %} {% set versions = blocking_job_versions.get(item.job.name, []) %} {% set tests = blocking_job_tests.get(item.job.name, []) %} -
+
{{ item.job.topology }} - {% set _detail_idx = blocking_job_first_idx.get(item.job.name) %} - {% if _detail_idx is not none %}{{ item.job.name }}{% else %}{{ item.job.name }}{% endif %} + {{ item.job.name }} {% if fc >= recurring_threshold %} {{ fc }}x {% endif %} @@ -367,23 +323,23 @@

Findings Summary {% endif %} - {# RETRIED: Blocking jobs that passed only after retry — flaky but not blocking #} + {# RETRIED: Blocking jobs that passed only after retry - flaky but not blocking #} {% if retried_successes %}
-
Warning — Passed After Retry
+
Warning - Passed After Retry
{{ retried_successes|length }} Blocking Job{{ 's' if retried_successes|length != 1 else '' }} Succeeded on Retry
-
first attempt failed — may indicate flakiness or transient infrastructure issues
+
first attempt failed - may indicate flakiness or transient infrastructure issues
{% for item in retried_successes %} -
+
{{ item.job.topology }} - {{ item.job.name }} + {{ item.job.name }} Succeeded {{ item.version }}
@@ -405,22 +361,22 @@

Findings Summary {% endif %} - {# WARNING: Informing failures — surface escalation risks by name #} + {# WARNING: Informing failures - surface escalation risks by name #} {% if ns.informing_failures > 0 %}
-
+
-
Warning — Monitor for Stability
+
Warning - Monitor for Stability
{{ ns.informing_failures }} Informing Job Failure{{ 's' if ns.informing_failures != 1 else '' }}
-
across {{ ns.total_payloads }} payload{{ 's' if ns.total_payloads != 1 else '' }} — not blocking
+
across {{ ns.total_payloads }} payload{{ 's' if ns.total_payloads != 1 else '' }} - not blocking
View All →
{% if escalation_risks %}
{% for er in escalation_risks %} - + {{ er.job_name }} Unstable @@ -435,13 +391,13 @@

Findings Summary {% endif %} - {# REGRESSIONS: Sippy + Component Readiness — compact #} + {# REGRESSIONS: Sippy + Component Readiness - compact #} {% if ns.regression_count > 0 or ns.component_regression_count > 0 %}