diff --git a/apps/api/src/api/v1/drift.py b/apps/api/src/api/v1/drift.py index ad8b7be2..7563b72f 100644 --- a/apps/api/src/api/v1/drift.py +++ b/apps/api/src/api/v1/drift.py @@ -13,10 +13,12 @@ leWOOOgo 積木化原則: 建立者: Claude Code (Phase 25 P2) """ +from typing import Literal + from fastapi import APIRouter, BackgroundTasks, HTTPException +from pydantic import BaseModel, Field from src.core.csrf import CSRFToken # Phase 20: CSRF Protection - from src.models.drift import ( DriftListResponse, DriftReport, @@ -28,6 +30,10 @@ from src.repositories.drift_repository import get_drift_repository from src.services.drift_adopt_service import get_drift_adopt_service from src.services.drift_analyzer import get_drift_analyzer from src.services.drift_detector import get_drift_detector +from src.services.drift_fingerprint_state_service import ( + DriftFingerprintStateNotFoundError, + get_drift_fingerprint_state_service, +) from src.services.drift_interpreter import get_drift_interpreter from src.services.drift_remediator import get_drift_remediator from src.utils.timezone import now_taipei @@ -37,6 +43,20 @@ router = APIRouter(prefix="/drift", tags=["drift"]) # 2026-04-09 Claude Sonnet 4.6: B4 drift_reports 持久化 — 改用 DB repository +class DriftFingerprintHandoffRequest(BaseModel): + """Record-only handoff request for a stable drift fingerprint.""" + + report_id: str | None = Field(default=None, min_length=1) + namespace: str | None = Field(default="awoooi-prod", min_length=1) + handoff_kind: Literal[ + "open_pr_review", + "manual_investigation", + "zero_diff_pr_cleanup", + ] = "open_pr_review" + pr_url: str | None = Field(default=None, min_length=1) + note: str | None = Field(default=None, max_length=500) + + @router.post("/scan", response_model=DriftScanResponse, summary="觸發漂移掃描") async def trigger_drift_scan( request: DriftScanRequest, @@ -99,6 +119,47 @@ async def list_drift_reports() -> DriftListResponse: return DriftListResponse(items=items, total=len(items)) +@router.get("/fingerprints/state", summary="查詢 Config Drift fingerprint 狀態") +async def get_drift_fingerprint_state( + report_id: str | None = None, + namespace: str | None = "awoooi-prod", +) -> dict: + """ + 以 stable fingerprint 聚合漂移狀態。 + + 此 endpoint 只建立 read model:重複次數、PR 狀態、是否零 diff、 + 人工交接歷史與下一步。它不修改 drift / incident / auto-repair 狀態。 + """ + svc = get_drift_fingerprint_state_service() + try: + return await svc.get_state(report_id=report_id, namespace=namespace) + except DriftFingerprintStateNotFoundError as exc: + raise HTTPException(status_code=404, detail="drift_report_not_found") from exc + + +@router.post("/fingerprints/handoff", summary="記錄 Config Drift fingerprint 交接") +async def record_drift_fingerprint_handoff( + request: DriftFingerprintHandoffRequest, +) -> dict: + """ + 記錄 stable fingerprint 已轉人工 / PR review 的歷史證據。 + + 安全邊界:只寫 alert_operation_log / timeline_events,不修改 drift 狀態、 + incident 狀態、自動修復結果,不建立外部 ticket,也不 merge PR。 + """ + svc = get_drift_fingerprint_state_service() + try: + return await svc.record_handoff( + report_id=request.report_id, + namespace=request.namespace, + handoff_kind=request.handoff_kind, + pr_url=request.pr_url, + note=request.note, + ) + except DriftFingerprintStateNotFoundError as exc: + raise HTTPException(status_code=404, detail="drift_report_not_found") from exc + + @router.post("/reports/{report_id}/rollback", summary="覆蓋回 Git 狀態") async def rollback_drift(report_id: str, _csrf_token: CSRFToken) -> dict: # Phase 20: CSRF Protection (驗證用,不需要使用值) """ diff --git a/apps/api/src/services/drift_fingerprint_state_service.py b/apps/api/src/services/drift_fingerprint_state_service.py new file mode 100644 index 00000000..ca4ecf26 --- /dev/null +++ b/apps/api/src/services/drift_fingerprint_state_service.py @@ -0,0 +1,578 @@ +"""Config Drift fingerprint FSM read model. + +The drift scanner creates a new report_id on every scan, so the operator-facing +state must be keyed by the stable drift fingerprint instead of the volatile +report id. This service is intentionally conservative: it can record a human +handoff breadcrumb, but it does not adopt, merge, roll back, or mutate incident +state. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +import httpx +import structlog +from sqlalchemy import text + +from src.core.config import get_settings +from src.db.base import get_db_context +from src.models.drift import DriftReport +from src.repositories.drift_repository import get_drift_repository +from src.services.drift_repeat_state import build_drift_fingerprint +from src.utils.timezone import now_taipei + +logger = structlog.get_logger(__name__) + +SCHEMA_VERSION = "drift_fingerprint_state_v1" +HANDOFF_SCHEMA_VERSION = "drift_fingerprint_handoff_history_v1" +SERVICE_ACTOR = "drift_fingerprint_state_service" +DEFAULT_NAMESPACE = "awoooi-prod" + +DriftFingerprintHandoffKind = Literal[ + "open_pr_review", + "manual_investigation", + "zero_diff_pr_cleanup", +] + + +class DriftFingerprintStateNotFoundError(Exception): + """Raised when no drift report can be found for the requested selector.""" + + +def _enum_value(value: Any) -> Any: + return getattr(value, "value", value) + + +def _iso(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + +def _report_summary(report: DriftReport) -> str: + return ( + f"HIGH×{report.high_count}, " + f"MEDIUM×{report.medium_count}, " + f"INFO×{report.info_count}" + ) + + +def _interpretation_summary(report: DriftReport) -> dict[str, Any] | None: + if report.interpretation is None: + return None + return { + "intent": _enum_value(report.interpretation.intent), + "risk": report.interpretation.risk, + "confidence": report.interpretation.confidence, + "explanation": report.interpretation.explanation, + } + + +def _pr_refs(pr: dict[str, Any] | None) -> list[str]: + if not pr: + return [] + refs = [pr.get("html_url"), pr.get("url")] + number = pr.get("number") + if number is not None: + refs.append(f"pull/{number}") + return [str(ref) for ref in refs if ref] + + +def _derive_fsm_state( + report: DriftReport, + repeat_state: dict[str, Any], + open_pr: dict[str, Any] | None, + latest_handoff: dict[str, Any] | None, +) -> str: + status = str(_enum_value(report.status)) + if status == "adopted": + return "adopted_unverified" + if status == "rolled_back": + return "rolled_back" + if status in {"acknowledged", "ignored"}: + return status + + if open_pr: + if open_pr.get("merged"): + return "pr_merged_unverified" + if open_pr.get("state") == "open" and open_pr.get("is_zero_diff"): + return "pr_open_zero_diff" + if open_pr.get("state") == "open": + return "pr_open_waiting_review" + + if latest_handoff: + return "handoff_recorded" + + if int(repeat_state.get("occurrences_12h") or 0) > 1: + return "pending_human_repeated" + return "pending_human" + + +def _next_step_for_state(state: str, open_pr: dict[str, Any] | None) -> str: + if state == "pr_open_zero_diff": + return "close_zero_diff_pr_and_prepare_real_yaml_patch" + if state == "pr_open_waiting_review": + return "review_pr_then_merge_or_reject" + if state == "pr_merged_unverified": + return "verify_git_baseline_then_mark_adopted" + if state == "handoff_recorded": + return "operator_review_handoff_and_execute_manual_plan" + if state == "adopted_unverified": + return "verify_k8s_matches_git_baseline" + if state == "rolled_back": + return "confirm_no_repeat_after_rollback" + if state in {"acknowledged", "ignored"}: + return "monitor_for_recurrence" + if open_pr and open_pr.get("lookup_error"): + return "retry_pr_lookup_then_review_drift" + return "manual_investigation_or_ansible_check_mode" + + +def build_drift_fingerprint_state( + report: DriftReport, + repeat_state: dict[str, Any], + *, + open_pr: dict[str, Any] | None = None, + latest_handoff: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the operator-facing Config Drift fingerprint state payload.""" + + fingerprint = repeat_state.get("fingerprint") or build_drift_fingerprint( + report.namespace, + report.items, + ) + fsm_state = _derive_fsm_state(report, repeat_state, open_pr, latest_handoff) + next_step = _next_step_for_state(fsm_state, open_pr) + + return { + "schema_version": SCHEMA_VERSION, + "namespace": report.namespace, + "fingerprint": fingerprint, + "latest_report_id": report.report_id, + "latest_status": str(_enum_value(report.status)), + "latest_scanned_at": _iso(report.scanned_at), + "latest_created_at": _iso(report.created_at), + "summary": _report_summary(report), + "high_count": report.high_count, + "medium_count": report.medium_count, + "info_count": report.info_count, + "interpretation": _interpretation_summary(report), + "repeat_state": repeat_state, + "occurrences_12h": repeat_state.get("occurrences_12h", 0), + "matching_strategy": repeat_state.get("matching_strategy"), + "operator_stage": fsm_state, + "fsm_state": fsm_state, + "next_step": next_step, + "open_pr": open_pr, + "latest_handoff": latest_handoff, + "p0_escalation": { + "suppresses_repeated_p0": True, + "dedup_key_strategy": "stable_drift_fingerprint", + "dedup_window_hours": 24, + }, + "read_model_route": { + "agent_id": "openclaw", + "tool_name": "drift_fingerprint_state", + "required_scope": "read:drift read:gitea", + "flywheel_node": ( + "drift_scanned>ai_analyzed>fingerprint_fsm>" + "operator_review" + ), + }, + "writes_incident_state": False, + "writes_auto_repair_result": False, + "writes_drift_status": False, + "writes_ticket": False, + "creates_external_ticket": False, + } + + +def _handoff_context( + state: dict[str, Any], + *, + handoff_kind: DriftFingerprintHandoffKind, + pr_url: str | None, + note: str | None, +) -> dict[str, Any]: + return { + "schema_version": HANDOFF_SCHEMA_VERSION, + "fingerprint": state.get("fingerprint"), + "namespace": state.get("namespace"), + "latest_report_id": state.get("latest_report_id"), + "handoff_kind": handoff_kind, + "handoff_status": "recorded", + "pr_url": pr_url, + "note": note, + "fsm_state": state.get("fsm_state"), + "next_step": state.get("next_step"), + "open_pr": state.get("open_pr"), + "writes_incident_state": False, + "writes_auto_repair_result": False, + "writes_drift_status": False, + "writes_ticket": False, + "creates_external_ticket": False, + "recorded_at": now_taipei().isoformat(), + } + + +def _handoff_description(context: dict[str, Any]) -> str: + return ( + f"fingerprint={context.get('fingerprint')} " + f"state={context.get('fsm_state')} " + f"kind={context.get('handoff_kind')} " + f"next={context.get('next_step')} " + f"pr={context.get('pr_url') or '--'}" + )[:500] + + +class DriftFingerprintStateService: + """Read and record the state of a stable Config Drift fingerprint.""" + + async def get_state( + self, + *, + report_id: str | None = None, + namespace: str | None = None, + ) -> dict[str, Any]: + report = await self._load_report(report_id=report_id, namespace=namespace) + repo = get_drift_repository() + repeat_state = await repo.get_repeat_state(report) + fingerprint = repeat_state["fingerprint"] + open_pr = await self._lookup_open_pr(report) + latest_handoff = await self._fetch_latest_handoff(fingerprint) + return build_drift_fingerprint_state( + report, + repeat_state, + open_pr=open_pr, + latest_handoff=latest_handoff, + ) + + async def record_handoff( + self, + *, + report_id: str | None = None, + namespace: str | None = None, + handoff_kind: DriftFingerprintHandoffKind = "open_pr_review", + pr_url: str | None = None, + note: str | None = None, + ) -> dict[str, Any]: + state = await self.get_state(report_id=report_id, namespace=namespace) + context = _handoff_context( + state, + handoff_kind=handoff_kind, + pr_url=pr_url or _default_pr_url(state.get("open_pr")), + note=note, + ) + + history = { + "recorded": False, + "alert_operation_id": None, + "timeline_event_id": None, + "reason": None, + } + incident_id = str(state.get("latest_report_id") or "") + + try: + from src.repositories.alert_operation_log_repository import ( + get_alert_operation_log_repository, + ) + + record = await get_alert_operation_log_repository().append( + "ESCALATED", + incident_id=incident_id or None, + actor=SERVICE_ACTOR, + action_detail=f"drift_fingerprint_handoff:{handoff_kind}"[:200], + success=True, + context=context, + ) + if record is not None: + history["alert_operation_id"] = getattr(record, "id", None) + except Exception as exc: + logger.warning( + "drift_fingerprint_handoff_aol_failed", + report_id=incident_id, + error=str(exc), + ) + + try: + from src.services.approval_db import get_timeline_service + + event = await get_timeline_service().add_event( + event_type="human", + status="warning", + title="AwoooP drift fingerprint handoff", + description=_handoff_description(context), + actor=SERVICE_ACTOR, + actor_role=handoff_kind, + incident_id=incident_id or None, + ) + if event: + history["timeline_event_id"] = event.get("id") + except Exception as exc: + logger.warning( + "drift_fingerprint_handoff_timeline_failed", + report_id=incident_id, + error=str(exc), + ) + + history["recorded"] = bool( + history.get("alert_operation_id") or history.get("timeline_event_id") + ) + if not history["recorded"]: + history["reason"] = "history_sink_unavailable" + + updated_state = { + **state, + "latest_handoff": { + "handoff_kind": handoff_kind, + "handoff_status": "recorded" if history["recorded"] else "record_failed", + "pr_url": context.get("pr_url"), + "note": note, + "created_at": context.get("recorded_at"), + "source": "current_request", + }, + } + updated_state["operator_stage"] = ( + "handoff_recorded" if history["recorded"] else state["operator_stage"] + ) + updated_state["fsm_state"] = updated_state["operator_stage"] + updated_state["next_step"] = _next_step_for_state( + updated_state["fsm_state"], + state.get("open_pr"), + ) + + return { + **updated_state, + "handoff_kind": handoff_kind, + "handoff_status": updated_state["latest_handoff"]["handoff_status"], + "history": history, + } + + async def _load_report( + self, + *, + report_id: str | None, + namespace: str | None, + ) -> DriftReport: + repo = get_drift_repository() + if report_id: + report = await repo.get(report_id) + if report is None: + raise DriftFingerprintStateNotFoundError(report_id) + return report + + desired_namespace = namespace or DEFAULT_NAMESPACE + for report in await repo.list_recent(limit=50): + if report.namespace == desired_namespace: + return report + raise DriftFingerprintStateNotFoundError(desired_namespace) + + async def _fetch_latest_handoff(self, fingerprint: str) -> dict[str, Any] | None: + try: + async with get_db_context() as db: + result = await db.execute( + text( + """ + SELECT id, action_detail, success, context, created_at + FROM alert_operation_log + WHERE actor = :actor + AND action_detail LIKE 'drift_fingerprint_handoff:%' + AND context ->> 'fingerprint' = :fingerprint + ORDER BY created_at DESC + LIMIT 1 + """ + ), + {"actor": SERVICE_ACTOR, "fingerprint": fingerprint}, + ) + row = result.mappings().first() + except Exception as exc: + logger.warning( + "drift_fingerprint_handoff_lookup_failed", + fingerprint=fingerprint, + error=str(exc), + ) + return { + "lookup_error": str(exc)[:160], + "handoff_status": "lookup_failed", + } + + if not row: + return None + context = row.get("context") or {} + return { + "alert_operation_id": row.get("id"), + "action_detail": row.get("action_detail"), + "success": row.get("success"), + "created_at": _iso(row.get("created_at")), + "handoff_kind": context.get("handoff_kind"), + "handoff_status": context.get("handoff_status") or "recorded", + "pr_url": context.get("pr_url"), + "note": context.get("note"), + } + + async def _lookup_open_pr(self, report: DriftReport) -> dict[str, Any] | None: + settings = get_settings() + api_url = settings.GITEA_API_URL.rstrip("/") + if not api_url: + return None + + headers = {"Accept": "application/json"} + if settings.GITEA_API_TOKEN: + headers["Authorization"] = f"token {settings.GITEA_API_TOKEN}" + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + pulls = await client.get( + ( + f"{api_url}/api/v1/repos/" + f"{settings.GITEA_REPO_OWNER}/{settings.GITEA_REPO_NAME}/pulls" + ), + params={"state": "open", "limit": 20}, + headers=headers, + ) + if pulls.status_code != 200: + return { + "lookup_status": "failed", + "lookup_error": f"pulls_http_{pulls.status_code}", + } + + for pr in pulls.json() or []: + if not _matches_drift_pr(report, pr): + continue + return await _build_pr_state( + client=client, + api_url=api_url, + owner=settings.GITEA_REPO_OWNER, + repo=settings.GITEA_REPO_NAME, + headers=headers, + pr=pr, + ) + except Exception as exc: + logger.warning( + "drift_fingerprint_pr_lookup_failed", + report_id=report.report_id, + error=str(exc), + ) + return { + "lookup_status": "failed", + "lookup_error": str(exc)[:160], + } + + return None + + +def _default_pr_url(open_pr: dict[str, Any] | None) -> str | None: + if not open_pr: + return None + return open_pr.get("html_url") or open_pr.get("url") + + +def _matches_drift_pr(report: DriftReport, pr: dict[str, Any]) -> bool: + text_blob = "\n".join( + str(value or "") + for value in (pr.get("title"), pr.get("body"), pr.get("html_url")) + ) + if report.report_id and report.report_id in text_blob: + return True + if report.namespace not in text_blob: + return False + expected_parts = [ + f"HIGH×{report.high_count}", + f"MEDIUM×{report.medium_count}", + f"INFO×{report.info_count}", + ] + return any(part in text_blob for part in expected_parts) + + +def _nested(mapping: dict[str, Any], *keys: str) -> Any: + current: Any = mapping + for key in keys: + if not isinstance(current, dict): + return None + current = current.get(key) + return current + + +async def _response_list_count( + client: httpx.AsyncClient, + url: str, + *, + headers: dict[str, str], +) -> tuple[int | None, str | None]: + try: + response = await client.get(url, headers=headers) + if response.status_code != 200: + return None, f"http_{response.status_code}" + payload = response.json() + if isinstance(payload, list): + return len(payload), None + if isinstance(payload, dict): + for key in ("items", "files", "commits"): + if isinstance(payload.get(key), list): + return len(payload[key]), None + return 0, None + except Exception as exc: + return None, str(exc)[:160] + + +async def _build_pr_state( + *, + client: httpx.AsyncClient, + api_url: str, + owner: str, + repo: str, + headers: dict[str, str], + pr: dict[str, Any], +) -> dict[str, Any]: + number = pr.get("number") or pr.get("index") + base_url = f"{api_url}/api/v1/repos/{owner}/{repo}/pulls/{number}" + files_count, files_error = await _response_list_count( + client, + f"{base_url}/files", + headers=headers, + ) + commits_count, commits_error = await _response_list_count( + client, + f"{base_url}/commits", + headers=headers, + ) + head_sha = _nested(pr, "head", "sha") or _nested(pr, "head", "ref") + base_sha = _nested(pr, "base", "sha") or _nested(pr, "base", "ref") + is_zero_diff = ( + (files_count == 0 and commits_count == 0) + or (bool(head_sha) and head_sha == base_sha) + ) + return { + "lookup_status": "ok", + "number": number, + "title": pr.get("title"), + "state": pr.get("state"), + "merged": bool(pr.get("merged")), + "mergeable": pr.get("mergeable"), + "html_url": pr.get("html_url"), + "url": pr.get("url"), + "head_ref": _nested(pr, "head", "ref"), + "base_ref": _nested(pr, "base", "ref"), + "head_sha": head_sha, + "base_sha": base_sha, + "file_count": files_count, + "commit_count": commits_count, + "files_lookup_error": files_error, + "commits_lookup_error": commits_error, + "is_zero_diff": is_zero_diff, + "refs": _pr_refs(pr), + } + + +_drift_fingerprint_state_service: DriftFingerprintStateService | None = None + + +def get_drift_fingerprint_state_service() -> DriftFingerprintStateService: + global _drift_fingerprint_state_service + if _drift_fingerprint_state_service is None: + _drift_fingerprint_state_service = DriftFingerprintStateService() + return _drift_fingerprint_state_service diff --git a/apps/api/tests/test_drift_fingerprint_state_service.py b/apps/api/tests/test_drift_fingerprint_state_service.py new file mode 100644 index 00000000..fec857fb --- /dev/null +++ b/apps/api/tests/test_drift_fingerprint_state_service.py @@ -0,0 +1,83 @@ +from src.models.drift import DriftItem, DriftLevel, DriftReport, DriftStatus +from src.services.drift_fingerprint_state_service import ( + build_drift_fingerprint_state, +) +from src.services.drift_repeat_state import build_drift_repeat_state + + +def _report(report_id: str = "drift-1", status: DriftStatus = DriftStatus.PENDING) -> DriftReport: + return DriftReport( + report_id=report_id, + namespace="awoooi-prod", + high_count=1, + medium_count=32, + info_count=23, + status=status, + items=[ + DriftItem( + resource_kind="Deployment", + resource_name="awoooi-api", + namespace="awoooi-prod", + field_path="spec.template.spec.volumes", + git_value=["a"], + actual_value=["a", "b"], + drift_level=DriftLevel.HIGH, + ) + ], + ) + + +def test_build_state_marks_repeated_pending_human() -> None: + current = _report("drift-3") + repeat = build_drift_repeat_state( + current, + [_report("drift-1"), _report("drift-2")], + ) + + state = build_drift_fingerprint_state(current, repeat) + + assert state["schema_version"] == "drift_fingerprint_state_v1" + assert state["fingerprint"].startswith("dfp_") + assert state["occurrences_12h"] == 3 + assert state["fsm_state"] == "pending_human_repeated" + assert state["writes_drift_status"] is False + assert state["writes_incident_state"] is False + assert state["p0_escalation"]["dedup_window_hours"] == 24 + + +def test_build_state_marks_zero_diff_open_pr() -> None: + report = _report("drift-4") + repeat = build_drift_repeat_state(report, []) + state = build_drift_fingerprint_state( + report, + repeat, + open_pr={ + "number": 145, + "state": "open", + "merged": False, + "file_count": 0, + "commit_count": 0, + "is_zero_diff": True, + "html_url": "http://gitea.local/wooo/awoooi/pulls/145", + }, + ) + + assert state["fsm_state"] == "pr_open_zero_diff" + assert state["next_step"] == "close_zero_diff_pr_and_prepare_real_yaml_patch" + assert state["open_pr"]["number"] == 145 + + +def test_build_state_marks_handoff_recorded() -> None: + report = _report("drift-5") + repeat = build_drift_repeat_state(report, []) + state = build_drift_fingerprint_state( + report, + repeat, + latest_handoff={ + "handoff_kind": "manual_investigation", + "handoff_status": "recorded", + }, + ) + + assert state["fsm_state"] == "handoff_recorded" + assert state["next_step"] == "operator_review_handoff_and_execute_manual_plan" diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 9e9ba6a6..a4a514cc 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1057,7 +1057,20 @@ "adoptConfirm": "Adopt this change and update Git?", "pending": "Pending", "resolved": "Resolved", - "ignored": "Ignored" + "acknowledged": "Acknowledged", + "rolled_back": "Rolled back", + "adopted": "Adopted", + "ignored": "Ignored", + "fingerprintState": { + "title": "Same-fingerprint state chain", + "occurrences": "12h {count}x", + "report": "Report: {report}", + "state": "State: {state}", + "next": "Next: {step}", + "writes": "Writes: drift={drift}; incident={incident}; repair={repair}; ticket={ticket}", + "pr": "PR: {pr}; zeroDiff={zeroDiff}", + "p0Dedup": "P0 dedupe {hours}h" + } }, "neuralCommand": { "title": "Neural Command Center", @@ -1751,6 +1764,9 @@ "recurrenceWorkItems": { "title": "Recurring alert work item / ticket entry" }, + "configDriftFsm": { + "title": "Config Drift fingerprint state machine" + }, "remediationQueue": { "title": "Non-success verification remediation queue" }, @@ -1777,6 +1793,7 @@ "sourceDossier": "Inbound alerts must show received / incident_linked / source refs", "autoRepair": "Requires auto_repair, verification_result=success, and KM writeback", "recurrenceWorkItems": "Completed-without-repair, failed repair, and manual gate groups must become trackable work items", + "configDriftFsm": "The same drift fingerprint must expose recurrence, PR, zero diff, handoff, and next step", "remediationQueue": "Every degraded / failed / timeout row must map to replay, reverify, ticket, or manual review", "telegramCallbacks": "Detail and history buttons cannot depend only on Redis TTL or stale snapshots", "ciSecretHygiene": "Workflows must not mount secrets in step env / action inputs; historical logs still need rotation and retention governance", @@ -1792,6 +1809,12 @@ "recurrenceLatest": "Latest: {alert} / {incident}", "recurrenceReason": "Reason: {reason}", "recurrenceEmpty": "No open recurring-alert work item in the recent window", + "driftFingerprint": "Config Drift: {state}; {count}x in 12h", + "driftFingerprintUnavailable": "Config Drift fingerprint state API has not responded", + "driftFingerprintId": "Fingerprint: {fingerprint}; Report: {report}", + "driftFingerprintPr": "PR: {pr}; zeroDiff={zeroDiff}", + "driftFingerprintNext": "Next: {step}", + "driftFingerprintEmpty": "No Config Drift fingerprint state yet", "remediationQueue": "Remediation work: {total}; AI-ready: {ready}; human: {human}", "telegramCallbacks": "Telegram callback lookup and history summary are being repaired", "telegramCallbacksLive": "Read-only callback toast 400 is nonfatal; detail / history replies now use DB truth-chain", @@ -1825,6 +1848,60 @@ "evaluatedUnknown": "Evaluated --", "gateFailuresUnknown": "Gaps --" }, + "driftFingerprint": { + "title": "Config Drift Fingerprint State", + "subtitle": "Collapses hourly drift reports into one state chain with PR, zero diff, P0 dedupe, and human handoff evidence", + "unavailable": "The drift fingerprint state API has not responded, so recurrence, PR, and handoff state cannot be claimed.", + "occurrences": "12h {count}x", + "risk": "HIGH {high} / MEDIUM {medium} / INFO {info}", + "report": "Report: {report}; Namespace: {namespace}", + "summary": "Summary: {summary}", + "next": "Next: {step}", + "p0Dedup": "P0 dedupe: {enabled}; window {hours}h", + "writes": "Writes: drift={drift}; incident={incident}; repair={repair}; ticket={ticket}", + "fsmStates": { + "pending_human": "Waiting for human", + "pending_human_repeated": "Repeated human wait", + "pr_open_zero_diff": "PR open but zero diff", + "pr_open_waiting_review": "PR waiting review", + "pr_merged_unverified": "PR merged, unverified", + "handoff_recorded": "Handoff recorded", + "adopted_unverified": "Adopted, unverified", + "rolled_back": "Rolled back", + "acknowledged": "Acknowledged", + "ignored": "Ignored", + "unknown": "Unknown" + }, + "nextSteps": { + "close_zero_diff_pr_and_prepare_real_yaml_patch": "Close zero-diff PR and prepare a real YAML patch", + "review_pr_then_merge_or_reject": "Review PR, then merge or reject", + "verify_git_baseline_then_mark_adopted": "Verify Git baseline, then mark adopted", + "operator_review_handoff_and_execute_manual_plan": "Operator reviews handoff and executes manual plan", + "verify_k8s_matches_git_baseline": "Verify K8s matches Git baseline", + "confirm_no_repeat_after_rollback": "Confirm no repeat after rollback", + "monitor_for_recurrence": "Monitor for recurrence", + "retry_pr_lookup_then_review_drift": "Retry PR lookup, then review drift", + "manual_investigation_or_ansible_check_mode": "Manual investigation or Ansible check-mode", + "unknown": "Unknown" + }, + "pr": { + "title": "PR / Baseline", + "number": "PR: {number}", + "zeroDiff": "zeroDiff={zeroDiff}; files={files}; commits={commits}", + "status": "Status: {status}" + }, + "handoff": { + "latest": "Latest handoff: {status}" + }, + "actions": { + "record": "Record handoff", + "recording": "Recording", + "openDrift": "Open Drift", + "failed": "The handoff API did not respond, so human handoff cannot be claimed.", + "recorded": "Handoff stored: {recorded}", + "handoffStatus": "Handoff status: {status}" + } + }, "recurrence": { "title": "Recurring Alert Work Items", "subtitle": "Turns run_completed_no_repair, failed repair, and manual gates into trackable work items", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index afe8f84e..b67da355 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1058,7 +1058,20 @@ "adoptConfirm": "確定要將此變更承認並更新至 Git 嗎?", "pending": "待處理", "resolved": "已解決", - "ignored": "已忽略" + "acknowledged": "已知悉", + "rolled_back": "已回滾", + "adopted": "已採納", + "ignored": "已忽略", + "fingerprintState": { + "title": "同指紋狀態鏈", + "occurrences": "12h {count} 次", + "report": "Report:{report}", + "state": "狀態:{state}", + "next": "下一步:{step}", + "writes": "寫入:drift={drift};incident={incident};repair={repair};ticket={ticket}", + "pr": "PR:{pr};zeroDiff={zeroDiff}", + "p0Dedup": "P0 去重 {hours}h" + } }, "neuralCommand": { "title": "神經指揮中心", @@ -1752,6 +1765,9 @@ "recurrenceWorkItems": { "title": "重複告警工作項 / Ticket 入口" }, + "configDriftFsm": { + "title": "Config Drift fingerprint 狀態機" + }, "remediationQueue": { "title": "非成功驗證補救工作佇列" }, @@ -1778,6 +1794,7 @@ "sourceDossier": "入站告警必須能查到 received / incident_linked / source refs", "autoRepair": "必須同時有 auto_repair、verification_result=success 與 KM 回寫", "recurrenceWorkItems": "Run 完成無修復、修復失敗與人工閘門必須進入可追蹤工作項", + "configDriftFsm": "同一 drift fingerprint 必須顯示重複、PR、零 diff、交接與下一步", "remediationQueue": "每筆 degraded / failed / timeout 都必須映射到重跑、重驗、Ticket 或人工檢查", "telegramCallbacks": "按下詳情與歷史不能再只依賴 Redis TTL 或舊快照", "ciSecretHygiene": "workflow 不可再把 secrets 掛在 step env / action input;歷史 log 需另做輪換與保留期治理", @@ -1793,6 +1810,12 @@ "recurrenceLatest": "最新:{alert} / {incident}", "recurrenceReason": "原因:{reason}", "recurrenceEmpty": "近期重複告警尚無待處理工作項", + "driftFingerprint": "Config Drift:{state};12h 內 {count} 次", + "driftFingerprintUnavailable": "Config Drift fingerprint state API 尚未回應", + "driftFingerprintId": "Fingerprint:{fingerprint};Report:{report}", + "driftFingerprintPr": "PR:{pr};zeroDiff={zeroDiff}", + "driftFingerprintNext": "下一步:{step}", + "driftFingerprintEmpty": "尚無 Config Drift fingerprint 狀態", "remediationQueue": "補救工作:{total};AI 可接手:{ready};人工:{human}", "telegramCallbacks": "目前修補 Telegram callback 查詢鏈與歷史摘要", "telegramCallbacksLive": "read-only callback toast 400 已非致命;詳情 / 歷史改由 DB truth-chain 回覆", @@ -1826,6 +1849,60 @@ "evaluatedUnknown": "已評估 --", "gateFailuresUnknown": "缺口 --" }, + "driftFingerprint": { + "title": "Config Drift fingerprint 狀態", + "subtitle": "把每小時 drift report 收斂成同一狀態鏈,顯示 PR、零 diff、P0 去重與人工交接", + "unavailable": "drift fingerprint state API 尚未回應,不能判定是否重複、是否已有 PR 或是否已交接。", + "occurrences": "12h {count} 次", + "risk": "HIGH {high} / MEDIUM {medium} / INFO {info}", + "report": "Report:{report};Namespace:{namespace}", + "summary": "摘要:{summary}", + "next": "下一步:{step}", + "p0Dedup": "P0 去重:{enabled};視窗 {hours}h", + "writes": "寫入:drift={drift};incident={incident};repair={repair};ticket={ticket}", + "fsmStates": { + "pending_human": "等待人工", + "pending_human_repeated": "重複等待人工", + "pr_open_zero_diff": "PR 開啟但零 diff", + "pr_open_waiting_review": "PR 等待 review", + "pr_merged_unverified": "PR 已 merge 待驗證", + "handoff_recorded": "交接已記錄", + "adopted_unverified": "已採納待驗證", + "rolled_back": "已回滾", + "acknowledged": "已知悉", + "ignored": "已忽略", + "unknown": "未知" + }, + "nextSteps": { + "close_zero_diff_pr_and_prepare_real_yaml_patch": "關閉零 diff PR,準備真實 YAML patch", + "review_pr_then_merge_or_reject": "review PR 後 merge 或 reject", + "verify_git_baseline_then_mark_adopted": "驗證 Git baseline 後標記採納", + "operator_review_handoff_and_execute_manual_plan": "Operator review 交接並執行人工方案", + "verify_k8s_matches_git_baseline": "驗證 K8s 與 Git baseline 一致", + "confirm_no_repeat_after_rollback": "確認回滾後不再重複", + "monitor_for_recurrence": "監控是否復發", + "retry_pr_lookup_then_review_drift": "重試 PR 查詢後 review drift", + "manual_investigation_or_ansible_check_mode": "人工調查或 Ansible check-mode", + "unknown": "未知" + }, + "pr": { + "title": "PR / Baseline", + "number": "PR:{number}", + "zeroDiff": "zeroDiff={zeroDiff};files={files};commits={commits}", + "status": "狀態:{status}" + }, + "handoff": { + "latest": "最近交接:{status}" + }, + "actions": { + "record": "記錄交接", + "recording": "記錄中", + "openDrift": "開啟 Drift", + "failed": "交接 API 未回應,不能宣稱已轉人工。", + "recorded": "交接入庫:{recorded}", + "handoffStatus": "交接狀態:{status}" + } + }, "recurrence": { "title": "重複告警工作項", "subtitle": "把 run_completed_no_repair、修復失敗與人工閘門接成可追蹤 work item", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index b5366636..4d54b4bb 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -13,8 +13,10 @@ import { ArrowRight, ClipboardList, Database, + Fingerprint, Gauge, GitBranch, + GitPullRequest, ListChecks, Network, RefreshCw, @@ -192,6 +194,63 @@ type RemediationHistoryResponse = { items?: RemediationHistoryItem[]; }; +type DriftFingerprintState = { + schema_version?: string; + namespace?: string; + fingerprint?: string; + latest_report_id?: string; + latest_status?: string; + summary?: string; + high_count?: number; + medium_count?: number; + info_count?: number; + occurrences_12h?: number; + fsm_state?: string; + operator_stage?: string; + next_step?: string; + open_pr?: { + number?: number | string | null; + title?: string | null; + state?: string | null; + merged?: boolean | null; + mergeable?: boolean | null; + html_url?: string | null; + url?: string | null; + file_count?: number | null; + commit_count?: number | null; + is_zero_diff?: boolean | null; + lookup_status?: string | null; + lookup_error?: string | null; + } | null; + latest_handoff?: { + handoff_kind?: string | null; + handoff_status?: string | null; + pr_url?: string | null; + created_at?: string | null; + lookup_error?: string | null; + } | null; + p0_escalation?: { + suppresses_repeated_p0?: boolean | null; + dedup_window_hours?: number | null; + } | null; + writes_incident_state?: boolean | null; + writes_auto_repair_result?: boolean | null; + writes_drift_status?: boolean | null; + writes_ticket?: boolean | null; + creates_external_ticket?: boolean | null; +}; + +type DriftFingerprintHandoffResult = DriftFingerprintState & { + handoff_kind?: string | null; + handoff_status?: string | null; + history?: { + recorded?: boolean | null; + alert_operation_id?: string | null; + timeline_event_id?: string | null; + reason?: string | null; + } | null; +}; + type Telemetry = { quality: AutomationQualitySummary | null; governanceEvents: GovernanceEventsResponse | null; @@ -200,6 +259,7 @@ type Telemetry = { eventRecurrence: RecurrenceResponse | null; slo: SloResponse | null; remediationHistory: RemediationHistoryResponse | null; + driftFingerprintState: DriftFingerprintState | null; }; type WorkItem = { @@ -354,6 +414,50 @@ function recurrenceHandoffKindKey(kind?: string | null) { return "unknown"; } +function driftFsmStateKey(state?: string | null) { + if ( + state === "pending_human" || + state === "pending_human_repeated" || + state === "pr_open_zero_diff" || + state === "pr_open_waiting_review" || + state === "pr_merged_unverified" || + state === "handoff_recorded" || + state === "adopted_unverified" || + state === "rolled_back" || + state === "acknowledged" || + state === "ignored" + ) { + return state; + } + return "unknown"; +} + +function driftNextStepKey(step?: string | null) { + if ( + step === "close_zero_diff_pr_and_prepare_real_yaml_patch" || + step === "review_pr_then_merge_or_reject" || + step === "verify_git_baseline_then_mark_adopted" || + step === "operator_review_handoff_and_execute_manual_plan" || + step === "verify_k8s_matches_git_baseline" || + step === "confirm_no_repeat_after_rollback" || + step === "monitor_for_recurrence" || + step === "retry_pr_lookup_then_review_drift" || + step === "manual_investigation_or_ansible_check_mode" + ) { + return step; + } + return "unknown"; +} + +function driftStatusForWorkItem(state: DriftFingerprintState | null): WorkStatus { + const fsm = state?.fsm_state; + if (!state) return "blocked"; + if (fsm === "adopted_unverified" || fsm === "pr_merged_unverified") return "in_progress"; + if (fsm === "rolled_back" || fsm === "acknowledged" || fsm === "ignored") return "watching"; + if (fsm === "pr_open_waiting_review" || fsm === "handoff_recorded") return "in_progress"; + return "blocked"; +} + function buildWorkItems( telemetry: Telemetry, t: ReturnType @@ -375,6 +479,9 @@ function buildWorkItems( const recurrenceFailedRepair = recurrenceSummary?.failed_repair_group_total ?? 0; const recurrenceManualGate = recurrenceSummary?.manual_gate_group_total ?? 0; const latestRecurrenceOpenItem = recurrenceOpenItems(telemetry.eventRecurrence)[0] ?? null; + const driftState = telemetry.driftFingerprintState; + const driftFsmKey = driftFsmStateKey(driftState?.fsm_state); + const driftNextKey = driftNextStepKey(driftState?.next_step); const governanceEventsUnavailable = telemetry.governanceEvents === null; const governanceQueueMissing = telemetry.governanceQueue?.table_pending === true; const governanceDispatchBlocked = @@ -442,6 +549,36 @@ function buildWorkItems( ? `/awooop/work-items?project_id=${encodeURIComponent(telemetry.eventRecurrence?.project_id ?? "awoooi")}&work_item_id=${encodeURIComponent(latestRecurrenceOpenItem.work_item.work_item_id)}${latestRecurrenceOpenItem.work_item.incident_id ? `&incident_id=${encodeURIComponent(latestRecurrenceOpenItem.work_item.incident_id)}` : ""}` : "/awooop/runs", }, + { + id: "configDriftFsm", + phase: "T64", + status: driftStatusForWorkItem(driftState), + surfaceKey: "workItems", + source: "/api/v1/drift/fingerprints/state", + gateKey: "configDriftFsm", + evidence: driftState + ? t("evidence.driftFingerprint", { + state: t(`driftFingerprint.fsmStates.${driftFsmKey}` as never), + count: driftState.occurrences_12h ?? 0, + }) + : t("evidence.driftFingerprintUnavailable"), + evidenceDetails: driftState + ? [ + t("evidence.driftFingerprintId", { + fingerprint: driftState.fingerprint ?? "--", + report: driftState.latest_report_id ?? "--", + }), + t("evidence.driftFingerprintPr", { + pr: driftState.open_pr?.number ?? "--", + zeroDiff: String(driftState.open_pr?.is_zero_diff ?? false), + }), + t("evidence.driftFingerprintNext", { + step: t(`driftFingerprint.nextSteps.${driftNextKey}` as never), + }), + ] + : [t("evidence.driftFingerprintEmpty")], + href: "/drift", + }, { id: "remediationQueue", phase: "T24", @@ -948,6 +1085,187 @@ function RecurrenceWorkQueuePanel({ ); } +function DriftFingerprintPanel({ + state, + onRecorded, +}: { + state: DriftFingerprintState | null; + onRecorded: () => void; +}) { + const t = useTranslations("awooop.workItems.driftFingerprint"); + const [action, setAction] = useState<{ + loading: boolean; + result: DriftFingerprintHandoffResult | null; + error: string | null; + }>({ + loading: false, + result: null, + error: null, + }); + const fsmKey = driftFsmStateKey(state?.fsm_state); + const nextKey = driftNextStepKey(state?.next_step); + const handoffKind = state?.open_pr?.is_zero_diff + ? "zero_diff_pr_cleanup" + : "open_pr_review"; + const prUrl = state?.open_pr?.html_url ?? state?.open_pr?.url ?? null; + + const recordHandoff = useCallback(async () => { + if (!state?.latest_report_id) return; + setAction({ loading: true, result: null, error: null }); + const result = await postJson( + `${API_BASE}/api/v1/drift/fingerprints/handoff`, + { + report_id: state.latest_report_id, + namespace: state.namespace ?? "awoooi-prod", + handoff_kind: handoffKind, + pr_url: prUrl, + }, + 12000 + ); + setAction({ + loading: false, + result, + error: result ? null : t("actions.failed"), + }); + if (result?.history?.recorded) onRecorded(); + }, [handoffKind, onRecorded, prUrl, state?.latest_report_id, state?.namespace, t]); + + return ( +
+
+
+
+
+ + {t("occurrences", { count: state?.occurrences_12h ?? 0 })} + + + {t("risk", { + high: state?.high_count ?? 0, + medium: state?.medium_count ?? 0, + info: state?.info_count ?? 0, + })} + +
+
+ + {state === null ? ( +
+ {t("unavailable")} +
+ ) : ( +
+
+
+
+

+ {state.fingerprint ?? "--"} +

+

+ {t("report", { + report: state.latest_report_id ?? "--", + namespace: state.namespace ?? "--", + })} +

+
+ + {t(`fsmStates.${fsmKey}` as never)} + +
+
+

{t("summary", { summary: state.summary ?? "--" })}

+

+ {t("next", { + step: t(`nextSteps.${nextKey}` as never), + })} +

+

+ {t("p0Dedup", { + hours: state.p0_escalation?.dedup_window_hours ?? 24, + enabled: String(state.p0_escalation?.suppresses_repeated_p0 ?? false), + })} +

+

+ {t("writes", { + drift: String(state.writes_drift_status ?? false), + incident: String(state.writes_incident_state ?? false), + repair: String(state.writes_auto_repair_result ?? false), + ticket: String(state.writes_ticket ?? false), + })} +

+
+
+ +
+
+
+
+

{t("pr.number", { number: state.open_pr?.number ?? "--" })}

+

+ {t("pr.zeroDiff", { + zeroDiff: String(state.open_pr?.is_zero_diff ?? false), + files: state.open_pr?.file_count ?? "--", + commits: state.open_pr?.commit_count ?? "--", + })} +

+

{t("pr.status", { status: state.open_pr?.state ?? "--" })}

+

+ {t("handoff.latest", { + status: state.latest_handoff?.handoff_status ?? "--", + })} +

+
+
+ + +
+ {action.error ? ( +
+ {action.error} +
+ ) : null} + {action.result ? ( +
+

+ {t("actions.recorded", { + recorded: String(action.result.history?.recorded ?? false), + })} +

+

+ {t("actions.handoffStatus", { + status: action.result.handoff_status ?? "--", + })} +

+
+ ) : null} +
+
+ )} +
+ ); +} + export default function AwoooPWorkItemsPage() { const t = useTranslations("awooop.workItems"); const locale = useLocale(); @@ -963,6 +1281,7 @@ export default function AwoooPWorkItemsPage() { eventRecurrence: null, slo: null, remediationHistory: null, + driftFingerprintState: null, }); const [loading, setLoading] = useState(true); const [lastUpdated, setLastUpdated] = useState(null); @@ -977,6 +1296,7 @@ export default function AwoooPWorkItemsPage() { const recurrenceUrl = `${API_BASE}/api/v1/platform/events/dossier/recurrence?project_id=${encodedProjectId}&limit=100`; const sloUrl = `${API_BASE}/api/v1/ai/slo`; const remediationHistoryUrl = `${API_BASE}/api/v1/ai/slo/remediation/history?limit=80`; + const driftFingerprintUrl = `${API_BASE}/api/v1/drift/fingerprints/state?namespace=awoooi-prod`; const [ quality, @@ -986,6 +1306,7 @@ export default function AwoooPWorkItemsPage() { eventRecurrence, slo, remediationHistory, + driftFingerprintState, ] = await Promise.all([ fetchJson(qualityUrl, 15000), fetchJson(governanceEventsUrl), @@ -994,6 +1315,7 @@ export default function AwoooPWorkItemsPage() { fetchJson(recurrenceUrl), fetchJson(sloUrl), fetchJson(remediationHistoryUrl), + fetchJson(driftFingerprintUrl, 12000), ]); setTelemetry({ @@ -1004,6 +1326,7 @@ export default function AwoooPWorkItemsPage() { eventRecurrence, slo, remediationHistory, + driftFingerprintState, }); setLastUpdated(new Date()); setLoading(false); @@ -1116,6 +1439,11 @@ export default function AwoooPWorkItemsPage() { projectId={projectId} /> + +
diff --git a/apps/web/src/components/panels/DriftPanel.tsx b/apps/web/src/components/panels/DriftPanel.tsx index fb5d8c80..7de7320a 100644 --- a/apps/web/src/components/panels/DriftPanel.tsx +++ b/apps/web/src/components/panels/DriftPanel.tsx @@ -14,7 +14,7 @@ import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' import { Diff, RefreshCw, AlertTriangle, CheckCircle2, - Clock, Terminal, GitMerge, Info, + Clock, Terminal, GitMerge, Info, Fingerprint, GitPullRequest, } from 'lucide-react' // ============================================================================= @@ -30,7 +30,7 @@ interface DriftReport { medium_count: number info_count: number interpretation: string | null - status: 'pending' | 'resolved' | 'ignored' + status: 'pending' | 'acknowledged' | 'rolled_back' | 'adopted' | 'ignored' created_at: string resolved_at: string | null } @@ -45,6 +45,39 @@ interface ScanResult { interpretation: string | null } +interface DriftFingerprintState { + namespace?: string + fingerprint?: string + latest_report_id?: string + latest_status?: string + summary?: string + occurrences_12h?: number + fsm_state?: string + next_step?: string + high_count?: number + medium_count?: number + info_count?: number + open_pr?: { + number?: number | string | null + state?: string | null + file_count?: number | null + commit_count?: number | null + is_zero_diff?: boolean | null + lookup_error?: string | null + } | null + latest_handoff?: { + handoff_status?: string | null + } | null + p0_escalation?: { + suppresses_repeated_p0?: boolean | null + dedup_window_hours?: number | null + } | null + writes_drift_status?: boolean | null + writes_incident_state?: boolean | null + writes_auto_repair_result?: boolean | null + writes_ticket?: boolean | null +} + // ============================================================================= // Helpers // ============================================================================= @@ -107,7 +140,9 @@ function DriftLevelBadge({ high, medium, info, t }: { function StatusBadge({ status, t }: { status: DriftReport['status']; t: (k: string) => string }) { const styles: Record = { pending: 'bg-status-warning/10 text-status-warning border-status-warning/20', - resolved: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20', + acknowledged: 'bg-neutral-100 text-neutral-500 border-neutral-200', + rolled_back: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20', + adopted: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20', ignored: 'bg-neutral-100 text-neutral-400 border-neutral-200', } return ( @@ -120,6 +155,63 @@ function StatusBadge({ status, t }: { status: DriftReport['status']; t: (k: stri ) } +function DriftFingerprintStateCard({ + state, + t, +}: { + state: DriftFingerprintState | null + t: (k: string, values?: Record) => string +}) { + if (!state) return null + return ( +
+
+
+ +
+

+ {t('fingerprintState.title')} +

+

+ {state.fingerprint ?? '--'} +

+
+
+ + {t('fingerprintState.occurrences', { count: state.occurrences_12h ?? 0 })} + +
+
+

{t('fingerprintState.report', { report: state.latest_report_id ?? '--' })}

+

{t('fingerprintState.state', { state: state.fsm_state ?? '--' })}

+

{t('fingerprintState.next', { step: state.next_step ?? '--' })}

+

+ {t('fingerprintState.writes', { + drift: String(state.writes_drift_status ?? false), + incident: String(state.writes_incident_state ?? false), + repair: String(state.writes_auto_repair_result ?? false), + ticket: String(state.writes_ticket ?? false), + })} +

+
+
+ + + {t('fingerprintState.pr', { + pr: state.open_pr?.number ?? '--', + zeroDiff: String(state.open_pr?.is_zero_diff ?? false), + })} + + + {t('fingerprintState.p0Dedup', { + hours: state.p0_escalation?.dedup_window_hours ?? 24, + })} + +
+
+ ) +} + // ============================================================================= // DriftPanel // ============================================================================= @@ -130,6 +222,7 @@ export function DriftPanel() { const [loading, setLoading] = useState(true) const [scanning, setScanning] = useState(false) const [scanResult, setScanResult] = useState(null) + const [fingerprintState, setFingerprintState] = useState(null) const [error, setError] = useState(null) const fetchReports = useCallback(async () => { @@ -139,7 +232,17 @@ export function DriftPanel() { const res = await fetch(`${getApiBase()}/api/v1/drift/reports?limit=20`) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() - setReports(data.items ?? []) + const nextReports = data.items ?? [] + setReports(nextReports) + const latestReportId = nextReports[0]?.report_id + if (latestReportId) { + const stateRes = await fetch( + `${getApiBase()}/api/v1/drift/fingerprints/state?report_id=${encodeURIComponent(latestReportId)}` + ) + setFingerprintState(stateRes.ok ? await stateRes.json() : null) + } else { + setFingerprintState(null) + } } catch (e) { setError(e instanceof Error ? e.message : 'Failed to fetch') } finally { @@ -252,6 +355,8 @@ export function DriftPanel() { )} + + {/* Content */}
{loading && reports.length === 0 ? (