Skip to content

Commit 0516f5d

Browse files
Add hand-editable CI status page to analytics dashboard (#10515)
## Summary - Adds a new "Status" page to the CI analytics site for posting known CI issues, maintenance windows, and notices - Status data lives in `status_updates.json` in the analytics repo (`shader-slang/slang-ci-analytics`) for easy editing without PRing the main repo - Entries support severity levels (`info`/`warning`/`critical`) with color-coded cards and a `visible` field to hide resolved entries while keeping history - Shows "All Systems Operational" when no visible entries exist - Regenerated on both daily analytics and 15-minute health cycles
1 parent 312d479 commit 0516f5d

File tree

5 files changed

+239
-1
lines changed

5 files changed

+239
-1
lines changed

.github/workflows/ci-analytics.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ jobs:
5353
--pr-input /tmp/pr_merges.json \
5454
--output ci_analytics_repo
5555
56+
- name: Generate status page
57+
run: |
58+
python3 extras/ci/analytics/ci_status.py \
59+
--output ci_analytics_repo
60+
5661
- name: Push to analytics repo
5762
run: |
5863
cd ci_analytics_repo

.github/workflows/ci-health.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,17 @@ jobs:
3939
python3 extras/ci/analytics/ci_health.py \
4040
--output ci_analytics_repo
4141
42+
- name: Generate status page
43+
run: |
44+
python3 extras/ci/analytics/ci_status.py \
45+
--output ci_analytics_repo
46+
4247
- name: Push health page
4348
run: |
4449
cd ci_analytics_repo
4550
git config user.name "github-actions[bot]"
4651
git config user.email "github-actions[bot]@users.noreply.github.com"
47-
git add health.html
52+
git add health.html status.html
4853
git add health_snapshots.jsonl 2>/dev/null || true
4954
git diff --cached --quiet || {
5055
git commit -m "Update CI health $(date -u +%Y-%m-%dT%H:%M)" &&

extras/ci/analytics/ci_status.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
#!/usr/bin/env python3
2+
"""
3+
CI Status Page Generator
4+
5+
Reads hand-edited status_updates.json from the analytics output directory
6+
and generates status.html for the CI analytics dashboard.
7+
8+
The status_updates.json file lives in the analytics repo (shader-slang/slang-ci-analytics)
9+
so it can be updated without PRing the main slang repo.
10+
11+
Usage:
12+
python3 ci_status.py --output ./ci_analytics_repo
13+
"""
14+
15+
import argparse
16+
import html as html_mod
17+
import json
18+
import os
19+
import sys
20+
21+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22+
from ci_visualization import page_template
23+
24+
SEVERITY_COLORS = {
25+
"info": ("#0d6efd", "#cfe2ff"),
26+
"warning": ("#fd7e14", "#fff3cd"),
27+
"critical": ("#dc3545", "#f8d7da"),
28+
}
29+
30+
31+
def parse_args():
32+
parser = argparse.ArgumentParser(
33+
description="Generate CI status page from status_updates.json."
34+
)
35+
parser.add_argument(
36+
"--output", default="ci_analytics", help="Output directory (also where status_updates.json is read from)"
37+
)
38+
return parser.parse_args()
39+
40+
41+
def load_status_updates(output_dir):
42+
"""Load status entries from the analytics repo directory."""
43+
path = os.path.join(output_dir, "status_updates.json")
44+
if not os.path.exists(path):
45+
return []
46+
try:
47+
with open(path, encoding="utf-8") as f:
48+
data = json.load(f)
49+
entries = data.get("entries", [])
50+
if not isinstance(entries, list):
51+
print(f"Warning: status_updates.json 'entries' is not a list", file=sys.stderr)
52+
return []
53+
return entries
54+
except (OSError, json.JSONDecodeError) as e:
55+
print(f"Warning: could not read status_updates.json: {e}", file=sys.stderr)
56+
return []
57+
58+
59+
def render_entry(entry):
60+
"""Render a single status entry as an HTML card."""
61+
sev = entry.get("severity", "info")
62+
fg, bg = SEVERITY_COLORS.get(sev, SEVERITY_COLORS["info"])
63+
title = html_mod.escape(str(entry.get("title", "")))
64+
body = html_mod.escape(str(entry.get("body", ""))).replace("\n", "<br>")
65+
date = html_mod.escape(str(entry.get("date", "")))
66+
author = html_mod.escape(str(entry.get("author", "")))
67+
68+
author_html = f" &mdash; {author}" if author else ""
69+
return f"""<div style="border-left:4px solid {fg};background:{bg};padding:15px 20px;margin-bottom:15px;border-radius:4px">
70+
<span style="background:{fg};color:white;padding:2px 8px;border-radius:3px;font-size:0.8em;text-transform:uppercase">{html_mod.escape(sev)}</span>
71+
<strong style="margin-left:10px;font-size:1.1em">{title}</strong>
72+
<div style="color:#6c757d;font-size:0.85em;margin-top:4px">{date}{author_html}</div>
73+
<div style="margin-top:8px">{body}</div>
74+
</div>"""
75+
76+
77+
def generate_status_html(output_dir):
78+
"""Generate status.html from status_updates.json."""
79+
entries = load_status_updates(output_dir)
80+
81+
# Filter to visible entries only (default visible if not specified)
82+
visible = [e for e in entries if e.get("visible", True)]
83+
84+
# Sort by date descending
85+
visible.sort(key=lambda e: e.get("date", ""), reverse=True)
86+
87+
if visible:
88+
cards = "\n".join(render_entry(e) for e in visible)
89+
body = f"""<h1>CI Status</h1>
90+
<p style="color:#6c757d">Known issues and maintenance notices. Edit <code>status_updates.json</code> in the
91+
<a href="https://github.com/shader-slang/slang-ci-analytics">analytics repo</a> to update.</p>
92+
{cards}"""
93+
else:
94+
body = """<h1>CI Status</h1>
95+
<p style="color:#6c757d">Known issues and maintenance notices. Edit <code>status_updates.json</code> in the
96+
<a href="https://github.com/shader-slang/slang-ci-analytics">analytics repo</a> to update.</p>
97+
<div style="background:#d1e7dd;border-left:4px solid #198754;padding:15px 20px;border-radius:4px;color:#0f5132">
98+
<strong>All Systems Operational</strong> &mdash; No known issues.
99+
</div>"""
100+
101+
os.makedirs(output_dir, exist_ok=True)
102+
with open(os.path.join(output_dir, "status.html"), "w") as f:
103+
f.write(page_template("Status", body, "Status"))
104+
105+
106+
def main():
107+
args = parse_args()
108+
generate_status_html(args.output)
109+
print(f"Generated status.html in {args.output}")
110+
111+
112+
if __name__ == "__main__":
113+
main()

extras/ci/analytics/ci_visualization.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ def nav_html(active=""):
281281
("index.html", "Home"),
282282
("statistics.html", "Statistics"),
283283
("health.html", "Health"),
284+
("status.html", "Status"),
284285
]
285286
items = []
286287
for href, label in links:

extras/ci/analytics/tests/test_ci_analytics.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
sys.path.insert(0, ANALYTICS_DIR)
1212

1313
import ci_health
14+
import ci_status
1415
import ci_visualization
1516

1617

@@ -448,5 +449,118 @@ def test_load_snapshots_reads_recent_only(self):
448449
self.assertEqual([s["id"] for s in snaps], [2])
449450

450451

452+
class TestStatusPage(unittest.TestCase):
453+
def test_generate_with_entries(self):
454+
with tempfile.TemporaryDirectory() as tmp:
455+
data = {
456+
"entries": [
457+
{
458+
"date": "2026-03-12",
459+
"severity": "warning",
460+
"title": "Runner flaky",
461+
"body": "Investigating.",
462+
"author": "testuser",
463+
},
464+
{
465+
"date": "2026-03-10",
466+
"severity": "info",
467+
"title": "Maintenance",
468+
"body": "Scheduled downtime.",
469+
"author": "admin",
470+
},
471+
]
472+
}
473+
with open(os.path.join(tmp, "status_updates.json"), "w") as f:
474+
json.dump(data, f)
475+
476+
ci_status.generate_status_html(tmp)
477+
478+
with open(os.path.join(tmp, "status.html")) as f:
479+
html = f.read()
480+
self.assertIn("Runner flaky", html)
481+
self.assertIn("Maintenance", html)
482+
self.assertIn("testuser", html)
483+
# warning entry should appear before info (sorted by date desc)
484+
self.assertGreater(html.index("Maintenance"), html.index("Runner flaky"))
485+
486+
def test_generate_empty_entries(self):
487+
with tempfile.TemporaryDirectory() as tmp:
488+
with open(os.path.join(tmp, "status_updates.json"), "w") as f:
489+
json.dump({"entries": []}, f)
490+
491+
ci_status.generate_status_html(tmp)
492+
493+
with open(os.path.join(tmp, "status.html")) as f:
494+
html = f.read()
495+
self.assertIn("All Systems Operational", html)
496+
497+
def test_generate_no_file(self):
498+
with tempfile.TemporaryDirectory() as tmp:
499+
ci_status.generate_status_html(tmp)
500+
501+
with open(os.path.join(tmp, "status.html")) as f:
502+
html = f.read()
503+
self.assertIn("All Systems Operational", html)
504+
505+
def test_severity_colors(self):
506+
for sev in ("info", "warning", "critical"):
507+
entry = {"severity": sev, "title": f"Test {sev}", "body": "x"}
508+
rendered = ci_status.render_entry(entry)
509+
fg, bg = ci_status.SEVERITY_COLORS[sev]
510+
self.assertIn(fg, rendered)
511+
self.assertIn(bg, rendered)
512+
513+
def test_html_escaping(self):
514+
entry = {
515+
"title": "<script>alert(1)</script>",
516+
"body": "a < b & c > d",
517+
"author": "<img>",
518+
}
519+
rendered = ci_status.render_entry(entry)
520+
self.assertNotIn("<script>", rendered)
521+
self.assertIn("&lt;script&gt;", rendered)
522+
self.assertIn("a &lt; b &amp; c &gt; d", rendered)
523+
524+
def test_hidden_entries_not_rendered(self):
525+
with tempfile.TemporaryDirectory() as tmp:
526+
data = {
527+
"entries": [
528+
{"date": "2026-03-12", "severity": "info", "title": "Visible", "body": "shown", "visible": True},
529+
{"date": "2026-03-11", "severity": "warning", "title": "Hidden", "body": "not shown", "visible": False},
530+
]
531+
}
532+
with open(os.path.join(tmp, "status_updates.json"), "w") as f:
533+
json.dump(data, f)
534+
535+
ci_status.generate_status_html(tmp)
536+
537+
with open(os.path.join(tmp, "status.html")) as f:
538+
html = f.read()
539+
self.assertIn("Visible", html)
540+
self.assertNotIn("Hidden", html)
541+
542+
def test_all_hidden_shows_all_clear(self):
543+
with tempfile.TemporaryDirectory() as tmp:
544+
data = {
545+
"entries": [
546+
{"date": "2026-03-12", "severity": "info", "title": "Old issue", "body": "resolved", "visible": False},
547+
]
548+
}
549+
with open(os.path.join(tmp, "status_updates.json"), "w") as f:
550+
json.dump(data, f)
551+
552+
ci_status.generate_status_html(tmp)
553+
554+
with open(os.path.join(tmp, "status.html")) as f:
555+
html = f.read()
556+
self.assertIn("All Systems Operational", html)
557+
self.assertNotIn("Old issue", html)
558+
559+
def test_nav_includes_status(self):
560+
nav = ci_visualization.nav_html("Status")
561+
self.assertIn("status.html", nav)
562+
self.assertIn("Status", nav)
563+
564+
451565
if __name__ == "__main__":
452566
unittest.main()

0 commit comments

Comments
 (0)