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
|
*.temp
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# Runtime Data
|
||||||
|
# ----------------------
|
||||||
|
data/ai/bgp-briefs/
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
from uuid import uuid4
|
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.core.security import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.ai import (
|
from app.schemas.ai import (
|
||||||
AIProviderStatusResponse,
|
AIProviderStatusResponse,
|
||||||
|
BGPBriefRequest,
|
||||||
|
BGPBriefRecordResponse,
|
||||||
|
BGPBriefRecordSummary,
|
||||||
SituationalAnalysisRequest,
|
SituationalAnalysisRequest,
|
||||||
SituationalAnalysisResponse,
|
SituationalAnalysisResponse,
|
||||||
)
|
)
|
||||||
from app.services.ai_client import AIProviderClient, get_ai_provider_client
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -37,3 +49,53 @@ async def analyze_situational_awareness(
|
|||||||
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||||
response.headers["X-Request-ID"] = request_id
|
response.headers["X-Request-ID"] = request_id
|
||||||
return await provider_client.analyze(payload, 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:
|
if not value:
|
||||||
return None
|
return None
|
||||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
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:
|
def _anomaly_filters(
|
||||||
if value is None:
|
*,
|
||||||
return False
|
severity: Optional[str],
|
||||||
if time_from and value < time_from:
|
anomaly_type: Optional[str],
|
||||||
return False
|
status: Optional[str],
|
||||||
if time_to and value > time_to:
|
prefix: Optional[str],
|
||||||
return False
|
origin_asn: Optional[int],
|
||||||
return True
|
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")
|
@router.get("/events")
|
||||||
@@ -49,41 +194,36 @@ async def list_bgp_events(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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_from = _parse_dt(time_from)
|
||||||
dt_to = _parse_dt(time_to)
|
dt_to = _parse_dt(time_to)
|
||||||
|
filters = _event_filters(
|
||||||
filtered = []
|
prefix=prefix,
|
||||||
for record in records:
|
origin_asn=origin_asn,
|
||||||
if prefix and record.prefix != prefix:
|
peer_asn=peer_asn,
|
||||||
continue
|
collector=collector,
|
||||||
if origin_asn is not None and record.origin_asn != origin_asn:
|
event_type=event_type,
|
||||||
continue
|
source=source,
|
||||||
if peer_asn is not None and record.peer_asn != peer_asn:
|
time_from=dt_from,
|
||||||
continue
|
time_to=dt_to,
|
||||||
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)
|
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
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 {
|
return {
|
||||||
"total": len(filtered),
|
"total": count_result.scalar() or 0,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"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),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(BGPObservation).where(BGPObservation.source.in_(BGP_SOURCES)))
|
return await _build_event_summary_payload(db)
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/collectors")
|
@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}")
|
@router.get("/events/{event_id}")
|
||||||
async def get_bgp_event(
|
async def get_bgp_event(
|
||||||
event_id: int,
|
event_id: int,
|
||||||
@@ -164,31 +316,35 @@ async def list_bgp_anomalies(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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_from = _parse_dt(time_from)
|
||||||
dt_to = _parse_dt(time_to)
|
dt_to = _parse_dt(time_to)
|
||||||
if dt_from or dt_to:
|
filters = _anomaly_filters(
|
||||||
records = [record for record in records if _matches_time(record.created_at, dt_from, dt_to)]
|
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
|
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 {
|
return {
|
||||||
"total": len(records),
|
"total": total_result.scalar() or 0,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"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),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
total_result = await db.execute(select(func.count(BGPAnomaly.id)))
|
return await _build_anomaly_summary_payload(db)
|
||||||
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()},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/anomalies/{anomaly_id}")
|
@router.get("/anomalies/{anomaly_id}")
|
||||||
@@ -244,22 +378,29 @@ async def list_bgp_incidents(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
|
filters = _incident_filters(
|
||||||
if severity:
|
severity=severity,
|
||||||
stmt = stmt.where(BGPIncident.severity == severity)
|
incident_type=incident_type,
|
||||||
if incident_type:
|
status=status,
|
||||||
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()
|
|
||||||
offset = (page - 1) * page_size
|
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 {
|
return {
|
||||||
"total": len(records),
|
"total": total_result.scalar() or 0,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"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),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
total_result = await db.execute(select(func.count(BGPIncident.id)))
|
return await _build_incident_summary_payload(db)
|
||||||
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("/incidents/{incident_id}")
|
@router.get("/incidents/{incident_id}")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ class SituationalAnalysisRequest(BaseModel):
|
|||||||
thinking: dict[str, Any] | None = None
|
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):
|
class SituationalAnalysisResponse(BaseModel):
|
||||||
provider: str
|
provider: str
|
||||||
model: str
|
model: str
|
||||||
@@ -31,6 +39,19 @@ class SituationalAnalysisResponse(BaseModel):
|
|||||||
raw_response: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class AIProviderStatusResponse(BaseModel):
|
||||||
provider: str
|
provider: str
|
||||||
api: str | None = None
|
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 datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import case, distinct, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.time import to_iso8601_utc
|
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
|
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(
|
async def build_bgp_collector_coverage(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -24,88 +34,148 @@ async def build_bgp_collector_coverage(
|
|||||||
recent_24h_threshold = now - timedelta(hours=24)
|
recent_24h_threshold = now - timedelta(hours=24)
|
||||||
recent_7d_threshold = now - timedelta(days=7)
|
recent_7d_threshold = now - timedelta(days=7)
|
||||||
|
|
||||||
stmt = select(BGPObservation).order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
|
filters = _collector_base_filters(source_filter)
|
||||||
if source_filter:
|
country_expr = func.nullif(BGPObservation.collector_geo["country"].astext, "")
|
||||||
stmt = stmt.where(BGPObservation.source.in_(source_filter))
|
city_expr = func.nullif(BGPObservation.collector_geo["city"].astext, "")
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
aggregate_stmt = (
|
||||||
records = list(result.scalars().all())
|
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]] = {}
|
by_collector: dict[str, dict[str, Any]] = {}
|
||||||
for record in records:
|
for row in aggregate_rows:
|
||||||
collector = str(record.collector or "").strip()
|
collector = row.collector
|
||||||
if not collector:
|
latest = latest_by_collector.get(collector, {})
|
||||||
continue
|
fallback_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
|
||||||
|
scope = scope_by_collector.get(collector, {"countries": set(), "cities": set()})
|
||||||
|
|
||||||
coverage = by_collector.get(collector)
|
by_collector[collector] = {
|
||||||
if coverage is None:
|
|
||||||
location = record.collector_geo or RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
|
|
||||||
coverage = {
|
|
||||||
"collector": collector,
|
"collector": collector,
|
||||||
"city": location.get("city"),
|
"city": latest.get("city") or fallback_location.get("city"),
|
||||||
"country": location.get("country"),
|
"country": latest.get("country") or fallback_location.get("country"),
|
||||||
"latitude": location.get("latitude"),
|
"latitude": fallback_location.get("latitude"),
|
||||||
"longitude": location.get("longitude"),
|
"longitude": fallback_location.get("longitude"),
|
||||||
"observation_count": 0,
|
"observation_count": row.observation_count or 0,
|
||||||
"prefixes": set(),
|
"prefix_count": row.prefix_count or 0,
|
||||||
"origin_asns": set(),
|
"origin_asn_count": row.origin_asn_count or 0,
|
||||||
"peer_asns": set(),
|
"peer_asn_count": row.peer_asn_count or 0,
|
||||||
"event_types": defaultdict(int),
|
"recent_15m_observation_count": row.recent_15m_observation_count or 0,
|
||||||
"countries": set(),
|
"recent_24h_observation_count": row.recent_24h_observation_count or 0,
|
||||||
"cities": set(),
|
"recent_7d_observation_count": row.recent_7d_observation_count or 0,
|
||||||
"recent_15m_observation_count": 0,
|
"recent_15m_prefix_count": row.recent_15m_prefix_count or 0,
|
||||||
"recent_24h_observation_count": 0,
|
"recent_24h_prefix_count": row.recent_24h_prefix_count or 0,
|
||||||
"recent_7d_observation_count": 0,
|
"recent_7d_prefix_count": row.recent_7d_prefix_count or 0,
|
||||||
"recent_15m_prefixes": set(),
|
"top_event_types": top_events_by_collector.get(collector, []),
|
||||||
"recent_24h_prefixes": set(),
|
"latest_observed_at": to_iso8601_utc(row.latest_observed_at),
|
||||||
"recent_7d_prefixes": set(),
|
"latest_event_type": latest.get("latest_event_type"),
|
||||||
"latest_observed_at": None,
|
"baseline_scope": {
|
||||||
"latest_event_type": None,
|
"countries": sorted(scope["countries"]),
|
||||||
|
"cities": sorted(scope["cities"]),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
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
|
|
||||||
|
|
||||||
for collector, location in RIPE_RIS_COLLECTOR_COORDS.items():
|
for collector, location in RIPE_RIS_COLLECTOR_COORDS.items():
|
||||||
if collector in by_collector:
|
if collector in by_collector:
|
||||||
@@ -117,57 +187,22 @@ async def build_bgp_collector_coverage(
|
|||||||
"latitude": location.get("latitude"),
|
"latitude": location.get("latitude"),
|
||||||
"longitude": location.get("longitude"),
|
"longitude": location.get("longitude"),
|
||||||
"observation_count": 0,
|
"observation_count": 0,
|
||||||
"prefixes": set(),
|
"prefix_count": 0,
|
||||||
"origin_asns": set(),
|
"origin_asn_count": 0,
|
||||||
"peer_asns": set(),
|
"peer_asn_count": 0,
|
||||||
"event_types": defaultdict(int),
|
|
||||||
"countries": {location.get("country")} if location.get("country") else set(),
|
|
||||||
"cities": {location.get("city")} if location.get("city") else set(),
|
|
||||||
"recent_15m_observation_count": 0,
|
"recent_15m_observation_count": 0,
|
||||||
"recent_24h_observation_count": 0,
|
"recent_24h_observation_count": 0,
|
||||||
"recent_7d_observation_count": 0,
|
"recent_7d_observation_count": 0,
|
||||||
"recent_15m_prefixes": set(),
|
"recent_15m_prefix_count": 0,
|
||||||
"recent_24h_prefixes": set(),
|
"recent_24h_prefix_count": 0,
|
||||||
"recent_7d_prefixes": set(),
|
"recent_7d_prefix_count": 0,
|
||||||
|
"top_event_types": [],
|
||||||
"latest_observed_at": None,
|
"latest_observed_at": None,
|
||||||
"latest_event_type": None,
|
"latest_event_type": None,
|
||||||
}
|
|
||||||
|
|
||||||
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": {
|
"baseline_scope": {
|
||||||
"countries": sorted(country for country in item["countries"] if country),
|
"countries": [location["country"]] if location.get("country") else [],
|
||||||
"cities": sorted(city for city in item["cities"] if city),
|
"cities": [location["city"]] if location.get("city") else [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
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`
|
- `feature` -> `+0.1.0`
|
||||||
- `bugfix` -> `+0.0.1`
|
- `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
|
## 0.24.4
|
||||||
|
|
||||||
Released: 2026-04-09
|
Released: 2026-04-09
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.24.4`
|
- `dev` 当前开发分支历史推导到:`0.24.5`
|
||||||
|
|
||||||
## Timeline
|
## 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.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.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.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
|
## Maintenance Commits Not Counted as Version Bumps
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.24.4",
|
"version": "0.24.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"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;
|
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 {
|
.bgp-page__summary-card .ant-card-body {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
@@ -1122,6 +1271,10 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bgp-page__tabs .ant-tabs-tabpane-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.bgp-page__tabs .ant-table-wrapper {
|
.bgp-page__tabs .ant-table-wrapper {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1175,6 +1328,16 @@ body {
|
|||||||
padding: 10px 12px;
|
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 {
|
.bgp-page--compact .bgp-page__summary-item {
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
@@ -1,19 +1,50 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
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 AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
import {
|
import {
|
||||||
getSituationalAwarenessGateway,
|
|
||||||
type BGPAnomaly,
|
type BGPAnomaly,
|
||||||
|
type BGPBriefRecord,
|
||||||
|
type BGPBriefRecordSummary,
|
||||||
type BGPCollectorCoverage,
|
type BGPCollectorCoverage,
|
||||||
type BGPEvent,
|
type BGPEvent,
|
||||||
type BGPIncident,
|
type BGPIncident,
|
||||||
type CollectorSummary,
|
type CollectorSummary,
|
||||||
type EventSummary,
|
type EventSummary,
|
||||||
type Summary,
|
type Summary,
|
||||||
|
getSituationalAwarenessGateway,
|
||||||
} from '../../services/situational-awareness'
|
} from '../../services/situational-awareness'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
const OVERVIEW_OPTIONS = {
|
||||||
|
incidentPageSize: 50,
|
||||||
|
anomalyPageSize: 100,
|
||||||
|
eventPageSize: 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BGP_TAB = 'collectors'
|
||||||
|
|
||||||
const situationalAwarenessGateway = getSituationalAwarenessGateway()
|
const situationalAwarenessGateway = getSituationalAwarenessGateway()
|
||||||
|
|
||||||
function severityColor(severity: string) {
|
function severityColor(severity: string) {
|
||||||
@@ -23,50 +54,402 @@ function severityColor(severity: string) {
|
|||||||
return 'blue'
|
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() {
|
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 [incidents, setIncidents] = useState<BGPIncident[]>([])
|
||||||
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
|
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
|
||||||
const [events, setEvents] = useState<BGPEvent[]>([])
|
const [events, setEvents] = useState<BGPEvent[]>([])
|
||||||
const [collectors, setCollectors] = useState<BGPCollectorCoverage[]>([])
|
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 [incidentSummary, setIncidentSummary] = useState<Summary | null>(null)
|
||||||
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
|
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
|
||||||
const [collectorSummary, setCollectorSummary] = useState<CollectorSummary | null>(null)
|
const [collectorSummary, setCollectorSummary] = useState<CollectorSummary | null>(null)
|
||||||
const [compactViewport, setCompactViewport] = useState(false)
|
const [compactViewport, setCompactViewport] = useState(false)
|
||||||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const [tableHeight, setTableHeight] = useState(360)
|
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(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setSummaryLoading(true)
|
||||||
try {
|
try {
|
||||||
const snapshot = await situationalAwarenessGateway.getBGPOverview({
|
const summary = await situationalAwarenessGateway.getBGPSummary()
|
||||||
incidentPageSize: 50,
|
|
||||||
anomalyPageSize: 100,
|
setIncidentSummary(summary.incidentSummary)
|
||||||
eventPageSize: 20,
|
setEventSummary(summary.eventSummary)
|
||||||
})
|
setCollectorSummary(summary.collectorSummary)
|
||||||
setIncidents(snapshot.incidents)
|
|
||||||
setIncidentSummary(snapshot.incidentSummary)
|
|
||||||
setAnomalies(snapshot.anomalies)
|
|
||||||
setEvents(snapshot.events)
|
|
||||||
setEventSummary(snapshot.eventSummary)
|
|
||||||
setCollectors(snapshot.collectors)
|
|
||||||
setCollectorSummary(snapshot.collectorSummary)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load BGP overview:', error)
|
console.error('Failed to load BGP overview:', error)
|
||||||
} finally {
|
} 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(() => {
|
useEffect(() => {
|
||||||
const updateViewportMode = () => {
|
const updateViewportMode = () => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
const compact = window.innerHeight <= 860 || window.innerWidth <= 1440
|
setCompactViewport(window.innerHeight <= 860 || window.innerWidth <= 1440)
|
||||||
setCompactViewport(compact)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateViewportMode()
|
updateViewportMode()
|
||||||
@@ -77,7 +460,9 @@ function BGP() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateTableHeight = () => {
|
const updateTableHeight = () => {
|
||||||
const regionHeight = tableRegionRef.current?.offsetHeight || 0
|
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()
|
updateTableHeight()
|
||||||
@@ -87,7 +472,9 @@ function BGP() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateTableHeight)
|
const observer = new ResizeObserver(updateTableHeight)
|
||||||
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
|
if (tableRegionRef.current) {
|
||||||
|
observer.observe(tableRegionRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [compactViewport, collectors.length, incidents.length, anomalies.length, events.length])
|
}, [compactViewport, collectors.length, incidents.length, anomalies.length, events.length])
|
||||||
@@ -101,8 +488,165 @@ function BGP() {
|
|||||||
{ label: '严重事件', value: incidentSummary?.by_severity?.critical || 0 },
|
{ 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 (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
|
{contextHolder}
|
||||||
<div className={`page-shell bgp-page${compactViewport ? ' bgp-page--compact' : ''}`}>
|
<div className={`page-shell bgp-page${compactViewport ? ' bgp-page--compact' : ''}`}>
|
||||||
<div className="page-shell__header bgp-page__header">
|
<div className="page-shell__header bgp-page__header">
|
||||||
<div>
|
<div>
|
||||||
@@ -152,277 +696,9 @@ function BGP() {
|
|||||||
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region">
|
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region">
|
||||||
<Tabs
|
<Tabs
|
||||||
className="bgp-page__tabs"
|
className="bgp-page__tabs"
|
||||||
items={[
|
activeKey={activeTab}
|
||||||
{
|
onChange={setActiveTab}
|
||||||
key: 'collectors',
|
items={tabItems}
|
||||||
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}` : '-'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import axios from 'axios'
|
|||||||
import type { SituationalAwarenessGateway } from './port'
|
import type { SituationalAwarenessGateway } from './port'
|
||||||
import type {
|
import type {
|
||||||
BGPAnomaly,
|
BGPAnomaly,
|
||||||
|
BGPBriefRecord,
|
||||||
|
BGPBriefRecordSummary,
|
||||||
BGPCollectorCoverage,
|
BGPCollectorCoverage,
|
||||||
BGPEvent,
|
BGPEvent,
|
||||||
BGPIncident,
|
BGPIncident,
|
||||||
BGPOverviewOptions,
|
BGPOverviewOptions,
|
||||||
BGPOverviewSnapshot,
|
BGPOverviewSnapshot,
|
||||||
|
BGPSummarySnapshot,
|
||||||
CollectorSummary,
|
CollectorSummary,
|
||||||
EventSummary,
|
EventSummary,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
@@ -16,6 +19,91 @@ import type {
|
|||||||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||||
|
|
||||||
export class HttpSituationalAwarenessGateway implements SituationalAwarenessGateway {
|
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> {
|
async getBGPOverview(options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
|
||||||
const {
|
const {
|
||||||
incidentPageSize = 50,
|
incidentPageSize = 50,
|
||||||
@@ -43,4 +131,46 @@ export class HttpSituationalAwarenessGateway implements SituationalAwarenessGate
|
|||||||
collectorSummary: collectorSummaryRes.data,
|
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 { SituationalAwarenessGateway } from './port'
|
||||||
import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
|
import type {
|
||||||
|
BGPBriefRecord,
|
||||||
|
BGPBriefRecordSummary,
|
||||||
|
BGPOverviewOptions,
|
||||||
|
BGPOverviewSnapshot,
|
||||||
|
BGPSummarySnapshot,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
|
const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
|
||||||
incidents: [],
|
incidents: [],
|
||||||
@@ -29,7 +35,69 @@ const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MockSituationalAwarenessGateway implements SituationalAwarenessGateway {
|
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> {
|
async getBGPOverview(_options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
|
||||||
return EMPTY_SNAPSHOT
|
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 {
|
export interface SituationalAwarenessGateway {
|
||||||
getBGPOverview(options?: BGPOverviewOptions): Promise<BGPOverviewSnapshot>
|
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
|
collectorSummary: CollectorSummary | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BGPSummarySnapshot {
|
||||||
|
incidentSummary: Summary | null
|
||||||
|
eventSummary: EventSummary | null
|
||||||
|
collectorSummary: CollectorSummary | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface BGPOverviewOptions {
|
export interface BGPOverviewOptions {
|
||||||
incidentPageSize?: number
|
incidentPageSize?: number
|
||||||
anomalyPageSize?: number
|
anomalyPageSize?: number
|
||||||
eventPageSize?: 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]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.24.4"
|
version = "0.24.5"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
15
rules.md
15
rules.md
@@ -224,3 +224,18 @@ class BaseCollector:
|
|||||||
- Dependency injection for testability
|
- Dependency injection for testability
|
||||||
- Feature flags for incomplete features
|
- Feature flags for incomplete features
|
||||||
- Use config files for environment-specific settings
|
- 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