fix: ship persistent bgp ai briefs and optimize bgp queries
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -145,3 +145,8 @@ docs/.venv/
|
||||
*.temp
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# ----------------------
|
||||
# Runtime Data
|
||||
# ----------------------
|
||||
data/ai/bgp-briefs/
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.ai import (
|
||||
AIProviderStatusResponse,
|
||||
BGPBriefRequest,
|
||||
BGPBriefRecordResponse,
|
||||
BGPBriefRecordSummary,
|
||||
SituationalAnalysisRequest,
|
||||
SituationalAnalysisResponse,
|
||||
)
|
||||
from app.services.ai_client import AIProviderClient, get_ai_provider_client
|
||||
from app.services.bgp_ai_brief import build_bgp_brief_request
|
||||
from app.services.bgp_ai_brief_store import (
|
||||
get_bgp_brief_record,
|
||||
get_latest_bgp_brief_record,
|
||||
list_bgp_brief_records,
|
||||
save_bgp_brief_record,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -37,3 +49,53 @@ async def analyze_situational_awareness(
|
||||
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return await provider_client.analyze(payload, request_id=request_id)
|
||||
|
||||
|
||||
@router.get("/bgp/briefs", response_model=list[BGPBriefRecordSummary])
|
||||
async def list_saved_bgp_briefs(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return list_bgp_brief_records()
|
||||
|
||||
|
||||
@router.get("/bgp/briefs/latest", response_model=BGPBriefRecordResponse | None)
|
||||
async def get_latest_saved_bgp_brief(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return get_latest_bgp_brief_record()
|
||||
|
||||
|
||||
@router.get("/bgp/briefs/{brief_id}", response_model=BGPBriefRecordResponse)
|
||||
async def get_saved_bgp_brief(
|
||||
brief_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
record = get_bgp_brief_record(brief_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="BGP brief not found")
|
||||
return record
|
||||
|
||||
|
||||
@router.post("/bgp/brief", response_model=BGPBriefRecordResponse)
|
||||
async def analyze_bgp_brief(
|
||||
payload: BGPBriefRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user),
|
||||
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
|
||||
brief_request = await build_bgp_brief_request(
|
||||
db,
|
||||
incident_limit=payload.incident_limit,
|
||||
anomaly_limit=payload.anomaly_limit,
|
||||
collector_limit=payload.collector_limit,
|
||||
)
|
||||
brief_request.preferred_model = payload.preferred_model
|
||||
brief_request.thinking = payload.thinking
|
||||
|
||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||
return save_bgp_brief_record(analysis, request_id=request_id)
|
||||
|
||||
@@ -22,16 +22,161 @@ def _parse_dt(value: Optional[str]) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
def _event_filters(
|
||||
*,
|
||||
prefix: Optional[str],
|
||||
origin_asn: Optional[int],
|
||||
peer_asn: Optional[int],
|
||||
collector: Optional[str],
|
||||
event_type: Optional[str],
|
||||
source: Optional[str],
|
||||
time_from: Optional[datetime],
|
||||
time_to: Optional[datetime],
|
||||
):
|
||||
filters = [BGPObservation.source.in_(BGP_SOURCES)]
|
||||
if source:
|
||||
filters.append(BGPObservation.source == source)
|
||||
if prefix:
|
||||
filters.append(BGPObservation.prefix == prefix)
|
||||
if origin_asn is not None:
|
||||
filters.append(BGPObservation.origin_asn == origin_asn)
|
||||
if peer_asn is not None:
|
||||
filters.append(BGPObservation.peer_asn == peer_asn)
|
||||
if collector:
|
||||
filters.append(BGPObservation.collector == collector)
|
||||
if event_type:
|
||||
filters.append(BGPObservation.event_type == event_type)
|
||||
if time_from:
|
||||
filters.append(BGPObservation.observed_at >= time_from)
|
||||
if time_to:
|
||||
filters.append(BGPObservation.observed_at <= time_to)
|
||||
return filters
|
||||
|
||||
|
||||
def _matches_time(value: Optional[datetime], time_from: Optional[datetime], time_to: Optional[datetime]) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if time_from and value < time_from:
|
||||
return False
|
||||
if time_to and value > time_to:
|
||||
return False
|
||||
return True
|
||||
def _anomaly_filters(
|
||||
*,
|
||||
severity: Optional[str],
|
||||
anomaly_type: Optional[str],
|
||||
status: Optional[str],
|
||||
prefix: Optional[str],
|
||||
origin_asn: Optional[int],
|
||||
time_from: Optional[datetime],
|
||||
time_to: Optional[datetime],
|
||||
):
|
||||
filters = []
|
||||
if severity:
|
||||
filters.append(BGPAnomaly.severity == severity)
|
||||
if anomaly_type:
|
||||
filters.append(BGPAnomaly.anomaly_type == anomaly_type)
|
||||
if status:
|
||||
filters.append(BGPAnomaly.status == status)
|
||||
if prefix:
|
||||
filters.append(BGPAnomaly.prefix == prefix)
|
||||
if origin_asn is not None:
|
||||
filters.append(BGPAnomaly.origin_asn == origin_asn)
|
||||
if time_from:
|
||||
filters.append(BGPAnomaly.created_at >= time_from)
|
||||
if time_to:
|
||||
filters.append(BGPAnomaly.created_at <= time_to)
|
||||
return filters
|
||||
|
||||
|
||||
def _incident_filters(
|
||||
*,
|
||||
severity: Optional[str],
|
||||
incident_type: Optional[str],
|
||||
status: Optional[str],
|
||||
):
|
||||
filters = []
|
||||
if severity:
|
||||
filters.append(BGPIncident.severity == severity)
|
||||
if incident_type:
|
||||
filters.append(BGPIncident.incident_type == incident_type)
|
||||
if status:
|
||||
filters.append(BGPIncident.status == status)
|
||||
return filters
|
||||
|
||||
|
||||
async def _build_event_summary_payload(db: AsyncSession) -> dict:
|
||||
base_filters = [BGPObservation.source.in_(BGP_SOURCES)]
|
||||
|
||||
total_result = await db.execute(
|
||||
select(func.count(BGPObservation.id)).where(*base_filters)
|
||||
)
|
||||
collectors_result = await db.execute(
|
||||
select(func.count(func.distinct(BGPObservation.collector))).where(
|
||||
*base_filters, BGPObservation.collector.isnot(None)
|
||||
)
|
||||
)
|
||||
prefixes_result = await db.execute(
|
||||
select(func.count(func.distinct(BGPObservation.prefix))).where(
|
||||
*base_filters, BGPObservation.prefix.isnot(None)
|
||||
)
|
||||
)
|
||||
type_result = await db.execute(
|
||||
select(BGPObservation.event_type, func.count(BGPObservation.id))
|
||||
.where(*base_filters)
|
||||
.group_by(BGPObservation.event_type)
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total_result.scalar() or 0,
|
||||
"collector_count": collectors_result.scalar() or 0,
|
||||
"prefix_count": prefixes_result.scalar() or 0,
|
||||
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
|
||||
}
|
||||
|
||||
|
||||
async def _build_anomaly_summary_payload(db: AsyncSession) -> dict:
|
||||
total_result = await db.execute(select(func.count(BGPAnomaly.id)))
|
||||
type_result = await db.execute(
|
||||
select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id))
|
||||
.group_by(BGPAnomaly.anomaly_type)
|
||||
.order_by(func.count(BGPAnomaly.id).desc())
|
||||
)
|
||||
severity_result = await db.execute(
|
||||
select(BGPAnomaly.severity, func.count(BGPAnomaly.id))
|
||||
.group_by(BGPAnomaly.severity)
|
||||
.order_by(func.count(BGPAnomaly.id).desc())
|
||||
)
|
||||
status_result = await db.execute(
|
||||
select(BGPAnomaly.status, func.count(BGPAnomaly.id))
|
||||
.group_by(BGPAnomaly.status)
|
||||
.order_by(func.count(BGPAnomaly.id).desc())
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total_result.scalar() or 0,
|
||||
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
|
||||
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
|
||||
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
|
||||
}
|
||||
|
||||
|
||||
async def _build_incident_summary_payload(db: AsyncSession) -> dict:
|
||||
total_result = await db.execute(select(func.count(BGPIncident.id)))
|
||||
type_result = await db.execute(
|
||||
select(BGPIncident.incident_type, func.count(BGPIncident.id))
|
||||
.group_by(BGPIncident.incident_type)
|
||||
.order_by(func.count(BGPIncident.id).desc())
|
||||
)
|
||||
severity_result = await db.execute(
|
||||
select(BGPIncident.severity, func.count(BGPIncident.id))
|
||||
.group_by(BGPIncident.severity)
|
||||
.order_by(func.count(BGPIncident.id).desc())
|
||||
)
|
||||
status_result = await db.execute(
|
||||
select(BGPIncident.status, func.count(BGPIncident.id))
|
||||
.group_by(BGPIncident.status)
|
||||
.order_by(func.count(BGPIncident.id).desc())
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total_result.scalar() or 0,
|
||||
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
|
||||
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
|
||||
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
@@ -49,41 +194,36 @@ async def list_bgp_events(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = (
|
||||
select(BGPObservation)
|
||||
.where(BGPObservation.source.in_(BGP_SOURCES))
|
||||
.order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
|
||||
)
|
||||
if source:
|
||||
stmt = stmt.where(BGPObservation.source == source)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
dt_from = _parse_dt(time_from)
|
||||
dt_to = _parse_dt(time_to)
|
||||
|
||||
filtered = []
|
||||
for record in records:
|
||||
if prefix and record.prefix != prefix:
|
||||
continue
|
||||
if origin_asn is not None and record.origin_asn != origin_asn:
|
||||
continue
|
||||
if peer_asn is not None and record.peer_asn != peer_asn:
|
||||
continue
|
||||
if collector and record.collector != collector:
|
||||
continue
|
||||
if event_type and record.event_type != event_type:
|
||||
continue
|
||||
if (dt_from or dt_to) and not _matches_time(record.observed_at, dt_from, dt_to):
|
||||
continue
|
||||
filtered.append(record)
|
||||
|
||||
filters = _event_filters(
|
||||
prefix=prefix,
|
||||
origin_asn=origin_asn,
|
||||
peer_asn=peer_asn,
|
||||
collector=collector,
|
||||
event_type=event_type,
|
||||
source=source,
|
||||
time_from=dt_from,
|
||||
time_to=dt_to,
|
||||
)
|
||||
offset = (page - 1) * page_size
|
||||
count_result = await db.execute(
|
||||
select(func.count(BGPObservation.id)).where(*filters)
|
||||
)
|
||||
data_result = await db.execute(
|
||||
select(BGPObservation)
|
||||
.where(*filters)
|
||||
.order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
records = data_result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": len(filtered),
|
||||
"total": count_result.scalar() or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"data": [record.to_dict() for record in filtered[offset : offset + page_size]],
|
||||
"data": [record.to_dict() for record in records],
|
||||
}
|
||||
|
||||
|
||||
@@ -92,21 +232,7 @@ async def get_bgp_event_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(BGPObservation).where(BGPObservation.source.in_(BGP_SOURCES)))
|
||||
records = result.scalars().all()
|
||||
|
||||
collectors = sorted({record.collector for record in records if record.collector})
|
||||
prefixes = sorted({record.prefix for record in records if record.prefix})
|
||||
by_type: dict[str, int] = {}
|
||||
for record in records:
|
||||
by_type[record.event_type] = by_type.get(record.event_type, 0) + 1
|
||||
|
||||
return {
|
||||
"total": len(records),
|
||||
"collector_count": len(collectors),
|
||||
"prefix_count": len(prefixes),
|
||||
"by_type": by_type,
|
||||
}
|
||||
return await _build_event_summary_payload(db)
|
||||
|
||||
|
||||
@router.get("/collectors")
|
||||
@@ -138,6 +264,32 @@ async def get_bgp_collector_summary(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/overview/summary")
|
||||
async def get_bgp_overview_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
event_summary = await _build_event_summary_payload(db)
|
||||
anomaly_summary = await _build_anomaly_summary_payload(db)
|
||||
incident_summary = await _build_incident_summary_payload(db)
|
||||
collectors = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
|
||||
active_collectors = [item for item in collectors if item["observation_count"] > 0]
|
||||
|
||||
return {
|
||||
"incidentSummary": incident_summary,
|
||||
"anomalySummary": anomaly_summary,
|
||||
"eventSummary": event_summary,
|
||||
"collectorSummary": {
|
||||
"total": len(collectors),
|
||||
"active_collectors": len(active_collectors),
|
||||
"observed_prefixes": sum(item["prefix_count"] for item in active_collectors),
|
||||
"observed_origins": sum(item["origin_asn_count"] for item in active_collectors),
|
||||
"recent_24h_events": sum(item["recent_24h_observation_count"] for item in active_collectors),
|
||||
"recent_7d_events": sum(item["recent_7d_observation_count"] for item in active_collectors),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/events/{event_id}")
|
||||
async def get_bgp_event(
|
||||
event_id: int,
|
||||
@@ -164,31 +316,35 @@ async def list_bgp_anomalies(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc())
|
||||
if severity:
|
||||
stmt = stmt.where(BGPAnomaly.severity == severity)
|
||||
if anomaly_type:
|
||||
stmt = stmt.where(BGPAnomaly.anomaly_type == anomaly_type)
|
||||
if status:
|
||||
stmt = stmt.where(BGPAnomaly.status == status)
|
||||
if prefix:
|
||||
stmt = stmt.where(BGPAnomaly.prefix == prefix)
|
||||
if origin_asn is not None:
|
||||
stmt = stmt.where(BGPAnomaly.origin_asn == origin_asn)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
dt_from = _parse_dt(time_from)
|
||||
dt_to = _parse_dt(time_to)
|
||||
if dt_from or dt_to:
|
||||
records = [record for record in records if _matches_time(record.created_at, dt_from, dt_to)]
|
||||
|
||||
filters = _anomaly_filters(
|
||||
severity=severity,
|
||||
anomaly_type=anomaly_type,
|
||||
status=status,
|
||||
prefix=prefix,
|
||||
origin_asn=origin_asn,
|
||||
time_from=dt_from,
|
||||
time_to=dt_to,
|
||||
)
|
||||
offset = (page - 1) * page_size
|
||||
total_result = await db.execute(
|
||||
select(func.count(BGPAnomaly.id)).where(*filters)
|
||||
)
|
||||
data_result = await db.execute(
|
||||
select(BGPAnomaly)
|
||||
.where(*filters)
|
||||
.order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
records = data_result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": len(records),
|
||||
"total": total_result.scalar() or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"data": [record.to_dict() for record in records[offset : offset + page_size]],
|
||||
"data": [record.to_dict() for record in records],
|
||||
}
|
||||
|
||||
|
||||
@@ -197,29 +353,7 @@ async def get_bgp_anomaly_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
total_result = await db.execute(select(func.count(BGPAnomaly.id)))
|
||||
type_result = await db.execute(
|
||||
select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id))
|
||||
.group_by(BGPAnomaly.anomaly_type)
|
||||
.order_by(func.count(BGPAnomaly.id).desc())
|
||||
)
|
||||
severity_result = await db.execute(
|
||||
select(BGPAnomaly.severity, func.count(BGPAnomaly.id))
|
||||
.group_by(BGPAnomaly.severity)
|
||||
.order_by(func.count(BGPAnomaly.id).desc())
|
||||
)
|
||||
status_result = await db.execute(
|
||||
select(BGPAnomaly.status, func.count(BGPAnomaly.id))
|
||||
.group_by(BGPAnomaly.status)
|
||||
.order_by(func.count(BGPAnomaly.id).desc())
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total_result.scalar() or 0,
|
||||
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
|
||||
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
|
||||
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
|
||||
}
|
||||
return await _build_anomaly_summary_payload(db)
|
||||
|
||||
|
||||
@router.get("/anomalies/{anomaly_id}")
|
||||
@@ -244,22 +378,29 @@ async def list_bgp_incidents(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
|
||||
if severity:
|
||||
stmt = stmt.where(BGPIncident.severity == severity)
|
||||
if incident_type:
|
||||
stmt = stmt.where(BGPIncident.incident_type == incident_type)
|
||||
if status:
|
||||
stmt = stmt.where(BGPIncident.status == status)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
filters = _incident_filters(
|
||||
severity=severity,
|
||||
incident_type=incident_type,
|
||||
status=status,
|
||||
)
|
||||
offset = (page - 1) * page_size
|
||||
total_result = await db.execute(
|
||||
select(func.count(BGPIncident.id)).where(*filters)
|
||||
)
|
||||
data_result = await db.execute(
|
||||
select(BGPIncident)
|
||||
.where(*filters)
|
||||
.order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
records = data_result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": len(records),
|
||||
"total": total_result.scalar() or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"data": [record.to_dict() for record in records[offset : offset + page_size]],
|
||||
"data": [record.to_dict() for record in records],
|
||||
}
|
||||
|
||||
|
||||
@@ -268,29 +409,7 @@ async def get_bgp_incident_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
total_result = await db.execute(select(func.count(BGPIncident.id)))
|
||||
type_result = await db.execute(
|
||||
select(BGPIncident.incident_type, func.count(BGPIncident.id))
|
||||
.group_by(BGPIncident.incident_type)
|
||||
.order_by(func.count(BGPIncident.id).desc())
|
||||
)
|
||||
severity_result = await db.execute(
|
||||
select(BGPIncident.severity, func.count(BGPIncident.id))
|
||||
.group_by(BGPIncident.severity)
|
||||
.order_by(func.count(BGPIncident.id).desc())
|
||||
)
|
||||
status_result = await db.execute(
|
||||
select(BGPIncident.status, func.count(BGPIncident.id))
|
||||
.group_by(BGPIncident.status)
|
||||
.order_by(func.count(BGPIncident.id).desc())
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total_result.scalar() or 0,
|
||||
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
|
||||
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
|
||||
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
|
||||
}
|
||||
return await _build_incident_summary_payload(db)
|
||||
|
||||
|
||||
@router.get("/incidents/{incident_id}")
|
||||
|
||||
@@ -21,6 +21,14 @@ class SituationalAnalysisRequest(BaseModel):
|
||||
thinking: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BGPBriefRequest(BaseModel):
|
||||
incident_limit: int = Field(default=5, ge=1, le=10)
|
||||
anomaly_limit: int = Field(default=6, ge=1, le=12)
|
||||
collector_limit: int = Field(default=5, ge=1, le=10)
|
||||
preferred_model: str | None = Field(default=None, max_length=200)
|
||||
thinking: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SituationalAnalysisResponse(BaseModel):
|
||||
provider: str
|
||||
model: str
|
||||
@@ -31,6 +39,19 @@ class SituationalAnalysisResponse(BaseModel):
|
||||
raw_response: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class BGPBriefRecordSummary(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
provider: str
|
||||
model: str
|
||||
request_id: str | None = None
|
||||
generated_at: str
|
||||
|
||||
|
||||
class BGPBriefRecordResponse(BGPBriefRecordSummary):
|
||||
content_markdown: str
|
||||
|
||||
|
||||
class AIProviderStatusResponse(BaseModel):
|
||||
provider: str
|
||||
api: str | None = None
|
||||
|
||||
147
backend/app/services/bgp_ai_brief.py
Normal file
147
backend/app/services/bgp_ai_brief.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.bgp import BGP_SOURCES
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
|
||||
|
||||
def _format_counter(counter: dict[str, int], empty_text: str = "无") -> str:
|
||||
if not counter:
|
||||
return empty_text
|
||||
return ",".join(f"{key} {value}" for key, value in counter.items())
|
||||
|
||||
|
||||
def _severity_rank(value: str | None) -> int:
|
||||
order = {
|
||||
"critical": 0,
|
||||
"high": 1,
|
||||
"medium": 2,
|
||||
"low": 3,
|
||||
"info": 4,
|
||||
}
|
||||
return order.get((value or "").lower(), 99)
|
||||
|
||||
|
||||
async def build_bgp_brief_request(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
incident_limit: int = 5,
|
||||
anomaly_limit: int = 6,
|
||||
collector_limit: int = 5,
|
||||
) -> SituationalAnalysisRequest:
|
||||
incidents_result = await db.execute(
|
||||
select(BGPIncident)
|
||||
.order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
|
||||
.limit(max(incident_limit, 1))
|
||||
)
|
||||
anomalies_result = await db.execute(
|
||||
select(BGPAnomaly)
|
||||
.order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc())
|
||||
.limit(max(anomaly_limit, 1))
|
||||
)
|
||||
observations_result = await db.execute(
|
||||
select(BGPObservation).where(BGPObservation.source.in_(BGP_SOURCES))
|
||||
)
|
||||
incident_count_result = await db.execute(select(func.count(BGPIncident.id)))
|
||||
anomaly_count_result = await db.execute(select(func.count(BGPAnomaly.id)))
|
||||
|
||||
incidents = incidents_result.scalars().all()
|
||||
anomalies = anomalies_result.scalars().all()
|
||||
observations = observations_result.scalars().all()
|
||||
collectors = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
|
||||
|
||||
total_incidents = incident_count_result.scalar() or 0
|
||||
total_anomalies = anomaly_count_result.scalar() or 0
|
||||
total_observations = len(observations)
|
||||
active_collectors = [item for item in collectors if item["observation_count"] > 0]
|
||||
|
||||
incident_status_counts = Counter((item.status or "unknown") for item in incidents)
|
||||
incident_severity_counts = Counter((item.severity or "unknown") for item in incidents)
|
||||
incident_type_counts = Counter((item.incident_type or "unknown") for item in incidents)
|
||||
anomaly_type_counts = Counter((item.anomaly_type or "unknown") for item in anomalies)
|
||||
event_type_counts = Counter((item.event_type or "unknown") for item in observations)
|
||||
|
||||
top_collectors = sorted(
|
||||
active_collectors,
|
||||
key=lambda item: (
|
||||
-int(item["recent_24h_observation_count"]),
|
||||
-int(item["observation_count"]),
|
||||
str(item["collector"]),
|
||||
),
|
||||
)[: max(collector_limit, 1)]
|
||||
|
||||
observations_lines: list[str] = [
|
||||
f"当前共有 {total_incidents} 起 BGP incidents、{total_anomalies} 条 anomalies、{total_observations} 条原始观测事件。",
|
||||
f"活跃观测站 {len(active_collectors)} 个;近 24 小时事件数合计 {sum(int(item['recent_24h_observation_count']) for item in active_collectors)}。",
|
||||
f"最近 incidents 严重度分布:{_format_counter(dict(sorted(incident_severity_counts.items(), key=lambda item: _severity_rank(item[0]))))}。",
|
||||
f"最近 incidents 状态分布:{_format_counter(dict(incident_status_counts))}。",
|
||||
f"最近 incidents 类型分布:{_format_counter(dict(incident_type_counts.most_common(5)))}。",
|
||||
f"最近 anomalies 类型分布:{_format_counter(dict(anomaly_type_counts.most_common(6)))}。",
|
||||
f"观测事件类型分布:{_format_counter(dict(event_type_counts.most_common(6)))}。",
|
||||
]
|
||||
|
||||
if incidents:
|
||||
observations_lines.append(
|
||||
"最近 incident 摘要:" + ";".join(
|
||||
[
|
||||
f"{item.incident_type} / {item.severity} / {item.status}"
|
||||
f" / 前缀 {', '.join(item.affected_prefixes[:2]) if item.affected_prefixes else '-'}"
|
||||
f" / 观测站 {len(item.affected_collectors or [])} 个"
|
||||
for item in incidents
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if anomalies:
|
||||
observations_lines.append(
|
||||
"最近 anomaly 摘要:" + ";".join(
|
||||
[
|
||||
f"{item.anomaly_type} / {item.severity}"
|
||||
f" / 前缀 {item.prefix or '-'}"
|
||||
f" / ASN {item.new_origin_asn or item.origin_asn or '-'}"
|
||||
for item in anomalies
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if top_collectors:
|
||||
observations_lines.append(
|
||||
"重点观测站:" + ";".join(
|
||||
[
|
||||
f"{item['collector']} ({', '.join([part for part in [item.get('city'), item.get('country')] if part]) or '未知位置'})"
|
||||
f" / 近24h {item['recent_24h_observation_count']} 条"
|
||||
f" / 前缀 {item['prefix_count']} 个"
|
||||
for item in top_collectors
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return SituationalAnalysisRequest(
|
||||
title="BGP 态势 AI 简报",
|
||||
objective="基于当前 BGP incidents、anomalies、原始观测事件与观测站覆盖情况,生成一份面向操作员的简明态势简报,突出当前风险、证据和优先动作。",
|
||||
observations=observations_lines,
|
||||
constraints=[
|
||||
"明确区分事实、推断与建议。",
|
||||
"优先指出需要立即关注的高严重度 incident 或异常模式。",
|
||||
"结论应服务值班排障,不要写成泛泛的模型演示文案。",
|
||||
"如果证据不足,要明确指出缺失数据。",
|
||||
],
|
||||
context={
|
||||
"source": "bgp-overview",
|
||||
"incident_total": total_incidents,
|
||||
"anomaly_total": total_anomalies,
|
||||
"observation_total": total_observations,
|
||||
"active_collectors": len(active_collectors),
|
||||
"top_incident_types": dict(incident_type_counts.most_common(5)),
|
||||
"top_anomaly_types": dict(anomaly_type_counts.most_common(6)),
|
||||
"top_event_types": dict(event_type_counts.most_common(6)),
|
||||
},
|
||||
)
|
||||
147
backend/app/services/bgp_ai_brief_store.py
Normal file
147
backend/app/services/bgp_ai_brief_store.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.config import ROOT_DIR
|
||||
from app.schemas.ai import BGPBriefRecordResponse, BGPBriefRecordSummary, SituationalAnalysisResponse
|
||||
|
||||
|
||||
_BRIEF_STORAGE_DIR = ROOT_DIR / "data" / "ai" / "bgp-briefs"
|
||||
_METADATA_PREFIX = "<!-- planet-bgp-brief-meta "
|
||||
_METADATA_SUFFIX = " -->"
|
||||
_BRIEF_TITLE = "BGP AI 简报"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _StoredBrief:
|
||||
id: str
|
||||
title: str
|
||||
provider: str
|
||||
model: str
|
||||
request_id: str | None
|
||||
generated_at: str
|
||||
content_markdown: str
|
||||
path: Path
|
||||
|
||||
|
||||
def _ensure_storage_dir() -> Path:
|
||||
_BRIEF_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _BRIEF_STORAGE_DIR
|
||||
|
||||
|
||||
def _build_metadata_line(metadata: dict[str, str | None]) -> str:
|
||||
return f"{_METADATA_PREFIX}{json.dumps(metadata, ensure_ascii=False)}{_METADATA_SUFFIX}"
|
||||
|
||||
|
||||
def _parse_brief_file(path: Path) -> _StoredBrief | None:
|
||||
try:
|
||||
raw_text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
first_line, separator, remainder = raw_text.partition("\n")
|
||||
if not separator or not first_line.startswith(_METADATA_PREFIX) or not first_line.endswith(_METADATA_SUFFIX):
|
||||
return None
|
||||
|
||||
metadata_payload = first_line[len(_METADATA_PREFIX) : -len(_METADATA_SUFFIX)]
|
||||
|
||||
try:
|
||||
metadata = json.loads(metadata_payload)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
return _StoredBrief(
|
||||
id=str(metadata.get("id") or path.stem),
|
||||
title=str(metadata.get("title") or _BRIEF_TITLE),
|
||||
provider=str(metadata.get("provider") or "-"),
|
||||
model=str(metadata.get("model") or "-"),
|
||||
request_id=metadata.get("request_id"),
|
||||
generated_at=str(metadata.get("generated_at") or datetime.fromtimestamp(path.stat().st_mtime, UTC).isoformat()),
|
||||
content_markdown=remainder.lstrip("\n"),
|
||||
path=path,
|
||||
)
|
||||
|
||||
|
||||
def list_bgp_brief_records(limit: int = 50) -> list[BGPBriefRecordSummary]:
|
||||
storage_dir = _ensure_storage_dir()
|
||||
records: list[_StoredBrief] = []
|
||||
|
||||
for path in storage_dir.glob("*.md"):
|
||||
parsed = _parse_brief_file(path)
|
||||
if parsed is not None:
|
||||
records.append(parsed)
|
||||
|
||||
records.sort(key=lambda item: item.generated_at, reverse=True)
|
||||
|
||||
return [
|
||||
BGPBriefRecordSummary(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
provider=item.provider,
|
||||
model=item.model,
|
||||
request_id=item.request_id,
|
||||
generated_at=item.generated_at,
|
||||
)
|
||||
for item in records[: max(limit, 1)]
|
||||
]
|
||||
|
||||
|
||||
def get_bgp_brief_record(brief_id: str) -> BGPBriefRecordResponse | None:
|
||||
path = _ensure_storage_dir() / f"{brief_id}.md"
|
||||
parsed = _parse_brief_file(path)
|
||||
if parsed is None:
|
||||
return None
|
||||
|
||||
return BGPBriefRecordResponse(
|
||||
id=parsed.id,
|
||||
title=parsed.title,
|
||||
provider=parsed.provider,
|
||||
model=parsed.model,
|
||||
request_id=parsed.request_id,
|
||||
generated_at=parsed.generated_at,
|
||||
content_markdown=parsed.content_markdown,
|
||||
)
|
||||
|
||||
|
||||
def get_latest_bgp_brief_record() -> BGPBriefRecordResponse | None:
|
||||
summaries = list_bgp_brief_records(limit=1)
|
||||
if not summaries:
|
||||
return None
|
||||
return get_bgp_brief_record(summaries[0].id)
|
||||
|
||||
|
||||
def save_bgp_brief_record(
|
||||
analysis: SituationalAnalysisResponse,
|
||||
*,
|
||||
request_id: str | None,
|
||||
generated_at: datetime | None = None,
|
||||
) -> BGPBriefRecordResponse:
|
||||
created_at = generated_at or datetime.now(UTC)
|
||||
brief_id = f"{created_at.strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
|
||||
path = _ensure_storage_dir() / f"{brief_id}.md"
|
||||
|
||||
metadata = {
|
||||
"id": brief_id,
|
||||
"title": _BRIEF_TITLE,
|
||||
"provider": analysis.provider,
|
||||
"model": analysis.model,
|
||||
"request_id": request_id,
|
||||
"generated_at": created_at.isoformat(),
|
||||
}
|
||||
|
||||
markdown_text = f"{_build_metadata_line(metadata)}\n\n{analysis.content.rstrip()}\n"
|
||||
path.write_text(markdown_text, encoding="utf-8")
|
||||
|
||||
return BGPBriefRecordResponse(
|
||||
id=brief_id,
|
||||
title=_BRIEF_TITLE,
|
||||
provider=analysis.provider,
|
||||
model=analysis.model,
|
||||
request_id=request_id,
|
||||
generated_at=created_at.isoformat(),
|
||||
content_markdown=analysis.content,
|
||||
)
|
||||
@@ -6,7 +6,7 @@ from collections import defaultdict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import case, distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
@@ -14,6 +14,16 @@ from app.models.bgp_observation import BGPObservation
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
|
||||
|
||||
def _collector_base_filters(source_filter: tuple[str, ...] | None) -> list[Any]:
|
||||
filters: list[Any] = [
|
||||
BGPObservation.collector.isnot(None),
|
||||
func.length(func.btrim(BGPObservation.collector)) > 0,
|
||||
]
|
||||
if source_filter:
|
||||
filters.append(BGPObservation.source.in_(source_filter))
|
||||
return filters
|
||||
|
||||
|
||||
async def build_bgp_collector_coverage(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -24,88 +34,148 @@ async def build_bgp_collector_coverage(
|
||||
recent_24h_threshold = now - timedelta(hours=24)
|
||||
recent_7d_threshold = now - timedelta(days=7)
|
||||
|
||||
stmt = select(BGPObservation).order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
|
||||
if source_filter:
|
||||
stmt = stmt.where(BGPObservation.source.in_(source_filter))
|
||||
filters = _collector_base_filters(source_filter)
|
||||
country_expr = func.nullif(BGPObservation.collector_geo["country"].astext, "")
|
||||
city_expr = func.nullif(BGPObservation.collector_geo["city"].astext, "")
|
||||
|
||||
result = await db.execute(stmt)
|
||||
records = list(result.scalars().all())
|
||||
aggregate_stmt = (
|
||||
select(
|
||||
BGPObservation.collector.label("collector"),
|
||||
func.count(BGPObservation.id).label("observation_count"),
|
||||
func.count(distinct(BGPObservation.prefix)).label("prefix_count"),
|
||||
func.count(distinct(BGPObservation.origin_asn)).label("origin_asn_count"),
|
||||
func.count(distinct(BGPObservation.peer_asn)).label("peer_asn_count"),
|
||||
func.sum(case((BGPObservation.observed_at >= recent_15m_threshold, 1), else_=0)).label("recent_15m_observation_count"),
|
||||
func.sum(case((BGPObservation.observed_at >= recent_24h_threshold, 1), else_=0)).label("recent_24h_observation_count"),
|
||||
func.sum(case((BGPObservation.observed_at >= recent_7d_threshold, 1), else_=0)).label("recent_7d_observation_count"),
|
||||
func.count(distinct(case((BGPObservation.observed_at >= recent_15m_threshold, BGPObservation.prefix), else_=None))).label("recent_15m_prefix_count"),
|
||||
func.count(distinct(case((BGPObservation.observed_at >= recent_24h_threshold, BGPObservation.prefix), else_=None))).label("recent_24h_prefix_count"),
|
||||
func.count(distinct(case((BGPObservation.observed_at >= recent_7d_threshold, BGPObservation.prefix), else_=None))).label("recent_7d_prefix_count"),
|
||||
func.max(BGPObservation.observed_at).label("latest_observed_at"),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by(BGPObservation.collector)
|
||||
)
|
||||
aggregate_rows = (await db.execute(aggregate_stmt)).all()
|
||||
|
||||
latest_subquery = (
|
||||
select(
|
||||
BGPObservation.collector.label("collector"),
|
||||
BGPObservation.event_type.label("latest_event_type"),
|
||||
country_expr.label("country"),
|
||||
city_expr.label("city"),
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=BGPObservation.collector,
|
||||
order_by=(BGPObservation.observed_at.desc(), BGPObservation.id.desc()),
|
||||
)
|
||||
.label("rn"),
|
||||
)
|
||||
.where(*filters)
|
||||
.subquery()
|
||||
)
|
||||
latest_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
latest_subquery.c.collector,
|
||||
latest_subquery.c.latest_event_type,
|
||||
latest_subquery.c.country,
|
||||
latest_subquery.c.city,
|
||||
).where(latest_subquery.c.rn == 1)
|
||||
)
|
||||
).all()
|
||||
|
||||
event_counts_subquery = (
|
||||
select(
|
||||
BGPObservation.collector.label("collector"),
|
||||
BGPObservation.event_type.label("event_type"),
|
||||
func.count(BGPObservation.id).label("count"),
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=BGPObservation.collector,
|
||||
order_by=(func.count(BGPObservation.id).desc(), BGPObservation.event_type.asc()),
|
||||
)
|
||||
.label("rn"),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by(BGPObservation.collector, BGPObservation.event_type)
|
||||
.subquery()
|
||||
)
|
||||
top_event_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
event_counts_subquery.c.collector,
|
||||
event_counts_subquery.c.event_type,
|
||||
event_counts_subquery.c.count,
|
||||
).where(event_counts_subquery.c.rn <= 3)
|
||||
)
|
||||
).all()
|
||||
|
||||
scope_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
BGPObservation.collector.label("collector"),
|
||||
country_expr.label("country"),
|
||||
city_expr.label("city"),
|
||||
)
|
||||
.where(*filters)
|
||||
.distinct()
|
||||
)
|
||||
).all()
|
||||
|
||||
latest_by_collector = {
|
||||
row.collector: {
|
||||
"latest_event_type": row.latest_event_type,
|
||||
"country": row.country,
|
||||
"city": row.city,
|
||||
}
|
||||
for row in latest_rows
|
||||
}
|
||||
|
||||
scope_by_collector: dict[str, dict[str, set[str]]] = defaultdict(lambda: {"countries": set(), "cities": set()})
|
||||
for row in scope_rows:
|
||||
if row.country:
|
||||
scope_by_collector[row.collector]["countries"].add(row.country)
|
||||
if row.city:
|
||||
scope_by_collector[row.collector]["cities"].add(row.city)
|
||||
|
||||
top_events_by_collector: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for row in top_event_rows:
|
||||
top_events_by_collector[row.collector].append(
|
||||
{"event_type": row.event_type, "count": row.count}
|
||||
)
|
||||
|
||||
by_collector: dict[str, dict[str, Any]] = {}
|
||||
for record in records:
|
||||
collector = str(record.collector or "").strip()
|
||||
if not collector:
|
||||
continue
|
||||
for row in aggregate_rows:
|
||||
collector = row.collector
|
||||
latest = latest_by_collector.get(collector, {})
|
||||
fallback_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
|
||||
scope = scope_by_collector.get(collector, {"countries": set(), "cities": set()})
|
||||
|
||||
coverage = by_collector.get(collector)
|
||||
if coverage is None:
|
||||
location = record.collector_geo or RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
|
||||
coverage = {
|
||||
"collector": collector,
|
||||
"city": location.get("city"),
|
||||
"country": location.get("country"),
|
||||
"latitude": location.get("latitude"),
|
||||
"longitude": location.get("longitude"),
|
||||
"observation_count": 0,
|
||||
"prefixes": set(),
|
||||
"origin_asns": set(),
|
||||
"peer_asns": set(),
|
||||
"event_types": defaultdict(int),
|
||||
"countries": set(),
|
||||
"cities": set(),
|
||||
"recent_15m_observation_count": 0,
|
||||
"recent_24h_observation_count": 0,
|
||||
"recent_7d_observation_count": 0,
|
||||
"recent_15m_prefixes": set(),
|
||||
"recent_24h_prefixes": set(),
|
||||
"recent_7d_prefixes": set(),
|
||||
"latest_observed_at": None,
|
||||
"latest_event_type": None,
|
||||
}
|
||||
by_collector[collector] = coverage
|
||||
|
||||
coverage["observation_count"] += 1
|
||||
if record.prefix:
|
||||
coverage["prefixes"].add(record.prefix)
|
||||
if record.origin_asn is not None:
|
||||
coverage["origin_asns"].add(record.origin_asn)
|
||||
if record.peer_asn is not None:
|
||||
coverage["peer_asns"].add(record.peer_asn)
|
||||
if record.event_type:
|
||||
coverage["event_types"][record.event_type] += 1
|
||||
|
||||
observed_at = record.observed_at
|
||||
if observed_at is not None:
|
||||
aware_observed_at = (
|
||||
observed_at.astimezone(UTC)
|
||||
if observed_at.tzinfo
|
||||
else observed_at.replace(tzinfo=UTC)
|
||||
)
|
||||
if aware_observed_at >= recent_15m_threshold:
|
||||
coverage["recent_15m_observation_count"] += 1
|
||||
if record.prefix:
|
||||
coverage["recent_15m_prefixes"].add(record.prefix)
|
||||
if aware_observed_at >= recent_24h_threshold:
|
||||
coverage["recent_24h_observation_count"] += 1
|
||||
if record.prefix:
|
||||
coverage["recent_24h_prefixes"].add(record.prefix)
|
||||
if aware_observed_at >= recent_7d_threshold:
|
||||
coverage["recent_7d_observation_count"] += 1
|
||||
if record.prefix:
|
||||
coverage["recent_7d_prefixes"].add(record.prefix)
|
||||
|
||||
geo = record.collector_geo or {}
|
||||
if geo.get("country"):
|
||||
coverage["countries"].add(geo["country"])
|
||||
if geo.get("city"):
|
||||
coverage["cities"].add(geo["city"])
|
||||
|
||||
current_latest = coverage["latest_observed_at"]
|
||||
if current_latest is None or (
|
||||
record.observed_at is not None and record.observed_at > current_latest
|
||||
):
|
||||
coverage["latest_observed_at"] = record.observed_at
|
||||
coverage["latest_event_type"] = record.event_type
|
||||
by_collector[collector] = {
|
||||
"collector": collector,
|
||||
"city": latest.get("city") or fallback_location.get("city"),
|
||||
"country": latest.get("country") or fallback_location.get("country"),
|
||||
"latitude": fallback_location.get("latitude"),
|
||||
"longitude": fallback_location.get("longitude"),
|
||||
"observation_count": row.observation_count or 0,
|
||||
"prefix_count": row.prefix_count or 0,
|
||||
"origin_asn_count": row.origin_asn_count or 0,
|
||||
"peer_asn_count": row.peer_asn_count or 0,
|
||||
"recent_15m_observation_count": row.recent_15m_observation_count or 0,
|
||||
"recent_24h_observation_count": row.recent_24h_observation_count or 0,
|
||||
"recent_7d_observation_count": row.recent_7d_observation_count or 0,
|
||||
"recent_15m_prefix_count": row.recent_15m_prefix_count or 0,
|
||||
"recent_24h_prefix_count": row.recent_24h_prefix_count or 0,
|
||||
"recent_7d_prefix_count": row.recent_7d_prefix_count or 0,
|
||||
"top_event_types": top_events_by_collector.get(collector, []),
|
||||
"latest_observed_at": to_iso8601_utc(row.latest_observed_at),
|
||||
"latest_event_type": latest.get("latest_event_type"),
|
||||
"baseline_scope": {
|
||||
"countries": sorted(scope["countries"]),
|
||||
"cities": sorted(scope["cities"]),
|
||||
},
|
||||
}
|
||||
|
||||
for collector, location in RIPE_RIS_COLLECTOR_COORDS.items():
|
||||
if collector in by_collector:
|
||||
@@ -117,57 +187,22 @@ async def build_bgp_collector_coverage(
|
||||
"latitude": location.get("latitude"),
|
||||
"longitude": location.get("longitude"),
|
||||
"observation_count": 0,
|
||||
"prefixes": set(),
|
||||
"origin_asns": set(),
|
||||
"peer_asns": set(),
|
||||
"event_types": defaultdict(int),
|
||||
"countries": {location.get("country")} if location.get("country") else set(),
|
||||
"cities": {location.get("city")} if location.get("city") else set(),
|
||||
"prefix_count": 0,
|
||||
"origin_asn_count": 0,
|
||||
"peer_asn_count": 0,
|
||||
"recent_15m_observation_count": 0,
|
||||
"recent_24h_observation_count": 0,
|
||||
"recent_7d_observation_count": 0,
|
||||
"recent_15m_prefixes": set(),
|
||||
"recent_24h_prefixes": set(),
|
||||
"recent_7d_prefixes": set(),
|
||||
"recent_15m_prefix_count": 0,
|
||||
"recent_24h_prefix_count": 0,
|
||||
"recent_7d_prefix_count": 0,
|
||||
"top_event_types": [],
|
||||
"latest_observed_at": None,
|
||||
"latest_event_type": None,
|
||||
"baseline_scope": {
|
||||
"countries": [location["country"]] if location.get("country") else [],
|
||||
"cities": [location["city"]] if location.get("city") else [],
|
||||
},
|
||||
}
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for collector in sorted(by_collector.keys()):
|
||||
item = by_collector[collector]
|
||||
top_event_types = sorted(
|
||||
item["event_types"].items(),
|
||||
key=lambda pair: (-pair[1], pair[0]),
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"collector": item["collector"],
|
||||
"city": item["city"],
|
||||
"country": item["country"],
|
||||
"latitude": item["latitude"],
|
||||
"longitude": item["longitude"],
|
||||
"observation_count": item["observation_count"],
|
||||
"prefix_count": len(item["prefixes"]),
|
||||
"origin_asn_count": len(item["origin_asns"]),
|
||||
"peer_asn_count": len(item["peer_asns"]),
|
||||
"recent_15m_observation_count": item["recent_15m_observation_count"],
|
||||
"recent_24h_observation_count": item["recent_24h_observation_count"],
|
||||
"recent_7d_observation_count": item["recent_7d_observation_count"],
|
||||
"recent_15m_prefix_count": len(item["recent_15m_prefixes"]),
|
||||
"recent_24h_prefix_count": len(item["recent_24h_prefixes"]),
|
||||
"recent_7d_prefix_count": len(item["recent_7d_prefixes"]),
|
||||
"top_event_types": [
|
||||
{"event_type": event_type, "count": count}
|
||||
for event_type, count in top_event_types[:3]
|
||||
],
|
||||
"latest_observed_at": to_iso8601_utc(item["latest_observed_at"]),
|
||||
"latest_event_type": item["latest_event_type"],
|
||||
"baseline_scope": {
|
||||
"countries": sorted(country for country in item["countries"] if country),
|
||||
"cities": sorted(city for city in item["cities"] if city),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
return [by_collector[collector] for collector in sorted(by_collector.keys())]
|
||||
|
||||
@@ -7,6 +7,35 @@ This project follows the repository versioning rule:
|
||||
- `feature` -> `+0.1.0`
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## 0.24.5
|
||||
|
||||
Released: 2026-04-10
|
||||
|
||||
### Highlights
|
||||
|
||||
- Turned the first BGP AI brief flow into a persistent operator-facing feature with saved Markdown history, tabbed review, and backend-driven fact assembly, while also cutting BGP page load cost by switching the hottest routes away from Python-side full-table scans.
|
||||
|
||||
### Added
|
||||
|
||||
- Added [backend/app/services/bgp_ai_brief.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_ai_brief.py), building evidence-first BGP brief prompts from incidents, anomalies, observations, and collector coverage instead of relying on manual Playground inputs.
|
||||
- Added [backend/app/services/bgp_ai_brief_store.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_ai_brief_store.py), persisting generated BGP AI briefs as Markdown files and exposing a stable record shape for history browsing.
|
||||
- Added [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx), giving the frontend a lightweight built-in Markdown renderer for saved AI brief content without introducing a new dependency.
|
||||
- Added [backend/app/api/v1/bgp.py](/home/ray/dev/linkong/planet/backend/app/api/v1/bgp.py) `GET /bgp/overview/summary`, consolidating the BGP overview summary hot path into a dedicated aggregate endpoint for the BGP workspace.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved [backend/app/api/v1/ai.py](/home/ray/dev/linkong/planet/backend/app/api/v1/ai.py) and [backend/app/schemas/ai.py](/home/ray/dev/linkong/planet/backend/app/schemas/ai.py) by extending the AI API from one-off analysis calls to saved BGP brief history endpoints, latest-brief lookup, and persisted brief responses.
|
||||
- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) by moving `AI 简报` into the trailing tab, loading tab data lazily, loading brief history only on demand, and wiring saved brief selection plus regeneration into the operator workflow.
|
||||
- Improved [frontend/src/services/situational-awareness/http-gateway.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/http-gateway.ts), [frontend/src/services/situational-awareness/port.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/port.ts), [frontend/src/services/situational-awareness/mock-gateway.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/mock-gateway.ts), and [frontend/src/services/situational-awareness/types.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/types.ts) by splitting BGP summary, list, and brief-history fetches into more granular gateway methods and de-duplicating in-flight requests on the hot path.
|
||||
- Improved [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by styling the saved BGP brief workspace, Markdown output, history selector, and responsive compact state without re-breaking tab switching.
|
||||
- Improved [backend/app/services/bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) by replacing full-table ORM loading with database-side collector aggregation, latest-record lookup, and top-event summarization for the collector coverage hot path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the BGP tab rendering regression in [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) where forcing tab panes to `display: flex` accidentally kept hidden panes visible and made the first tab look permanently locked.
|
||||
- Fixed [backend/app/api/v1/bgp.py](/home/ray/dev/linkong/planet/backend/app/api/v1/bgp.py) so `events`, `anomalies`, and `incidents` no longer fetch whole tables into Python just to apply filtering, pagination, and counting.
|
||||
- Fixed the repository workflow around saved BGP brief artifacts by ignoring [data/ai/bgp-briefs/](/home/ray/dev/linkong/planet/data/ai/bgp-briefs/) in [.gitignore](/home/ray/dev/linkong/planet/.gitignore) instead of leaving runtime Markdown output to pollute git status during normal operator use.
|
||||
|
||||
## 0.24.4
|
||||
|
||||
Released: 2026-04-09
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.24.4`
|
||||
- `dev` 当前开发分支历史推导到:`0.24.5`
|
||||
|
||||
## Timeline
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
| `0.24.2` | bugfix | `dev` | `pending` | restore public Earth entry, refresh Playground provider diagnostics correctly, fit help-card content, and split frontend bundles by route/vendor |
|
||||
| `0.24.3` | bugfix | `dev` | `pending` | expand Playground diagnostics presets and result inspection, and make `planet.sh` rebuild changed AI Provider images with explicit Compose fallback reporting |
|
||||
| `0.24.4` | bugfix | `dev` | `pending` | polish `planet.sh` AI Provider rebuild stage boundaries, hide raw Compose build logs on success, and add explicit image-build completion feedback |
|
||||
| `0.24.5` | bugfix | `dev` | `pending` | add persistent BGP AI briefs with Markdown history, lazy-load BGP tabs, and move BGP hot-path filtering and aggregation back into the database |
|
||||
|
||||
## Maintenance Commits Not Counted as Version Bumps
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.24.4",
|
||||
"version": "0.24.5",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
167
frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx
Normal file
167
frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
markdown: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text: string): ReactNode[] {
|
||||
const result: ReactNode[] = []
|
||||
const pattern = /(\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g
|
||||
let lastIndex = 0
|
||||
let key = 0
|
||||
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const matchedText = match[0]
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > lastIndex) {
|
||||
result.push(text.slice(lastIndex, start))
|
||||
}
|
||||
|
||||
if (matchedText.startsWith('[')) {
|
||||
const linkMatch = matchedText.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
|
||||
if (linkMatch) {
|
||||
result.push(
|
||||
<a key={`inline-${key}`} href={linkMatch[2]} target="_blank" rel="noreferrer">
|
||||
{linkMatch[1]}
|
||||
</a>,
|
||||
)
|
||||
key += 1
|
||||
lastIndex = start + matchedText.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedText.startsWith('`')) {
|
||||
result.push(<code key={`inline-${key}`}>{matchedText.slice(1, -1)}</code>)
|
||||
key += 1
|
||||
lastIndex = start + matchedText.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (matchedText.startsWith('**')) {
|
||||
result.push(<strong key={`inline-${key}`}>{matchedText.slice(2, -2)}</strong>)
|
||||
key += 1
|
||||
lastIndex = start + matchedText.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (matchedText.startsWith('*')) {
|
||||
result.push(<em key={`inline-${key}`}>{matchedText.slice(1, -1)}</em>)
|
||||
key += 1
|
||||
lastIndex = start + matchedText.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
result.push(text.slice(lastIndex))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ markdown, className }: MarkdownRendererProps) {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
||||
const nodes: ReactNode[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('```')) {
|
||||
const codeLines: string[] = []
|
||||
index += 1
|
||||
while (index < lines.length && !lines[index].trim().startsWith('```')) {
|
||||
codeLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
if (index < lines.length) {
|
||||
index += 1
|
||||
}
|
||||
nodes.push(
|
||||
<pre key={`block-${index}`}>
|
||||
<code>{codeLines.join('\n')}</code>
|
||||
</pre>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length
|
||||
const content = renderInlineMarkdown(headingMatch[2])
|
||||
if (level === 1) nodes.push(<h1 key={`block-${index}`}>{content}</h1>)
|
||||
else if (level === 2) nodes.push(<h2 key={`block-${index}`}>{content}</h2>)
|
||||
else if (level === 3) nodes.push(<h3 key={`block-${index}`}>{content}</h3>)
|
||||
else if (level === 4) nodes.push(<h4 key={`block-${index}`}>{content}</h4>)
|
||||
else nodes.push(<p key={`block-${index}`} className="markdown-renderer__heading-fallback">{content}</p>)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('> ')) {
|
||||
const quoteLines: string[] = []
|
||||
while (index < lines.length && lines[index].trim().startsWith('> ')) {
|
||||
quoteLines.push(lines[index].trim().slice(2))
|
||||
index += 1
|
||||
}
|
||||
nodes.push(<blockquote key={`block-${index}`}>{quoteLines.join(' ')}</blockquote>)
|
||||
continue
|
||||
}
|
||||
|
||||
const unorderedMatch = trimmed.match(/^[-*]\s+(.+)$/)
|
||||
if (unorderedMatch) {
|
||||
const items: string[] = []
|
||||
while (index < lines.length) {
|
||||
const itemMatch = lines[index].trim().match(/^[-*]\s+(.+)$/)
|
||||
if (!itemMatch) break
|
||||
items.push(itemMatch[1])
|
||||
index += 1
|
||||
}
|
||||
nodes.push(
|
||||
<ul key={`block-${index}`}>
|
||||
{items.map((item, itemIndex) => (
|
||||
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item)}</li>
|
||||
))}
|
||||
</ul>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const orderedMatch = trimmed.match(/^\d+\.\s+(.+)$/)
|
||||
if (orderedMatch) {
|
||||
const items: string[] = []
|
||||
while (index < lines.length) {
|
||||
const itemMatch = lines[index].trim().match(/^\d+\.\s+(.+)$/)
|
||||
if (!itemMatch) break
|
||||
items.push(itemMatch[1])
|
||||
index += 1
|
||||
}
|
||||
nodes.push(
|
||||
<ol key={`block-${index}`}>
|
||||
{items.map((item, itemIndex) => (
|
||||
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item)}</li>
|
||||
))}
|
||||
</ol>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = []
|
||||
while (index < lines.length && lines[index].trim()) {
|
||||
paragraphLines.push(lines[index].trim())
|
||||
index += 1
|
||||
}
|
||||
nodes.push(<p key={`block-${index}`}>{renderInlineMarkdown(paragraphLines.join(' '))}</p>)
|
||||
}
|
||||
|
||||
return <div className={className ? `markdown-renderer ${className}` : 'markdown-renderer'}>{nodes}</div>
|
||||
}
|
||||
@@ -1006,6 +1006,155 @@ body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bgp-page__brief-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.bgp-page__brief-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bgp-page__brief-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bgp-page__brief-select {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.bgp-page__brief-subtitle {
|
||||
margin-top: 4px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bgp-page__brief-loading,
|
||||
.bgp-page__brief-empty {
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bgp-page__brief-meta .ant-descriptions-view {
|
||||
background: #f7f8fa;
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.bgp-page__brief-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 248, 251, 0.98));
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
}
|
||||
|
||||
.markdown-renderer {
|
||||
color: #262626;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.markdown-renderer > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-renderer > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-renderer h1,
|
||||
.markdown-renderer h2,
|
||||
.markdown-renderer h3,
|
||||
.markdown-renderer h4 {
|
||||
margin: 1.2em 0 0.5em;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markdown-renderer h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.markdown-renderer h2 {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.markdown-renderer h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown-renderer p,
|
||||
.markdown-renderer ul,
|
||||
.markdown-renderer ol,
|
||||
.markdown-renderer blockquote,
|
||||
.markdown-renderer pre {
|
||||
margin: 0 0 0.9em;
|
||||
}
|
||||
|
||||
.markdown-renderer ul,
|
||||
.markdown-renderer ol {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.markdown-renderer li + li {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.markdown-renderer blockquote {
|
||||
padding: 10px 14px;
|
||||
border-left: 3px solid #91caff;
|
||||
border-radius: 0 10px 10px 0;
|
||||
background: rgba(230, 244, 255, 0.8);
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.markdown-renderer pre {
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.markdown-renderer code {
|
||||
padding: 0.08em 0.32em;
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.markdown-renderer pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-renderer a {
|
||||
color: #1677ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-renderer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bgp-page__summary-card .ant-card-body {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
@@ -1122,6 +1271,10 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bgp-page__tabs .ant-tabs-tabpane-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bgp-page__tabs .ant-table-wrapper {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -1175,6 +1328,16 @@ body {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__brief-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__brief-actions,
|
||||
.bgp-page--compact .bgp-page__brief-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__summary-item {
|
||||
min-height: 64px;
|
||||
padding: 8px 10px;
|
||||
|
||||
@@ -1,19 +1,50 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Alert, Card, Col, Row, Space, Statistic, Table, Tabs, Tag, Typography } from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
type TableColumnsType,
|
||||
type TabsProps,
|
||||
} from 'antd'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
import {
|
||||
getSituationalAwarenessGateway,
|
||||
type BGPAnomaly,
|
||||
type BGPBriefRecord,
|
||||
type BGPBriefRecordSummary,
|
||||
type BGPCollectorCoverage,
|
||||
type BGPEvent,
|
||||
type BGPIncident,
|
||||
type CollectorSummary,
|
||||
type EventSummary,
|
||||
type Summary,
|
||||
getSituationalAwarenessGateway,
|
||||
} from '../../services/situational-awareness'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const OVERVIEW_OPTIONS = {
|
||||
incidentPageSize: 50,
|
||||
anomalyPageSize: 100,
|
||||
eventPageSize: 20,
|
||||
}
|
||||
|
||||
const DEFAULT_BGP_TAB = 'collectors'
|
||||
|
||||
const situationalAwarenessGateway = getSituationalAwarenessGateway()
|
||||
|
||||
function severityColor(severity: string) {
|
||||
@@ -23,50 +54,402 @@ function severityColor(severity: string) {
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function formatBriefOptions(records: BGPBriefRecordSummary[]) {
|
||||
return records.map((item) => ({
|
||||
value: item.id,
|
||||
label: `${formatDateTimeZhCN(item.generated_at)} · ${item.model}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function sortBriefRecords<T extends BGPBriefRecordSummary>(records: T[]) {
|
||||
return [...records].sort((left, right) => right.generated_at.localeCompare(left.generated_at))
|
||||
}
|
||||
|
||||
function renderCollectorLocation(_: unknown, record: BGPCollectorCoverage) {
|
||||
return [record.city, record.country].filter(Boolean).join(', ') || '-'
|
||||
}
|
||||
|
||||
function renderCollectorLatestEvent(_: unknown, record: BGPCollectorCoverage) {
|
||||
const time = formatDateTimeZhCN(record.latest_observed_at)
|
||||
return record.latest_event_type ? `${record.latest_event_type} @ ${time}` : time
|
||||
}
|
||||
|
||||
function renderCollectorScope(value: BGPCollectorCoverage['baseline_scope']) {
|
||||
const cities = value?.cities?.slice(0, 3).join(' / ') || ''
|
||||
const countries = value?.countries?.slice(0, 3).join(' / ') || ''
|
||||
return cities && countries ? `${cities} | ${countries}` : cities || countries || '-'
|
||||
}
|
||||
|
||||
function renderIncidentCollectors(value: string[]) {
|
||||
return value && value.length > 0 ? `${value.length}个 (${value.slice(0, 3).join(', ')})` : '-'
|
||||
}
|
||||
|
||||
function renderIncidentRegions(value: Array<{ country?: string; city?: string }>) {
|
||||
if (!value || value.length === 0) return '-'
|
||||
return value
|
||||
.slice(0, 3)
|
||||
.map((item) => [item.city, item.country].filter(Boolean).join(', '))
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
function renderIncidentCables(value: BGPIncident['related_cables']) {
|
||||
if (!value || value.length === 0) return '-'
|
||||
|
||||
return value
|
||||
.slice(0, 2)
|
||||
.map((item) => {
|
||||
const landing = item.landing_point || [item.city, item.country].filter(Boolean).join(', ')
|
||||
const cable = item.cable_names && item.cable_names.length > 0 ? item.cable_names[0] : '附近登陆点'
|
||||
const distance = item.distance_km !== undefined ? ` ${item.distance_km}km` : ''
|
||||
return `${landing} (${cable}${distance})`
|
||||
})
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
function renderPercentage(value: number) {
|
||||
return `${Math.round((value || 0) * 100)}%`
|
||||
}
|
||||
|
||||
function renderAnomalyAsn(_: unknown, record: BGPAnomaly) {
|
||||
if (record.origin_asn && record.new_origin_asn) {
|
||||
return `AS${record.origin_asn} -> AS${record.new_origin_asn}`
|
||||
}
|
||||
if (record.origin_asn) {
|
||||
return `AS${record.origin_asn}`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
function renderOptionalAsn(value: number | null) {
|
||||
return value ? `AS${value}` : '-'
|
||||
}
|
||||
|
||||
const collectorColumns: TableColumnsType<BGPCollectorCoverage> = [
|
||||
{
|
||||
title: '观测站',
|
||||
dataIndex: 'collector',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
width: 180,
|
||||
render: renderCollectorLocation,
|
||||
},
|
||||
{
|
||||
title: '近24h事件数',
|
||||
dataIndex: 'recent_24h_observation_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '近7d事件数',
|
||||
dataIndex: 'recent_7d_observation_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '前缀数',
|
||||
dataIndex: 'prefix_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: 'Origin ASN 数',
|
||||
dataIndex: 'origin_asn_count',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '最近事件',
|
||||
width: 220,
|
||||
render: renderCollectorLatestEvent,
|
||||
},
|
||||
{
|
||||
title: '日常覆盖范围',
|
||||
dataIndex: 'baseline_scope',
|
||||
width: 280,
|
||||
render: renderCollectorScope,
|
||||
},
|
||||
]
|
||||
|
||||
const incidentColumns: TableColumnsType<BGPIncident> = [
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'incident_type',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '严重度',
|
||||
dataIndex: 'severity',
|
||||
width: 120,
|
||||
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '影响前缀',
|
||||
dataIndex: 'affected_prefixes',
|
||||
width: 200,
|
||||
render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'),
|
||||
},
|
||||
{
|
||||
title: '观测站',
|
||||
dataIndex: 'affected_collectors',
|
||||
width: 180,
|
||||
render: renderIncidentCollectors,
|
||||
},
|
||||
{
|
||||
title: '区域',
|
||||
dataIndex: 'affected_regions',
|
||||
width: 220,
|
||||
render: renderIncidentRegions,
|
||||
},
|
||||
{
|
||||
title: '附近基础设施',
|
||||
dataIndex: 'related_cables',
|
||||
width: 260,
|
||||
render: renderIncidentCables,
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
width: 120,
|
||||
render: renderPercentage,
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
dataIndex: 'summary',
|
||||
width: 320,
|
||||
},
|
||||
]
|
||||
|
||||
const anomalyColumns: TableColumnsType<BGPAnomaly> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'anomaly_type',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '严重度',
|
||||
dataIndex: 'severity',
|
||||
width: 120,
|
||||
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '前缀',
|
||||
dataIndex: 'prefix',
|
||||
width: 180,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: 'ASN',
|
||||
key: 'asn',
|
||||
width: 160,
|
||||
render: renderAnomalyAsn,
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
width: 120,
|
||||
render: renderPercentage,
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
dataIndex: 'summary',
|
||||
width: 320,
|
||||
},
|
||||
]
|
||||
|
||||
const eventColumns: TableColumnsType<BGPEvent> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'observed_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '观测站',
|
||||
dataIndex: 'collector',
|
||||
width: 140,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'event_type',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '前缀',
|
||||
dataIndex: 'prefix',
|
||||
width: 200,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: 'Origin ASN',
|
||||
dataIndex: 'origin_asn',
|
||||
width: 140,
|
||||
render: renderOptionalAsn,
|
||||
},
|
||||
{
|
||||
title: 'Peer ASN',
|
||||
dataIndex: 'peer_asn',
|
||||
width: 140,
|
||||
render: renderOptionalAsn,
|
||||
},
|
||||
]
|
||||
|
||||
async function loadInitialBrief(savedBriefs: BGPBriefRecordSummary[]) {
|
||||
const latestBrief = await situationalAwarenessGateway.getLatestBGPBrief()
|
||||
if (latestBrief) {
|
||||
return latestBrief
|
||||
}
|
||||
if (savedBriefs[0]) {
|
||||
return situationalAwarenessGateway.getBGPBrief(savedBriefs[0].id)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function BGP() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [summaryLoading, setSummaryLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState(DEFAULT_BGP_TAB)
|
||||
const [incidents, setIncidents] = useState<BGPIncident[]>([])
|
||||
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
|
||||
const [events, setEvents] = useState<BGPEvent[]>([])
|
||||
const [collectors, setCollectors] = useState<BGPCollectorCoverage[]>([])
|
||||
const [collectorsLoading, setCollectorsLoading] = useState(false)
|
||||
const [incidentsLoading, setIncidentsLoading] = useState(false)
|
||||
const [anomaliesLoading, setAnomaliesLoading] = useState(false)
|
||||
const [eventsLoading, setEventsLoading] = useState(false)
|
||||
const [incidentSummary, setIncidentSummary] = useState<Summary | null>(null)
|
||||
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
|
||||
const [collectorSummary, setCollectorSummary] = useState<CollectorSummary | null>(null)
|
||||
const [compactViewport, setCompactViewport] = useState(false)
|
||||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [tableHeight, setTableHeight] = useState(360)
|
||||
const [briefLoading, setBriefLoading] = useState(false)
|
||||
const [briefDetailLoading, setBriefDetailLoading] = useState(false)
|
||||
const [brief, setBrief] = useState<BGPBriefRecord | null>(null)
|
||||
const [briefOptions, setBriefOptions] = useState<BGPBriefRecordSummary[]>([])
|
||||
const [selectedBriefId, setSelectedBriefId] = useState<string | null>(null)
|
||||
const [briefListLoaded, setBriefListLoaded] = useState(false)
|
||||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setSummaryLoading(true)
|
||||
try {
|
||||
const snapshot = await situationalAwarenessGateway.getBGPOverview({
|
||||
incidentPageSize: 50,
|
||||
anomalyPageSize: 100,
|
||||
eventPageSize: 20,
|
||||
})
|
||||
setIncidents(snapshot.incidents)
|
||||
setIncidentSummary(snapshot.incidentSummary)
|
||||
setAnomalies(snapshot.anomalies)
|
||||
setEvents(snapshot.events)
|
||||
setEventSummary(snapshot.eventSummary)
|
||||
setCollectors(snapshot.collectors)
|
||||
setCollectorSummary(snapshot.collectorSummary)
|
||||
const summary = await situationalAwarenessGateway.getBGPSummary()
|
||||
|
||||
setIncidentSummary(summary.incidentSummary)
|
||||
setEventSummary(summary.eventSummary)
|
||||
setCollectorSummary(summary.collectorSummary)
|
||||
} catch (error) {
|
||||
console.error('Failed to load BGP overview:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setSummaryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadTabData = async () => {
|
||||
if (activeTab === 'collectors' && collectors.length === 0 && !collectorsLoading) {
|
||||
setCollectorsLoading(true)
|
||||
try {
|
||||
setCollectors(await situationalAwarenessGateway.getBGPCollectors())
|
||||
} catch (error) {
|
||||
console.error('Failed to load BGP collectors:', error)
|
||||
} finally {
|
||||
setCollectorsLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTab === 'incidents' && incidents.length === 0 && !incidentsLoading) {
|
||||
setIncidentsLoading(true)
|
||||
try {
|
||||
setIncidents(await situationalAwarenessGateway.getBGPIncidents(OVERVIEW_OPTIONS.incidentPageSize))
|
||||
} catch (error) {
|
||||
console.error('Failed to load BGP incidents:', error)
|
||||
} finally {
|
||||
setIncidentsLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTab === 'anomalies' && anomalies.length === 0 && !anomaliesLoading) {
|
||||
setAnomaliesLoading(true)
|
||||
try {
|
||||
setAnomalies(await situationalAwarenessGateway.getBGPAnomalies(OVERVIEW_OPTIONS.anomalyPageSize))
|
||||
} catch (error) {
|
||||
console.error('Failed to load BGP anomalies:', error)
|
||||
} finally {
|
||||
setAnomaliesLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTab === 'events' && events.length === 0 && !eventsLoading) {
|
||||
setEventsLoading(true)
|
||||
try {
|
||||
setEvents(await situationalAwarenessGateway.getBGPEvents(OVERVIEW_OPTIONS.eventPageSize))
|
||||
} catch (error) {
|
||||
console.error('Failed to load BGP events:', error)
|
||||
} finally {
|
||||
setEventsLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTab === 'brief' && !briefListLoaded && !briefDetailLoading) {
|
||||
setBriefDetailLoading(true)
|
||||
try {
|
||||
const savedBriefs = sortBriefRecords(await situationalAwarenessGateway.listBGPBriefs())
|
||||
const initialBrief = await loadInitialBrief(savedBriefs)
|
||||
setBriefOptions(savedBriefs)
|
||||
setBrief(initialBrief)
|
||||
setSelectedBriefId(initialBrief?.id || savedBriefs[0]?.id || null)
|
||||
setBriefListLoaded(true)
|
||||
} catch (error) {
|
||||
console.error('Failed to load BGP briefs:', error)
|
||||
} finally {
|
||||
setBriefDetailLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadTabData()
|
||||
}, [
|
||||
activeTab,
|
||||
anomalies.length,
|
||||
anomaliesLoading,
|
||||
briefDetailLoading,
|
||||
briefListLoaded,
|
||||
collectors.length,
|
||||
collectorsLoading,
|
||||
events.length,
|
||||
eventsLoading,
|
||||
incidents.length,
|
||||
incidentsLoading,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportMode = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
const compact = window.innerHeight <= 860 || window.innerWidth <= 1440
|
||||
setCompactViewport(compact)
|
||||
setCompactViewport(window.innerHeight <= 860 || window.innerWidth <= 1440)
|
||||
}
|
||||
|
||||
updateViewportMode()
|
||||
@@ -77,7 +460,9 @@ function BGP() {
|
||||
useEffect(() => {
|
||||
const updateTableHeight = () => {
|
||||
const regionHeight = tableRegionRef.current?.offsetHeight || 0
|
||||
setTableHeight(Math.max(compactViewport ? 180 : 240, regionHeight - (compactViewport ? 40 : 52)))
|
||||
const minimumHeight = compactViewport ? 180 : 240
|
||||
const verticalOffset = compactViewport ? 40 : 52
|
||||
setTableHeight(Math.max(minimumHeight, regionHeight - verticalOffset))
|
||||
}
|
||||
|
||||
updateTableHeight()
|
||||
@@ -87,7 +472,9 @@ function BGP() {
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateTableHeight)
|
||||
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
|
||||
if (tableRegionRef.current) {
|
||||
observer.observe(tableRegionRef.current)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [compactViewport, collectors.length, incidents.length, anomalies.length, events.length])
|
||||
@@ -101,8 +488,165 @@ function BGP() {
|
||||
{ label: '严重事件', value: incidentSummary?.by_severity?.critical || 0 },
|
||||
]
|
||||
|
||||
const handleGenerateBrief = async () => {
|
||||
setBriefLoading(true)
|
||||
try {
|
||||
const record = await situationalAwarenessGateway.generateBGPBrief()
|
||||
setBrief(record)
|
||||
setSelectedBriefId(record.id)
|
||||
setBriefOptions((current) => sortBriefRecords([record, ...current.filter((item) => item.id !== record.id)]))
|
||||
setBriefListLoaded(true)
|
||||
messageApi.success('BGP AI 简报已生成')
|
||||
} catch (error) {
|
||||
console.error('Failed to generate BGP brief:', error)
|
||||
messageApi.error('BGP AI 简报生成失败,请检查 AI Provider 或稍后再试')
|
||||
} finally {
|
||||
setBriefLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBriefSelectionChange = async (briefId: string) => {
|
||||
setSelectedBriefId(briefId)
|
||||
setBriefDetailLoading(true)
|
||||
try {
|
||||
setBrief(await situationalAwarenessGateway.getBGPBrief(briefId))
|
||||
} catch (error) {
|
||||
console.error('Failed to load saved BGP brief:', error)
|
||||
messageApi.error('BGP AI 简报加载失败,请稍后再试')
|
||||
} finally {
|
||||
setBriefDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const briefTabContent = (
|
||||
<div className="bgp-page__brief-card">
|
||||
<div className="bgp-page__brief-head">
|
||||
<div>
|
||||
<Text strong>BGP AI 简报</Text>
|
||||
<div className="bgp-page__brief-subtitle">
|
||||
每次生成都会保存为 Markdown。打开页面时默认展示最近一次简报,也可以从历史列表切换查看。
|
||||
</div>
|
||||
</div>
|
||||
<div className="bgp-page__brief-actions">
|
||||
<Select
|
||||
className="bgp-page__brief-select"
|
||||
placeholder="选择历史简报"
|
||||
value={selectedBriefId || undefined}
|
||||
options={formatBriefOptions(briefOptions)}
|
||||
onChange={(value) => void handleBriefSelectionChange(value)}
|
||||
disabled={briefLoading || briefDetailLoading || briefOptions.length === 0}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={briefLoading}
|
||||
onClick={() => void handleGenerateBrief()}
|
||||
>
|
||||
生成 AI 简报
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{briefLoading || briefDetailLoading ? (
|
||||
<div className="bgp-page__brief-loading">
|
||||
<Spin />
|
||||
<Text type="secondary">
|
||||
{briefLoading ? '正在整理 BGP 事实并生成简报...' : '正在加载已保存的 BGP 简报...'}
|
||||
</Text>
|
||||
</div>
|
||||
) : brief ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Descriptions size="small" column={compactViewport ? 1 : 3} className="bgp-page__brief-meta">
|
||||
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="请求ID">{brief.request_id || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="生成时间" span={compactViewport ? 1 : 3}>
|
||||
{formatDateTimeZhCN(brief.generated_at)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div className="bgp-page__brief-content">
|
||||
<MarkdownRenderer markdown={brief.content_markdown} />
|
||||
</div>
|
||||
</Space>
|
||||
) : (
|
||||
<div className="bgp-page__brief-empty">
|
||||
<Text type="secondary">还没有生成简报。点击右侧按钮,用当前 BGP 真实数据生成并保存第一份 Markdown 简报。</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const tabItems: TabsProps['items'] = [
|
||||
{
|
||||
key: 'collectors',
|
||||
label: '观测站覆盖',
|
||||
children: (
|
||||
<Table<BGPCollectorCoverage>
|
||||
rowKey="collector"
|
||||
loading={summaryLoading || collectorsLoading}
|
||||
dataSource={collectors}
|
||||
pagination={false}
|
||||
scroll={{ x: 1240, y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={collectorColumns}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'incidents',
|
||||
label: '事件列表',
|
||||
children: (
|
||||
<Table<BGPIncident>
|
||||
rowKey="id"
|
||||
loading={summaryLoading || incidentsLoading}
|
||||
dataSource={incidents}
|
||||
pagination={false}
|
||||
scroll={{ x: 1660, y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={incidentColumns}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'anomalies',
|
||||
label: '异常明细',
|
||||
children: (
|
||||
<Table<BGPAnomaly>
|
||||
rowKey="id"
|
||||
loading={summaryLoading || anomaliesLoading}
|
||||
dataSource={anomalies}
|
||||
pagination={false}
|
||||
scroll={{ x: 1380, y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={anomalyColumns}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'events',
|
||||
label: '最近观测事件',
|
||||
children: (
|
||||
<Table<BGPEvent>
|
||||
rowKey="id"
|
||||
loading={summaryLoading || eventsLoading}
|
||||
dataSource={events}
|
||||
pagination={false}
|
||||
scroll={{ x: 980, y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={eventColumns}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'brief',
|
||||
label: 'AI 简报',
|
||||
children: briefTabContent,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
{contextHolder}
|
||||
<div className={`page-shell bgp-page${compactViewport ? ' bgp-page--compact' : ''}`}>
|
||||
<div className="page-shell__header bgp-page__header">
|
||||
<div>
|
||||
@@ -152,277 +696,9 @@ function BGP() {
|
||||
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region">
|
||||
<Tabs
|
||||
className="bgp-page__tabs"
|
||||
items={[
|
||||
{
|
||||
key: 'collectors',
|
||||
label: '观测站覆盖',
|
||||
children: (
|
||||
<Table<BGPCollectorCoverage>
|
||||
rowKey="collector"
|
||||
loading={loading}
|
||||
dataSource={collectors}
|
||||
pagination={false}
|
||||
scroll={{ x: 1240, y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{
|
||||
title: '观测站',
|
||||
dataIndex: 'collector',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
width: 180,
|
||||
render: (_, record) => [record.city, record.country].filter(Boolean).join(', ') || '-',
|
||||
},
|
||||
{
|
||||
title: '近24h事件数',
|
||||
dataIndex: 'recent_24h_observation_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '近7d事件数',
|
||||
dataIndex: 'recent_7d_observation_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '前缀数',
|
||||
dataIndex: 'prefix_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: 'Origin ASN 数',
|
||||
dataIndex: 'origin_asn_count',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '最近事件',
|
||||
width: 220,
|
||||
render: (_, record) => {
|
||||
const time = formatDateTimeZhCN(record.latest_observed_at)
|
||||
return record.latest_event_type ? `${record.latest_event_type} @ ${time}` : time
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '日常覆盖范围',
|
||||
dataIndex: 'baseline_scope',
|
||||
width: 280,
|
||||
render: (value: BGPCollectorCoverage['baseline_scope']) => {
|
||||
const cities = value?.cities?.slice(0, 3).join(' / ') || ''
|
||||
const countries = value?.countries?.slice(0, 3).join(' / ') || ''
|
||||
return cities && countries ? `${cities} | ${countries}` : cities || countries || '-'
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'incidents',
|
||||
label: '事件列表',
|
||||
children: (
|
||||
<Table<BGPIncident>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={incidents}
|
||||
pagination={false}
|
||||
scroll={{ x: 1660, y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'incident_type',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '严重度',
|
||||
dataIndex: 'severity',
|
||||
width: 120,
|
||||
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '影响前缀',
|
||||
dataIndex: 'affected_prefixes',
|
||||
width: 200,
|
||||
render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'),
|
||||
},
|
||||
{
|
||||
title: '观测站',
|
||||
dataIndex: 'affected_collectors',
|
||||
width: 180,
|
||||
render: (value: string[]) => (value && value.length > 0 ? `${value.length}个 (${value.slice(0, 3).join(', ')})` : '-'),
|
||||
},
|
||||
{
|
||||
title: '区域',
|
||||
dataIndex: 'affected_regions',
|
||||
width: 220,
|
||||
render: (value: Array<{ country?: string; city?: string }>) => {
|
||||
if (!value || value.length === 0) return '-'
|
||||
return value
|
||||
.slice(0, 3)
|
||||
.map((item) => [item.city, item.country].filter(Boolean).join(', '))
|
||||
.join(' / ')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '附近基础设施',
|
||||
dataIndex: 'related_cables',
|
||||
width: 260,
|
||||
render: (value: BGPIncident['related_cables']) => {
|
||||
if (!value || value.length === 0) return '-'
|
||||
return value
|
||||
.slice(0, 2)
|
||||
.map((item) => {
|
||||
const landing = item.landing_point || [item.city, item.country].filter(Boolean).join(', ')
|
||||
const cable = item.cable_names && item.cable_names.length > 0 ? item.cable_names[0] : '附近登陆点'
|
||||
const distance = item.distance_km !== undefined ? ` ${item.distance_km}km` : ''
|
||||
return `${landing} (${cable}${distance})`
|
||||
})
|
||||
.join(' / ')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
width: 120,
|
||||
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
dataIndex: 'summary',
|
||||
width: 320,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'anomalies',
|
||||
label: '异常明细',
|
||||
children: (
|
||||
<Table<BGPAnomaly>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={anomalies}
|
||||
pagination={false}
|
||||
scroll={{ x: 1380, y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'anomaly_type',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '严重度',
|
||||
dataIndex: 'severity',
|
||||
width: 120,
|
||||
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '前缀',
|
||||
dataIndex: 'prefix',
|
||||
width: 180,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: 'ASN',
|
||||
key: 'asn',
|
||||
width: 160,
|
||||
render: (_, record) => {
|
||||
if (record.origin_asn && record.new_origin_asn) {
|
||||
return `AS${record.origin_asn} -> AS${record.new_origin_asn}`
|
||||
}
|
||||
if (record.origin_asn) {
|
||||
return `AS${record.origin_asn}`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
width: 120,
|
||||
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
dataIndex: 'summary',
|
||||
width: 320,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'events',
|
||||
label: '最近观测事件',
|
||||
children: (
|
||||
<Table<BGPEvent>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={events}
|
||||
pagination={false}
|
||||
scroll={{ x: 980, y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'observed_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '观测站',
|
||||
dataIndex: 'collector',
|
||||
width: 140,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'event_type',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '前缀',
|
||||
dataIndex: 'prefix',
|
||||
width: 200,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: 'Origin ASN',
|
||||
dataIndex: 'origin_asn',
|
||||
width: 140,
|
||||
render: (value: number | null) => (value ? `AS${value}` : '-'),
|
||||
},
|
||||
{
|
||||
title: 'Peer ASN',
|
||||
dataIndex: 'peer_asn',
|
||||
width: 140,
|
||||
render: (value: number | null) => (value ? `AS${value}` : '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -2,11 +2,14 @@ import axios from 'axios'
|
||||
import type { SituationalAwarenessGateway } from './port'
|
||||
import type {
|
||||
BGPAnomaly,
|
||||
BGPBriefRecord,
|
||||
BGPBriefRecordSummary,
|
||||
BGPCollectorCoverage,
|
||||
BGPEvent,
|
||||
BGPIncident,
|
||||
BGPOverviewOptions,
|
||||
BGPOverviewSnapshot,
|
||||
BGPSummarySnapshot,
|
||||
CollectorSummary,
|
||||
EventSummary,
|
||||
ListResponse,
|
||||
@@ -16,6 +19,91 @@ import type {
|
||||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||
|
||||
export class HttpSituationalAwarenessGateway implements SituationalAwarenessGateway {
|
||||
private bgpSummaryPromise: Promise<BGPSummarySnapshot> | null = null
|
||||
private collectorsPromise: Promise<BGPCollectorCoverage[]> | null = null
|
||||
private incidentsPromises = new Map<number, Promise<BGPIncident[]>>()
|
||||
private anomaliesPromises = new Map<number, Promise<BGPAnomaly[]>>()
|
||||
private eventsPromises = new Map<number, Promise<BGPEvent[]>>()
|
||||
private briefListPromise: Promise<BGPBriefRecordSummary[]> | null = null
|
||||
private latestBriefPromise: Promise<BGPBriefRecord | null> | null = null
|
||||
|
||||
async getBGPSummary(): Promise<BGPSummarySnapshot> {
|
||||
if (!this.bgpSummaryPromise) {
|
||||
this.bgpSummaryPromise = axios
|
||||
.get<BGPSummarySnapshot>(`${API_BASE_URL}/bgp/overview/summary`)
|
||||
.then((response) => response.data)
|
||||
.finally(() => {
|
||||
this.bgpSummaryPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return this.bgpSummaryPromise
|
||||
}
|
||||
|
||||
async getBGPCollectors(): Promise<BGPCollectorCoverage[]> {
|
||||
if (!this.collectorsPromise) {
|
||||
this.collectorsPromise = axios
|
||||
.get<ListResponse<BGPCollectorCoverage>>(`${API_BASE_URL}/bgp/collectors`)
|
||||
.then((response) => response.data.data || [])
|
||||
.finally(() => {
|
||||
this.collectorsPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return this.collectorsPromise
|
||||
}
|
||||
|
||||
async getBGPIncidents(pageSize = 50): Promise<BGPIncident[]> {
|
||||
const existing = this.incidentsPromises.get(pageSize)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const request = axios
|
||||
.get<ListResponse<BGPIncident>>(`${API_BASE_URL}/bgp/incidents`, { params: { page_size: pageSize } })
|
||||
.then((response) => response.data.data || [])
|
||||
.finally(() => {
|
||||
this.incidentsPromises.delete(pageSize)
|
||||
})
|
||||
|
||||
this.incidentsPromises.set(pageSize, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async getBGPAnomalies(pageSize = 100): Promise<BGPAnomaly[]> {
|
||||
const existing = this.anomaliesPromises.get(pageSize)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const request = axios
|
||||
.get<ListResponse<BGPAnomaly>>(`${API_BASE_URL}/bgp/anomalies`, { params: { page_size: pageSize } })
|
||||
.then((response) => response.data.data || [])
|
||||
.finally(() => {
|
||||
this.anomaliesPromises.delete(pageSize)
|
||||
})
|
||||
|
||||
this.anomaliesPromises.set(pageSize, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async getBGPEvents(pageSize = 20): Promise<BGPEvent[]> {
|
||||
const existing = this.eventsPromises.get(pageSize)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const request = axios
|
||||
.get<ListResponse<BGPEvent>>(`${API_BASE_URL}/bgp/events`, { params: { page_size: pageSize } })
|
||||
.then((response) => response.data.data || [])
|
||||
.finally(() => {
|
||||
this.eventsPromises.delete(pageSize)
|
||||
})
|
||||
|
||||
this.eventsPromises.set(pageSize, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async getBGPOverview(options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
|
||||
const {
|
||||
incidentPageSize = 50,
|
||||
@@ -43,4 +131,46 @@ export class HttpSituationalAwarenessGateway implements SituationalAwarenessGate
|
||||
collectorSummary: collectorSummaryRes.data,
|
||||
}
|
||||
}
|
||||
|
||||
async generateBGPBrief(): Promise<BGPBriefRecord> {
|
||||
const response = await axios.post<BGPBriefRecord>(`${API_BASE_URL}/ai/bgp/brief`, {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async listBGPBriefs(): Promise<BGPBriefRecordSummary[]> {
|
||||
if (!this.briefListPromise) {
|
||||
this.briefListPromise = axios
|
||||
.get<BGPBriefRecordSummary[]>(`${API_BASE_URL}/ai/bgp/briefs`)
|
||||
.then((response) => response.data || [])
|
||||
.finally(() => {
|
||||
this.briefListPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return this.briefListPromise
|
||||
}
|
||||
|
||||
async getBGPBrief(briefId: string): Promise<BGPBriefRecord> {
|
||||
const response = await axios.get<BGPBriefRecord>(`${API_BASE_URL}/ai/bgp/briefs/${briefId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getLatestBGPBrief(): Promise<BGPBriefRecord | null> {
|
||||
if (!this.latestBriefPromise) {
|
||||
this.latestBriefPromise = axios
|
||||
.get<BGPBriefRecord | null>(`${API_BASE_URL}/ai/bgp/briefs/latest`)
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
this.latestBriefPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return this.latestBriefPromise
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { SituationalAwarenessGateway } from './port'
|
||||
import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
|
||||
import type {
|
||||
BGPBriefRecord,
|
||||
BGPBriefRecordSummary,
|
||||
BGPOverviewOptions,
|
||||
BGPOverviewSnapshot,
|
||||
BGPSummarySnapshot,
|
||||
} from './types'
|
||||
|
||||
const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
|
||||
incidents: [],
|
||||
@@ -29,7 +35,69 @@ const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
|
||||
}
|
||||
|
||||
export class MockSituationalAwarenessGateway implements SituationalAwarenessGateway {
|
||||
private readonly brief: BGPBriefRecord = {
|
||||
id: 'mock-bgp-brief-001',
|
||||
title: 'BGP AI 简报',
|
||||
provider: 'mock',
|
||||
model: 'mock-brief',
|
||||
request_id: 'mock-bgp-brief',
|
||||
generated_at: '2026-04-09T12:00:00+08:00',
|
||||
content_markdown: [
|
||||
'# BGP 态势简报',
|
||||
'',
|
||||
'## 当前判断',
|
||||
'',
|
||||
'- 当前 BGP 态势以高严重度事件为主。',
|
||||
'- 建议优先核查活跃 incidents 涉及的受影响前缀与重点观测站。',
|
||||
'',
|
||||
'## 值班建议',
|
||||
'',
|
||||
'1. 先确认高严重度 incident 是否持续活跃。',
|
||||
'2. 对照重点 collector 的近 24h 波动,避免将控制平面噪声误判为真实业务中断。',
|
||||
].join('\n'),
|
||||
}
|
||||
|
||||
async getBGPOverview(_options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
|
||||
return EMPTY_SNAPSHOT
|
||||
}
|
||||
|
||||
async getBGPSummary(): Promise<BGPSummarySnapshot> {
|
||||
return {
|
||||
incidentSummary: EMPTY_SNAPSHOT.incidentSummary,
|
||||
eventSummary: EMPTY_SNAPSHOT.eventSummary,
|
||||
collectorSummary: EMPTY_SNAPSHOT.collectorSummary,
|
||||
}
|
||||
}
|
||||
|
||||
async getBGPCollectors() {
|
||||
return EMPTY_SNAPSHOT.collectors
|
||||
}
|
||||
|
||||
async getBGPIncidents() {
|
||||
return EMPTY_SNAPSHOT.incidents
|
||||
}
|
||||
|
||||
async getBGPAnomalies() {
|
||||
return EMPTY_SNAPSHOT.anomalies
|
||||
}
|
||||
|
||||
async getBGPEvents() {
|
||||
return EMPTY_SNAPSHOT.events
|
||||
}
|
||||
|
||||
async generateBGPBrief(): Promise<BGPBriefRecord> {
|
||||
return this.brief
|
||||
}
|
||||
|
||||
async listBGPBriefs(): Promise<BGPBriefRecordSummary[]> {
|
||||
return [this.brief]
|
||||
}
|
||||
|
||||
async getBGPBrief(_briefId: string): Promise<BGPBriefRecord> {
|
||||
return this.brief
|
||||
}
|
||||
|
||||
async getLatestBGPBrief(): Promise<BGPBriefRecord | null> {
|
||||
return this.brief
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
|
||||
import type {
|
||||
BGPAnomaly,
|
||||
BGPBriefRecord,
|
||||
BGPBriefRecordSummary,
|
||||
BGPCollectorCoverage,
|
||||
BGPEvent,
|
||||
BGPIncident,
|
||||
BGPOverviewOptions,
|
||||
BGPOverviewSnapshot,
|
||||
BGPSummarySnapshot,
|
||||
} from './types'
|
||||
|
||||
export interface SituationalAwarenessGateway {
|
||||
getBGPOverview(options?: BGPOverviewOptions): Promise<BGPOverviewSnapshot>
|
||||
getBGPSummary(): Promise<BGPSummarySnapshot>
|
||||
getBGPCollectors(): Promise<BGPCollectorCoverage[]>
|
||||
getBGPIncidents(pageSize?: number): Promise<BGPIncident[]>
|
||||
getBGPAnomalies(pageSize?: number): Promise<BGPAnomaly[]>
|
||||
getBGPEvents(pageSize?: number): Promise<BGPEvent[]>
|
||||
generateBGPBrief(): Promise<BGPBriefRecord>
|
||||
listBGPBriefs(): Promise<BGPBriefRecordSummary[]>
|
||||
getBGPBrief(briefId: string): Promise<BGPBriefRecord>
|
||||
getLatestBGPBrief(): Promise<BGPBriefRecord | null>
|
||||
}
|
||||
|
||||
@@ -105,8 +105,43 @@ export interface BGPOverviewSnapshot {
|
||||
collectorSummary: CollectorSummary | null
|
||||
}
|
||||
|
||||
export interface BGPSummarySnapshot {
|
||||
incidentSummary: Summary | null
|
||||
eventSummary: EventSummary | null
|
||||
collectorSummary: CollectorSummary | null
|
||||
}
|
||||
|
||||
export interface BGPOverviewOptions {
|
||||
incidentPageSize?: number
|
||||
anomalyPageSize?: number
|
||||
eventPageSize?: number
|
||||
}
|
||||
|
||||
export interface AIContentBlock {
|
||||
type: string
|
||||
text?: string | null
|
||||
thinking?: string | null
|
||||
}
|
||||
|
||||
export interface AnalysisResponse {
|
||||
provider: string
|
||||
model: string
|
||||
content: string
|
||||
content_blocks: AIContentBlock[]
|
||||
text_blocks: string[]
|
||||
thinking_blocks: string[]
|
||||
raw_response: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface BGPBriefRecordSummary {
|
||||
id: string
|
||||
title: string
|
||||
provider: string
|
||||
model: string
|
||||
request_id?: string | null
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export interface BGPBriefRecord extends BGPBriefRecordSummary {
|
||||
content_markdown: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.24.4"
|
||||
version = "0.24.5"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
15
rules.md
15
rules.md
@@ -224,3 +224,18 @@ class BaseCollector:
|
||||
- Dependency injection for testability
|
||||
- Feature flags for incomplete features
|
||||
- Use config files for environment-specific settings
|
||||
|
||||
---
|
||||
|
||||
## Query Performance - MANDATORY
|
||||
|
||||
- **NEVER** load whole tables into Python just to do filtering, pagination, counting, dedupe, or summary aggregation
|
||||
- Filters, sorting, pagination, `count`, `distinct`, and grouped statistics **MUST** be pushed down to the database whenever the ORM/query builder can express them
|
||||
- Summary/dashboard endpoints should prefer dedicated aggregate queries or aggregate endpoints, not multiple full-table scans
|
||||
- For hot paths, avoid selecting large JSON/text payload columns unless the response really needs them
|
||||
- If an endpoint returns a list, default to database-side pagination instead of `scalars().all()` followed by Python slicing
|
||||
- When you suspect a query is slow, first check for:
|
||||
- full-table ORM loads
|
||||
- Python-side post-filtering
|
||||
- repeated summary queries that can be merged
|
||||
- repeated per-request recomputation that should be cached or aggregated once
|
||||
|
||||
Reference in New Issue
Block a user