diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py
index 357b9015..ae858d22 100644
--- a/apps/api/src/api/v1/platform/operator_runs.py
+++ b/apps/api/src/api/v1/platform/operator_runs.py
@@ -97,7 +97,7 @@ class DecideApprovalResponse(BaseModel):
response_model=ListRunsResponse,
summary="列出 Runs",
description=(
- "返回 awooop_run_state 記錄,支援 project_id / state / remediation_status filter 與分頁。\n\n"
+ "返回 awooop_run_state 記錄,支援 project_id / state / remediation_status / incident_id filter 與分頁。\n\n"
"- 按 created_at DESC 排序\n"
"- 注意:此路徑為 /runs/list 以避免與 runs.py 的 /runs/{run_id} 衝突"
),
@@ -109,6 +109,7 @@ async def list_runs(
None,
description="AI 補救證據狀態 filter(no_evidence/read_only_dry_run/write_observed/blocked/observed)",
),
+ incident_id: str | None = Query(None, description="關聯 Incident ID filter(可選)"),
page: int = Query(1, ge=1, description="頁碼,從 1 開始"),
per_page: int = Query(_DEFAULT_PER_PAGE, ge=1, le=_MAX_PER_PAGE, description="每頁筆數"),
) -> dict[str, Any]:
@@ -116,6 +117,7 @@ async def list_runs(
project_id=project_id,
state=state,
remediation_status=remediation_status,
+ incident_id=incident_id,
page=page,
per_page=per_page,
)
diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py
index e929bd17..50248a6a 100644
--- a/apps/api/src/services/platform_operator_service.py
+++ b/apps/api/src/services/platform_operator_service.py
@@ -141,11 +141,13 @@ async def list_runs(
project_id: str | None,
state: str | None,
remediation_status: str | None,
+ incident_id: str | None,
page: int,
per_page: int,
) -> dict[str, Any]:
- """列出 runs,支援 project_id、state、remediation_status filter 與分頁。"""
+ """列出 runs,支援 project_id、state、remediation_status、incident_id filter 與分頁。"""
_validate_remediation_status_filter(remediation_status)
+ _validate_incident_id_filter(incident_id)
async with get_db_context("awoooi") as db:
stmt = select(AwoooPRunState).order_by(AwoooPRunState.created_at.desc())
@@ -155,7 +157,7 @@ async def list_runs(
stmt = stmt.where(AwoooPRunState.state == state)
offset = (page - 1) * per_page
- if remediation_status:
+ if remediation_status or incident_id:
result = await db.execute(stmt)
candidate_rows = list(result.scalars().all())
context_limit = _list_filter_context_limit(len(candidate_rows))
@@ -176,6 +178,10 @@ async def list_runs(
remediation_summaries.get(row.run_id),
remediation_status,
)
+ and _remediation_summary_matches_incident_id(
+ remediation_summaries.get(row.run_id),
+ incident_id,
+ )
]
total = len(filtered_rows)
rows = filtered_rows[offset : offset + per_page]
@@ -521,14 +527,36 @@ def _validate_remediation_status_filter(value: str | None) -> None:
)
+def _validate_incident_id_filter(value: str | None) -> None:
+ if value is None:
+ return
+ if not _INCIDENT_ID_RE.fullmatch(value):
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="incident_id 格式錯誤,必須是 INC-YYYYMMDD-XXXX",
+ )
+
+
def _remediation_summary_matches_status(
summary: dict[str, Any] | None,
- remediation_status: str,
+ remediation_status: str | None,
) -> bool:
+ if remediation_status is None:
+ return True
status_value = str((summary or {}).get("status") or "no_evidence")
return status_value == remediation_status
+def _remediation_summary_matches_incident_id(
+ summary: dict[str, Any] | None,
+ incident_id: str | None,
+) -> bool:
+ if incident_id is None:
+ return True
+ incident_ids = (summary or {}).get("incident_ids")
+ return isinstance(incident_ids, list) and incident_id in incident_ids
+
+
async def _build_run_remediation_summaries(
*,
runs: list[AwoooPRunState],
diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py
index 45c1a771..423d26f7 100644
--- a/apps/api/src/services/telegram_gateway.py
+++ b/apps/api/src/services/telegram_gateway.py
@@ -30,6 +30,7 @@ import os
import re
from dataclasses import dataclass
from datetime import UTC, datetime
+from urllib.parse import quote
from uuid import NAMESPACE_URL, UUID, uuid5
import httpx
@@ -71,6 +72,7 @@ _TELEGRAM_BOT_URL_RE = re.compile(r"(api\.telegram\.org/bot)[^/\s]+")
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
_CODE_REF_RE = re.compile(r"([0-9a-f]{7,12})", re.IGNORECASE)
_TELEGRAM_HTML_CHUNK_LIMIT = 3600
+_AWOOOP_WEB_BASE_URL = "https://awoooi.wooo.work"
def _top_gateway_bucket(
@@ -196,6 +198,23 @@ def _format_remediation_history_lines(history: dict[str, object] | None) -> list
]
+def _awooop_runs_url_for_incident(incident_id: str) -> str:
+ safe_incident_id = quote(str(incident_id or ""), safe="")
+ return (
+ f"{_AWOOOP_WEB_BASE_URL}/zh-TW/awooop/runs"
+ f"?project_id=awoooi&incident_id={safe_incident_id}"
+ )
+
+
+def _awooop_runs_button_row(incident_id: str) -> list[dict[str, str]]:
+ if not incident_id:
+ return []
+ return [{
+ "text": "🧭 AwoooP",
+ "url": _awooop_runs_url_for_incident(incident_id),
+ }]
+
+
def _latest_remediation_history_item(history: dict[str, object] | None) -> dict[str, object]:
if not history:
return {}
@@ -2187,6 +2206,10 @@ class TelegramGateway:
tuning_nonce = self._security.generate_callback_nonce(approval_id, "tune")
buttons.append([{"text": "⚡ 執行自動調優", "callback_data": tuning_nonce}])
+ awooop_row = _awooop_runs_button_row(incident_id)
+ if awooop_row:
+ buttons.append(awooop_row)
+
return {"inline_keyboard": buttons}
# ── YAML Fallback 路徑(原有邏輯,不改動任何行為)────────────────────
@@ -2232,6 +2255,9 @@ class TelegramGateway:
{"text": "📋 詳情", "callback_data": f"detail:{incident_id}"},
{"text": "🔕 忽略", "callback_data": silence_nonce},
])
+ awooop_row = _awooop_runs_button_row(incident_id)
+ if awooop_row:
+ rows.append(awooop_row)
buttons = rows
else:
# 舊版通用鍵(向下相容)
@@ -2249,6 +2275,9 @@ class TelegramGateway:
{"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"},
{"text": "📊 歷史", "callback_data": f"history:{incident_id}"},
])
+ awooop_row = _awooop_runs_button_row(incident_id)
+ if awooop_row:
+ buttons.append(awooop_row)
logger.info(
"telegram_keyboard_built",
@@ -2837,12 +2866,14 @@ class TelegramGateway:
# read-only 查類按鈕(2-part info 格式,handler 已在 handle_callback 實作)
# detail/history 均在 INFO_ACTIONS 白名單,無 nonce 無副作用
- keyboard = {
- "inline_keyboard": [[
- {"text": "📋 詳情", "callback_data": f"detail:{incident_id}"},
- {"text": "📊 歷史", "callback_data": f"history:{incident_id}"},
- ]]
- }
+ inline_keyboard = [[
+ {"text": "📋 詳情", "callback_data": f"detail:{incident_id}"},
+ {"text": "📊 歷史", "callback_data": f"history:{incident_id}"},
+ ]]
+ awooop_row = _awooop_runs_button_row(incident_id)
+ if awooop_row:
+ inline_keyboard.append(awooop_row)
+ keyboard = {"inline_keyboard": inline_keyboard}
return await self._send_request(
"sendMessage",
{
@@ -5189,13 +5220,17 @@ class TelegramGateway:
# Step 1: 換掉按鈕 (移除 Row 1 批准/拒絕/靜默,保留 Row 2 資訊按鈕)
if keep_info_buttons:
- new_keyboard = {"inline_keyboard": [
+ inline_keyboard = [
[
{"text": "📋 詳情", "callback_data": f"detail:{incident_id}"},
{"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"},
{"text": "📊 歷史", "callback_data": f"history:{incident_id}"},
],
- ]}
+ ]
+ awooop_row = _awooop_runs_button_row(incident_id)
+ if awooop_row:
+ inline_keyboard.append(awooop_row)
+ new_keyboard = {"inline_keyboard": inline_keyboard}
else:
new_keyboard = {"inline_keyboard": []}
@@ -6942,6 +6977,9 @@ class TelegramGateway:
{"text": "📋 詳情", "callback_data": f"detail:{incident_id}"},
{"text": "📊 歷史", "callback_data": f"history:{incident_id}"},
]]
+ awooop_row = _awooop_runs_button_row(incident_id)
+ if awooop_row:
+ info_buttons.append(awooop_row)
await self._send_request(
"editMessageReplyMarkup",
{
diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py
index 86ffe2ec..452521ad 100644
--- a/apps/api/tests/test_awooop_operator_timeline_labels.py
+++ b/apps/api/tests/test_awooop_operator_timeline_labels.py
@@ -6,6 +6,7 @@ from src.services.platform_operator_service import (
_list_filter_context_limit,
_outbound_timeline_title,
_run_remediation_list_summary,
+ _remediation_summary_matches_incident_id,
_remediation_summary_matches_status,
_remediation_timeline_summary,
_timeline_sort_key,
@@ -172,6 +173,18 @@ def test_remediation_summary_matches_status_filter() -> None:
assert _remediation_summary_matches_status(None, "no_evidence")
+def test_remediation_summary_matches_incident_id_filter() -> None:
+ assert _remediation_summary_matches_incident_id(
+ {"incident_ids": ["INC-20260514-F85F21"]},
+ "INC-20260514-F85F21",
+ )
+ assert not _remediation_summary_matches_incident_id(
+ {"incident_ids": ["INC-20260514-F85F21"]},
+ "INC-20260513-79ED5E",
+ )
+ assert _remediation_summary_matches_incident_id(None, None)
+
+
def test_list_filter_context_limit_scales_with_candidate_rows() -> None:
assert _list_filter_context_limit(2) == 500
assert _list_filter_context_limit(4176) == 16704
diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py
index bbd92d68..a2e33fd9 100644
--- a/apps/api/tests/test_telegram_message_templates.py
+++ b/apps/api/tests/test_telegram_message_templates.py
@@ -71,6 +71,42 @@ def test_telegram_html_chunks_preserve_complete_lines() -> None:
assert all(chunk.count("") == chunk.count("") for chunk in chunks)
+def test_awooop_runs_url_for_incident_uses_public_incident_filter() -> None:
+ """Telegram URL button 必須導到公開 AwoooP Run list,並帶 incident filter。"""
+ url = telegram_gateway_module._awooop_runs_url_for_incident("INC-20260514-F85F21")
+
+ assert url == (
+ "https://awoooi.wooo.work/zh-TW/awooop/runs"
+ "?project_id=awoooi&incident_id=INC-20260514-F85F21"
+ )
+
+
+@pytest.mark.asyncio
+async def test_build_inline_keyboard_includes_awooop_deep_link() -> None:
+ """主告警卡的 read-only 按鈕列要能回到 AwoooP evidence view。"""
+ gateway = TelegramGateway()
+
+ keyboard = await gateway._build_inline_keyboard(
+ approval_id="INC-20260514-F85F21",
+ include_auto_tuning=False,
+ incident_id="INC-20260514-F85F21",
+ )
+ buttons = [
+ button
+ for row in keyboard["inline_keyboard"]
+ for button in row
+ ]
+
+ awooop_buttons = [button for button in buttons if button["text"] == "🧭 AwoooP"]
+ assert awooop_buttons == [{
+ "text": "🧭 AwoooP",
+ "url": (
+ "https://awoooi.wooo.work/zh-TW/awooop/runs"
+ "?project_id=awoooi&incident_id=INC-20260514-F85F21"
+ ),
+ }]
+
+
@pytest.mark.asyncio
async def test_send_html_line_message_falls_back_to_plain_text_on_parse_error(monkeypatch):
"""Telegram HTML parse 400 時要送純文字 fallback,不可回報成歷史查詢失敗。"""
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 6dbaa7d1..a199846d 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -1804,7 +1804,9 @@
"manualGate": "Next: human approval",
"filters": {
"label": "AI evidence filter",
- "all": "All AI evidence"
+ "all": "All AI evidence",
+ "incidentLabel": "Incident ID filter",
+ "incidentPlaceholder": "Enter Incident ID"
},
"statuses": {
"noEvidence": "No dry-run yet",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index 16df25e9..4cb3b57d 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -1805,7 +1805,9 @@
"manualGate": "下一步:人工審批",
"filters": {
"label": "AI 證據篩選",
- "all": "所有 AI 證據"
+ "all": "所有 AI 證據",
+ "incidentLabel": "Incident ID 篩選",
+ "incidentPlaceholder": "輸入 Incident ID"
},
"statuses": {
"noEvidence": "尚無試跑",
diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx
index 49a4b9af..4016d768 100644
--- a/apps/web/src/app/[locale]/awooop/runs/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx
@@ -116,6 +116,7 @@ interface RecentEventsResponse {
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const PER_PAGE = 50;
const AUTO_REFRESH_INTERVAL = 30_000; // 30 秒
+const INCIDENT_ID_FILTER_RE = /^INC-\d{8}-[A-Z0-9]{4,}$/;
const STATE_CONFIG: Record<
RunState,
@@ -518,10 +519,20 @@ export default function RunsPage() {
const [projectFilter, setProjectFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>("");
+ const [incidentFilter, setIncidentFilter] = useState("");
const [page, setPage] = useState(1);
const [lastRefresh, setLastRefresh] = useState(null);
const intervalRef = useRef | null>(null);
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const linkedProject = params.get("project_id") || "";
+ const linkedIncident = params.get("incident_id") || "";
+ if (linkedProject) setProjectFilter(linkedProject);
+ if (linkedIncident) setIncidentFilter(linkedIncident);
+ if (linkedProject || linkedIncident) setPage(1);
+ }, []);
+
// 取得租戶清單
useEffect(() => {
fetch(`${API_BASE}/api/v1/platform/tenants`)
@@ -540,6 +551,10 @@ export default function RunsPage() {
if (projectFilter) params.set("project_id", projectFilter);
if (statusFilter) params.set("state", statusFilter);
if (evidenceFilter) params.set("remediation_status", evidenceFilter);
+ const normalizedIncidentFilter = incidentFilter.trim().toUpperCase();
+ if (INCIDENT_ID_FILTER_RE.test(normalizedIncidentFilter)) {
+ params.set("incident_id", normalizedIncidentFilter);
+ }
params.set("page", String(page));
params.set("per_page", String(PER_PAGE));
@@ -572,7 +587,7 @@ export default function RunsPage() {
} finally {
setLoading(false);
}
- }, [projectFilter, statusFilter, evidenceFilter, page]);
+ }, [projectFilter, statusFilter, evidenceFilter, incidentFilter, page]);
// 初次載入
useEffect(() => {
@@ -791,6 +806,18 @@ export default function RunsPage() {
+
+ {/* Incident Filter */}
+ {
+ setIncidentFilter(e.target.value.trim().toUpperCase());
+ setPage(1);
+ }}
+ className="min-w-[260px] rounded-lg border border-border bg-background px-3 py-1.5 font-mono text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-brand-accent/50"
+ aria-label={tEvidence("filters.incidentLabel")}
+ placeholder={tEvidence("filters.incidentPlaceholder")}
+ />
{/* Error State */}