From 83839b8b11fbbc1887084b2537b19f5ec5550c95 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Fri, 10 Apr 2026 00:33:01 +0800 Subject: [PATCH] fix: ship persistent bgp ai briefs and optimize bgp queries --- .gitignore | 5 + VERSION | 2 +- backend/app/api/v1/ai.py | 64 +- backend/app/api/v1/bgp.py | 377 +++++--- backend/app/schemas/ai.py | 21 + backend/app/services/bgp_ai_brief.py | 147 +++ backend/app/services/bgp_ai_brief_store.py | 147 +++ backend/app/services/bgp_collectors.py | 287 +++--- docs/CHANGELOG.md | 29 + docs/version-history.md | 3 +- frontend/package.json | 2 +- .../MarkdownRenderer/MarkdownRenderer.tsx | 167 ++++ frontend/src/index.css | 163 ++++ frontend/src/pages/BGP/BGP.tsx | 864 ++++++++++++------ .../situational-awareness/http-gateway.ts | 130 +++ .../situational-awareness/mock-gateway.ts | 70 +- .../services/situational-awareness/port.ts | 21 +- .../services/situational-awareness/types.ts | 35 + pyproject.toml | 2 +- rules.md | 15 + uv.lock | 2 +- 21 files changed, 1996 insertions(+), 557 deletions(-) create mode 100644 backend/app/services/bgp_ai_brief.py create mode 100644 backend/app/services/bgp_ai_brief_store.py create mode 100644 frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx diff --git a/.gitignore b/.gitignore index bb7024c5..dff37800 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,8 @@ docs/.venv/ *.temp tmp/ temp/ + +# ---------------------- +# Runtime Data +# ---------------------- +data/ai/bgp-briefs/ diff --git a/VERSION b/VERSION index 4b8c9a55..7a8169a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.4 +0.24.5 diff --git a/backend/app/api/v1/ai.py b/backend/app/api/v1/ai.py index 490d356b..364db973 100644 --- a/backend/app/api/v1/ai.py +++ b/backend/app/api/v1/ai.py @@ -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) diff --git a/backend/app/api/v1/bgp.py b/backend/app/api/v1/bgp.py index 26067b1e..ff32dd83 100644 --- a/backend/app/api/v1/bgp.py +++ b/backend/app/api/v1/bgp.py @@ -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}") diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py index 27d1046e..3bcdac9c 100644 --- a/backend/app/schemas/ai.py +++ b/backend/app/schemas/ai.py @@ -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 diff --git a/backend/app/services/bgp_ai_brief.py b/backend/app/services/bgp_ai_brief.py new file mode 100644 index 00000000..998b440e --- /dev/null +++ b/backend/app/services/bgp_ai_brief.py @@ -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)), + }, + ) diff --git a/backend/app/services/bgp_ai_brief_store.py b/backend/app/services/bgp_ai_brief_store.py new file mode 100644 index 00000000..66eb2302 --- /dev/null +++ b/backend/app/services/bgp_ai_brief_store.py @@ -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 = "" +_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, + ) diff --git a/backend/app/services/bgp_collectors.py b/backend/app/services/bgp_collectors.py index 56a7a209..7cb96f75 100644 --- a/backend/app/services/bgp_collectors.py +++ b/backend/app/services/bgp_collectors.py @@ -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())] diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cb3fc110..bb1d8f5f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/version-history.md b/docs/version-history.md index 6cd063ac..b02792d0 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index ff6012de..6f3ccf85 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.24.4", + "version": "0.24.5", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx b/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx new file mode 100644 index 00000000..3ed87218 --- /dev/null +++ b/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx @@ -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( + + {linkMatch[1]} + , + ) + key += 1 + lastIndex = start + matchedText.length + continue + } + } + + if (matchedText.startsWith('`')) { + result.push({matchedText.slice(1, -1)}) + key += 1 + lastIndex = start + matchedText.length + continue + } + + if (matchedText.startsWith('**')) { + result.push({matchedText.slice(2, -2)}) + key += 1 + lastIndex = start + matchedText.length + continue + } + + if (matchedText.startsWith('*')) { + result.push({matchedText.slice(1, -1)}) + 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( +
+          {codeLines.join('\n')}
+        
, + ) + 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(

{content}

) + else if (level === 2) nodes.push(

{content}

) + else if (level === 3) nodes.push(

{content}

) + else if (level === 4) nodes.push(

{content}

) + else nodes.push(

{content}

) + 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(
{quoteLines.join(' ')}
) + 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( + , + ) + 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( +
    + {items.map((item, itemIndex) => ( +
  1. {renderInlineMarkdown(item)}
  2. + ))} +
, + ) + continue + } + + const paragraphLines: string[] = [] + while (index < lines.length && lines[index].trim()) { + paragraphLines.push(lines[index].trim()) + index += 1 + } + nodes.push(

{renderInlineMarkdown(paragraphLines.join(' '))}

) + } + + return
{nodes}
+} diff --git a/frontend/src/index.css b/frontend/src/index.css index a390ee7b..9d75a680 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index bb62cac1..1233e0db 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -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(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 = [ + { + 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 = [ + { + 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) => {value}, + }, + { + 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 = [ + { + 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) => {value}, + }, + { + 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 = [ + { + 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([]) const [anomalies, setAnomalies] = useState([]) const [events, setEvents] = useState([]) const [collectors, setCollectors] = useState([]) + const [collectorsLoading, setCollectorsLoading] = useState(false) + const [incidentsLoading, setIncidentsLoading] = useState(false) + const [anomaliesLoading, setAnomaliesLoading] = useState(false) + const [eventsLoading, setEventsLoading] = useState(false) const [incidentSummary, setIncidentSummary] = useState(null) const [eventSummary, setEventSummary] = useState(null) const [collectorSummary, setCollectorSummary] = useState(null) const [compactViewport, setCompactViewport] = useState(false) - const tableRegionRef = useRef(null) const [tableHeight, setTableHeight] = useState(360) + const [briefLoading, setBriefLoading] = useState(false) + const [briefDetailLoading, setBriefDetailLoading] = useState(false) + const [brief, setBrief] = useState(null) + const [briefOptions, setBriefOptions] = useState([]) + const [selectedBriefId, setSelectedBriefId] = useState(null) + const [briefListLoaded, setBriefListLoaded] = useState(false) + const tableRegionRef = useRef(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 = ( +
+
+
+ BGP AI 简报 +
+ 每次生成都会保存为 Markdown。打开页面时默认展示最近一次简报,也可以从历史列表切换查看。 +
+
+
+