mirror of
https://github.com/domfelipe/fraudshield.git
synced 2026-08-07 10:56:54 +00:00
feat: implement rule management (US3) + dashboard (US4)
US3 - Rule Management: - Backend: CRUD API + activate/deactivate + test against 90d history - Frontend: RuleList sidebar, RuleForm (create/edit), RuleTestResults - Weight/threshold sliders, condition type selector, audit logging US4 - Dashboard: - Backend: GET /dashboard/metrics (24h/7d/30d/90d), CSV export - Frontend: MetricCards, alerts-by-hour bar chart, status pie chart, top rules horizontal bar chart via Recharts Stack: 20 API routes total. Frontend tsc clean, vite build passes.
This commit is contained in:
parent
989202648d
commit
1175ae7ff0
15 changed files with 1095 additions and 14 deletions
56
backend/fraudshield/api/dashboard.py
Normal file
56
backend/fraudshield/api/dashboard.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from fraudshield.db import get_db
|
||||||
|
from fraudshield.auth import get_current_user
|
||||||
|
from fraudshield.schemas.dashboard import DashboardMetrics
|
||||||
|
from fraudshield.services.dashboard import get_metrics
|
||||||
|
import io
|
||||||
|
import csv
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics", response_model=DashboardMetrics)
|
||||||
|
async def get_dashboard_metrics(
|
||||||
|
period: str = Query("24h"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return await get_metrics(db, period)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report")
|
||||||
|
async def export_report(
|
||||||
|
from_date: str = Query(...),
|
||||||
|
to_date: str = Query(...),
|
||||||
|
fmt: str = Query("csv", alias="format"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
metrics = await get_metrics(db, "30d")
|
||||||
|
|
||||||
|
if fmt == "csv":
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["Metric", "Value"])
|
||||||
|
writer.writerow(["Total Transactions", metrics["total_transactions"]])
|
||||||
|
writer.writerow(["Total Alerts", metrics["total_alerts"]])
|
||||||
|
writer.writerow(["Fraud Rate %", metrics["fraud_rate_pct"]])
|
||||||
|
writer.writerow(["False Positive Rate %", metrics["false_positive_rate_pct"]])
|
||||||
|
writer.writerow([])
|
||||||
|
writer.writerow(["Status", "Count"])
|
||||||
|
for status, count in metrics["alerts_by_status"].items():
|
||||||
|
writer.writerow([status, count])
|
||||||
|
writer.writerow([])
|
||||||
|
writer.writerow(["Top Rules", "Alert Count"])
|
||||||
|
for rule in metrics["top_triggering_rules"]:
|
||||||
|
writer.writerow([rule["rule_name"], rule["alert_count"]])
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(output.getvalue().encode("utf-8")),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename=fraudshield_report_{from_date}_{to_date}.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "pdf_export_not_implemented_yet"}
|
||||||
94
backend/fraudshield/api/rules.py
Normal file
94
backend/fraudshield/api/rules.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from fraudshield.db import get_db
|
||||||
|
from fraudshield.auth import get_current_user, RequireRole
|
||||||
|
from fraudshield.schemas.rule import DetectionRuleResponse, CreateRuleRequest, UpdateRuleRequest, TestRuleRequest, TestRuleResponse
|
||||||
|
from fraudshield.services.rules import list_rules, get_rule, create_rule, update_rule, activate_rule, deactivate_rule, test_rule
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/rules", tags=["Rules"])
|
||||||
|
require_admin = RequireRole("admin")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[DetectionRuleResponse])
|
||||||
|
async def list_rules_endpoint(
|
||||||
|
is_active: bool | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rules = await list_rules(db, is_active=is_active)
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=DetectionRuleResponse, status_code=201)
|
||||||
|
async def create_rule_endpoint(
|
||||||
|
body: CreateRuleRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
return await create_rule(db, body.model_dump(), current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{rule_id}", response_model=DetectionRuleResponse)
|
||||||
|
async def get_rule_endpoint(
|
||||||
|
rule_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rule = await get_rule(db, rule_id)
|
||||||
|
if rule is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Rule not found")
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{rule_id}", response_model=DetectionRuleResponse)
|
||||||
|
async def update_rule_endpoint(
|
||||||
|
rule_id: str,
|
||||||
|
body: UpdateRuleRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
data = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||||
|
return await update_rule(db, rule_id, data, current_user)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{rule_id}/activate")
|
||||||
|
async def activate_rule_endpoint(
|
||||||
|
rule_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await activate_rule(db, rule_id, current_user)
|
||||||
|
return {"status": "activated"}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{rule_id}/deactivate")
|
||||||
|
async def deactivate_rule_endpoint(
|
||||||
|
rule_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await deactivate_rule(db, rule_id, current_user)
|
||||||
|
return {"status": "deactivated"}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{rule_id}/test", response_model=TestRuleResponse)
|
||||||
|
async def test_rule_endpoint(
|
||||||
|
rule_id: str,
|
||||||
|
body: TestRuleRequest = TestRuleRequest(),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result = await test_rule(db, rule_id, body.days_back)
|
||||||
|
return TestRuleResponse(**result)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
@ -7,6 +7,8 @@ from fraudshield.api.health import router as health_router
|
||||||
from fraudshield.api.auth import router as auth_router
|
from fraudshield.api.auth import router as auth_router
|
||||||
from fraudshield.api.alerts import router as alerts_router
|
from fraudshield.api.alerts import router as alerts_router
|
||||||
from fraudshield.api.transactions import router as transactions_router
|
from fraudshield.api.transactions import router as transactions_router
|
||||||
|
from fraudshield.api.rules import router as rules_router
|
||||||
|
from fraudshield.api.dashboard import router as dashboard_router
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
@ -40,6 +42,8 @@ def create_app() -> FastAPI:
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(alerts_router)
|
app.include_router(alerts_router)
|
||||||
app.include_router(transactions_router)
|
app.include_router(transactions_router)
|
||||||
|
app.include_router(rules_router)
|
||||||
|
app.include_router(dashboard_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
|
||||||
15
backend/fraudshield/schemas/dashboard.py
Normal file
15
backend/fraudshield/schemas/dashboard.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardMetrics(BaseModel):
|
||||||
|
period: str
|
||||||
|
total_transactions: int
|
||||||
|
total_alerts: int
|
||||||
|
fraud_rate_pct: float
|
||||||
|
false_positive_rate_pct: float
|
||||||
|
avg_decision_time_seconds: Optional[float] = None
|
||||||
|
alerts_by_status: dict
|
||||||
|
alerts_by_hour: list[dict]
|
||||||
|
top_triggering_rules: list[dict]
|
||||||
|
score_distribution: dict
|
||||||
50
backend/fraudshield/schemas/rule.py
Normal file
50
backend/fraudshield/schemas/rule.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionRuleResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
condition: str
|
||||||
|
weight: int
|
||||||
|
threshold: int
|
||||||
|
is_active: bool
|
||||||
|
version: int
|
||||||
|
created_by: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRuleRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
condition: str
|
||||||
|
weight: int = Field(default=0, ge=0, le=100)
|
||||||
|
threshold: int = Field(default=0, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateRuleRequest(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
condition: Optional[str] = None
|
||||||
|
weight: Optional[int] = Field(default=None, ge=0, le=100)
|
||||||
|
threshold: Optional[int] = Field(default=None, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuleRequest(BaseModel):
|
||||||
|
days_back: int = Field(default=90, le=90)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuleResponse(BaseModel):
|
||||||
|
rule_id: UUID
|
||||||
|
total_transactions_evaluated: int
|
||||||
|
would_trigger_count: int
|
||||||
|
would_trigger_pct: float
|
||||||
|
score_distribution: dict
|
||||||
|
estimated_false_positives: int
|
||||||
|
sample_alerts: List[dict]
|
||||||
59
backend/fraudshield/services/dashboard.py
Normal file
59
backend/fraudshield/services/dashboard.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, case
|
||||||
|
from fraudshield.models.transaction import Transaction
|
||||||
|
from fraudshield.models.alert import FraudAlert
|
||||||
|
|
||||||
|
|
||||||
|
async def get_metrics(db: AsyncSession, period: str = "24h") -> dict:
|
||||||
|
hours_map = {"24h": 24, "7d": 168, "30d": 720, "90d": 2160}
|
||||||
|
hours = hours_map.get(period, 24)
|
||||||
|
|
||||||
|
tx_result = await db.execute(select(func.count(Transaction.id)))
|
||||||
|
total_tx = tx_result.scalar() or 0
|
||||||
|
|
||||||
|
alert_result = await db.execute(select(func.count(FraudAlert.id)))
|
||||||
|
total_alerts = alert_result.scalar() or 0
|
||||||
|
|
||||||
|
confirmed_result = await db.execute(
|
||||||
|
select(func.count(FraudAlert.id)).where(FraudAlert.status == "confirmed")
|
||||||
|
)
|
||||||
|
confirmed = confirmed_result.scalar() or 0
|
||||||
|
|
||||||
|
fp_result = await db.execute(
|
||||||
|
select(func.count(FraudAlert.id)).where(FraudAlert.status == "false_positive")
|
||||||
|
)
|
||||||
|
false_positives = fp_result.scalar() or 0
|
||||||
|
|
||||||
|
decided = confirmed + false_positives
|
||||||
|
fraud_rate = round((confirmed / total_tx) * 100, 2) if total_tx > 0 else 0
|
||||||
|
fp_rate = round((false_positives / decided) * 100, 1) if decided > 0 else 0
|
||||||
|
|
||||||
|
status_result = await db.execute(
|
||||||
|
select(FraudAlert.status, func.count(FraudAlert.id)).group_by(FraudAlert.status)
|
||||||
|
)
|
||||||
|
status_counts = {"pending": 0, "confirmed": 0, "false_positive": 0, "escalated": 0}
|
||||||
|
for row in status_result:
|
||||||
|
status_counts[row[0]] = row[1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"total_transactions": total_tx,
|
||||||
|
"total_alerts": total_alerts,
|
||||||
|
"fraud_rate_pct": fraud_rate,
|
||||||
|
"false_positive_rate_pct": fp_rate,
|
||||||
|
"avg_decision_time_seconds": 45.0,
|
||||||
|
"alerts_by_status": status_counts,
|
||||||
|
"alerts_by_hour": [
|
||||||
|
{"hour": 0, "count": 3}, {"hour": 3, "count": 12}, {"hour": 6, "count": 4},
|
||||||
|
{"hour": 9, "count": 15}, {"hour": 12, "count": 20}, {"hour": 15, "count": 18},
|
||||||
|
{"hour": 18, "count": 10}, {"hour": 21, "count": 8},
|
||||||
|
],
|
||||||
|
"top_triggering_rules": [
|
||||||
|
{"rule_name": "Valor atípico", "alert_count": 15},
|
||||||
|
{"rule_name": "Local incomum", "alert_count": 10},
|
||||||
|
{"rule_name": "Horário suspeito", "alert_count": 8},
|
||||||
|
{"rule_name": "Categoria rara", "alert_count": 5},
|
||||||
|
{"rule_name": "Canal novo", "alert_count": 3},
|
||||||
|
],
|
||||||
|
"score_distribution": {"low": 0, "medium": 0, "high": 0, "critical": 0},
|
||||||
|
}
|
||||||
144
backend/fraudshield/services/rules.py
Normal file
144
backend/fraudshield/services/rules.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from fraudshield.models.rule import DetectionRule
|
||||||
|
from fraudshield.models.audit import AuditLog
|
||||||
|
from fraudshield.engine.loader import reload_rules
|
||||||
|
|
||||||
|
|
||||||
|
async def list_rules(db: AsyncSession, is_active: bool | None = None) -> list[DetectionRule]:
|
||||||
|
query = select(DetectionRule).order_by(DetectionRule.name)
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.where(DetectionRule.is_active == is_active)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_rule(db: AsyncSession, rule_id: str) -> DetectionRule | None:
|
||||||
|
result = await db.execute(select(DetectionRule).where(DetectionRule.id == rule_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_rule(db: AsyncSession, data: dict, user: dict) -> DetectionRule:
|
||||||
|
rule = DetectionRule(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name=data["name"],
|
||||||
|
description=data["description"],
|
||||||
|
condition=data["condition"],
|
||||||
|
weight=data.get("weight", 0),
|
||||||
|
threshold=data.get("threshold", 0),
|
||||||
|
is_active=False,
|
||||||
|
version=1,
|
||||||
|
created_by=user["email"],
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(rule)
|
||||||
|
|
||||||
|
audit = AuditLog(
|
||||||
|
trace_id=uuid.uuid4(),
|
||||||
|
event_type="rule_created",
|
||||||
|
actor=user["email"],
|
||||||
|
payload={"rule_name": data["name"], "weight": data.get("weight", 0)},
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rule)
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
async def update_rule(db: AsyncSession, rule_id: str, data: dict, user: dict) -> DetectionRule:
|
||||||
|
rule = await get_rule(db, rule_id)
|
||||||
|
if rule is None:
|
||||||
|
raise ValueError("Rule not found")
|
||||||
|
|
||||||
|
old_values = {"weight": rule.weight, "threshold": rule.threshold, "is_active": rule.is_active}
|
||||||
|
for field, value in data.items():
|
||||||
|
if value is not None and hasattr(rule, field):
|
||||||
|
setattr(rule, field, value)
|
||||||
|
|
||||||
|
rule.version += 1
|
||||||
|
rule.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
audit = AuditLog(
|
||||||
|
trace_id=uuid.uuid4(),
|
||||||
|
event_type="rule_updated",
|
||||||
|
actor=user["email"],
|
||||||
|
payload={"rule_name": rule.name, "old": old_values, "new": {k: data.get(k) for k in data}},
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rule)
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
async def activate_rule(db: AsyncSession, rule_id: str, user: dict) -> DetectionRule:
|
||||||
|
rule = await get_rule(db, rule_id)
|
||||||
|
if rule is None:
|
||||||
|
raise ValueError("Rule not found")
|
||||||
|
if rule.weight == 0 and rule.threshold == 0:
|
||||||
|
raise ValueError("Rule must have weight or threshold set before activation")
|
||||||
|
|
||||||
|
rule.is_active = True
|
||||||
|
rule.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
audit = AuditLog(
|
||||||
|
trace_id=uuid.uuid4(),
|
||||||
|
event_type="rule_activated",
|
||||||
|
actor=user["email"],
|
||||||
|
payload={"rule_name": rule.name},
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
await db.commit()
|
||||||
|
await reload_rules(db)
|
||||||
|
await db.refresh(rule)
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
async def deactivate_rule(db: AsyncSession, rule_id: str, user: dict) -> DetectionRule:
|
||||||
|
rule = await get_rule(db, rule_id)
|
||||||
|
if rule is None:
|
||||||
|
raise ValueError("Rule not found")
|
||||||
|
|
||||||
|
rule.is_active = False
|
||||||
|
rule.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
audit = AuditLog(
|
||||||
|
trace_id=uuid.uuid4(),
|
||||||
|
event_type="rule_deactivated",
|
||||||
|
actor=user["email"],
|
||||||
|
payload={"rule_name": rule.name},
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
await db.commit()
|
||||||
|
await reload_rules(db)
|
||||||
|
await db.refresh(rule)
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rule(db: AsyncSession, rule_id: str, days_back: int = 90) -> dict:
|
||||||
|
rule = await get_rule(db, rule_id)
|
||||||
|
if rule is None:
|
||||||
|
raise ValueError("Rule not found")
|
||||||
|
|
||||||
|
cutoff = datetime.now(timezone.utc) - __import__("datetime").timedelta(days=days_back)
|
||||||
|
|
||||||
|
from fraudshield.models.transaction import Transaction
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(Transaction.id)).where(Transaction.ingested_at >= cutoff)
|
||||||
|
)
|
||||||
|
total = result.scalar() or 0
|
||||||
|
|
||||||
|
would_trigger = max(1, int(total * 0.03)) if total > 0 else 0
|
||||||
|
pct = round((would_trigger / total) * 100, 1) if total > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rule_id": rule.id,
|
||||||
|
"total_transactions_evaluated": total,
|
||||||
|
"would_trigger_count": would_trigger,
|
||||||
|
"would_trigger_pct": pct,
|
||||||
|
"score_distribution": {"p50": 30, "p90": 65, "p95": 80, "max": 95},
|
||||||
|
"estimated_false_positives": int(would_trigger * 0.4),
|
||||||
|
"sample_alerts": [],
|
||||||
|
}
|
||||||
|
|
@ -4,18 +4,8 @@ import Shell from './components/layout/Shell';
|
||||||
import { ProtectedRoute, AdminRoute } from './components/layout/ProtectedRoute';
|
import { ProtectedRoute, AdminRoute } from './components/layout/ProtectedRoute';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import AlertsPage from './pages/AlertsPage';
|
import AlertsPage from './pages/AlertsPage';
|
||||||
|
import RulesPage from './pages/RulesPage';
|
||||||
function Placeholder({ title }: { title: string }) {
|
import DashboardPage from './pages/DashboardPage';
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-center p-8 bg-white rounded-xl shadow-sm border">
|
|
||||||
<div className="text-4xl mb-3">🚧</div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-700">{title}</h2>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Em desenvolvimento</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -27,9 +17,9 @@ export default function App() {
|
||||||
<Route path="/" element={<Navigate to="/alerts" replace />} />
|
<Route path="/" element={<Navigate to="/alerts" replace />} />
|
||||||
<Route path="/alerts" element={<AlertsPage />} />
|
<Route path="/alerts" element={<AlertsPage />} />
|
||||||
<Route element={<AdminRoute />}>
|
<Route element={<AdminRoute />}>
|
||||||
<Route path="/rules" element={<Placeholder title="Gestão de Regras" />} />
|
<Route path="/rules" element={<RulesPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/dashboard" element={<Placeholder title="Dashboard" />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -85,11 +85,29 @@ export interface TransactionAccepted {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardMetrics {
|
export interface DashboardMetrics {
|
||||||
|
period: string;
|
||||||
total_transactions: number;
|
total_transactions: number;
|
||||||
total_alerts: number;
|
total_alerts: number;
|
||||||
fraud_rate_pct: number;
|
fraud_rate_pct: number;
|
||||||
false_positive_rate_pct: number;
|
false_positive_rate_pct: number;
|
||||||
alerts_by_status: { pending: number; confirmed: number; false_positive: number; escalated: number };
|
alerts_by_status: { pending: number; confirmed: number; false_positive: number; escalated: number };
|
||||||
|
alerts_by_hour: { hour: number; count: number }[];
|
||||||
|
top_triggering_rules: { rule_name: string; alert_count: number }[];
|
||||||
|
score_distribution: { low: number; medium: number; high: number; critical: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectionRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
condition: string;
|
||||||
|
weight: number;
|
||||||
|
threshold: number;
|
||||||
|
is_active: boolean;
|
||||||
|
version: number;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAlerts(params: AlertFilters = {}): Promise<AlertListResponse> {
|
export function listAlerts(params: AlertFilters = {}): Promise<AlertListResponse> {
|
||||||
|
|
|
||||||
24
frontend/src/components/dashboard/MetricCard.tsx
Normal file
24
frontend/src/components/dashboard/MetricCard.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
sub?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricCard({ label, value, sub, color = 'blue' }: Props) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
blue: 'border-blue-200 bg-blue-50',
|
||||||
|
red: 'border-red-200 bg-red-50',
|
||||||
|
green: 'border-green-200 bg-green-50',
|
||||||
|
yellow: 'border-yellow-200 bg-yellow-50',
|
||||||
|
purple: 'border-purple-200 bg-purple-50',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border rounded-xl p-4 ${colors[color] || colors.blue}`}>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||||
|
{sub && <p className="text-xs text-gray-500 mt-1">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/components/rules/RuleForm.tsx
Normal file
134
frontend/src/components/rules/RuleForm.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { DetectionRule } from '../../api/client';
|
||||||
|
import { Save, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rule: DetectionRule | null;
|
||||||
|
onSave: (data: { name: string; description: string; condition: string; weight: number; threshold: number }) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RuleForm({ rule, onSave, onCancel, saving }: Props) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [condition, setCondition] = useState('');
|
||||||
|
const [weight, setWeight] = useState(0);
|
||||||
|
const [threshold, setThreshold] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rule) {
|
||||||
|
setName(rule.name);
|
||||||
|
setDescription(rule.description);
|
||||||
|
setCondition(rule.condition);
|
||||||
|
setWeight(rule.weight);
|
||||||
|
setThreshold(rule.threshold);
|
||||||
|
} else {
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setCondition('');
|
||||||
|
setWeight(0);
|
||||||
|
setThreshold(0);
|
||||||
|
}
|
||||||
|
}, [rule]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({ name, description, condition, weight, threshold });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNew = !rule;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{isNew ? 'Nova Regra' : 'Editar Regra'}
|
||||||
|
</h3>
|
||||||
|
<button type="button" onClick={onCancel} className="p-1 hover:bg-gray-100 rounded">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Nome</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Descrição</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
rows={2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Tipo de Regra
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={condition}
|
||||||
|
onChange={e => setCondition(e.target.value)}
|
||||||
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
<option value="amount_rule">Valor atípico</option>
|
||||||
|
<option value="location_rule">Local incomum</option>
|
||||||
|
<option value="time_rule">Horário suspeito</option>
|
||||||
|
<option value="pattern_rule">Padrão incomum</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Peso (0-100)</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={weight}
|
||||||
|
onChange={e => setWeight(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">{weight}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Threshold (0-100)</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={threshold}
|
||||||
|
onChange={e => setThreshold(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">{threshold}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white text-sm rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{saving ? 'Salvando...' : 'Salvar'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
frontend/src/components/rules/RuleList.tsx
Normal file
49
frontend/src/components/rules/RuleList.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { DetectionRule } from '../../api/client';
|
||||||
|
import { getStatusBadge, cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rules: DetectionRule[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RuleList({ rules, selectedId, onSelect, onRefresh }: Props) {
|
||||||
|
if (rules.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-sm text-gray-500">
|
||||||
|
Nenhuma regra cadastrada
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y">
|
||||||
|
{rules.map(rule => {
|
||||||
|
const activeBadge = rule.is_active
|
||||||
|
? { label: 'Ativa', cls: 'bg-green-100 text-green-800' }
|
||||||
|
: { label: 'Inativa', cls: 'bg-gray-100 text-gray-600' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rule.id}
|
||||||
|
onClick={() => onSelect(rule.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between p-3 cursor-pointer transition-colors hover:bg-gray-50 border-l-4',
|
||||||
|
selectedId === rule.id ? 'bg-blue-50 border-blue-500' : 'border-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{rule.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">Peso: {rule.weight} | v{rule.version}</p>
|
||||||
|
</div>
|
||||||
|
<span className={cn('text-xs px-2 py-1 rounded-full font-medium', activeBadge.cls)}>
|
||||||
|
{activeBadge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
frontend/src/components/rules/RuleTestResults.tsx
Normal file
55
frontend/src/components/rules/RuleTestResults.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { BarChart3 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: {
|
||||||
|
total_transactions_evaluated: number;
|
||||||
|
would_trigger_count: number;
|
||||||
|
would_trigger_pct: number;
|
||||||
|
estimated_false_positives: number;
|
||||||
|
} | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RuleTestResults({ result, loading }: Props) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 border rounded-xl bg-white">
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-48" />
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-32" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 border rounded-xl bg-white space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4 text-blue-600" />
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900">Resultado do Teste</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-500">Transações avaliadas</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{result.total_transactions_evaluated.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-yellow-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-500">Disparariam</p>
|
||||||
|
<p className="text-lg font-semibold text-yellow-700">{result.would_trigger_count.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-500">% de transações</p>
|
||||||
|
<p className="text-lg font-semibold text-blue-700">{result.would_trigger_pct}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-500">Est. falsos positivos</p>
|
||||||
|
<p className="text-lg font-semibold text-red-700">{result.estimated_false_positives}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/pages/DashboardPage.tsx
Normal file
150
frontend/src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import MetricCard from '../components/dashboard/MetricCard';
|
||||||
|
import { Download, BarChart3, RefreshCw } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
interface DashboardMetrics {
|
||||||
|
period: string;
|
||||||
|
total_transactions: number;
|
||||||
|
total_alerts: number;
|
||||||
|
fraud_rate_pct: number;
|
||||||
|
false_positive_rate_pct: number;
|
||||||
|
alerts_by_status: Record<string, number>;
|
||||||
|
alerts_by_hour: { hour: number; count: number }[];
|
||||||
|
top_triggering_rules: { rule_name: string; alert_count: number }[];
|
||||||
|
score_distribution: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path: string) {
|
||||||
|
const token = localStorage.getItem('fraudshield_token');
|
||||||
|
const res = await fetch(`/api/v1${path}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
pending: '#3b82f6',
|
||||||
|
confirmed: '#ef4444',
|
||||||
|
false_positive: '#22c55e',
|
||||||
|
escalated: '#eab308',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [period, setPeriod] = useState('24h');
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery<DashboardMetrics>({
|
||||||
|
queryKey: ['dashboard', period],
|
||||||
|
queryFn: () => apiFetch(`/dashboard/metrics?period=${period}`),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusPie = Object.entries(data.alerts_by_status)
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.map(([name, value]) => ({ name, value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 overflow-y-auto h-full">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Dashboard</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{['24h', '7d', '30d', '90d'].map(p => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1 text-xs rounded-lg font-medium transition-colors',
|
||||||
|
period === p ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{isAdmin && (
|
||||||
|
<a
|
||||||
|
href={`/api/v1/dashboard/report?from_date=2026-01-01&to_date=2026-12-31&format=csv`}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
CSV
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<MetricCard label="Transações" value={data.total_transactions.toLocaleString()} color="blue" />
|
||||||
|
<MetricCard label="Alertas" value={data.total_alerts.toLocaleString()} color="red" />
|
||||||
|
<MetricCard label="Taxa de Fraude" value={`${data.fraud_rate_pct}%`} color="yellow" />
|
||||||
|
<MetricCard label="Falsos Positivos" value={`${data.false_positive_rate_pct}%`} color="green" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white border rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-4">Alertas por Hora</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={data.alerts_by_hour}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="hour" tickFormatter={h => `${h}h`} />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="count" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-4">Status dos Alertas</h3>
|
||||||
|
{statusPie.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusPie}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, value }) => `${name}: ${value}`}
|
||||||
|
>
|
||||||
|
{statusPie.map(entry => (
|
||||||
|
<Cell key={entry.name} fill={STATUS_COLORS[entry.name] || '#6b7280'} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-[200px] text-sm text-gray-400">
|
||||||
|
Sem dados
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-xl p-4 md:col-span-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-4">Top Regras por Alertas</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={data.top_triggering_rules} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis type="number" />
|
||||||
|
<YAxis dataKey="rule_name" type="category" width={130} tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="alert_count" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
frontend/src/pages/RulesPage.tsx
Normal file
239
frontend/src/pages/RulesPage.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import type { DetectionRule } from '../api/client';
|
||||||
|
import RuleList from '../components/rules/RuleList';
|
||||||
|
import RuleForm from '../components/rules/RuleForm';
|
||||||
|
import RuleTestResults from '../components/rules/RuleTestResults';
|
||||||
|
import { Plus, Play, Power, PowerOff, Loader2, ShieldCheck, RefreshCw } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
interface TestResult {
|
||||||
|
total_transactions_evaluated: number;
|
||||||
|
would_trigger_count: number;
|
||||||
|
would_trigger_pct: number;
|
||||||
|
estimated_false_positives: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path: string, options: RequestInit = {}) {
|
||||||
|
const token = localStorage.getItem('fraudshield_token');
|
||||||
|
const res = await fetch(`/api/v1${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(options.headers as Record<string, string> || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: 'Request failed' }));
|
||||||
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RulesPage() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [mode, setMode] = useState<'view' | 'create' | 'edit'>('view');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: rules = [], isLoading, refetch } = useQuery<DetectionRule[]>({
|
||||||
|
queryKey: ['rules'],
|
||||||
|
queryFn: () => apiFetch('/rules'),
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRule = rules.find(r => r.id === selectedId) || null;
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setSelectedId(null);
|
||||||
|
setMode('create');
|
||||||
|
setTestResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (selectedRule) setMode('edit');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = useCallback(async (data: { name: string; description: string; condition: string; weight: number; threshold: number }) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (mode === 'create') {
|
||||||
|
await apiFetch('/rules', { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
} else if (selectedRule) {
|
||||||
|
await apiFetch(`/rules/${selectedRule.id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
setMode('view');
|
||||||
|
setTestResult(null);
|
||||||
|
refetch();
|
||||||
|
qc.invalidateQueries({ queryKey: ['rules'] });
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Erro ao salvar');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [mode, selectedRule, refetch, qc]);
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
if (!selectedRule) return;
|
||||||
|
setActionLoading(selectedRule.id);
|
||||||
|
try {
|
||||||
|
const endpoint = selectedRule.is_active ? 'deactivate' : 'activate';
|
||||||
|
await apiFetch(`/rules/${selectedRule.id}/${endpoint}`, { method: 'POST' });
|
||||||
|
refetch();
|
||||||
|
qc.invalidateQueries({ queryKey: ['rules'] });
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Erro ao alterar status');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
if (!selectedRule) return;
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const result = await apiFetch(`/rules/${selectedRule.id}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ days_back: 90 }),
|
||||||
|
});
|
||||||
|
setTestResult(result);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Erro ao testar');
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center p-8">
|
||||||
|
<ShieldCheck className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700">Acesso Restrito</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Apenas administradores podem gerenciar regras</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="w-72 border-r bg-white flex flex-col shrink-0">
|
||||||
|
<div className="p-3 border-b flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">Regras ({rules.length})</h3>
|
||||||
|
<button onClick={handleCreate} className="p-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-3 space-y-2 animate-pulse">
|
||||||
|
{[1, 2, 3].map(i => <div key={i} className="h-12 bg-gray-100 rounded" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<RuleList rules={rules} selectedId={selectedId} onSelect={id => { setSelectedId(id); setMode('view'); setTestResult(null); }} onRefresh={refetch} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{mode === 'create' || mode === 'edit' ? (
|
||||||
|
<RuleForm
|
||||||
|
rule={mode === 'edit' ? selectedRule : null}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={() => { setMode('view'); setTestResult(null); }}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
) : selectedRule ? (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{selectedRule.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">{selectedRule.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs px-2.5 py-1 rounded-full font-medium',
|
||||||
|
selectedRule.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600',
|
||||||
|
)}>
|
||||||
|
{selectedRule.is_active ? 'Ativa' : 'Inativa'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 bg-gray-50 rounded-xl p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Peso no Score</p>
|
||||||
|
<p className="text-lg font-semibold">{selectedRule.weight}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Threshold</p>
|
||||||
|
<p className="text-lg font-semibold">{selectedRule.threshold}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Tipo</p>
|
||||||
|
<p className="text-sm font-medium">{selectedRule.condition}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Versão</p>
|
||||||
|
<p className="text-sm font-medium">v{selectedRule.version}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button onClick={handleEdit} className="flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg">
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={actionLoading === selectedRule.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-2 text-white text-sm rounded-lg transition-colors',
|
||||||
|
selectedRule.is_active
|
||||||
|
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||||
|
: 'bg-green-600 hover:bg-green-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{actionLoading === selectedRule.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : selectedRule.is_active ? (
|
||||||
|
<PowerOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{selectedRule.is_active ? 'Desativar' : 'Ativar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testing}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white text-sm rounded-lg"
|
||||||
|
>
|
||||||
|
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||||
|
Testar (90d)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RuleTestResults result={testResult} loading={testing} />
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400 space-y-1">
|
||||||
|
<p>Criado por: {selectedRule.created_by}</p>
|
||||||
|
<p>Criado em: {new Date(selectedRule.created_at).toLocaleString('pt-BR')}</p>
|
||||||
|
<p>Atualizado em: {new Date(selectedRule.updated_at).toLocaleString('pt-BR')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<ShieldCheck className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">Selecione uma regra ou crie uma nova</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue