Skip to content

Commit 24434c7

Browse files
committed
feat: colored type/severity badges, treemap + scatter charts, cleaned evidence text; dashboard waits through API cold start
1 parent 48082e2 commit 24434c7

4 files changed

Lines changed: 149 additions & 22 deletions

File tree

dashboard/app.py

Lines changed: 131 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,40 @@
5353
"insufficient_evidence": "#c9ced6",
5454
}
5555

56+
# Severity 1 (minor) → 5 (critical).
57+
SEV_COLORS = {1: "#5b8c5a", 2: "#a3b18a", 3: "#f3a712", 4: "#e4572e", 5: "#d7263d"}
58+
59+
60+
def _clean_text(s: str | None) -> str:
61+
"""Strip HTML tags/entities from source text (e.g. Hacker News) for display."""
62+
import html as _html
63+
import re
64+
65+
if not s:
66+
return ""
67+
s = re.sub(r"<[^>]+>", " ", s)
68+
return re.sub(r"\s+", " ", _html.unescape(s)).strip()
69+
70+
71+
def _pill(text: str, color: str) -> str:
72+
return (
73+
f'<span style="background:{color};color:#fff;padding:2px 10px;border-radius:12px;'
74+
f'font-size:0.82em;font-weight:600;white-space:nowrap;">{text}</span>'
75+
)
76+
77+
78+
def _type_badge(itype: str | None) -> str:
79+
if not itype:
80+
return _pill("unclassified", "#9aa5b1")
81+
return _pill(itype, TYPE_COLORS.get(itype, "#6c757d"))
82+
83+
84+
def _severity_chip(sev) -> str:
85+
if sev is None or (isinstance(sev, float) and sev != sev): # None / NaN
86+
return _pill("severity —", "#9aa5b1")
87+
sev = int(sev)
88+
return _pill(f"severity {sev}/5", SEV_COLORS.get(sev, "#6c757d"))
89+
5690

5791
# ----------------------------------------------------------------------------- data
5892

@@ -180,8 +214,8 @@ def _render_overview(st, api):
180214
)
181215
st.plotly_chart(fig, use_container_width=True)
182216

183-
tab_time, tab_sev, tab_conf = st.tabs(
184-
["📈 Over time", "🔥 Severity", "🎯 Confidence"]
217+
tab_time, tab_sev, tab_conf, tab_mix, tab_scatter = st.tabs(
218+
["📈 Over time", "🔥 Severity", "🎯 Confidence", "🌳 Type mix", "⚖️ Confidence × severity"]
185219
)
186220
with tab_time:
187221
st.caption("When the underlying posts were published.")
@@ -220,6 +254,32 @@ def _render_overview(st, api):
220254
fig.update_layout(template="plotly_white", height=320,
221255
margin=dict(l=0, r=0, t=10, b=0), yaxis_title="incidents")
222256
st.plotly_chart(fig, use_container_width=True)
257+
with tab_mix:
258+
st.caption("The relative mix of incident types — area is proportional to count.")
259+
counts2 = df["type"].value_counts().reset_index()
260+
counts2.columns = ["type", "count"]
261+
fig = px.treemap(counts2, path=["type"], values="count",
262+
color="type", color_discrete_map=TYPE_COLORS)
263+
fig.update_layout(template="plotly_white", height=360, margin=dict(l=0, r=0, t=10, b=0))
264+
st.plotly_chart(fig, use_container_width=True)
265+
with tab_scatter:
266+
st.caption(
267+
"Each dot is an incident. Bottom-left (low confidence, high severity) is where "
268+
"human review matters most."
269+
)
270+
sc = df.dropna(subset=["severity", "confidence"])
271+
if sc.empty:
272+
st.info("Not enough classified data yet.")
273+
else:
274+
fig = px.scatter(
275+
sc, x="confidence", y="severity", color="type",
276+
color_discrete_map=TYPE_COLORS, hover_data=["title"],
277+
)
278+
fig.update_traces(marker=dict(size=12, opacity=0.75))
279+
fig.update_layout(template="plotly_white", height=360,
280+
margin=dict(l=0, r=0, t=10, b=0),
281+
legend=dict(orientation="h", y=-0.25))
282+
st.plotly_chart(fig, use_container_width=True)
223283

224284
st.caption(
225285
f"Charts based on the latest {len(df)} of {total} incidents · "
@@ -228,15 +288,56 @@ def _render_overview(st, api):
228288
_glossary(st)
229289

230290

231-
def _classification_caption(cls: dict) -> str:
291+
def _styled_table(st, view):
292+
if view.empty:
293+
st.info("No incidents match the current filters.")
294+
return
295+
disp = view[["id", "type", "severity", "confidence", "source", "title", "url"]].copy()
296+
297+
def _sty_type(v):
298+
c = TYPE_COLORS.get(v, "")
299+
return f"background-color:{c};color:white" if c else ""
300+
301+
def _sty_sev(v):
302+
try:
303+
c = SEV_COLORS.get(int(v), "")
304+
except (TypeError, ValueError):
305+
c = ""
306+
return f"background-color:{c};color:white" if c else ""
307+
308+
styler = disp.style.map(_sty_type, subset=["type"]).map(_sty_sev, subset=["severity"])
309+
st.dataframe(
310+
styler,
311+
use_container_width=True,
312+
hide_index=True,
313+
column_config={
314+
"confidence": st.column_config.ProgressColumn(
315+
"confidence", min_value=0.0, max_value=1.0, format="%.2f"
316+
),
317+
"severity": st.column_config.NumberColumn("severity", format="%d"),
318+
"url": st.column_config.LinkColumn("link", display_text="open ↗"),
319+
"title": st.column_config.TextColumn("title", width="large"),
320+
},
321+
)
322+
323+
324+
def _classification_badges(st, cls: dict) -> None:
232325
if not cls:
233-
return "Not yet classified."
326+
st.markdown(_pill("unclassified", "#9aa5b1"), unsafe_allow_html=True)
327+
return
234328
conf = cls.get("confidence")
235329
conf_s = f"{conf:.0%}" if isinstance(conf, (int, float)) else "—"
236-
return (
237-
f"**{cls.get('incident_type')}** · severity {cls.get('severity') or '—'} · "
238-
f"confidence {conf_s} · model `{cls.get('model_name')}`"
330+
badges = " &nbsp; ".join(
331+
[
332+
_type_badge(cls.get("incident_type")),
333+
_severity_chip(cls.get("severity")),
334+
_pill(f"confidence {conf_s}", "#3f88c5"),
335+
_pill(f"model {cls.get('model_name', '—')}", "#495057"),
336+
]
239337
)
338+
if cls.get("abstained"):
339+
badges += " &nbsp; " + _pill("abstained", "#6c757d")
340+
st.markdown(badges, unsafe_allow_html=True)
240341

241342

242343
def _render_explorer(st, api):
@@ -261,12 +362,7 @@ def _render_explorer(st, api):
261362
view = df
262363

263364
st.caption(f"Showing {len(view)} of {total} incidents.")
264-
st.dataframe(
265-
view,
266-
use_container_width=True,
267-
hide_index=True,
268-
column_config=None,
269-
)
365+
_styled_table(st, view)
270366

271367
st.markdown("### 🔎 Inspect an incident")
272368
st.caption("See the original evidence and why it was classified.")
@@ -282,11 +378,11 @@ def _show_detail(st, api, incident_id: int):
282378
if detail.get("url"):
283379
st.markdown(f"[View original post ↗]({detail['url']})")
284380
cls = detail.get("classification") or {}
285-
st.markdown(_classification_caption(cls))
381+
_classification_badges(st, cls)
286382
if cls.get("reasoning_summary"):
287383
st.info(f"Classifier reasoning: {cls['reasoning_summary']}")
288384
with st.expander("Evidence (original text)"):
289-
st.write(detail.get("body") or "(no body text)")
385+
st.write(_clean_text(detail.get("body")) or "(no body text)")
290386

291387

292388
def _render_review(st, api):
@@ -308,11 +404,11 @@ def _render_review(st, api):
308404
st.markdown("#### Evidence")
309405
if detail.get("url"):
310406
st.markdown(f"[View original post ↗]({detail['url']})")
311-
st.write(detail.get("body") or "(no body text)")
407+
st.write(_clean_text(detail.get("body")) or "(no body text)")
312408

313409
st.markdown("#### Machine classification")
314410
cls = detail.get("classification") or {}
315-
st.markdown(_classification_caption(cls))
411+
_classification_badges(st, cls)
316412
if cls.get("reasoning_summary"):
317413
st.info(f"Classifier reasoning: {cls['reasoning_summary']}")
318414

@@ -337,6 +433,23 @@ def render() -> None:
337433
page = _sidebar(st)
338434
st.title("AgentWatch — AI Incident Observatory")
339435

436+
# Both the dashboard and API sleep on Render's free tier. On the first load after
437+
# idle, patiently wait (with a spinner) for the API to wake instead of erroring out.
438+
with st.spinner(
439+
"Connecting to the API… first load after idle can take up to ~60s while "
440+
"Render's free tier wakes the service. Thanks for your patience!"
441+
):
442+
ready = api.wait_until_ready(timeout=75)
443+
444+
if not ready:
445+
st.warning(
446+
"⏳ The API is still waking up (Render free-tier cold start). It should be "
447+
"ready in a few more seconds — please retry."
448+
)
449+
if st.button("Retry"):
450+
st.rerun()
451+
return
452+
340453
try:
341454
if page == "Overview":
342455
_render_overview(st, api)
@@ -345,11 +458,7 @@ def render() -> None:
345458
elif page == "Review Queue":
346459
_render_review(st, api)
347460
except APIUnavailable:
348-
st.warning(
349-
"⏳ The API isn't responding yet. This demo runs on Render's free tier, where "
350-
"services sleep after ~15 minutes idle and take 30–60s to wake. Give it a "
351-
"moment and retry."
352-
)
461+
st.warning("⏳ The API stopped responding mid-request. Please retry.")
353462
if st.button("Retry"):
354463
st.rerun()
355464

dashboard/client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ def __init__(self, base_url: str | None = None, api_key: str | None = None, clie
2121
def _headers(self) -> dict:
2222
return {"X-API-Key": self.api_key} if self.api_key else {}
2323

24+
def health(self) -> bool:
25+
"""True if the API is up. Swallows errors so callers can poll during cold start."""
26+
try:
27+
resp = self._client.get("/health")
28+
return resp.status_code == 200 and resp.json().get("status") == "ok"
29+
except Exception:
30+
return False
31+
32+
def wait_until_ready(self, timeout: float = 75.0, interval: float = 3.0) -> bool:
33+
"""Poll /health until the API responds or the timeout elapses (covers cold starts)."""
34+
waited = 0.0
35+
while waited < timeout:
36+
if self.health():
37+
return True
38+
time.sleep(interval)
39+
waited += interval
40+
return self.health()
41+
2442
def _get_json(self, path: str, params: dict | None = None, *, attempts: int = 3):
2543
last: Exception | None = None
2644
for i in range(attempts):
25.2 KB
Loading
2.38 KB
Loading

0 commit comments

Comments
 (0)