feat(awooop): expose incident evidence links
This commit is contained in:
@@ -215,6 +215,13 @@ def _awooop_runs_button_row(incident_id: str) -> list[dict[str, str]]:
|
||||
}]
|
||||
|
||||
|
||||
def _awooop_runs_reply_markup(incident_id: str) -> dict | None:
|
||||
row = _awooop_runs_button_row(incident_id)
|
||||
if not row:
|
||||
return None
|
||||
return {"inline_keyboard": [row]}
|
||||
|
||||
|
||||
def _latest_remediation_history_item(history: dict[str, object] | None) -> dict[str, object]:
|
||||
if not history:
|
||||
return {}
|
||||
@@ -5549,6 +5556,7 @@ class TelegramGateway:
|
||||
await self._send_html_line_message(
|
||||
lines,
|
||||
failure_context="incident_detail",
|
||||
reply_markup=_awooop_runs_reply_markup(incident_id),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -5687,6 +5695,7 @@ class TelegramGateway:
|
||||
await self._send_html_line_message(
|
||||
lines,
|
||||
failure_context="incident_history",
|
||||
reply_markup=_awooop_runs_reply_markup(incident_id),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -5906,18 +5915,22 @@ class TelegramGateway:
|
||||
*,
|
||||
chat_id: str | int | None = None,
|
||||
failure_context: str,
|
||||
reply_markup: dict | None = None,
|
||||
) -> None:
|
||||
"""Send a multi-line HTML message without cutting Telegram tags in half."""
|
||||
chunks = _telegram_html_chunks(lines)
|
||||
for index, chunk in enumerate(chunks):
|
||||
try:
|
||||
payload: dict = {
|
||||
"chat_id": chat_id or self.alert_chat_id,
|
||||
"text": chunk,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
if index == 0 and reply_markup:
|
||||
payload["reply_markup"] = reply_markup
|
||||
await self._send_request(
|
||||
"sendMessage",
|
||||
{
|
||||
"chat_id": chat_id or self.alert_chat_id,
|
||||
"text": chunk,
|
||||
"parse_mode": "HTML",
|
||||
},
|
||||
payload,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -5927,12 +5940,15 @@ class TelegramGateway:
|
||||
chunk_count=len(chunks),
|
||||
error=str(exc),
|
||||
)
|
||||
fallback_payload: dict = {
|
||||
"chat_id": chat_id or self.alert_chat_id,
|
||||
"text": _plain_text_from_html(chunk),
|
||||
}
|
||||
if index == 0 and reply_markup:
|
||||
fallback_payload["reply_markup"] = reply_markup
|
||||
await self._send_request(
|
||||
"sendMessage",
|
||||
{
|
||||
"chat_id": chat_id or self.alert_chat_id,
|
||||
"text": _plain_text_from_html(chunk),
|
||||
},
|
||||
fallback_payload,
|
||||
)
|
||||
|
||||
async def send_alert_notification(
|
||||
|
||||
@@ -112,6 +112,7 @@ async def test_send_html_line_message_falls_back_to_plain_text_on_parse_error(mo
|
||||
"""Telegram HTML parse 400 時要送純文字 fallback,不可回報成歷史查詢失敗。"""
|
||||
sent_requests = []
|
||||
gateway = TelegramGateway()
|
||||
reply_markup = telegram_gateway_module._awooop_runs_reply_markup("INC-20260514-F85F21")
|
||||
|
||||
async def fake_send_request(method, payload):
|
||||
sent_requests.append((method, payload))
|
||||
@@ -125,15 +126,46 @@ async def test_send_html_line_message_falls_back_to_plain_text_on_parse_error(mo
|
||||
["📊 <b>事件歷史統計</b>", "階段: <code>blocked</code>"],
|
||||
chat_id="chat",
|
||||
failure_context="test_history",
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
|
||||
assert len(sent_requests) == 2
|
||||
assert sent_requests[0][1]["parse_mode"] == "HTML"
|
||||
assert sent_requests[0][1]["reply_markup"] == reply_markup
|
||||
assert "parse_mode" not in sent_requests[1][1]
|
||||
assert sent_requests[1][1]["reply_markup"] == reply_markup
|
||||
assert "<code>" not in sent_requests[1][1]["text"]
|
||||
assert "blocked" in sent_requests[1][1]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_html_line_message_attaches_awooop_markup_to_first_chunk(monkeypatch):
|
||||
"""詳情/歷史這類 HTML reply 要能帶 AwoooP evidence URL,且長訊息只掛第一段。"""
|
||||
sent_requests = []
|
||||
gateway = TelegramGateway()
|
||||
reply_markup = telegram_gateway_module._awooop_runs_reply_markup("INC-20260514-F85F21")
|
||||
|
||||
async def fake_send_request(method, payload):
|
||||
sent_requests.append((method, payload))
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(gateway, "_send_request", fake_send_request)
|
||||
|
||||
await gateway._send_html_line_message(
|
||||
["📊 <b>事件歷史統計</b>"] + [
|
||||
f"<code>INC-20260514-F85F21 trace line {index:03d}</code>"
|
||||
for index in range(80)
|
||||
],
|
||||
chat_id="chat",
|
||||
failure_context="test_history",
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
|
||||
assert len(sent_requests) > 1
|
||||
assert sent_requests[0][1]["reply_markup"] == reply_markup
|
||||
assert all("reply_markup" not in payload for _, payload in sent_requests[1:])
|
||||
|
||||
|
||||
class TestTelegramMessageFormat:
|
||||
"""測試現有 TelegramMessage 格式化"""
|
||||
|
||||
|
||||
@@ -1808,6 +1808,12 @@
|
||||
"incidentLabel": "Incident ID filter",
|
||||
"incidentPlaceholder": "Enter Incident ID"
|
||||
},
|
||||
"incident": {
|
||||
"column": "Incident",
|
||||
"empty": "Not linked",
|
||||
"filterTitle": "Show only {incidentId}",
|
||||
"more": "+{count} more"
|
||||
},
|
||||
"statuses": {
|
||||
"noEvidence": "No dry-run yet",
|
||||
"readOnlyDryRun": "AI dry-run: read-only",
|
||||
|
||||
@@ -1809,6 +1809,12 @@
|
||||
"incidentLabel": "Incident ID 篩選",
|
||||
"incidentPlaceholder": "輸入 Incident ID"
|
||||
},
|
||||
"incident": {
|
||||
"column": "Incident",
|
||||
"empty": "尚未關聯",
|
||||
"filterTitle": "只看 {incidentId}",
|
||||
"more": "+{count} 筆"
|
||||
},
|
||||
"statuses": {
|
||||
"noEvidence": "尚無試跑",
|
||||
"readOnlyDryRun": "AI 已試跑:只讀",
|
||||
|
||||
@@ -379,6 +379,55 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n
|
||||
);
|
||||
}
|
||||
|
||||
function linkedIncidentIds(summary?: RemediationSummary | null): string[] {
|
||||
const rawIds = summary?.incident_ids ?? [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
rawIds
|
||||
.map((incidentId) => String(incidentId || "").trim().toUpperCase())
|
||||
.filter((incidentId) => INCIDENT_ID_FILTER_RE.test(incidentId))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentIdsCell({ run }: { run: Run }) {
|
||||
const t = useTranslations("awooop.listEvidence");
|
||||
const incidentIds = linkedIncidentIds(run.remediation_summary);
|
||||
const visibleIds = incidentIds.slice(0, 2);
|
||||
const hiddenCount = Math.max(incidentIds.length - visibleIds.length, 0);
|
||||
|
||||
if (visibleIds.length === 0) {
|
||||
return (
|
||||
<span className="text-xs text-[#77736a]">
|
||||
{t("incident.empty")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[220px] flex-wrap gap-1.5">
|
||||
{visibleIds.map((incidentId) => (
|
||||
<Link
|
||||
key={incidentId}
|
||||
href={`/awooop/runs?project_id=${encodeURIComponent(run.project_id)}&incident_id=${encodeURIComponent(incidentId)}` as never}
|
||||
className="inline-flex items-center border border-[#d8d3c7] bg-[#faf9f3] px-2 py-1 font-mono text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
|
||||
title={t("incident.filterTitle", { incidentId })}
|
||||
>
|
||||
{incidentId}
|
||||
</Link>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center border border-[#d8d3c7] bg-white px-2 py-1 font-mono text-xs text-[#5f5b52]"
|
||||
title={incidentIds.slice(2).join(", ")}
|
||||
>
|
||||
{t("incident.more", { count: hiddenCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunRow({ run }: { run: Run }) {
|
||||
const formattedDate = run.created_at
|
||||
? new Date(run.created_at).toLocaleDateString("zh-TW", {
|
||||
@@ -406,6 +455,9 @@ function RunRow({ run }: { run: Run }) {
|
||||
{run.project_id || "--"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<IncidentIdsCell run={run} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{run.agent_id || "--"}
|
||||
@@ -843,6 +895,9 @@ export default function RunsPage() {
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Project ID
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{tEvidence("incident.column")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agent
|
||||
</th>
|
||||
@@ -870,7 +925,7 @@ export default function RunsPage() {
|
||||
{loading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
{Array.from({ length: 9 }).map((_, j) => (
|
||||
{Array.from({ length: 10 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-5 bg-muted animate-pulse rounded w-20" />
|
||||
</td>
|
||||
@@ -879,7 +934,7 @@ export default function RunsPage() {
|
||||
))
|
||||
) : runs.length === 0 && !error ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-16 text-center">
|
||||
<td colSpan={10} className="px-4 py-16 text-center">
|
||||
<Activity className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">尚無 Run 資料</p>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user