fix: ship persistent bgp ai briefs and optimize bgp queries

This commit is contained in:
rayd1o
2026-04-10 00:33:01 +08:00
parent ed898aef9c
commit 83839b8b11
21 changed files with 1996 additions and 557 deletions

5
.gitignore vendored
View File

@@ -145,3 +145,8 @@ docs/.venv/
*.temp
tmp/
temp/
# ----------------------
# Runtime Data
# ----------------------
data/ai/bgp-briefs/

View File

@@ -1 +1 @@
0.24.4
0.24.5

View File

@@ -1,15 +1,27 @@
from uuid import uuid4
from fastapi import APIRouter, Depends, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user
from app.db.session import get_db
from app.models.user import User
from app.schemas.ai import (
AIProviderStatusResponse,
BGPBriefRequest,
BGPBriefRecordResponse,
BGPBriefRecordSummary,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
from app.services.ai_client import AIProviderClient, get_ai_provider_client
from app.services.bgp_ai_brief import build_bgp_brief_request
from app.services.bgp_ai_brief_store import (
get_bgp_brief_record,
get_latest_bgp_brief_record,
list_bgp_brief_records,
save_bgp_brief_record,
)
router = APIRouter()
@@ -37,3 +49,53 @@ async def analyze_situational_awareness(
request_id = request.headers.get("X-Request-ID") or str(uuid4())
response.headers["X-Request-ID"] = request_id
return await provider_client.analyze(payload, request_id=request_id)
@router.get("/bgp/briefs", response_model=list[BGPBriefRecordSummary])
async def list_saved_bgp_briefs(
current_user: User = Depends(get_current_user),
):
return list_bgp_brief_records()
@router.get("/bgp/briefs/latest", response_model=BGPBriefRecordResponse | None)
async def get_latest_saved_bgp_brief(
current_user: User = Depends(get_current_user),
):
return get_latest_bgp_brief_record()
@router.get("/bgp/briefs/{brief_id}", response_model=BGPBriefRecordResponse)
async def get_saved_bgp_brief(
brief_id: str,
current_user: User = Depends(get_current_user),
):
record = get_bgp_brief_record(brief_id)
if record is None:
raise HTTPException(status_code=404, detail="BGP brief not found")
return record
@router.post("/bgp/brief", response_model=BGPBriefRecordResponse)
async def analyze_bgp_brief(
payload: BGPBriefRequest,
request: Request,
response: Response,
current_user: User = Depends(get_current_user),
provider_client: AIProviderClient = Depends(get_ai_provider_client),
db: AsyncSession = Depends(get_db),
):
request_id = request.headers.get("X-Request-ID") or str(uuid4())
response.headers["X-Request-ID"] = request_id
brief_request = await build_bgp_brief_request(
db,
incident_limit=payload.incident_limit,
anomaly_limit=payload.anomaly_limit,
collector_limit=payload.collector_limit,
)
brief_request.preferred_model = payload.preferred_model
brief_request.thinking = payload.thinking
analysis = await provider_client.analyze(brief_request, request_id=request_id)
return save_bgp_brief_record(analysis, request_id=request_id)

View File

@@ -22,16 +22,161 @@ def _parse_dt(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _event_filters(
*,
prefix: Optional[str],
origin_asn: Optional[int],
peer_asn: Optional[int],
collector: Optional[str],
event_type: Optional[str],
source: Optional[str],
time_from: Optional[datetime],
time_to: Optional[datetime],
):
filters = [BGPObservation.source.in_(BGP_SOURCES)]
if source:
filters.append(BGPObservation.source == source)
if prefix:
filters.append(BGPObservation.prefix == prefix)
if origin_asn is not None:
filters.append(BGPObservation.origin_asn == origin_asn)
if peer_asn is not None:
filters.append(BGPObservation.peer_asn == peer_asn)
if collector:
filters.append(BGPObservation.collector == collector)
if event_type:
filters.append(BGPObservation.event_type == event_type)
if time_from:
filters.append(BGPObservation.observed_at >= time_from)
if time_to:
filters.append(BGPObservation.observed_at <= time_to)
return filters
def _matches_time(value: Optional[datetime], time_from: Optional[datetime], time_to: Optional[datetime]) -> bool:
if value is None:
return False
if time_from and value < time_from:
return False
if time_to and value > time_to:
return False
return True
def _anomaly_filters(
*,
severity: Optional[str],
anomaly_type: Optional[str],
status: Optional[str],
prefix: Optional[str],
origin_asn: Optional[int],
time_from: Optional[datetime],
time_to: Optional[datetime],
):
filters = []
if severity:
filters.append(BGPAnomaly.severity == severity)
if anomaly_type:
filters.append(BGPAnomaly.anomaly_type == anomaly_type)
if status:
filters.append(BGPAnomaly.status == status)
if prefix:
filters.append(BGPAnomaly.prefix == prefix)
if origin_asn is not None:
filters.append(BGPAnomaly.origin_asn == origin_asn)
if time_from:
filters.append(BGPAnomaly.created_at >= time_from)
if time_to:
filters.append(BGPAnomaly.created_at <= time_to)
return filters
def _incident_filters(
*,
severity: Optional[str],
incident_type: Optional[str],
status: Optional[str],
):
filters = []
if severity:
filters.append(BGPIncident.severity == severity)
if incident_type:
filters.append(BGPIncident.incident_type == incident_type)
if status:
filters.append(BGPIncident.status == status)
return filters
async def _build_event_summary_payload(db: AsyncSession) -> dict:
base_filters = [BGPObservation.source.in_(BGP_SOURCES)]
total_result = await db.execute(
select(func.count(BGPObservation.id)).where(*base_filters)
)
collectors_result = await db.execute(
select(func.count(func.distinct(BGPObservation.collector))).where(
*base_filters, BGPObservation.collector.isnot(None)
)
)
prefixes_result = await db.execute(
select(func.count(func.distinct(BGPObservation.prefix))).where(
*base_filters, BGPObservation.prefix.isnot(None)
)
)
type_result = await db.execute(
select(BGPObservation.event_type, func.count(BGPObservation.id))
.where(*base_filters)
.group_by(BGPObservation.event_type)
)
return {
"total": total_result.scalar() or 0,
"collector_count": collectors_result.scalar() or 0,
"prefix_count": prefixes_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
}
async def _build_anomaly_summary_payload(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(BGPAnomaly.id)))
type_result = await db.execute(
select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.anomaly_type)
.order_by(func.count(BGPAnomaly.id).desc())
)
severity_result = await db.execute(
select(BGPAnomaly.severity, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.severity)
.order_by(func.count(BGPAnomaly.id).desc())
)
status_result = await db.execute(
select(BGPAnomaly.status, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.status)
.order_by(func.count(BGPAnomaly.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
async def _build_incident_summary_payload(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(BGPIncident.id)))
type_result = await db.execute(
select(BGPIncident.incident_type, func.count(BGPIncident.id))
.group_by(BGPIncident.incident_type)
.order_by(func.count(BGPIncident.id).desc())
)
severity_result = await db.execute(
select(BGPIncident.severity, func.count(BGPIncident.id))
.group_by(BGPIncident.severity)
.order_by(func.count(BGPIncident.id).desc())
)
status_result = await db.execute(
select(BGPIncident.status, func.count(BGPIncident.id))
.group_by(BGPIncident.status)
.order_by(func.count(BGPIncident.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
@router.get("/events")
@@ -49,41 +194,36 @@ async def list_bgp_events(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(BGPObservation)
.where(BGPObservation.source.in_(BGP_SOURCES))
.order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
)
if source:
stmt = stmt.where(BGPObservation.source == source)
result = await db.execute(stmt)
records = result.scalars().all()
dt_from = _parse_dt(time_from)
dt_to = _parse_dt(time_to)
filtered = []
for record in records:
if prefix and record.prefix != prefix:
continue
if origin_asn is not None and record.origin_asn != origin_asn:
continue
if peer_asn is not None and record.peer_asn != peer_asn:
continue
if collector and record.collector != collector:
continue
if event_type and record.event_type != event_type:
continue
if (dt_from or dt_to) and not _matches_time(record.observed_at, dt_from, dt_to):
continue
filtered.append(record)
filters = _event_filters(
prefix=prefix,
origin_asn=origin_asn,
peer_asn=peer_asn,
collector=collector,
event_type=event_type,
source=source,
time_from=dt_from,
time_to=dt_to,
)
offset = (page - 1) * page_size
count_result = await db.execute(
select(func.count(BGPObservation.id)).where(*filters)
)
data_result = await db.execute(
select(BGPObservation)
.where(*filters)
.order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
.offset(offset)
.limit(page_size)
)
records = data_result.scalars().all()
return {
"total": len(filtered),
"total": count_result.scalar() or 0,
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in filtered[offset : offset + page_size]],
"data": [record.to_dict() for record in records],
}
@@ -92,21 +232,7 @@ async def get_bgp_event_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(BGPObservation).where(BGPObservation.source.in_(BGP_SOURCES)))
records = result.scalars().all()
collectors = sorted({record.collector for record in records if record.collector})
prefixes = sorted({record.prefix for record in records if record.prefix})
by_type: dict[str, int] = {}
for record in records:
by_type[record.event_type] = by_type.get(record.event_type, 0) + 1
return {
"total": len(records),
"collector_count": len(collectors),
"prefix_count": len(prefixes),
"by_type": by_type,
}
return await _build_event_summary_payload(db)
@router.get("/collectors")
@@ -138,6 +264,32 @@ async def get_bgp_collector_summary(
}
@router.get("/overview/summary")
async def get_bgp_overview_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
event_summary = await _build_event_summary_payload(db)
anomaly_summary = await _build_anomaly_summary_payload(db)
incident_summary = await _build_incident_summary_payload(db)
collectors = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
active_collectors = [item for item in collectors if item["observation_count"] > 0]
return {
"incidentSummary": incident_summary,
"anomalySummary": anomaly_summary,
"eventSummary": event_summary,
"collectorSummary": {
"total": len(collectors),
"active_collectors": len(active_collectors),
"observed_prefixes": sum(item["prefix_count"] for item in active_collectors),
"observed_origins": sum(item["origin_asn_count"] for item in active_collectors),
"recent_24h_events": sum(item["recent_24h_observation_count"] for item in active_collectors),
"recent_7d_events": sum(item["recent_7d_observation_count"] for item in active_collectors),
},
}
@router.get("/events/{event_id}")
async def get_bgp_event(
event_id: int,
@@ -164,31 +316,35 @@ async def list_bgp_anomalies(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc())
if severity:
stmt = stmt.where(BGPAnomaly.severity == severity)
if anomaly_type:
stmt = stmt.where(BGPAnomaly.anomaly_type == anomaly_type)
if status:
stmt = stmt.where(BGPAnomaly.status == status)
if prefix:
stmt = stmt.where(BGPAnomaly.prefix == prefix)
if origin_asn is not None:
stmt = stmt.where(BGPAnomaly.origin_asn == origin_asn)
result = await db.execute(stmt)
records = result.scalars().all()
dt_from = _parse_dt(time_from)
dt_to = _parse_dt(time_to)
if dt_from or dt_to:
records = [record for record in records if _matches_time(record.created_at, dt_from, dt_to)]
filters = _anomaly_filters(
severity=severity,
anomaly_type=anomaly_type,
status=status,
prefix=prefix,
origin_asn=origin_asn,
time_from=dt_from,
time_to=dt_to,
)
offset = (page - 1) * page_size
total_result = await db.execute(
select(func.count(BGPAnomaly.id)).where(*filters)
)
data_result = await db.execute(
select(BGPAnomaly)
.where(*filters)
.order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc())
.offset(offset)
.limit(page_size)
)
records = data_result.scalars().all()
return {
"total": len(records),
"total": total_result.scalar() or 0,
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in records[offset : offset + page_size]],
"data": [record.to_dict() for record in records],
}
@@ -197,29 +353,7 @@ async def get_bgp_anomaly_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
total_result = await db.execute(select(func.count(BGPAnomaly.id)))
type_result = await db.execute(
select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.anomaly_type)
.order_by(func.count(BGPAnomaly.id).desc())
)
severity_result = await db.execute(
select(BGPAnomaly.severity, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.severity)
.order_by(func.count(BGPAnomaly.id).desc())
)
status_result = await db.execute(
select(BGPAnomaly.status, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.status)
.order_by(func.count(BGPAnomaly.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
return await _build_anomaly_summary_payload(db)
@router.get("/anomalies/{anomaly_id}")
@@ -244,22 +378,29 @@ async def list_bgp_incidents(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
if severity:
stmt = stmt.where(BGPIncident.severity == severity)
if incident_type:
stmt = stmt.where(BGPIncident.incident_type == incident_type)
if status:
stmt = stmt.where(BGPIncident.status == status)
result = await db.execute(stmt)
records = result.scalars().all()
filters = _incident_filters(
severity=severity,
incident_type=incident_type,
status=status,
)
offset = (page - 1) * page_size
total_result = await db.execute(
select(func.count(BGPIncident.id)).where(*filters)
)
data_result = await db.execute(
select(BGPIncident)
.where(*filters)
.order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
.offset(offset)
.limit(page_size)
)
records = data_result.scalars().all()
return {
"total": len(records),
"total": total_result.scalar() or 0,
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in records[offset : offset + page_size]],
"data": [record.to_dict() for record in records],
}
@@ -268,29 +409,7 @@ async def get_bgp_incident_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
total_result = await db.execute(select(func.count(BGPIncident.id)))
type_result = await db.execute(
select(BGPIncident.incident_type, func.count(BGPIncident.id))
.group_by(BGPIncident.incident_type)
.order_by(func.count(BGPIncident.id).desc())
)
severity_result = await db.execute(
select(BGPIncident.severity, func.count(BGPIncident.id))
.group_by(BGPIncident.severity)
.order_by(func.count(BGPIncident.id).desc())
)
status_result = await db.execute(
select(BGPIncident.status, func.count(BGPIncident.id))
.group_by(BGPIncident.status)
.order_by(func.count(BGPIncident.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
return await _build_incident_summary_payload(db)
@router.get("/incidents/{incident_id}")

View File

@@ -21,6 +21,14 @@ class SituationalAnalysisRequest(BaseModel):
thinking: dict[str, Any] | None = None
class BGPBriefRequest(BaseModel):
incident_limit: int = Field(default=5, ge=1, le=10)
anomaly_limit: int = Field(default=6, ge=1, le=12)
collector_limit: int = Field(default=5, ge=1, le=10)
preferred_model: str | None = Field(default=None, max_length=200)
thinking: dict[str, Any] | None = None
class SituationalAnalysisResponse(BaseModel):
provider: str
model: str
@@ -31,6 +39,19 @@ class SituationalAnalysisResponse(BaseModel):
raw_response: dict[str, Any] = Field(default_factory=dict)
class BGPBriefRecordSummary(BaseModel):
id: str
title: str
provider: str
model: str
request_id: str | None = None
generated_at: str
class BGPBriefRecordResponse(BGPBriefRecordSummary):
content_markdown: str
class AIProviderStatusResponse(BaseModel):
provider: str
api: str | None = None

View 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)),
},
)

View 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,
)

View File

@@ -6,7 +6,7 @@ from collections import defaultdict
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy import case, distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import to_iso8601_utc
@@ -14,6 +14,16 @@ from app.models.bgp_observation import BGPObservation
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
def _collector_base_filters(source_filter: tuple[str, ...] | None) -> list[Any]:
filters: list[Any] = [
BGPObservation.collector.isnot(None),
func.length(func.btrim(BGPObservation.collector)) > 0,
]
if source_filter:
filters.append(BGPObservation.source.in_(source_filter))
return filters
async def build_bgp_collector_coverage(
db: AsyncSession,
*,
@@ -24,88 +34,148 @@ async def build_bgp_collector_coverage(
recent_24h_threshold = now - timedelta(hours=24)
recent_7d_threshold = now - timedelta(days=7)
stmt = select(BGPObservation).order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
if source_filter:
stmt = stmt.where(BGPObservation.source.in_(source_filter))
filters = _collector_base_filters(source_filter)
country_expr = func.nullif(BGPObservation.collector_geo["country"].astext, "")
city_expr = func.nullif(BGPObservation.collector_geo["city"].astext, "")
result = await db.execute(stmt)
records = list(result.scalars().all())
aggregate_stmt = (
select(
BGPObservation.collector.label("collector"),
func.count(BGPObservation.id).label("observation_count"),
func.count(distinct(BGPObservation.prefix)).label("prefix_count"),
func.count(distinct(BGPObservation.origin_asn)).label("origin_asn_count"),
func.count(distinct(BGPObservation.peer_asn)).label("peer_asn_count"),
func.sum(case((BGPObservation.observed_at >= recent_15m_threshold, 1), else_=0)).label("recent_15m_observation_count"),
func.sum(case((BGPObservation.observed_at >= recent_24h_threshold, 1), else_=0)).label("recent_24h_observation_count"),
func.sum(case((BGPObservation.observed_at >= recent_7d_threshold, 1), else_=0)).label("recent_7d_observation_count"),
func.count(distinct(case((BGPObservation.observed_at >= recent_15m_threshold, BGPObservation.prefix), else_=None))).label("recent_15m_prefix_count"),
func.count(distinct(case((BGPObservation.observed_at >= recent_24h_threshold, BGPObservation.prefix), else_=None))).label("recent_24h_prefix_count"),
func.count(distinct(case((BGPObservation.observed_at >= recent_7d_threshold, BGPObservation.prefix), else_=None))).label("recent_7d_prefix_count"),
func.max(BGPObservation.observed_at).label("latest_observed_at"),
)
.where(*filters)
.group_by(BGPObservation.collector)
)
aggregate_rows = (await db.execute(aggregate_stmt)).all()
latest_subquery = (
select(
BGPObservation.collector.label("collector"),
BGPObservation.event_type.label("latest_event_type"),
country_expr.label("country"),
city_expr.label("city"),
func.row_number()
.over(
partition_by=BGPObservation.collector,
order_by=(BGPObservation.observed_at.desc(), BGPObservation.id.desc()),
)
.label("rn"),
)
.where(*filters)
.subquery()
)
latest_rows = (
await db.execute(
select(
latest_subquery.c.collector,
latest_subquery.c.latest_event_type,
latest_subquery.c.country,
latest_subquery.c.city,
).where(latest_subquery.c.rn == 1)
)
).all()
event_counts_subquery = (
select(
BGPObservation.collector.label("collector"),
BGPObservation.event_type.label("event_type"),
func.count(BGPObservation.id).label("count"),
func.row_number()
.over(
partition_by=BGPObservation.collector,
order_by=(func.count(BGPObservation.id).desc(), BGPObservation.event_type.asc()),
)
.label("rn"),
)
.where(*filters)
.group_by(BGPObservation.collector, BGPObservation.event_type)
.subquery()
)
top_event_rows = (
await db.execute(
select(
event_counts_subquery.c.collector,
event_counts_subquery.c.event_type,
event_counts_subquery.c.count,
).where(event_counts_subquery.c.rn <= 3)
)
).all()
scope_rows = (
await db.execute(
select(
BGPObservation.collector.label("collector"),
country_expr.label("country"),
city_expr.label("city"),
)
.where(*filters)
.distinct()
)
).all()
latest_by_collector = {
row.collector: {
"latest_event_type": row.latest_event_type,
"country": row.country,
"city": row.city,
}
for row in latest_rows
}
scope_by_collector: dict[str, dict[str, set[str]]] = defaultdict(lambda: {"countries": set(), "cities": set()})
for row in scope_rows:
if row.country:
scope_by_collector[row.collector]["countries"].add(row.country)
if row.city:
scope_by_collector[row.collector]["cities"].add(row.city)
top_events_by_collector: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in top_event_rows:
top_events_by_collector[row.collector].append(
{"event_type": row.event_type, "count": row.count}
)
by_collector: dict[str, dict[str, Any]] = {}
for record in records:
collector = str(record.collector or "").strip()
if not collector:
continue
for row in aggregate_rows:
collector = row.collector
latest = latest_by_collector.get(collector, {})
fallback_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
scope = scope_by_collector.get(collector, {"countries": set(), "cities": set()})
coverage = by_collector.get(collector)
if coverage is None:
location = record.collector_geo or RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
coverage = {
"collector": collector,
"city": location.get("city"),
"country": location.get("country"),
"latitude": location.get("latitude"),
"longitude": location.get("longitude"),
"observation_count": 0,
"prefixes": set(),
"origin_asns": set(),
"peer_asns": set(),
"event_types": defaultdict(int),
"countries": set(),
"cities": set(),
"recent_15m_observation_count": 0,
"recent_24h_observation_count": 0,
"recent_7d_observation_count": 0,
"recent_15m_prefixes": set(),
"recent_24h_prefixes": set(),
"recent_7d_prefixes": set(),
"latest_observed_at": None,
"latest_event_type": None,
}
by_collector[collector] = coverage
coverage["observation_count"] += 1
if record.prefix:
coverage["prefixes"].add(record.prefix)
if record.origin_asn is not None:
coverage["origin_asns"].add(record.origin_asn)
if record.peer_asn is not None:
coverage["peer_asns"].add(record.peer_asn)
if record.event_type:
coverage["event_types"][record.event_type] += 1
observed_at = record.observed_at
if observed_at is not None:
aware_observed_at = (
observed_at.astimezone(UTC)
if observed_at.tzinfo
else observed_at.replace(tzinfo=UTC)
)
if aware_observed_at >= recent_15m_threshold:
coverage["recent_15m_observation_count"] += 1
if record.prefix:
coverage["recent_15m_prefixes"].add(record.prefix)
if aware_observed_at >= recent_24h_threshold:
coverage["recent_24h_observation_count"] += 1
if record.prefix:
coverage["recent_24h_prefixes"].add(record.prefix)
if aware_observed_at >= recent_7d_threshold:
coverage["recent_7d_observation_count"] += 1
if record.prefix:
coverage["recent_7d_prefixes"].add(record.prefix)
geo = record.collector_geo or {}
if geo.get("country"):
coverage["countries"].add(geo["country"])
if geo.get("city"):
coverage["cities"].add(geo["city"])
current_latest = coverage["latest_observed_at"]
if current_latest is None or (
record.observed_at is not None and record.observed_at > current_latest
):
coverage["latest_observed_at"] = record.observed_at
coverage["latest_event_type"] = record.event_type
by_collector[collector] = {
"collector": collector,
"city": latest.get("city") or fallback_location.get("city"),
"country": latest.get("country") or fallback_location.get("country"),
"latitude": fallback_location.get("latitude"),
"longitude": fallback_location.get("longitude"),
"observation_count": row.observation_count or 0,
"prefix_count": row.prefix_count or 0,
"origin_asn_count": row.origin_asn_count or 0,
"peer_asn_count": row.peer_asn_count or 0,
"recent_15m_observation_count": row.recent_15m_observation_count or 0,
"recent_24h_observation_count": row.recent_24h_observation_count or 0,
"recent_7d_observation_count": row.recent_7d_observation_count or 0,
"recent_15m_prefix_count": row.recent_15m_prefix_count or 0,
"recent_24h_prefix_count": row.recent_24h_prefix_count or 0,
"recent_7d_prefix_count": row.recent_7d_prefix_count or 0,
"top_event_types": top_events_by_collector.get(collector, []),
"latest_observed_at": to_iso8601_utc(row.latest_observed_at),
"latest_event_type": latest.get("latest_event_type"),
"baseline_scope": {
"countries": sorted(scope["countries"]),
"cities": sorted(scope["cities"]),
},
}
for collector, location in RIPE_RIS_COLLECTOR_COORDS.items():
if collector in by_collector:
@@ -117,57 +187,22 @@ async def build_bgp_collector_coverage(
"latitude": location.get("latitude"),
"longitude": location.get("longitude"),
"observation_count": 0,
"prefixes": set(),
"origin_asns": set(),
"peer_asns": set(),
"event_types": defaultdict(int),
"countries": {location.get("country")} if location.get("country") else set(),
"cities": {location.get("city")} if location.get("city") else set(),
"prefix_count": 0,
"origin_asn_count": 0,
"peer_asn_count": 0,
"recent_15m_observation_count": 0,
"recent_24h_observation_count": 0,
"recent_7d_observation_count": 0,
"recent_15m_prefixes": set(),
"recent_24h_prefixes": set(),
"recent_7d_prefixes": set(),
"recent_15m_prefix_count": 0,
"recent_24h_prefix_count": 0,
"recent_7d_prefix_count": 0,
"top_event_types": [],
"latest_observed_at": None,
"latest_event_type": None,
"baseline_scope": {
"countries": [location["country"]] if location.get("country") else [],
"cities": [location["city"]] if location.get("city") else [],
},
}
results: list[dict[str, Any]] = []
for collector in sorted(by_collector.keys()):
item = by_collector[collector]
top_event_types = sorted(
item["event_types"].items(),
key=lambda pair: (-pair[1], pair[0]),
)
results.append(
{
"collector": item["collector"],
"city": item["city"],
"country": item["country"],
"latitude": item["latitude"],
"longitude": item["longitude"],
"observation_count": item["observation_count"],
"prefix_count": len(item["prefixes"]),
"origin_asn_count": len(item["origin_asns"]),
"peer_asn_count": len(item["peer_asns"]),
"recent_15m_observation_count": item["recent_15m_observation_count"],
"recent_24h_observation_count": item["recent_24h_observation_count"],
"recent_7d_observation_count": item["recent_7d_observation_count"],
"recent_15m_prefix_count": len(item["recent_15m_prefixes"]),
"recent_24h_prefix_count": len(item["recent_24h_prefixes"]),
"recent_7d_prefix_count": len(item["recent_7d_prefixes"]),
"top_event_types": [
{"event_type": event_type, "count": count}
for event_type, count in top_event_types[:3]
],
"latest_observed_at": to_iso8601_utc(item["latest_observed_at"]),
"latest_event_type": item["latest_event_type"],
"baseline_scope": {
"countries": sorted(country for country in item["countries"] if country),
"cities": sorted(city for city in item["cities"] if city),
},
}
)
return results
return [by_collector[collector] for collector in sorted(by_collector.keys())]

View File

@@ -7,6 +7,35 @@ This project follows the repository versioning rule:
- `feature` -> `+0.1.0`
- `bugfix` -> `+0.0.1`
## 0.24.5
Released: 2026-04-10
### Highlights
- Turned the first BGP AI brief flow into a persistent operator-facing feature with saved Markdown history, tabbed review, and backend-driven fact assembly, while also cutting BGP page load cost by switching the hottest routes away from Python-side full-table scans.
### Added
- Added [backend/app/services/bgp_ai_brief.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_ai_brief.py), building evidence-first BGP brief prompts from incidents, anomalies, observations, and collector coverage instead of relying on manual Playground inputs.
- Added [backend/app/services/bgp_ai_brief_store.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_ai_brief_store.py), persisting generated BGP AI briefs as Markdown files and exposing a stable record shape for history browsing.
- Added [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx), giving the frontend a lightweight built-in Markdown renderer for saved AI brief content without introducing a new dependency.
- Added [backend/app/api/v1/bgp.py](/home/ray/dev/linkong/planet/backend/app/api/v1/bgp.py) `GET /bgp/overview/summary`, consolidating the BGP overview summary hot path into a dedicated aggregate endpoint for the BGP workspace.
### Improved
- Improved [backend/app/api/v1/ai.py](/home/ray/dev/linkong/planet/backend/app/api/v1/ai.py) and [backend/app/schemas/ai.py](/home/ray/dev/linkong/planet/backend/app/schemas/ai.py) by extending the AI API from one-off analysis calls to saved BGP brief history endpoints, latest-brief lookup, and persisted brief responses.
- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) by moving `AI 简报` into the trailing tab, loading tab data lazily, loading brief history only on demand, and wiring saved brief selection plus regeneration into the operator workflow.
- Improved [frontend/src/services/situational-awareness/http-gateway.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/http-gateway.ts), [frontend/src/services/situational-awareness/port.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/port.ts), [frontend/src/services/situational-awareness/mock-gateway.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/mock-gateway.ts), and [frontend/src/services/situational-awareness/types.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/types.ts) by splitting BGP summary, list, and brief-history fetches into more granular gateway methods and de-duplicating in-flight requests on the hot path.
- Improved [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by styling the saved BGP brief workspace, Markdown output, history selector, and responsive compact state without re-breaking tab switching.
- Improved [backend/app/services/bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) by replacing full-table ORM loading with database-side collector aggregation, latest-record lookup, and top-event summarization for the collector coverage hot path.
### Fixed
- Fixed the BGP tab rendering regression in [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) where forcing tab panes to `display: flex` accidentally kept hidden panes visible and made the first tab look permanently locked.
- Fixed [backend/app/api/v1/bgp.py](/home/ray/dev/linkong/planet/backend/app/api/v1/bgp.py) so `events`, `anomalies`, and `incidents` no longer fetch whole tables into Python just to apply filtering, pagination, and counting.
- Fixed the repository workflow around saved BGP brief artifacts by ignoring [data/ai/bgp-briefs/](/home/ray/dev/linkong/planet/data/ai/bgp-briefs/) in [.gitignore](/home/ray/dev/linkong/planet/.gitignore) instead of leaving runtime Markdown output to pollute git status during normal operator use.
## 0.24.4
Released: 2026-04-09

View File

@@ -16,7 +16,7 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.24.4`
- `dev` 当前开发分支历史推导到:`0.24.5`
## Timeline
@@ -77,6 +77,7 @@
| `0.24.2` | bugfix | `dev` | `pending` | restore public Earth entry, refresh Playground provider diagnostics correctly, fit help-card content, and split frontend bundles by route/vendor |
| `0.24.3` | bugfix | `dev` | `pending` | expand Playground diagnostics presets and result inspection, and make `planet.sh` rebuild changed AI Provider images with explicit Compose fallback reporting |
| `0.24.4` | bugfix | `dev` | `pending` | polish `planet.sh` AI Provider rebuild stage boundaries, hide raw Compose build logs on success, and add explicit image-build completion feedback |
| `0.24.5` | bugfix | `dev` | `pending` | add persistent BGP AI briefs with Markdown history, lazy-load BGP tabs, and move BGP hot-path filtering and aggregation back into the database |
## Maintenance Commits Not Counted as Version Bumps

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.24.4",
"version": "0.24.5",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View 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>
}

View File

@@ -1006,6 +1006,155 @@ body {
font-size: 13px;
}
.bgp-page__brief-card {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 100%;
}
.bgp-page__brief-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.bgp-page__brief-actions {
display: flex;
align-items: center;
gap: 12px;
}
.bgp-page__brief-select {
min-width: 260px;
}
.bgp-page__brief-subtitle {
margin-top: 4px;
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
line-height: 1.5;
}
.bgp-page__brief-loading,
.bgp-page__brief-empty {
min-height: 72px;
display: flex;
align-items: center;
gap: 12px;
}
.bgp-page__brief-meta .ant-descriptions-view {
background: #f7f8fa;
border-radius: 12px;
padding: 8px 12px;
}
.bgp-page__brief-content {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 14px 16px;
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 248, 251, 0.98));
border: 1px solid rgba(5, 5, 5, 0.08);
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.markdown-renderer {
color: #262626;
font-size: 13px;
line-height: 1.7;
}
.markdown-renderer > :first-child {
margin-top: 0;
}
.markdown-renderer > :last-child {
margin-bottom: 0;
}
.markdown-renderer h1,
.markdown-renderer h2,
.markdown-renderer h3,
.markdown-renderer h4 {
margin: 1.2em 0 0.5em;
color: #111827;
font-weight: 600;
line-height: 1.4;
}
.markdown-renderer h1 {
font-size: 24px;
}
.markdown-renderer h2 {
font-size: 19px;
}
.markdown-renderer h3 {
font-size: 16px;
}
.markdown-renderer p,
.markdown-renderer ul,
.markdown-renderer ol,
.markdown-renderer blockquote,
.markdown-renderer pre {
margin: 0 0 0.9em;
}
.markdown-renderer ul,
.markdown-renderer ol {
padding-left: 1.4em;
}
.markdown-renderer li + li {
margin-top: 0.25em;
}
.markdown-renderer blockquote {
padding: 10px 14px;
border-left: 3px solid #91caff;
border-radius: 0 10px 10px 0;
background: rgba(230, 244, 255, 0.8);
color: #1f2937;
}
.markdown-renderer pre {
overflow: auto;
padding: 12px 14px;
border-radius: 10px;
background: #0f172a;
color: #e2e8f0;
}
.markdown-renderer code {
padding: 0.08em 0.32em;
border-radius: 6px;
background: rgba(15, 23, 42, 0.08);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
}
.markdown-renderer pre code {
padding: 0;
background: transparent;
color: inherit;
}
.markdown-renderer a {
color: #1677ff;
text-decoration: none;
}
.markdown-renderer a:hover {
text-decoration: underline;
}
.bgp-page__summary-card .ant-card-body {
padding: 10px 12px;
}
@@ -1122,6 +1271,10 @@ body {
overflow: hidden;
}
.bgp-page__tabs .ant-tabs-tabpane-hidden {
display: none;
}
.bgp-page__tabs .ant-table-wrapper {
flex: 1 1 auto;
min-width: 0;
@@ -1175,6 +1328,16 @@ body {
padding: 10px 12px;
}
.bgp-page--compact .bgp-page__brief-head {
flex-direction: column;
align-items: stretch;
}
.bgp-page--compact .bgp-page__brief-actions,
.bgp-page--compact .bgp-page__brief-select {
width: 100%;
}
.bgp-page--compact .bgp-page__summary-item {
min-height: 64px;
padding: 8px 10px;

View File

@@ -1,19 +1,50 @@
import { useEffect, useRef, useState } from 'react'
import { Alert, Card, Col, Row, Space, Statistic, Table, Tabs, Tag, Typography } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Col,
Descriptions,
Row,
Select,
Space,
Spin,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
type TableColumnsType,
type TabsProps,
} from 'antd'
import AppLayout from '../../components/AppLayout/AppLayout'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import { formatDateTimeZhCN } from '../../utils/datetime'
import {
getSituationalAwarenessGateway,
type BGPAnomaly,
type BGPBriefRecord,
type BGPBriefRecordSummary,
type BGPCollectorCoverage,
type BGPEvent,
type BGPIncident,
type CollectorSummary,
type EventSummary,
type Summary,
getSituationalAwarenessGateway,
} from '../../services/situational-awareness'
const { Title, Text } = Typography
const OVERVIEW_OPTIONS = {
incidentPageSize: 50,
anomalyPageSize: 100,
eventPageSize: 20,
}
const DEFAULT_BGP_TAB = 'collectors'
const situationalAwarenessGateway = getSituationalAwarenessGateway()
function severityColor(severity: string) {
@@ -23,50 +54,402 @@ function severityColor(severity: string) {
return 'blue'
}
function formatBriefOptions(records: BGPBriefRecordSummary[]) {
return records.map((item) => ({
value: item.id,
label: `${formatDateTimeZhCN(item.generated_at)} · ${item.model}`,
}))
}
function sortBriefRecords<T extends BGPBriefRecordSummary>(records: T[]) {
return [...records].sort((left, right) => right.generated_at.localeCompare(left.generated_at))
}
function renderCollectorLocation(_: unknown, record: BGPCollectorCoverage) {
return [record.city, record.country].filter(Boolean).join(', ') || '-'
}
function renderCollectorLatestEvent(_: unknown, record: BGPCollectorCoverage) {
const time = formatDateTimeZhCN(record.latest_observed_at)
return record.latest_event_type ? `${record.latest_event_type} @ ${time}` : time
}
function renderCollectorScope(value: BGPCollectorCoverage['baseline_scope']) {
const cities = value?.cities?.slice(0, 3).join(' / ') || ''
const countries = value?.countries?.slice(0, 3).join(' / ') || ''
return cities && countries ? `${cities} | ${countries}` : cities || countries || '-'
}
function renderIncidentCollectors(value: string[]) {
return value && value.length > 0 ? `${value.length}个 (${value.slice(0, 3).join(', ')})` : '-'
}
function renderIncidentRegions(value: Array<{ country?: string; city?: string }>) {
if (!value || value.length === 0) return '-'
return value
.slice(0, 3)
.map((item) => [item.city, item.country].filter(Boolean).join(', '))
.join(' / ')
}
function renderIncidentCables(value: BGPIncident['related_cables']) {
if (!value || value.length === 0) return '-'
return value
.slice(0, 2)
.map((item) => {
const landing = item.landing_point || [item.city, item.country].filter(Boolean).join(', ')
const cable = item.cable_names && item.cable_names.length > 0 ? item.cable_names[0] : '附近登陆点'
const distance = item.distance_km !== undefined ? ` ${item.distance_km}km` : ''
return `${landing} (${cable}${distance})`
})
.join(' / ')
}
function renderPercentage(value: number) {
return `${Math.round((value || 0) * 100)}%`
}
function renderAnomalyAsn(_: unknown, record: BGPAnomaly) {
if (record.origin_asn && record.new_origin_asn) {
return `AS${record.origin_asn} -> AS${record.new_origin_asn}`
}
if (record.origin_asn) {
return `AS${record.origin_asn}`
}
return '-'
}
function renderOptionalAsn(value: number | null) {
return value ? `AS${value}` : '-'
}
const collectorColumns: TableColumnsType<BGPCollectorCoverage> = [
{
title: '观测站',
dataIndex: 'collector',
width: 120,
},
{
title: '位置',
width: 180,
render: renderCollectorLocation,
},
{
title: '近24h事件数',
dataIndex: 'recent_24h_observation_count',
width: 120,
},
{
title: '近7d事件数',
dataIndex: 'recent_7d_observation_count',
width: 120,
},
{
title: '前缀数',
dataIndex: 'prefix_count',
width: 120,
},
{
title: 'Origin ASN 数',
dataIndex: 'origin_asn_count',
width: 140,
},
{
title: '最近事件',
width: 220,
render: renderCollectorLatestEvent,
},
{
title: '日常覆盖范围',
dataIndex: 'baseline_scope',
width: 280,
render: renderCollectorScope,
},
]
const incidentColumns: TableColumnsType<BGPIncident> = [
{
title: '开始时间',
dataIndex: 'started_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '类型',
dataIndex: 'incident_type',
width: 180,
},
{
title: '严重度',
dataIndex: 'severity',
width: 120,
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
},
{
title: '影响前缀',
dataIndex: 'affected_prefixes',
width: 200,
render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'),
},
{
title: '观测站',
dataIndex: 'affected_collectors',
width: 180,
render: renderIncidentCollectors,
},
{
title: '区域',
dataIndex: 'affected_regions',
width: 220,
render: renderIncidentRegions,
},
{
title: '附近基础设施',
dataIndex: 'related_cables',
width: 260,
render: renderIncidentCables,
},
{
title: '置信度',
dataIndex: 'confidence',
width: 120,
render: renderPercentage,
},
{
title: '摘要',
dataIndex: 'summary',
width: 320,
},
]
const anomalyColumns: TableColumnsType<BGPAnomaly> = [
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '类型',
dataIndex: 'anomaly_type',
width: 180,
},
{
title: '严重度',
dataIndex: 'severity',
width: 120,
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
},
{
title: '前缀',
dataIndex: 'prefix',
width: 180,
render: (value: string | null) => value || '-',
},
{
title: 'ASN',
key: 'asn',
width: 160,
render: renderAnomalyAsn,
},
{
title: '来源',
dataIndex: 'source',
width: 140,
},
{
title: '置信度',
dataIndex: 'confidence',
width: 120,
render: renderPercentage,
},
{
title: '摘要',
dataIndex: 'summary',
width: 320,
},
]
const eventColumns: TableColumnsType<BGPEvent> = [
{
title: '时间',
dataIndex: 'observed_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '观测站',
dataIndex: 'collector',
width: 140,
render: (value: string | null) => value || '-',
},
{
title: '类型',
dataIndex: 'event_type',
width: 120,
},
{
title: '前缀',
dataIndex: 'prefix',
width: 200,
render: (value: string | null) => value || '-',
},
{
title: 'Origin ASN',
dataIndex: 'origin_asn',
width: 140,
render: renderOptionalAsn,
},
{
title: 'Peer ASN',
dataIndex: 'peer_asn',
width: 140,
render: renderOptionalAsn,
},
]
async function loadInitialBrief(savedBriefs: BGPBriefRecordSummary[]) {
const latestBrief = await situationalAwarenessGateway.getLatestBGPBrief()
if (latestBrief) {
return latestBrief
}
if (savedBriefs[0]) {
return situationalAwarenessGateway.getBGPBrief(savedBriefs[0].id)
}
return null
}
function BGP() {
const [loading, setLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const [summaryLoading, setSummaryLoading] = useState(false)
const [activeTab, setActiveTab] = useState(DEFAULT_BGP_TAB)
const [incidents, setIncidents] = useState<BGPIncident[]>([])
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
const [events, setEvents] = useState<BGPEvent[]>([])
const [collectors, setCollectors] = useState<BGPCollectorCoverage[]>([])
const [collectorsLoading, setCollectorsLoading] = useState(false)
const [incidentsLoading, setIncidentsLoading] = useState(false)
const [anomaliesLoading, setAnomaliesLoading] = useState(false)
const [eventsLoading, setEventsLoading] = useState(false)
const [incidentSummary, setIncidentSummary] = useState<Summary | null>(null)
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
const [collectorSummary, setCollectorSummary] = useState<CollectorSummary | null>(null)
const [compactViewport, setCompactViewport] = useState(false)
const tableRegionRef = useRef<HTMLDivElement | null>(null)
const [tableHeight, setTableHeight] = useState(360)
const [briefLoading, setBriefLoading] = useState(false)
const [briefDetailLoading, setBriefDetailLoading] = useState(false)
const [brief, setBrief] = useState<BGPBriefRecord | null>(null)
const [briefOptions, setBriefOptions] = useState<BGPBriefRecordSummary[]>([])
const [selectedBriefId, setSelectedBriefId] = useState<string | null>(null)
const [briefListLoaded, setBriefListLoaded] = useState(false)
const tableRegionRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const load = async () => {
setLoading(true)
setSummaryLoading(true)
try {
const snapshot = await situationalAwarenessGateway.getBGPOverview({
incidentPageSize: 50,
anomalyPageSize: 100,
eventPageSize: 20,
})
setIncidents(snapshot.incidents)
setIncidentSummary(snapshot.incidentSummary)
setAnomalies(snapshot.anomalies)
setEvents(snapshot.events)
setEventSummary(snapshot.eventSummary)
setCollectors(snapshot.collectors)
setCollectorSummary(snapshot.collectorSummary)
const summary = await situationalAwarenessGateway.getBGPSummary()
setIncidentSummary(summary.incidentSummary)
setEventSummary(summary.eventSummary)
setCollectorSummary(summary.collectorSummary)
} catch (error) {
console.error('Failed to load BGP overview:', error)
} finally {
setLoading(false)
setSummaryLoading(false)
}
}
load()
void load()
}, [])
useEffect(() => {
const loadTabData = async () => {
if (activeTab === 'collectors' && collectors.length === 0 && !collectorsLoading) {
setCollectorsLoading(true)
try {
setCollectors(await situationalAwarenessGateway.getBGPCollectors())
} catch (error) {
console.error('Failed to load BGP collectors:', error)
} finally {
setCollectorsLoading(false)
}
return
}
if (activeTab === 'incidents' && incidents.length === 0 && !incidentsLoading) {
setIncidentsLoading(true)
try {
setIncidents(await situationalAwarenessGateway.getBGPIncidents(OVERVIEW_OPTIONS.incidentPageSize))
} catch (error) {
console.error('Failed to load BGP incidents:', error)
} finally {
setIncidentsLoading(false)
}
return
}
if (activeTab === 'anomalies' && anomalies.length === 0 && !anomaliesLoading) {
setAnomaliesLoading(true)
try {
setAnomalies(await situationalAwarenessGateway.getBGPAnomalies(OVERVIEW_OPTIONS.anomalyPageSize))
} catch (error) {
console.error('Failed to load BGP anomalies:', error)
} finally {
setAnomaliesLoading(false)
}
return
}
if (activeTab === 'events' && events.length === 0 && !eventsLoading) {
setEventsLoading(true)
try {
setEvents(await situationalAwarenessGateway.getBGPEvents(OVERVIEW_OPTIONS.eventPageSize))
} catch (error) {
console.error('Failed to load BGP events:', error)
} finally {
setEventsLoading(false)
}
return
}
if (activeTab === 'brief' && !briefListLoaded && !briefDetailLoading) {
setBriefDetailLoading(true)
try {
const savedBriefs = sortBriefRecords(await situationalAwarenessGateway.listBGPBriefs())
const initialBrief = await loadInitialBrief(savedBriefs)
setBriefOptions(savedBriefs)
setBrief(initialBrief)
setSelectedBriefId(initialBrief?.id || savedBriefs[0]?.id || null)
setBriefListLoaded(true)
} catch (error) {
console.error('Failed to load BGP briefs:', error)
} finally {
setBriefDetailLoading(false)
}
}
}
void loadTabData()
}, [
activeTab,
anomalies.length,
anomaliesLoading,
briefDetailLoading,
briefListLoaded,
collectors.length,
collectorsLoading,
events.length,
eventsLoading,
incidents.length,
incidentsLoading,
])
useEffect(() => {
const updateViewportMode = () => {
if (typeof window === 'undefined') return
const compact = window.innerHeight <= 860 || window.innerWidth <= 1440
setCompactViewport(compact)
setCompactViewport(window.innerHeight <= 860 || window.innerWidth <= 1440)
}
updateViewportMode()
@@ -77,7 +460,9 @@ function BGP() {
useEffect(() => {
const updateTableHeight = () => {
const regionHeight = tableRegionRef.current?.offsetHeight || 0
setTableHeight(Math.max(compactViewport ? 180 : 240, regionHeight - (compactViewport ? 40 : 52)))
const minimumHeight = compactViewport ? 180 : 240
const verticalOffset = compactViewport ? 40 : 52
setTableHeight(Math.max(minimumHeight, regionHeight - verticalOffset))
}
updateTableHeight()
@@ -87,7 +472,9 @@ function BGP() {
}
const observer = new ResizeObserver(updateTableHeight)
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
if (tableRegionRef.current) {
observer.observe(tableRegionRef.current)
}
return () => observer.disconnect()
}, [compactViewport, collectors.length, incidents.length, anomalies.length, events.length])
@@ -101,8 +488,165 @@ function BGP() {
{ label: '严重事件', value: incidentSummary?.by_severity?.critical || 0 },
]
const handleGenerateBrief = async () => {
setBriefLoading(true)
try {
const record = await situationalAwarenessGateway.generateBGPBrief()
setBrief(record)
setSelectedBriefId(record.id)
setBriefOptions((current) => sortBriefRecords([record, ...current.filter((item) => item.id !== record.id)]))
setBriefListLoaded(true)
messageApi.success('BGP AI 简报已生成')
} catch (error) {
console.error('Failed to generate BGP brief:', error)
messageApi.error('BGP AI 简报生成失败,请检查 AI Provider 或稍后再试')
} finally {
setBriefLoading(false)
}
}
const handleBriefSelectionChange = async (briefId: string) => {
setSelectedBriefId(briefId)
setBriefDetailLoading(true)
try {
setBrief(await situationalAwarenessGateway.getBGPBrief(briefId))
} catch (error) {
console.error('Failed to load saved BGP brief:', error)
messageApi.error('BGP AI 简报加载失败,请稍后再试')
} finally {
setBriefDetailLoading(false)
}
}
const briefTabContent = (
<div className="bgp-page__brief-card">
<div className="bgp-page__brief-head">
<div>
<Text strong>BGP AI </Text>
<div className="bgp-page__brief-subtitle">
Markdown
</div>
</div>
<div className="bgp-page__brief-actions">
<Select
className="bgp-page__brief-select"
placeholder="选择历史简报"
value={selectedBriefId || undefined}
options={formatBriefOptions(briefOptions)}
onChange={(value) => void handleBriefSelectionChange(value)}
disabled={briefLoading || briefDetailLoading || briefOptions.length === 0}
/>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={briefLoading}
onClick={() => void handleGenerateBrief()}
>
AI
</Button>
</div>
</div>
{briefLoading || briefDetailLoading ? (
<div className="bgp-page__brief-loading">
<Spin />
<Text type="secondary">
{briefLoading ? '正在整理 BGP 事实并生成简报...' : '正在加载已保存的 BGP 简报...'}
</Text>
</div>
) : brief ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Descriptions size="small" column={compactViewport ? 1 : 3} className="bgp-page__brief-meta">
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
<Descriptions.Item label="请求ID">{brief.request_id || '-'}</Descriptions.Item>
<Descriptions.Item label="生成时间" span={compactViewport ? 1 : 3}>
{formatDateTimeZhCN(brief.generated_at)}
</Descriptions.Item>
</Descriptions>
<div className="bgp-page__brief-content">
<MarkdownRenderer markdown={brief.content_markdown} />
</div>
</Space>
) : (
<div className="bgp-page__brief-empty">
<Text type="secondary"> BGP Markdown </Text>
</div>
)}
</div>
)
const tabItems: TabsProps['items'] = [
{
key: 'collectors',
label: '观测站覆盖',
children: (
<Table<BGPCollectorCoverage>
rowKey="collector"
loading={summaryLoading || collectorsLoading}
dataSource={collectors}
pagination={false}
scroll={{ x: 1240, y: tableHeight }}
tableLayout="fixed"
columns={collectorColumns}
/>
),
},
{
key: 'incidents',
label: '事件列表',
children: (
<Table<BGPIncident>
rowKey="id"
loading={summaryLoading || incidentsLoading}
dataSource={incidents}
pagination={false}
scroll={{ x: 1660, y: tableHeight }}
tableLayout="fixed"
columns={incidentColumns}
/>
),
},
{
key: 'anomalies',
label: '异常明细',
children: (
<Table<BGPAnomaly>
rowKey="id"
loading={summaryLoading || anomaliesLoading}
dataSource={anomalies}
pagination={false}
scroll={{ x: 1380, y: tableHeight }}
tableLayout="fixed"
columns={anomalyColumns}
/>
),
},
{
key: 'events',
label: '最近观测事件',
children: (
<Table<BGPEvent>
rowKey="id"
loading={summaryLoading || eventsLoading}
dataSource={events}
pagination={false}
scroll={{ x: 980, y: tableHeight }}
tableLayout="fixed"
columns={eventColumns}
/>
),
},
{
key: 'brief',
label: 'AI 简报',
children: briefTabContent,
},
]
return (
<AppLayout>
{contextHolder}
<div className={`page-shell bgp-page${compactViewport ? ' bgp-page--compact' : ''}`}>
<div className="page-shell__header bgp-page__header">
<div>
@@ -152,277 +696,9 @@ function BGP() {
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region">
<Tabs
className="bgp-page__tabs"
items={[
{
key: 'collectors',
label: '观测站覆盖',
children: (
<Table<BGPCollectorCoverage>
rowKey="collector"
loading={loading}
dataSource={collectors}
pagination={false}
scroll={{ x: 1240, y: tableHeight }}
tableLayout="fixed"
columns={[
{
title: '观测站',
dataIndex: 'collector',
width: 120,
},
{
title: '位置',
width: 180,
render: (_, record) => [record.city, record.country].filter(Boolean).join(', ') || '-',
},
{
title: '近24h事件数',
dataIndex: 'recent_24h_observation_count',
width: 120,
},
{
title: '近7d事件数',
dataIndex: 'recent_7d_observation_count',
width: 120,
},
{
title: '前缀数',
dataIndex: 'prefix_count',
width: 120,
},
{
title: 'Origin ASN 数',
dataIndex: 'origin_asn_count',
width: 140,
},
{
title: '最近事件',
width: 220,
render: (_, record) => {
const time = formatDateTimeZhCN(record.latest_observed_at)
return record.latest_event_type ? `${record.latest_event_type} @ ${time}` : time
},
},
{
title: '日常覆盖范围',
dataIndex: 'baseline_scope',
width: 280,
render: (value: BGPCollectorCoverage['baseline_scope']) => {
const cities = value?.cities?.slice(0, 3).join(' / ') || ''
const countries = value?.countries?.slice(0, 3).join(' / ') || ''
return cities && countries ? `${cities} | ${countries}` : cities || countries || '-'
},
},
]}
/>
),
},
{
key: 'incidents',
label: '事件列表',
children: (
<Table<BGPIncident>
rowKey="id"
loading={loading}
dataSource={incidents}
pagination={false}
scroll={{ x: 1660, y: tableHeight }}
tableLayout="fixed"
columns={[
{
title: '开始时间',
dataIndex: 'started_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '类型',
dataIndex: 'incident_type',
width: 180,
},
{
title: '严重度',
dataIndex: 'severity',
width: 120,
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
},
{
title: '影响前缀',
dataIndex: 'affected_prefixes',
width: 200,
render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'),
},
{
title: '观测站',
dataIndex: 'affected_collectors',
width: 180,
render: (value: string[]) => (value && value.length > 0 ? `${value.length}个 (${value.slice(0, 3).join(', ')})` : '-'),
},
{
title: '区域',
dataIndex: 'affected_regions',
width: 220,
render: (value: Array<{ country?: string; city?: string }>) => {
if (!value || value.length === 0) return '-'
return value
.slice(0, 3)
.map((item) => [item.city, item.country].filter(Boolean).join(', '))
.join(' / ')
},
},
{
title: '附近基础设施',
dataIndex: 'related_cables',
width: 260,
render: (value: BGPIncident['related_cables']) => {
if (!value || value.length === 0) return '-'
return value
.slice(0, 2)
.map((item) => {
const landing = item.landing_point || [item.city, item.country].filter(Boolean).join(', ')
const cable = item.cable_names && item.cable_names.length > 0 ? item.cable_names[0] : '附近登陆点'
const distance = item.distance_km !== undefined ? ` ${item.distance_km}km` : ''
return `${landing} (${cable}${distance})`
})
.join(' / ')
},
},
{
title: '置信度',
dataIndex: 'confidence',
width: 120,
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
},
{
title: '摘要',
dataIndex: 'summary',
width: 320,
},
]}
/>
),
},
{
key: 'anomalies',
label: '异常明细',
children: (
<Table<BGPAnomaly>
rowKey="id"
loading={loading}
dataSource={anomalies}
pagination={false}
scroll={{ x: 1380, y: tableHeight }}
tableLayout="fixed"
columns={[
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '类型',
dataIndex: 'anomaly_type',
width: 180,
},
{
title: '严重度',
dataIndex: 'severity',
width: 120,
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
},
{
title: '前缀',
dataIndex: 'prefix',
width: 180,
render: (value: string | null) => value || '-',
},
{
title: 'ASN',
key: 'asn',
width: 160,
render: (_, record) => {
if (record.origin_asn && record.new_origin_asn) {
return `AS${record.origin_asn} -> AS${record.new_origin_asn}`
}
if (record.origin_asn) {
return `AS${record.origin_asn}`
}
return '-'
},
},
{
title: '来源',
dataIndex: 'source',
width: 140,
},
{
title: '置信度',
dataIndex: 'confidence',
width: 120,
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
},
{
title: '摘要',
dataIndex: 'summary',
width: 320,
},
]}
/>
),
},
{
key: 'events',
label: '最近观测事件',
children: (
<Table<BGPEvent>
rowKey="id"
loading={loading}
dataSource={events}
pagination={false}
scroll={{ x: 980, y: tableHeight }}
tableLayout="fixed"
columns={[
{
title: '时间',
dataIndex: 'observed_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '观测站',
dataIndex: 'collector',
width: 140,
render: (value: string | null) => value || '-',
},
{
title: '类型',
dataIndex: 'event_type',
width: 120,
},
{
title: '前缀',
dataIndex: 'prefix',
width: 200,
render: (value: string | null) => value || '-',
},
{
title: 'Origin ASN',
dataIndex: 'origin_asn',
width: 140,
render: (value: number | null) => (value ? `AS${value}` : '-'),
},
{
title: 'Peer ASN',
dataIndex: 'peer_asn',
width: 140,
render: (value: number | null) => (value ? `AS${value}` : '-'),
},
]}
/>
),
},
]}
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
/>
</div>
</Card>

View File

@@ -2,11 +2,14 @@ import axios from 'axios'
import type { SituationalAwarenessGateway } from './port'
import type {
BGPAnomaly,
BGPBriefRecord,
BGPBriefRecordSummary,
BGPCollectorCoverage,
BGPEvent,
BGPIncident,
BGPOverviewOptions,
BGPOverviewSnapshot,
BGPSummarySnapshot,
CollectorSummary,
EventSummary,
ListResponse,
@@ -16,6 +19,91 @@ import type {
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
export class HttpSituationalAwarenessGateway implements SituationalAwarenessGateway {
private bgpSummaryPromise: Promise<BGPSummarySnapshot> | null = null
private collectorsPromise: Promise<BGPCollectorCoverage[]> | null = null
private incidentsPromises = new Map<number, Promise<BGPIncident[]>>()
private anomaliesPromises = new Map<number, Promise<BGPAnomaly[]>>()
private eventsPromises = new Map<number, Promise<BGPEvent[]>>()
private briefListPromise: Promise<BGPBriefRecordSummary[]> | null = null
private latestBriefPromise: Promise<BGPBriefRecord | null> | null = null
async getBGPSummary(): Promise<BGPSummarySnapshot> {
if (!this.bgpSummaryPromise) {
this.bgpSummaryPromise = axios
.get<BGPSummarySnapshot>(`${API_BASE_URL}/bgp/overview/summary`)
.then((response) => response.data)
.finally(() => {
this.bgpSummaryPromise = null
})
}
return this.bgpSummaryPromise
}
async getBGPCollectors(): Promise<BGPCollectorCoverage[]> {
if (!this.collectorsPromise) {
this.collectorsPromise = axios
.get<ListResponse<BGPCollectorCoverage>>(`${API_BASE_URL}/bgp/collectors`)
.then((response) => response.data.data || [])
.finally(() => {
this.collectorsPromise = null
})
}
return this.collectorsPromise
}
async getBGPIncidents(pageSize = 50): Promise<BGPIncident[]> {
const existing = this.incidentsPromises.get(pageSize)
if (existing) {
return existing
}
const request = axios
.get<ListResponse<BGPIncident>>(`${API_BASE_URL}/bgp/incidents`, { params: { page_size: pageSize } })
.then((response) => response.data.data || [])
.finally(() => {
this.incidentsPromises.delete(pageSize)
})
this.incidentsPromises.set(pageSize, request)
return request
}
async getBGPAnomalies(pageSize = 100): Promise<BGPAnomaly[]> {
const existing = this.anomaliesPromises.get(pageSize)
if (existing) {
return existing
}
const request = axios
.get<ListResponse<BGPAnomaly>>(`${API_BASE_URL}/bgp/anomalies`, { params: { page_size: pageSize } })
.then((response) => response.data.data || [])
.finally(() => {
this.anomaliesPromises.delete(pageSize)
})
this.anomaliesPromises.set(pageSize, request)
return request
}
async getBGPEvents(pageSize = 20): Promise<BGPEvent[]> {
const existing = this.eventsPromises.get(pageSize)
if (existing) {
return existing
}
const request = axios
.get<ListResponse<BGPEvent>>(`${API_BASE_URL}/bgp/events`, { params: { page_size: pageSize } })
.then((response) => response.data.data || [])
.finally(() => {
this.eventsPromises.delete(pageSize)
})
this.eventsPromises.set(pageSize, request)
return request
}
async getBGPOverview(options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
const {
incidentPageSize = 50,
@@ -43,4 +131,46 @@ export class HttpSituationalAwarenessGateway implements SituationalAwarenessGate
collectorSummary: collectorSummaryRes.data,
}
}
async generateBGPBrief(): Promise<BGPBriefRecord> {
const response = await axios.post<BGPBriefRecord>(`${API_BASE_URL}/ai/bgp/brief`, {})
return response.data
}
async listBGPBriefs(): Promise<BGPBriefRecordSummary[]> {
if (!this.briefListPromise) {
this.briefListPromise = axios
.get<BGPBriefRecordSummary[]>(`${API_BASE_URL}/ai/bgp/briefs`)
.then((response) => response.data || [])
.finally(() => {
this.briefListPromise = null
})
}
return this.briefListPromise
}
async getBGPBrief(briefId: string): Promise<BGPBriefRecord> {
const response = await axios.get<BGPBriefRecord>(`${API_BASE_URL}/ai/bgp/briefs/${briefId}`)
return response.data
}
async getLatestBGPBrief(): Promise<BGPBriefRecord | null> {
if (!this.latestBriefPromise) {
this.latestBriefPromise = axios
.get<BGPBriefRecord | null>(`${API_BASE_URL}/ai/bgp/briefs/latest`)
.then((response) => response.data)
.catch((error) => {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null
}
throw error
})
.finally(() => {
this.latestBriefPromise = null
})
}
return this.latestBriefPromise
}
}

View File

@@ -1,5 +1,11 @@
import type { SituationalAwarenessGateway } from './port'
import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
import type {
BGPBriefRecord,
BGPBriefRecordSummary,
BGPOverviewOptions,
BGPOverviewSnapshot,
BGPSummarySnapshot,
} from './types'
const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
incidents: [],
@@ -29,7 +35,69 @@ const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
}
export class MockSituationalAwarenessGateway implements SituationalAwarenessGateway {
private readonly brief: BGPBriefRecord = {
id: 'mock-bgp-brief-001',
title: 'BGP AI 简报',
provider: 'mock',
model: 'mock-brief',
request_id: 'mock-bgp-brief',
generated_at: '2026-04-09T12:00:00+08:00',
content_markdown: [
'# BGP 态势简报',
'',
'## 当前判断',
'',
'- 当前 BGP 态势以高严重度事件为主。',
'- 建议优先核查活跃 incidents 涉及的受影响前缀与重点观测站。',
'',
'## 值班建议',
'',
'1. 先确认高严重度 incident 是否持续活跃。',
'2. 对照重点 collector 的近 24h 波动,避免将控制平面噪声误判为真实业务中断。',
].join('\n'),
}
async getBGPOverview(_options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
return EMPTY_SNAPSHOT
}
async getBGPSummary(): Promise<BGPSummarySnapshot> {
return {
incidentSummary: EMPTY_SNAPSHOT.incidentSummary,
eventSummary: EMPTY_SNAPSHOT.eventSummary,
collectorSummary: EMPTY_SNAPSHOT.collectorSummary,
}
}
async getBGPCollectors() {
return EMPTY_SNAPSHOT.collectors
}
async getBGPIncidents() {
return EMPTY_SNAPSHOT.incidents
}
async getBGPAnomalies() {
return EMPTY_SNAPSHOT.anomalies
}
async getBGPEvents() {
return EMPTY_SNAPSHOT.events
}
async generateBGPBrief(): Promise<BGPBriefRecord> {
return this.brief
}
async listBGPBriefs(): Promise<BGPBriefRecordSummary[]> {
return [this.brief]
}
async getBGPBrief(_briefId: string): Promise<BGPBriefRecord> {
return this.brief
}
async getLatestBGPBrief(): Promise<BGPBriefRecord | null> {
return this.brief
}
}

View File

@@ -1,5 +1,24 @@
import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
import type {
BGPAnomaly,
BGPBriefRecord,
BGPBriefRecordSummary,
BGPCollectorCoverage,
BGPEvent,
BGPIncident,
BGPOverviewOptions,
BGPOverviewSnapshot,
BGPSummarySnapshot,
} from './types'
export interface SituationalAwarenessGateway {
getBGPOverview(options?: BGPOverviewOptions): Promise<BGPOverviewSnapshot>
getBGPSummary(): Promise<BGPSummarySnapshot>
getBGPCollectors(): Promise<BGPCollectorCoverage[]>
getBGPIncidents(pageSize?: number): Promise<BGPIncident[]>
getBGPAnomalies(pageSize?: number): Promise<BGPAnomaly[]>
getBGPEvents(pageSize?: number): Promise<BGPEvent[]>
generateBGPBrief(): Promise<BGPBriefRecord>
listBGPBriefs(): Promise<BGPBriefRecordSummary[]>
getBGPBrief(briefId: string): Promise<BGPBriefRecord>
getLatestBGPBrief(): Promise<BGPBriefRecord | null>
}

View File

@@ -105,8 +105,43 @@ export interface BGPOverviewSnapshot {
collectorSummary: CollectorSummary | null
}
export interface BGPSummarySnapshot {
incidentSummary: Summary | null
eventSummary: EventSummary | null
collectorSummary: CollectorSummary | null
}
export interface BGPOverviewOptions {
incidentPageSize?: number
anomalyPageSize?: number
eventPageSize?: number
}
export interface AIContentBlock {
type: string
text?: string | null
thinking?: string | null
}
export interface AnalysisResponse {
provider: string
model: string
content: string
content_blocks: AIContentBlock[]
text_blocks: string[]
thinking_blocks: string[]
raw_response: Record<string, unknown>
}
export interface BGPBriefRecordSummary {
id: string
title: string
provider: string
model: string
request_id?: string | null
generated_at: string
}
export interface BGPBriefRecord extends BGPBriefRecordSummary {
content_markdown: string
}

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.24.4"
version = "0.24.5"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

View File

@@ -224,3 +224,18 @@ class BaseCollector:
- Dependency injection for testability
- Feature flags for incomplete features
- Use config files for environment-specific settings
---
## Query Performance - MANDATORY
- **NEVER** load whole tables into Python just to do filtering, pagination, counting, dedupe, or summary aggregation
- Filters, sorting, pagination, `count`, `distinct`, and grouped statistics **MUST** be pushed down to the database whenever the ORM/query builder can express them
- Summary/dashboard endpoints should prefer dedicated aggregate queries or aggregate endpoints, not multiple full-table scans
- For hot paths, avoid selecting large JSON/text payload columns unless the response really needs them
- If an endpoint returns a list, default to database-side pagination instead of `scalars().all()` followed by Python slicing
- When you suspect a query is slow, first check for:
- full-table ORM loads
- Python-side post-filtering
- repeated summary queries that can be merged
- repeated per-request recomputation that should be cached or aggregated once

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.24.4"
version = "0.24.5"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },