From f2dbf30e81cb0a8f645b708db8433713260d1b3d Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Sat, 25 Jul 2026 18:48:36 -0400 Subject: [PATCH] feat(webui): Sentry/GlitchTip observability console & correlation view (Closes #649) --- control_plane_db.py | 26 ++ .../webui-observability-console.md | 35 +++ docs/webui-authz-audit.md | 2 + tests/test_webui_observability.py | 190 ++++++++++++ webui/app.py | 21 ++ webui/console_authz.py | 23 ++ webui/gated_actions.py | 5 + webui/nav.py | 1 + webui/observability_loader.py | 275 ++++++++++++++++++ webui/observability_views.py | 143 +++++++++ 10 files changed, 721 insertions(+) create mode 100644 docs/observability/webui-observability-console.md create mode 100644 tests/test_webui_observability.py create mode 100644 webui/observability_loader.py create mode 100644 webui/observability_views.py diff --git a/control_plane_db.py b/control_plane_db.py index a1f90d2..422003c 100644 --- a/control_plane_db.py +++ b/control_plane_db.py @@ -1568,6 +1568,32 @@ class ControlPlaneDB: ).fetchone() return dict(row) if row else None + def list_incident_links( + self, + *, + provider: str | None = None, + gitea_org: str | None = None, + gitea_repo: str | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + """List stored incident_links rows, optionally filtered by provider/repo (#612 / #649).""" + query = "SELECT * FROM incident_links WHERE 1=1" + params: list[Any] = [] + if provider: + query += " AND provider = ?" + params.append(provider.strip().lower()) + if gitea_org: + query += " AND gitea_org = ?" + params.append(_norm_scope(gitea_org)) + if gitea_repo: + query += " AND gitea_repo = ?" + params.append(_norm_scope(gitea_repo)) + query += " ORDER BY link_id DESC LIMIT ?" + params.append(max(1, limit)) + with self._tx(immediate=False) as conn: + rows = conn.execute(query, params).fetchall() + return [dict(r) for r in rows] + # ── lease lifecycle (#601) ──────────────────────────────────────────── diff --git a/docs/observability/webui-observability-console.md b/docs/observability/webui-observability-console.md new file mode 100644 index 0000000..84ace13 --- /dev/null +++ b/docs/observability/webui-observability-console.md @@ -0,0 +1,35 @@ +# Web Console: Sentry/GlitchTip Observability & Incident Bridge Console (#649) + +This document describes the Phase 4 observability console surface integrated into the MCP Control Plane Web Console (`webui/`), backed by the #612 incident bridge and the #613 control-plane DB substrate. + +## Architectural Authority Model (ADR Alignment) + +Per the Web Console Architecture ADR (`docs/architecture/webui-control-plane-console-architecture-adr.md`): + +| Layer | Responsibility | Authority | +|---|---|---| +| **Gitea** | Durable work record | Issues, PRs, comments, reviews, labels, merges | +| **Control-plane DB** | Live coordination & linkage | `incident_links` table, session leases, allocations | +| **Sentry / GlitchTip** | Observability input | Unresolved incidents, error events, stack traces | +| **Incident Bridge (#612)** | Reconciliation engine | Reconciles provider observations into Gitea issues | +| **Web Console (`webui/`)** | Read-only projection & gated actions | Projects connection health & correlation links; gates writes | + +> **Key Rule:** Raw monitoring incidents are **never** assignable control-plane `work_items`. They remain observation input only. + +## Redaction Boundary Invariants + +1. **No secrets in returns or rendering:** Auth tokens (`SENTRY_AUTH_TOKEN`, `GLITCHTIP_AUTH_TOKEN`), DSNs, `Authorization` headers, and sensitive local file paths are passed through `webui.console_redaction` before leaving the server. +2. **Safe projection:** Connection objects report `credentials_present: true/false` rather than exposing raw keys or headers. + +## Console Endpoints + +- **HTML Surface:** `GET /observability` — Renders provider connection cards, error correlation tables, and gated reconcile controls. +- **Versioned API:** `GET /api/v1/observability` — Returns structured JSON snapshot with `schema_version`, `providers`, `links`, and `metrics`. +- **Legacy Compatibility Alias:** `GET /api/observability` — Read-only compatibility alias for Phase 4. + +## Gated Actions + +- `observability_reconcile_incident` (`gitea_observability_reconcile_incident`): Triggers or previews dry-run issue reconciliation for a provider incident. +- `observability_link_issue` (`gitea_observability_link_issue`): Links a provider incident to an existing Gitea tracking issue. + +Both actions require `operator` role and gate through `task_capability_map`. Execution fails closed in read-only MVP mode. diff --git a/docs/webui-authz-audit.md b/docs/webui-authz-audit.md index 70d7a3d..be7e4ee 100644 --- a/docs/webui-authz-audit.md +++ b/docs/webui-authz-audit.md @@ -98,6 +98,8 @@ already define, and a regression test asserts each mapping matches. | `system.rebind_session_worktree` | operator | gated_write | `gitea.read` | Yes | No | No | 2 | | `system.reconcile_cleanups` | controller | privileged | `gitea.pr.close` | Yes | No | No | 2 | | `initiate_workflow` | operator | gated_write | `gitea.read` | Yes | No | No | 2 | +| `observability_reconcile_incident` | operator | gated_write | `gitea.read` | Yes | No | No | 4 | +| `observability_link_issue` | operator | gated_write | `gitea.read` | Yes | No | No | 4 | **Dual control** means the acting principal may not be the sole authority: a second distinct principal must confirm. **Break-glass** means the action is diff --git a/tests/test_webui_observability.py b/tests/test_webui_observability.py new file mode 100644 index 0000000..a30a9e8 --- /dev/null +++ b/tests/test_webui_observability.py @@ -0,0 +1,190 @@ +"""Tests for Sentry/GlitchTip observability console (#649, Phase 4).""" + +from __future__ import annotations + +import os +import pytest +from control_plane_db import ControlPlaneDB +from webui.app import create_app +from webui.console_authz import authorize, resolve_principal +from webui.gated_actions import load_action_registry, preview_action, attempt_action +from webui.observability_loader import ( + load_provider_health, + load_observability_snapshot, + snapshot_to_dict, + ObservabilitySnapshot, +) +from webui.observability_views import render_observability_page +from tests.webui_testclient import TestClient + + +@pytest.fixture +def test_db(tmp_path): + db_path = str(tmp_path / "test_control_plane.db") + db = ControlPlaneDB(db_path) + return db + + +def test_load_provider_health_redaction(): + """Ensure tokens and secrets are never returned in provider health data.""" + env = { + "SENTRY_BASE_URL": "https://sentry.prgs.cc", + "SENTRY_ORG": "my-org", + "SENTRY_PROJECT": "my-project", + "SENTRY_AUTH_TOKEN": "secret-sentry-token-12345", + "MCP_SENTRY_ISSUE_BRIDGE_ENABLED": "true", + } + health = load_provider_health("sentry", env) + data = health.to_dict() + + assert data["provider"] == "sentry" + assert data["base_url"] in {"https://sentry.prgs.cc", "[REDACTED_URL]"} + assert data["org"] == "my-org" + assert data["project"] == "my-project" + assert data["configured"] is True + assert data["status"] == "healthy" + assert data["credentials_present"] is True + + # Token must NOT be in the dict keys or values + serialized = str(data) + assert "secret-sentry-token-12345" not in serialized + assert "SENTRY_AUTH_TOKEN" not in serialized + + +def test_load_provider_health_statuses(): + """Test unconfigured, missing token, and disabled statuses.""" + # Not configured + h1 = load_provider_health("sentry", {}) + d1 = h1.to_dict() + assert d1["configured"] is False + assert d1["status"] == "not_configured" + + # Missing token + h2 = load_provider_health( + "sentry", {"SENTRY_ORG": "org", "SENTRY_PROJECT": "proj"} + ) + d2 = h2.to_dict() + assert d2["configured"] is False + assert d2["status"] == "missing_token" + + # Disabled + h3 = load_provider_health( + "sentry", + { + "SENTRY_ORG": "org", + "SENTRY_PROJECT": "proj", + "SENTRY_AUTH_TOKEN": "token", + "MCP_SENTRY_ISSUE_BRIDGE_ENABLED": "false", + }, + ) + d3 = h3.to_dict() + assert d3["configured"] is True + assert d3["status"] == "disabled" + + +def test_observability_snapshot_with_db_links(test_db): + """Test loading observability snapshot with incident links in DB.""" + test_db.upsert_incident_link( + provider="sentry", + provider_issue_id="101", + gitea_org="Scaled-Tech-Consulting", + gitea_repo="Gitea-Tools", + gitea_issue_number=649, + provider_base_url="https://sentry.prgs.cc", + provider_org="Scaled-Tech-Consulting", + provider_project="Gitea-Tools", + provider_short_id="ST-101", + provider_permalink="https://sentry.prgs.cc/issues/101/", + fingerprint="err-fingerprint-001", + linked_pr_numbers=[901, 902], + last_seen="2026-07-25T12:00:00Z", + event_count=5, + ) + + snapshot = load_observability_snapshot(db=test_db, env={}) + data = snapshot.to_dict() + + assert data["schema_version"] == 1 + assert data["metrics"]["total_links"] == 1 + assert data["metrics"]["sentry_links_count"] == 1 + assert data["metrics"]["glitchtip_links_count"] == 0 + + link = data["links"][0] + assert link["provider"] == "sentry" + assert link["provider_issue_id"] == "101" + assert link["provider_short_id"] == "ST-101" + assert link["gitea_issue_number"] == 649 + assert link["event_count"] == 5 + assert link["linked_pr_numbers"] == [901, 902] + + +def test_observability_views_rendering(test_db): + """Test HTML rendering of the observability dashboard.""" + snapshot = load_observability_snapshot(db=test_db, env={}) + html_output = render_observability_page(snapshot) + + assert "Observability & Incident Bridge (#649)" in html_output or "Observability & Incident Bridge (#649)" in html_output or "Observability" in html_output + assert "ADR Authority Model:" in html_output + assert "Provider Connections" in html_output + assert "Correlated Incidents" in html_output + + +def test_webui_observability_routes(): + """Test Starlette HTTP routes for /observability and /api/v1/observability.""" + client = TestClient(create_app()) + + # HTML page route + res_html = client.get("/observability") + assert res_html.status_code == 200 + assert "text/html" in res_html.headers["content-type"] + assert "Observability" in res_html.text + + # Versioned API route + res_api_v1 = client.get("/api/v1/observability") + assert res_api_v1.status_code == 200 + assert "application/json" in res_api_v1.headers["content-type"] + data_v1 = res_api_v1.json() + assert "schema_version" in data_v1 + assert "providers" in data_v1 + assert "links" in data_v1 + assert "metrics" in data_v1 + + # Compatibility alias route + res_api_alias = client.get("/api/observability") + assert res_api_alias.status_code == 200 + assert res_api_alias.json() == data_v1 + + +def test_observability_gated_actions(): + """Ensure observability actions are registered, gated, and fail closed in MVP mode.""" + registry = load_action_registry() + + action_reconcile = registry.get("observability_reconcile_incident") + assert action_reconcile is not None + assert action_reconcile.task_key == "observability_reconcile_incident" + assert action_reconcile.mcp_tool == "gitea_observability_reconcile_incident" + + action_link = registry.get("observability_link_issue") + assert action_link is not None + assert action_link.task_key == "observability_link_issue" + + # Preview returns mutation ledger + prev = preview_action("observability_reconcile_incident", provider="sentry", issue_id="101") + assert prev["action_id"] == "observability_reconcile_incident" + assert prev["enabled"] is False + + # Execution fails closed in MVP mode + att = attempt_action("observability_reconcile_incident", provider="sentry", issue_id="101") + assert att["success"] is False + assert att["error"] == "action_disabled" + + +def test_observability_authz_rbac(): + """Test RBAC authorization for observability actions.""" + principal = resolve_principal({}) + + # Check authorize decision + decision = authorize("observability_reconcile_incident", principal) + assert decision.action_id == "observability_reconcile_incident" + # Phase 4 action denies in Phase 1 runtime by default + assert decision.allowed is False diff --git a/webui/app.py b/webui/app.py index 2da41e2..3dddaa7 100644 --- a/webui/app.py +++ b/webui/app.py @@ -87,6 +87,11 @@ from webui.notifications import ( from webui.notification_views import render_notifications_page from webui import request_service from webui.request_views import render_requests_page +from webui.observability_loader import ( + load_observability_snapshot, + snapshot_to_dict as observability_snapshot_to_dict, +) +from webui.observability_views import render_observability_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) _AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"}) @@ -910,6 +915,19 @@ async def api_notifications(request: Request) -> JSONResponse: data = notifications_snapshot_to_dict(snap) return JSONResponse(data) + +async def observability_route(request: Request) -> HTMLResponse: + snap = load_observability_snapshot() + html_content = render_observability_page(snap) + return HTMLResponse(html_content) + + +async def api_observability(request: Request) -> JSONResponse: + snap = load_observability_snapshot() + data = observability_snapshot_to_dict(snap) + return JSONResponse(data) + + def _default_request_scope() -> dict[str, str]: """Resolve remote/org/repo from the project registry for request forms. @@ -1075,6 +1093,9 @@ def create_app(*, bind_host: str | None = None) -> Starlette: Route("/api/analytics", api_v1_analytics, methods=["GET"]), Route("/api/v1/analytics", api_v1_analytics, methods=["GET"]), Route("/api/v1/analytics/usage", api_v1_analytics_ingest, methods=["POST"]), + Route("/observability", observability_route, methods=["GET"]), + Route("/api/observability", api_observability, methods=["GET"]), + Route("/api/v1/observability", api_observability, methods=["GET"]), Route("/audit", audit, methods=["GET", "POST"]), Route("/api/audit", api_audit, methods=["GET", "POST"]), Route("/worktrees", worktrees, methods=["GET"]), diff --git a/webui/console_authz.py b/webui/console_authz.py index 266d6b2..6d6d0f9 100644 --- a/webui/console_authz.py +++ b/webui/console_authz.py @@ -317,6 +317,29 @@ _ACTION_SPECS: tuple[ConsoleAction, ...] = ( phase=2, summary="Run reconciler cleanup for merged or superseded PR branches.", ), + # #649: Phase 4 observability & incident bridge actions. + ConsoleAction( + action_id="observability_reconcile_incident", + task_key="observability_reconcile_incident", + action_class=CLASS_WRITE, + minimum_role=OPERATOR, + requires_confirmation=True, + dual_control=False, + break_glass=False, + phase=4, + summary="Trigger/reconcile durable Gitea issue creation from a provider incident.", + ), + ConsoleAction( + action_id="observability_link_issue", + task_key="observability_link_issue", + action_class=CLASS_WRITE, + minimum_role=OPERATOR, + requires_confirmation=True, + dual_control=False, + break_glass=False, + phase=4, + summary="Link a provider incident to an existing Gitea issue.", + ), # #643: submit a work request — desired role, issue/PR, intent — and let # the allocator reserve it. This is the one Phase 2 action whose execution # path is actually implemented (``webui.request_service``), so it carries diff --git a/webui/gated_actions.py b/webui/gated_actions.py index 7e42861..a258b0e 100644 --- a/webui/gated_actions.py +++ b/webui/gated_actions.py @@ -185,6 +185,11 @@ def build_action_registry() -> ActionRegistry: "console.rebind_session_worktree", "Rebind session worktree to verified lease."), ("system.reconcile_cleanups", "Reconcile cleanups", "reconcile_cleanups", "console.reconcile_cleanups", "Run reconciler cleanup for merged or superseded PRs."), + # #649: Phase 4 observability & incident bridge actions. + ("observability_reconcile_incident", "Reconcile incident", "observability_reconcile_incident", + "gitea_observability_reconcile_incident", "Trigger or dry-run durable issue reconciliation for a provider incident."), + ("observability_link_issue", "Link incident issue", "observability_link_issue", + "gitea_observability_link_issue", "Link a provider incident to a Gitea tracking issue."), ) actions = tuple( GatedAction( diff --git a/webui/nav.py b/webui/nav.py index 2c2bdc3..6acbecc 100644 --- a/webui/nav.py +++ b/webui/nav.py @@ -73,6 +73,7 @@ NAV_GROUPS: tuple[NavGroup, ...] = ( )), NavGroup("Insights", ( NavItem("/insights", "Insights", "stub"), + NavItem("/observability", "Observability"), NavItem("/analytics", "Analytics"), NavItem("/audit", "Audit"), )), diff --git a/webui/observability_loader.py b/webui/observability_loader.py new file mode 100644 index 0000000..6793368 --- /dev/null +++ b/webui/observability_loader.py @@ -0,0 +1,275 @@ +"""Sentry/GlitchTip observability and incident correlation loader for the console (#649, Phase 4). + +Operators need to inspect provider connection status (Sentry/GlitchTip), error +correlations, and durable Gitea issue linkage — without treating raw incidents +as allocator work. + +ADR authority model: +* Gitea owns work. +* Providers (Sentry/GlitchTip) observe incidents. +* Control-plane DB coordinates incident links. +* The #612 bridge reconciles observations into durable Gitea issues. +* The web console projects read-only state and gates mutations. + +Redaction boundary: +* Provider auth tokens, DSNs, Authorization headers, and sensitive local file + paths are ALWAYS redacted before leaving this module. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +from control_plane_db import ControlPlaneDB +import sentry_incident_bridge +from webui import console_redaction + +OBSERVABILITY_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class ProviderHealth: + """Connection and health status of an observability provider.""" + + provider: str + base_url: str + org: str + project: str + configured: bool + status: str + bridge_enabled: bool + lookback: str + min_events_for_issue: int + self_hosted: bool + environment: str | None = None + credentials_present: bool = False + + def to_dict(self) -> dict[str, Any]: + data = { + "provider": self.provider, + "base_url": self.base_url, + "org": self.org, + "project": self.project, + "configured": self.configured, + "status": self.status, + "bridge_enabled": self.bridge_enabled, + "lookback": self.lookback, + "min_events_for_issue": self.min_events_for_issue, + "self_hosted": self.self_hosted, + "environment": self.environment, + "credentials_present": self.credentials_present, + } + return console_redaction.redact_payload(data) + + +@dataclass(frozen=True) +class CorrelatedIncidentLink: + """One linked provider incident ↔ Gitea issue correlation record.""" + + link_id: int + provider: str + provider_base_url: str + provider_org: str + provider_project: str + provider_issue_id: str + provider_short_id: str | None + provider_permalink: str | None + fingerprint: str | None + gitea_org: str + gitea_repo: str + gitea_issue_number: int + linked_pr_numbers: list[int] + last_seen: str | None + event_count: int + created_at: str | None + updated_at: str | None + + def to_dict(self) -> dict[str, Any]: + data = { + "link_id": self.link_id, + "provider": self.provider, + "provider_base_url": self.provider_base_url, + "provider_org": self.provider_org, + "provider_project": self.provider_project, + "provider_issue_id": self.provider_issue_id, + "provider_short_id": self.provider_short_id, + "provider_permalink": self.provider_permalink, + "fingerprint": self.fingerprint, + "gitea_org": self.gitea_org, + "gitea_repo": self.gitea_repo, + "gitea_issue_number": self.gitea_issue_number, + "linked_pr_numbers": self.linked_pr_numbers, + "last_seen": self.last_seen, + "event_count": self.event_count, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + return console_redaction.redact_payload(data) + + +@dataclass(frozen=True) +class ObservabilitySnapshot: + """Read-only snapshot of observability provider status and incident correlations.""" + + schema_version: int + providers: list[ProviderHealth] + links: list[CorrelatedIncidentLink] + total_links: int + sentry_links_count: int + glitchtip_links_count: int + bridge_active: bool + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "providers": [p.to_dict() for p in self.providers], + "links": [link.to_dict() for link in self.links], + "metrics": { + "total_links": self.total_links, + "sentry_links_count": self.sentry_links_count, + "glitchtip_links_count": self.glitchtip_links_count, + "bridge_active": self.bridge_active, + }, + } + + +def load_provider_health( + provider_name: str = "sentry", + env: dict[str, str] | None = None, +) -> ProviderHealth: + """Inspect configuration and connection health for an observability provider.""" + source_env = dict(env if env is not None else os.environ) + if provider_name.lower() == "sentry": + config = sentry_incident_bridge.load_bridge_config(source_env) + token = sentry_incident_bridge.resolve_token(source_env) + has_token = bool(token) + configured = bool(config.org and config.project and has_token) + + if not config.org or not config.project: + status = "not_configured" + elif not has_token: + status = "missing_token" + elif not config.bridge_enabled: + status = "disabled" + else: + status = "healthy" + + return ProviderHealth( + provider="sentry", + base_url=config.base_url, + org=config.org or "unconfigured", + project=config.project or "unconfigured", + configured=configured, + status=status, + bridge_enabled=config.bridge_enabled, + lookback=config.lookback, + min_events_for_issue=config.min_events_for_issue, + self_hosted=not config.base_url.rstrip("/").endswith("sentry.io"), + environment=config.environment, + credentials_present=has_token, + ) + + # GlitchTip or fallback provider configuration + glitchtip_url = (source_env.get("GLITCHTIP_BASE_URL") or "https://glitchtip.prgs.cc").strip() + glitchtip_org = (source_env.get("GLITCHTIP_ORG") or "").strip() + glitchtip_proj = (source_env.get("GLITCHTIP_PROJECT") or "").strip() + glitchtip_token = (source_env.get("GLITCHTIP_AUTH_TOKEN") or "").strip() + + has_token = bool(glitchtip_token) + configured = bool(glitchtip_org and glitchtip_proj and has_token) + status = "healthy" if configured else ("missing_token" if glitchtip_org and glitchtip_proj else "not_configured") + + return ProviderHealth( + provider="glitchtip", + base_url=glitchtip_url, + org=glitchtip_org or "unconfigured", + project=glitchtip_proj or "unconfigured", + configured=configured, + status=status, + bridge_enabled=configured, + lookback="24h", + min_events_for_issue=2, + self_hosted=True, + environment=source_env.get("GLITCHTIP_ENVIRONMENT"), + credentials_present=has_token, + ) + + +def _parse_pr_numbers(raw: Any) -> list[int]: + if isinstance(raw, list): + return [int(x) for x in raw if str(x).isdigit()] + if isinstance(raw, str) and raw.strip(): + import json + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [int(x) for x in parsed if str(x).isdigit()] + except Exception: + pass + return [] + + +def load_observability_snapshot( + db: ControlPlaneDB | None = None, + env: dict[str, str] | None = None, +) -> ObservabilitySnapshot: + """Build a read-only snapshot of observability connection health and incident links.""" + sentry_health = load_provider_health("sentry", env) + glitchtip_health = load_provider_health("glitchtip", env) + providers = [sentry_health, glitchtip_health] + + target_db = db or ControlPlaneDB() + raw_links = target_db.list_incident_links(limit=100) + + links: list[CorrelatedIncidentLink] = [] + sentry_cnt = 0 + glitchtip_cnt = 0 + + for r in raw_links: + prov = (r.get("provider") or "sentry").lower() + if prov == "sentry": + sentry_cnt += 1 + elif prov == "glitchtip": + glitchtip_cnt += 1 + + pr_nums = _parse_pr_numbers(r.get("linked_pr_numbers")) + + links.append( + CorrelatedIncidentLink( + link_id=int(r.get("link_id", 0)), + provider=prov, + provider_base_url=r.get("provider_base_url") or "", + provider_org=r.get("provider_org") or "", + provider_project=r.get("provider_project") or "", + provider_issue_id=str(r.get("provider_issue_id") or ""), + provider_short_id=r.get("provider_short_id"), + provider_permalink=r.get("provider_permalink"), + fingerprint=r.get("fingerprint"), + gitea_org=r.get("gitea_org") or "Scaled-Tech-Consulting", + gitea_repo=r.get("gitea_repo") or "Gitea-Tools", + gitea_issue_number=int(r.get("gitea_issue_number", 0)), + linked_pr_numbers=pr_nums, + last_seen=r.get("last_seen"), + event_count=int(r.get("event_count", 1)), + created_at=r.get("created_at"), + updated_at=r.get("updated_at"), + ) + ) + + bridge_active = any(p.bridge_enabled for p in providers) + + return ObservabilitySnapshot( + schema_version=OBSERVABILITY_SCHEMA_VERSION, + providers=providers, + links=links, + total_links=len(links), + sentry_links_count=sentry_cnt, + glitchtip_links_count=glitchtip_cnt, + bridge_active=bridge_active, + ) + + +def snapshot_to_dict(snapshot: ObservabilitySnapshot) -> dict[str, Any]: + return snapshot.to_dict() diff --git a/webui/observability_views.py b/webui/observability_views.py new file mode 100644 index 0000000..29463fa --- /dev/null +++ b/webui/observability_views.py @@ -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 'healthy' + if st == "disabled": + return 'disabled (dry-run)' + if st in {"missing_token", "not_configured"}: + return f'{html.escape(st)}' + return f'{html.escape(st)}' + + +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""" +
+
+

{name} Connection

+
{status_badge}
+
+ + + + + + +
Base URL:{base_url}
Scope:{org} / {proj}
Bridge Enabled:{bridge_enabled}
Min Events for Issue:{min_events}
Lookback Window:{lookback}
+
+ """ + + +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 "

No providers configured.

" + + 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'#{g_issue_num} ({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""" + + {prov} + {short_id}
id: {p_issue_id} + {fingerprint} + {g_issue_link} + {event_cnt} + {last_seen} + + """) + + table_body = "".join(link_rows) if link_rows else 'No correlated incident links stored. Bridge operates under dry-run default.' + + 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""" +

Observability & Incident Bridge (#649)

+

Read-only console surface for Sentry/GlitchTip provider connections, error correlation, + and durable Gitea issue linkage.

+ +
+ ADR Authority Model: Gitea records durable issue history. Control-plane DB coordinates incident links. + Sentry/GlitchTip observe errors. Raw monitoring incidents are never assignable control-plane work items. + Durable issue creation is gated and dry-runable via the #612 bridge APIs. +
+ +

Provider Connections

+
+ {provider_cards} +
+ +
+

Correlated Incidents ({total_links})

+
+ Sentry: {sentry_cnt} + GlitchTip: {glitchtip_cnt} +
+
+ + + + + + + + + + + + + + {table_body} + +
ProviderIncident IDFingerprintGitea Issue LinkEventsLast Seen
+ +
+

Reconcile & Link Controls (Gated)

+

+ Create or reconcile durable Gitea issues from provider observations using the #612 incident bridge: +

+ mcp call gitea_observability_reconcile_incident --provider sentry --apply false +
+ """ + + return render_page(title="Observability", body_html=body_html)