feat(webui): implement notifications and human-attention routing (#648)
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""HTML rendering for Phase 3 Notifications and Human-Attention Console (#648)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
from typing import Sequence
|
||||
|
||||
from webui.layout import render_page
|
||||
from webui.notifications import (
|
||||
ATTENTION_HUMAN_REQUIRED,
|
||||
ATTENTION_OPERATOR,
|
||||
ATTENTION_ROUTINE,
|
||||
NotificationItem,
|
||||
NotificationSnapshot,
|
||||
)
|
||||
|
||||
|
||||
def _render_attention_badge(attention_class: str) -> str:
|
||||
cls = "badge"
|
||||
if attention_class == ATTENTION_HUMAN_REQUIRED:
|
||||
cls += " badge-blocked"
|
||||
elif attention_class == ATTENTION_OPERATOR:
|
||||
cls += " badge-claimed"
|
||||
else:
|
||||
cls += " muted"
|
||||
return f'<span class="{cls}">{escape(attention_class)}</span>'
|
||||
|
||||
|
||||
def _render_notification_row(item: NotificationItem) -> str:
|
||||
category_label = escape(item.category.upper())
|
||||
id_str = escape(item.id)
|
||||
title_str = escape(item.title)
|
||||
summary_str = escape(item.summary)
|
||||
att_badge = _render_attention_badge(item.attention_class)
|
||||
|
||||
work_item_html = "—"
|
||||
if item.work_number and item.work_kind:
|
||||
kind_label = escape(item.work_kind.upper())
|
||||
num_str = f"#{item.work_number}"
|
||||
link = item.deep_link or "#"
|
||||
work_item_html = f'<a href="{escape(link)}"><code>{kind_label} {num_str}</code></a>'
|
||||
|
||||
requires_human_label = (
|
||||
'<span class="badge badge-blocked" style="font-size:0.75rem;">HUMAN REQUIRED</span>'
|
||||
if item.requires_human
|
||||
else ""
|
||||
)
|
||||
|
||||
return f"""<tr>
|
||||
<td><code>{category_label}</code><br><span class="muted" style="font-size:0.75rem;">{id_str}</span></td>
|
||||
<td>
|
||||
<div><strong>{title_str}</strong> {att_badge} {requires_human_label}</div>
|
||||
<div class="muted" style="font-size:0.85rem; margin-top:0.25rem;">{summary_str}</div>
|
||||
</td>
|
||||
<td>{work_item_html}</td>
|
||||
<td><span class="muted" style="font-size:0.8rem;">{escape(item.created_at[:19])}</span></td>
|
||||
</tr>"""
|
||||
|
||||
|
||||
def _render_notifications_table(items: Sequence[NotificationItem], empty_message: str) -> str:
|
||||
if not items:
|
||||
return f'<p class="muted" style="padding:1rem 0;">{escape(empty_message)}</p>'
|
||||
|
||||
rows = "".join(_render_notification_row(item) for item in items)
|
||||
return f"""<table class="registry">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 18%;">Category & ID</th>
|
||||
<th style="width: 52%;">Title & Attention Summary</th>
|
||||
<th style="width: 15%;">Work Item</th>
|
||||
<th style="width: 15%;">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>"""
|
||||
|
||||
|
||||
def render_notifications_page(
|
||||
snapshot: NotificationSnapshot,
|
||||
*,
|
||||
filter_class: str = "inbox",
|
||||
filter_project: str | None = None,
|
||||
) -> str:
|
||||
"""Render the notifications and attention inbox page."""
|
||||
title = "Notifications & Attention Inbox"
|
||||
|
||||
err_html = ""
|
||||
if snapshot.fetch_error:
|
||||
err_html = f'<div class="stub" style="border-color:#e53e3e; background:#fff5f5; color:#c53030; margin-bottom:1rem;"><p><strong>Fetch Warning:</strong> {escape(snapshot.fetch_error)}</p></div>'
|
||||
|
||||
# Determine items to render based on filter_class
|
||||
if filter_class == ATTENTION_HUMAN_REQUIRED:
|
||||
display_items = snapshot.human_required_items
|
||||
active_tab_title = "Human-Required Escalations"
|
||||
elif filter_class == ATTENTION_OPERATOR:
|
||||
display_items = snapshot.operator_items
|
||||
active_tab_title = "Operator Inbox Items"
|
||||
elif filter_class == ATTENTION_ROUTINE:
|
||||
display_items = snapshot.routine_items
|
||||
active_tab_title = "Routine Workflow Transitions"
|
||||
elif filter_class == "all":
|
||||
display_items = snapshot.items
|
||||
active_tab_title = "All Events (including Routine)"
|
||||
else: # "inbox" default
|
||||
display_items = snapshot.inbox_items
|
||||
active_tab_title = "Attention Inbox (Human + Operator)"
|
||||
|
||||
hr_cls = "badge-blocked" if snapshot.human_required_count > 0 else "muted"
|
||||
op_cls = "badge-claimed" if snapshot.operator_count > 0 else "muted"
|
||||
|
||||
metrics_html = f"""<div style="display:flex; gap:1rem; margin-bottom:1.5rem;">
|
||||
<div class="health-card" style="flex:1;">
|
||||
<span class="muted" style="font-size:0.85rem;">Human Required</span>
|
||||
<h2 style="margin:0.2rem 0;"><span class="badge {hr_cls}" style="font-size:1.4rem;">{snapshot.human_required_count}</span></h2>
|
||||
<p class="muted" style="font-size:0.8rem; margin:0;">Critical escalation boundary</p>
|
||||
</div>
|
||||
<div class="health-card" style="flex:1;">
|
||||
<span class="muted" style="font-size:0.85rem;">Operator Inbox</span>
|
||||
<h2 style="margin:0.2rem 0;"><span class="badge {op_cls}" style="font-size:1.4rem;">{snapshot.operator_count}</span></h2>
|
||||
<p class="muted" style="font-size:0.8rem; margin:0;">Operational items needing review</p>
|
||||
</div>
|
||||
<div class="health-card" style="flex:1;">
|
||||
<span class="muted" style="font-size:0.85rem;">Routine Transitions</span>
|
||||
<h2 style="margin:0.2rem 0;"><span class="badge muted" style="font-size:1.4rem;">{snapshot.routine_count}</span></h2>
|
||||
<p class="muted" style="font-size:0.8rem; margin:0;">Background transitions (filtered)</p>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
# Filter navigation links
|
||||
def _tab_link(target_class: str, label: str) -> str:
|
||||
is_active = (filter_class == target_class)
|
||||
style = "font-weight:bold; border-bottom:2px solid currentColor;" if is_active else "color:#4a5568;"
|
||||
return f'<a href="/notifications?attention_class={target_class}" style="margin-right:1.25rem; text-decoration:none; padding-bottom:0.25rem; {style}">{label}</a>'
|
||||
|
||||
tabs_html = f"""<div style="margin-bottom:1.25rem; border-bottom:1px solid #e2e8f0; padding-bottom:0.5rem;">
|
||||
{_tab_link("inbox", f"Attention Inbox ({snapshot.human_required_count + snapshot.operator_count})")}
|
||||
{_tab_link("human-required", f"Human Required ({snapshot.human_required_count})")}
|
||||
{_tab_link("operator", f"Operator ({snapshot.operator_count})")}
|
||||
{_tab_link("routine", f"Routine ({snapshot.routine_count})")}
|
||||
{_tab_link("all", f"All Events ({snapshot.total_count})")}
|
||||
</div>"""
|
||||
|
||||
table_html = _render_notifications_table(
|
||||
display_items,
|
||||
f"No items match attention filter '{filter_class}'.",
|
||||
)
|
||||
|
||||
body = f"""<h2>{escape(title)}</h2>
|
||||
<p class="muted">Phase 3 console surface for human-attention routing (#648). Routine workflow transitions are filtered by default to eliminate notification fatigue.</p>
|
||||
{err_html}
|
||||
{metrics_html}
|
||||
{tabs_html}
|
||||
<h3>{escape(active_tab_title)}</h3>
|
||||
{table_html}"""
|
||||
|
||||
return render_page(title=title, body_html=body)
|
||||
Reference in New Issue
Block a user