159 lines
6.3 KiB
Python
159 lines
6.3 KiB
Python
"""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)
|