feat(webui): Sentry/GlitchTip observability console & correlation view (Closes #649)
This commit is contained in:
@@ -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"]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -73,6 +73,7 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
|
||||
)),
|
||||
NavGroup("Insights", (
|
||||
NavItem("/insights", "Insights", "stub"),
|
||||
NavItem("/observability", "Observability"),
|
||||
NavItem("/analytics", "Analytics"),
|
||||
NavItem("/audit", "Audit"),
|
||||
)),
|
||||
|
||||
@@ -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()
|
||||
@@ -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