feat(webui): Sentry/GlitchTip observability console & correlation view (Closes #649)
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""HTML view renderer for the Sentry/GlitchTip observability console (#649, Phase 4).
|
||||
|
||||
Renders connection status widgets, error correlation links, and gated issue creation
|
||||
affordances over the read-only observability snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from typing import Any
|
||||
|
||||
from webui.layout import render_page
|
||||
from webui.observability_loader import ObservabilitySnapshot, snapshot_to_dict
|
||||
|
||||
|
||||
def _badge(status: str) -> str:
|
||||
st = (status or "").lower()
|
||||
if st == "healthy":
|
||||
return '<span class="badge badge-success">healthy</span>'
|
||||
if st == "disabled":
|
||||
return '<span class="badge badge-warning">disabled (dry-run)</span>'
|
||||
if st in {"missing_token", "not_configured"}:
|
||||
return f'<span class="badge badge-muted">{html.escape(st)}</span>'
|
||||
return f'<span class="badge">{html.escape(st)}</span>'
|
||||
|
||||
|
||||
def _provider_card(p: dict[str, Any]) -> str:
|
||||
name = html.escape(str(p.get("provider", "provider")).upper())
|
||||
base_url = html.escape(str(p.get("base_url", "")))
|
||||
org = html.escape(str(p.get("org", "")))
|
||||
proj = html.escape(str(p.get("project", "")))
|
||||
status_badge = _badge(str(p.get("status", "")))
|
||||
min_events = p.get("min_events_for_issue", 2)
|
||||
lookback = html.escape(str(p.get("lookback", "24h")))
|
||||
bridge_enabled = "yes" if p.get("bridge_enabled") else "no"
|
||||
|
||||
return f"""
|
||||
<div class="card" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid #ccc; border-radius: 6px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="margin: 0;">{name} Connection</h3>
|
||||
<div>{status_badge}</div>
|
||||
</div>
|
||||
<table style="width: 100%; margin-top: 0.5rem; border-collapse: collapse;">
|
||||
<tr><td><strong>Base URL:</strong></td><td><code>{base_url}</code></td></tr>
|
||||
<tr><td><strong>Scope:</strong></td><td><code>{org} / {proj}</code></td></tr>
|
||||
<tr><td><strong>Bridge Enabled:</strong></td><td><code>{bridge_enabled}</code></td></tr>
|
||||
<tr><td><strong>Min Events for Issue:</strong></td><td><code>{min_events}</code></td></tr>
|
||||
<tr><td><strong>Lookback Window:</strong></td><td><code>{lookback}</code></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_observability_page(snapshot: ObservabilitySnapshot | dict[str, Any]) -> str:
|
||||
"""Render the observability dashboard HTML page."""
|
||||
data = snapshot.to_dict() if isinstance(snapshot, ObservabilitySnapshot) else dict(snapshot)
|
||||
|
||||
providers_raw = data.get("providers", [])
|
||||
provider_cards = "".join(_provider_card(p) for p in providers_raw) if providers_raw else "<p>No providers configured.</p>"
|
||||
|
||||
links = data.get("links", [])
|
||||
link_rows = []
|
||||
|
||||
for l in links:
|
||||
prov = html.escape(str(l.get("provider", "")))
|
||||
p_issue_id = html.escape(str(l.get("provider_issue_id", "")))
|
||||
fingerprint = html.escape(str(l.get("fingerprint") or "—"))
|
||||
g_issue_num = int(l.get("gitea_issue_number", 0))
|
||||
g_org = html.escape(str(l.get("gitea_org", "")))
|
||||
g_repo = html.escape(str(l.get("gitea_repo", "")))
|
||||
g_issue_link = f'<strong>#{g_issue_num}</strong> ({g_org}/{g_repo})'
|
||||
event_cnt = int(l.get("event_count", 1))
|
||||
last_seen = html.escape(str(l.get("last_seen") or "—"))
|
||||
short_id = html.escape(str(l.get("provider_short_id") or p_issue_id))
|
||||
|
||||
link_rows.append(f"""
|
||||
<tr>
|
||||
<td><code>{prov}</code></td>
|
||||
<td><strong>{short_id}</strong><br><small style="color: #666;">id: {p_issue_id}</small></td>
|
||||
<td><code>{fingerprint}</code></td>
|
||||
<td>{g_issue_link}</td>
|
||||
<td>{event_cnt}</td>
|
||||
<td><small>{last_seen}</small></td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
table_body = "".join(link_rows) if link_rows else '<tr><td colspan="6" style="text-align: center; padding: 1.5rem; color: #666;">No correlated incident links stored. Bridge operates under dry-run default.</td></tr>'
|
||||
|
||||
metrics = data.get("metrics", {})
|
||||
total_links = metrics.get("total_links", 0)
|
||||
sentry_cnt = metrics.get("sentry_links_count", 0)
|
||||
glitchtip_cnt = metrics.get("glitchtip_links_count", 0)
|
||||
|
||||
body_html = f"""
|
||||
<h2>Observability & Incident Bridge (#649)</h2>
|
||||
<p>Read-only console surface for Sentry/GlitchTip provider connections, error correlation,
|
||||
and durable Gitea issue linkage.</p>
|
||||
|
||||
<div class="alert alert-info" style="background: #f0f4f8; padding: 1rem; border-left: 4px solid #0052cc; margin-bottom: 1.5rem;">
|
||||
<strong>ADR Authority Model:</strong> Gitea records durable issue history. Control-plane DB coordinates incident links.
|
||||
Sentry/GlitchTip observe errors. Raw monitoring incidents are <em>never</em> assignable control-plane work items.
|
||||
Durable issue creation is gated and dry-runable via the <code>#612</code> bridge APIs.
|
||||
</div>
|
||||
|
||||
<h3>Provider Connections</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
{provider_cards}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0;">Correlated Incidents ({total_links})</h3>
|
||||
<div>
|
||||
<span class="badge" style="margin-right: 0.5rem;">Sentry: {sentry_cnt}</span>
|
||||
<span class="badge">GlitchTip: {glitchtip_cnt}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table" style="width: 100%; border-collapse: collapse; border: 1px solid #ddd;">
|
||||
<thead>
|
||||
<tr style="background: #f9f9f9; text-align: left;">
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Provider</th>
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Incident ID</th>
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Fingerprint</th>
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Gitea Issue Link</th>
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Events</th>
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table_body}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top: 2rem; padding: 1rem; background: #fafafa; border: 1px solid #eee; border-radius: 4px;">
|
||||
<h4 style="margin-top: 0;">Reconcile & Link Controls (Gated)</h4>
|
||||
<p style="margin-bottom: 0.5rem; color: #555;">
|
||||
Create or reconcile durable Gitea issues from provider observations using the <code>#612</code> incident bridge:
|
||||
</p>
|
||||
<code>mcp call gitea_observability_reconcile_incident --provider sentry --apply false</code>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return render_page(title="Observability", body_html=body_html)
|
||||
Reference in New Issue
Block a user