feat(drift): surface fingerprint state handoff
This commit is contained in:
@@ -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 (驗證用,不需要使用值)
|
||||
"""
|
||||
|
||||
578
apps/api/src/services/drift_fingerprint_state_service.py
Normal file
578
apps/api/src/services/drift_fingerprint_state_service.py
Normal file
@@ -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
|
||||
83
apps/api/tests/test_drift_fingerprint_state_service.py
Normal file
83
apps/api/tests/test_drift_fingerprint_state_service.py
Normal file
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof useTranslations>
|
||||
@@ -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<DriftFingerprintHandoffResult>(
|
||||
`${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 (
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fingerprint className="h-4 w-4 text-[#8a5a08]" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
|
||||
<p className="text-xs text-[#77736a]">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 font-mono text-xs text-[#5f5b52]">
|
||||
<span className="border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5">
|
||||
{t("occurrences", { count: state?.occurrences_12h ?? 0 })}
|
||||
</span>
|
||||
<span className="border border-[#d8d3c7] bg-white px-2 py-0.5">
|
||||
{t("risk", {
|
||||
high: state?.high_count ?? 0,
|
||||
medium: state?.medium_count ?? 0,
|
||||
info: state?.info_count ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state === null ? (
|
||||
<div className="px-4 py-4 text-sm text-[#8a5a08]">
|
||||
{t("unavailable")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-px bg-[#eee9dd] lg:grid-cols-[1.3fr_1fr]">
|
||||
<div className="bg-white px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-xs font-semibold text-[#141413]">
|
||||
{state.fingerprint ?? "--"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-[#77736a]">
|
||||
{t("report", {
|
||||
report: state.latest_report_id ?? "--",
|
||||
namespace: state.namespace ?? "--",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<span className="border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 text-xs font-semibold text-[#8a5a08]">
|
||||
{t(`fsmStates.${fsmKey}` as never)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-1 text-xs leading-5 text-[#5f5b52]">
|
||||
<p>{t("summary", { summary: state.summary ?? "--" })}</p>
|
||||
<p>
|
||||
{t("next", {
|
||||
step: t(`nextSteps.${nextKey}` as never),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("p0Dedup", {
|
||||
hours: state.p0_escalation?.dedup_window_hours ?? 24,
|
||||
enabled: String(state.p0_escalation?.suppresses_repeated_p0 ?? false),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{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),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitPullRequest className="h-4 w-4 text-[#87867f]" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-[#141413]">{t("pr.title")}</h4>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-1 text-xs leading-5 text-[#5f5b52]">
|
||||
<p>{t("pr.number", { number: state.open_pr?.number ?? "--" })}</p>
|
||||
<p>
|
||||
{t("pr.zeroDiff", {
|
||||
zeroDiff: String(state.open_pr?.is_zero_diff ?? false),
|
||||
files: state.open_pr?.file_count ?? "--",
|
||||
commits: state.open_pr?.commit_count ?? "--",
|
||||
})}
|
||||
</p>
|
||||
<p>{t("pr.status", { status: state.open_pr?.state ?? "--" })}</p>
|
||||
<p>
|
||||
{t("handoff.latest", {
|
||||
status: state.latest_handoff?.handoff_status ?? "--",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={recordHandoff}
|
||||
disabled={!state.latest_report_id || action.loading}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{action.loading ? t("actions.recording") : t("actions.record")}
|
||||
</button>
|
||||
<Link
|
||||
href={"/drift" as never}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("actions.openDrift")}
|
||||
</Link>
|
||||
</div>
|
||||
{action.error ? (
|
||||
<div className="mt-3 border border-[#e2a29b] bg-[#fff0ef] px-3 py-2 text-xs leading-5 text-[#9f2f25]">
|
||||
{action.error}
|
||||
</div>
|
||||
) : null}
|
||||
{action.result ? (
|
||||
<div className="mt-3 border border-[#9bc7a4] bg-[#f0faf2] px-3 py-2 text-xs leading-5 text-[#17602a]">
|
||||
<p className="font-semibold">
|
||||
{t("actions.recorded", {
|
||||
recorded: String(action.result.history?.recorded ?? false),
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-[#5f5b52]">
|
||||
{t("actions.handoffStatus", {
|
||||
status: action.result.handoff_status ?? "--",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<Date | null>(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<AutomationQualitySummary>(qualityUrl, 15000),
|
||||
fetchJson<GovernanceEventsResponse>(governanceEventsUrl),
|
||||
@@ -994,6 +1315,7 @@ export default function AwoooPWorkItemsPage() {
|
||||
fetchJson<RecurrenceResponse>(recurrenceUrl),
|
||||
fetchJson<SloResponse>(sloUrl),
|
||||
fetchJson<RemediationHistoryResponse>(remediationHistoryUrl),
|
||||
fetchJson<DriftFingerprintState>(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}
|
||||
/>
|
||||
|
||||
<DriftFingerprintPanel
|
||||
state={telemetry.driftFingerprintState}
|
||||
onRecorded={fetchTelemetry}
|
||||
/>
|
||||
|
||||
<div className="overflow-hidden border border-[#e0ddd4] bg-white">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" role="table" aria-label={t("tableLabel")}>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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, string | number>) => string
|
||||
}) {
|
||||
if (!state) return null
|
||||
return (
|
||||
<div className="mx-6 mt-4 border border-neutral-200 bg-neutral-50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<Fingerprint size={15} className="mt-0.5 shrink-0 text-neutral-500" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-semibold text-neutral-800">
|
||||
{t('fingerprintState.title')}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-[11px] text-neutral-500">
|
||||
{state.fingerprint ?? '--'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="border border-status-warning/20 bg-status-warning/10 px-2 py-0.5 text-[11px] font-medium text-status-warning">
|
||||
{t('fingerprintState.occurrences', { count: state.occurrences_12h ?? 0 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-[11px] text-neutral-500 md:grid-cols-2">
|
||||
<p>{t('fingerprintState.report', { report: state.latest_report_id ?? '--' })}</p>
|
||||
<p>{t('fingerprintState.state', { state: state.fsm_state ?? '--' })}</p>
|
||||
<p>{t('fingerprintState.next', { step: state.next_step ?? '--' })}</p>
|
||||
<p>
|
||||
{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),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-neutral-500">
|
||||
<span className="inline-flex items-center gap-1 border border-neutral-200 bg-white px-2 py-0.5">
|
||||
<GitPullRequest size={11} />
|
||||
{t('fingerprintState.pr', {
|
||||
pr: state.open_pr?.number ?? '--',
|
||||
zeroDiff: String(state.open_pr?.is_zero_diff ?? false),
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-neutral-200 bg-white px-2 py-0.5">
|
||||
{t('fingerprintState.p0Dedup', {
|
||||
hours: state.p0_escalation?.dedup_window_hours ?? 24,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DriftPanel
|
||||
// =============================================================================
|
||||
@@ -130,6 +222,7 @@ export function DriftPanel() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null)
|
||||
const [fingerprintState, setFingerprintState] = useState<DriftFingerprintState | null>(null)
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DriftFingerprintStateCard state={fingerprintState} t={t} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 px-6 py-4">
|
||||
{loading && reports.length === 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user