From f12719914db53c22b6eca53b56768289dc5eb04e Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 7 Apr 2026 17:54:44 +0800 Subject: [PATCH 01/34] feat: align aiprovider with adapter-based compatibility --- TODO.md | 3 + aiprovider/.env.example | 48 ++++++++--- aiprovider/README.md | 44 ++++++---- aiprovider/config.py | 1 + aiprovider/provider_service.py | 150 +++++++++++++++++++++++++++++++-- aiprovider/schemas.py | 13 +++ backend/app/schemas/ai.py | 13 +++ backend/tests/test_api.py | 10 ++- docs/aiprovider.md | 67 ++++++++++++--- 9 files changed, 299 insertions(+), 50 deletions(-) diff --git a/TODO.md b/TODO.md index b2aa5385..6da363a7 100644 --- a/TODO.md +++ b/TODO.md @@ -13,6 +13,9 @@ - [x] 接入 `IPtoASN / IPtoCountry` 作为 prefix-centric geography 的主数据源 - [x] 接入 `OpenGeoFeed` 作为 prefix geography 的高质量覆盖/override 数据源 - [x] 把 RIR delegated 设计成 prefix geography 的 fallback,而不是主来源 +- [ ] 为 `aiprovider` 建立 `provider -> api adapter -> compat policy` 的配置中心,优先落成 `json` 或 `yaml` 文件,运行时按 `provider/model` 读取兼容设置,而不是把专项兼容继续散落在 Python 分支里 +- [ ] 为市面上主流 AI 服务补专项兼容配置并固化到配置文件中,至少覆盖 `OpenAI / Anthropic / MiniMax / Ollama / Moonshot / DeepSeek / Qwen / GLM / Gemini / OpenRouter / vLLM / LM Studio / One API` +- [ ] 在兼容配置中补齐可声明项:`api adapter`、`base_url pattern`、`auth header`、`thinking default`、`reasoning block mapping`、`stream path`、`tool-call capability`、`multimodal capability`、`provider-specific request patch` - [ ] 接入 `inetnum` / `inet6num` whois 作为比 RIR 更细粒度的后备层 - [x] 在 activity layer 之后继续补 `route leak` 和 `path instability / flap` detector - [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation,降低后续维护复杂度 diff --git a/aiprovider/.env.example b/aiprovider/.env.example index cd5f57e1..fdfad118 100644 --- a/aiprovider/.env.example +++ b/aiprovider/.env.example @@ -6,29 +6,51 @@ AI_TIMEOUT_SECONDS=60 AI_HTTP_RETRY_ATTEMPTS=2 AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。 -# Select one provider mode: -# - openai_compatible -# - claude_compatible +# Provider identity. Recommended values: +# - minimax +# - openai # - ollama -AI_PROVIDER=ollama +# Compatibility aliases still accepted: +# - openai_compatible +# - anthropic_compatible +# - claude_compatible +AI_PROVIDER=minimax + +# Request adapter style, following OpenClaw's API-seam pattern: +# - auto +# - openai-completions +# - anthropic-messages +# - ollama-generate +AI_PROVIDER_API=anthropic-messages # Common model selection -AI_MODEL=qwen2.5:7b +AI_MODEL=MiniMax-M2.7 + +# MiniMax CN Anthropic-compatible example +AI_BASE_URL=https://api.minimaxi.com/anthropic +AI_API_KEY=sk-cp-change-me +AI_MAX_TOKENS=1200 +AI_ANTHROPIC_VERSION=2023-06-01 # OpenAI-compatible example (vLLM / LM Studio / One API / local gateway) -# AI_PROVIDER=openai_compatible +# AI_PROVIDER=openai +# AI_PROVIDER_API=openai-completions # AI_BASE_URL=http://127.0.0.1:8001/v1 # AI_API_KEY=local-key +# AI_MODEL=your-local-model -# Claude-compatible example (Anthropic / MiniMax / Claude-compatible gateway) -# AI_PROVIDER=claude_compatible -# AI_BASE_URL=http://127.0.0.1:8002 +# Anthropic-compatible example (Claude-compatible gateway) +# AI_PROVIDER=anthropic +# AI_PROVIDER_API=anthropic-messages +# AI_BASE_URL=http://127.0.0.1:8002/anthropic # AI_API_KEY=local-key +# AI_MODEL=your-model # AI_MAX_TOKENS=1200 # AI_ANTHROPIC_VERSION=2023-06-01 # Ollama native example -AI_BASE_URL=http://127.0.0.1:11434 -AI_API_KEY= -AI_MAX_TOKENS=1200 -AI_ANTHROPIC_VERSION=2023-06-01 +# AI_PROVIDER=ollama +# AI_PROVIDER_API=ollama-generate +# AI_BASE_URL=http://127.0.0.1:11434 +# AI_API_KEY= +# AI_MODEL=qwen2.5:7b diff --git a/aiprovider/README.md b/aiprovider/README.md index 2aae82be..c65a0ba9 100644 --- a/aiprovider/README.md +++ b/aiprovider/README.md @@ -8,17 +8,27 @@ 当前支持: -- `AI_PROVIDER=openai` -- `AI_PROVIDER=openai_compatible` -- `AI_PROVIDER=anthropic` -- `AI_PROVIDER=anthropic_compatible` -- `AI_PROVIDER=claude_compatible` -- `AI_PROVIDER=ollama` +- provider identity: + - `AI_PROVIDER=openai` + - `AI_PROVIDER=anthropic` + - `AI_PROVIDER=minimax` + - `AI_PROVIDER=ollama` +- request adapter: + - `AI_PROVIDER_API=openai-completions` + - `AI_PROVIDER_API=anthropic-messages` + - `AI_PROVIDER_API=ollama-generate` + +兼容别名仍然保留: + +- `openai_compatible` +- `anthropic_compatible` +- `claude_compatible` 典型配置: ```env -AI_PROVIDER=openai_compatible +AI_PROVIDER=openai +AI_PROVIDER_API=openai-completions AI_BASE_URL=https://api.openai.com/v1 AI_API_KEY=your_api_key AI_MODEL=gpt-4o-mini @@ -26,13 +36,14 @@ AI_TIMEOUT_SECONDS=60 AI_PROVIDER_SERVICE_TOKEN=change_me ``` -Claude 兼容供应商示例: +MiniMax 中国大陆节点示例: ```env -AI_PROVIDER=claude_compatible -AI_BASE_URL=https://your-claude-compatible-endpoint.example.com -AI_API_KEY=your_api_key -AI_MODEL=your-claude-compatible-model +AI_PROVIDER=minimax +AI_PROVIDER_API=anthropic-messages +AI_BASE_URL=https://api.minimaxi.com/anthropic +AI_API_KEY=sk-cp-xxxxx +AI_MODEL=MiniMax-M2.7 AI_TIMEOUT_SECONDS=60 AI_MAX_TOKENS=1200 AI_ANTHROPIC_VERSION=2023-06-01 @@ -43,12 +54,15 @@ AI_PROVIDER_SERVICE_TOKEN=change_me - Anthropic 官方 Claude API - Claude 兼容网关 -- MiniMax 等提供 Claude/Anthropic 风格消息接口的服务 +- MiniMax 等提供 Anthropic Messages 风格接口的服务 + +这套命名方式参考了 OpenClaw 的接入模式: provider 负责标识供应商, `AI_PROVIDER_API` 负责标识协议适配层, 避免把“供应商”和“协议”绑死在一起。 Ollama 原生示例: ```env AI_PROVIDER=ollama +AI_PROVIDER_API=ollama-generate AI_BASE_URL=http://127.0.0.1:11434 AI_API_KEY= AI_MODEL=qwen2.5:7b @@ -58,8 +72,8 @@ AI_PROVIDER_SERVICE_TOKEN=change_me 本地模型接入建议: -- `vLLM`、`LM Studio`、`One API`:优先使用 `openai_compatible` -- `MiniMax`、Claude 兼容网关:使用 `claude_compatible` +- `vLLM`、`LM Studio`、`One API`:`AI_PROVIDER=openai` + `AI_PROVIDER_API=openai-completions` +- `MiniMax`、Claude 兼容网关:`AI_PROVIDER=minimax|anthropic` + `AI_PROVIDER_API=anthropic-messages` - `Ollama`:可直接使用 `ollama` 启动模板: diff --git a/aiprovider/config.py b/aiprovider/config.py index 4d668b4f..b9c1e218 100644 --- a/aiprovider/config.py +++ b/aiprovider/config.py @@ -9,6 +9,7 @@ class Settings(BaseSettings): SERVICE_VERSION: str = "0.1.0" AI_PROVIDER: str = "disabled" + AI_PROVIDER_API: str = "auto" AI_BASE_URL: str = "https://api.openai.com/v1" AI_API_KEY: str = "" AI_MODEL: str = "" diff --git a/aiprovider/provider_service.py b/aiprovider/provider_service.py index 763e9ca1..0bdbc6bb 100644 --- a/aiprovider/provider_service.py +++ b/aiprovider/provider_service.py @@ -8,6 +8,7 @@ from fastapi import HTTPException, status from aiprovider.config import settings from aiprovider.schemas import ( + AIContentBlock, AIProviderStatusResponse, SituationalAnalysisRequest, SituationalAnalysisResponse, @@ -18,9 +19,39 @@ def _normalize_provider(value: str) -> str: return (value or "disabled").strip().lower() +def _normalize_provider_api(value: str) -> str: + return (value or "auto").strip().lower().replace("_", "-") + + +def _resolve_provider_api(provider: str, configured_api: str) -> str: + if configured_api and configured_api != "auto": + return configured_api + + if provider in {"openai", "openai-compatible", "openai_compatible"}: + return "openai-completions" + if provider in { + "anthropic", + "anthropic-compatible", + "anthropic_compatible", + "claude-compatible", + "claude_compatible", + "minimax", + "kimi-coding", + "moonshot-anthropic", + }: + return "anthropic-messages" + if provider == "ollama": + return "ollama-generate" + return "disabled" + + class ProviderService: def __init__(self) -> None: self.provider = _normalize_provider(settings.AI_PROVIDER) + self.provider_api = _resolve_provider_api( + self.provider, + _normalize_provider_api(settings.AI_PROVIDER_API), + ) self.base_url = settings.AI_BASE_URL.rstrip("/") self.api_key = settings.AI_API_KEY self.default_model = settings.AI_MODEL @@ -32,9 +63,11 @@ class ProviderService: def get_status(self) -> AIProviderStatusResponse: enabled = self.provider != "disabled" - configured = enabled and bool(self.base_url and self.api_key and self.default_model) + has_credentials = bool(self.api_key) if self._requires_api_key() else True + configured = enabled and bool(self.base_url and has_credentials and self.default_model) return AIProviderStatusResponse( provider=self.provider, + api=self.provider_api if enabled else None, enabled=enabled, configured=configured, model=self.default_model or None, @@ -49,7 +82,8 @@ class ProviderService: ) model = payload.preferred_model or self.default_model - if not self.base_url or not self.api_key or not model: + has_credentials = bool(self.api_key) if self._requires_api_key() else True + if not self.base_url or not has_credentials or not model: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="AI provider is not fully configured. Check AI_BASE_URL, AI_API_KEY, and AI_MODEL.", @@ -57,28 +91,40 @@ class ProviderService: prompt = self._build_prompt(payload) - if self.provider in {"openai", "openai_compatible"}: + if self.provider_api == "openai-completions": data = await self._request_openai_compatible(model, prompt) content = self._extract_openai_content(data) - elif self.provider in {"anthropic", "anthropic_compatible", "claude_compatible"}: - data = await self._request_anthropic_compatible(model, prompt) + content_blocks = self._extract_openai_blocks(data) + elif self.provider_api == "anthropic-messages": + data = await self._request_anthropic_messages(model, prompt, payload.thinking) content = self._extract_anthropic_content(data) - elif self.provider == "ollama": + content_blocks = self._extract_anthropic_blocks(data) + elif self.provider_api == "ollama-generate": data = await self._request_ollama(model, prompt) content = self._extract_ollama_content(data) + content_blocks = self._extract_ollama_blocks(data) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Unsupported AI provider: {self.provider}", + detail=f"Unsupported AI provider API: {self.provider_api}", ) + text_blocks = [block.text for block in content_blocks if block.text] + thinking_blocks = [block.thinking for block in content_blocks if block.thinking] + return SituationalAnalysisResponse( provider=self.provider, model=model, content=content, + content_blocks=content_blocks, + text_blocks=text_blocks, + thinking_blocks=thinking_blocks, raw_response=data, ) + def _requires_api_key(self) -> bool: + return self.provider_api != "ollama-generate" + def _build_prompt(self, payload: SituationalAnalysisRequest) -> str: sections = [ f"任务标题:\n{payload.title}", @@ -113,7 +159,12 @@ class ProviderService: request_body=request_body, ) - async def _request_anthropic_compatible(self, model: str, prompt: str) -> dict[str, Any]: + async def _request_anthropic_messages( + self, + model: str, + prompt: str, + thinking: dict[str, Any] | None = None, + ) -> dict[str, Any]: request_body = { "model": model, "system": self.system_prompt, @@ -131,8 +182,15 @@ class ProviderService: "max_tokens": self.max_tokens, "temperature": 0.2, } + resolved_thinking = self._resolve_anthropic_thinking(thinking) + if resolved_thinking: + request_body["thinking"] = resolved_thinking + if self.provider == "minimax" and self.base_url.endswith("/anthropic"): + path = "/v1/messages" + else: + path = "/messages" return await self._post( - path="/messages", + path=path, headers={ "x-api-key": self.api_key, "anthropic-version": self.anthropic_version, @@ -141,6 +199,25 @@ class ProviderService: request_body=request_body, ) + def _resolve_anthropic_thinking(self, thinking: dict[str, Any] | None) -> dict[str, Any] | None: + if thinking: + return thinking + + # OpenClaw treats MiniMax's Anthropic-compatible path specially: + # disable thinking by default unless the caller explicitly opts in. + if self.provider == "minimax": + return {"type": "disabled"} + + return None + + async def _request_anthropic_compatible( + self, + model: str, + prompt: str, + thinking: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return await self._request_anthropic_messages(model, prompt, thinking) + async def _request_ollama(self, model: str, prompt: str) -> dict[str, Any]: request_body = { "model": model, @@ -218,6 +295,31 @@ class ProviderService: ) return "" + def _extract_openai_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]: + choices = payload.get("choices") or [] + if not choices: + return [] + + message = choices[0].get("message") or {} + content = message.get("content") + if isinstance(content, str): + return [AIContentBlock(type="text", text=content)] + if not isinstance(content, list): + return [] + + blocks: list[AIContentBlock] = [] + for item in content: + if not isinstance(item, dict): + continue + blocks.append( + AIContentBlock( + type=str(item.get("type", "text")), + text=item.get("text") if isinstance(item.get("text"), str) else None, + metadata={k: v for k, v in item.items() if k not in {"type", "text"}}, + ) + ) + return blocks + def _extract_anthropic_content(self, payload: dict[str, Any]) -> str: content = payload.get("content") if isinstance(content, str): @@ -233,8 +335,38 @@ class ProviderService: fragments.append(item["text"]) return "".join(fragments) + def _extract_anthropic_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]: + content = payload.get("content") + if not isinstance(content, list): + return [] + + blocks: list[AIContentBlock] = [] + for item in content: + if not isinstance(item, dict): + continue + blocks.append( + AIContentBlock( + type=str(item.get("type", "unknown")), + text=item.get("text") if isinstance(item.get("text"), str) else None, + thinking=item.get("thinking") if isinstance(item.get("thinking"), str) else None, + signature=item.get("signature") if isinstance(item.get("signature"), str) else None, + metadata={ + k: v + for k, v in item.items() + if k not in {"type", "text", "thinking", "signature"} + }, + ) + ) + return blocks + def _extract_ollama_content(self, payload: dict[str, Any]) -> str: response = payload.get("response") if isinstance(response, str): return response return "" + + def _extract_ollama_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]: + response = payload.get("response") + if isinstance(response, str) and response: + return [AIContentBlock(type="text", text=response)] + return [] diff --git a/aiprovider/schemas.py b/aiprovider/schemas.py index 5e07fbf1..27d1046e 100644 --- a/aiprovider/schemas.py +++ b/aiprovider/schemas.py @@ -3,6 +3,14 @@ from typing import Any from pydantic import BaseModel, Field +class AIContentBlock(BaseModel): + type: str + text: str | None = None + thinking: str | None = None + signature: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + class SituationalAnalysisRequest(BaseModel): title: str = Field(..., min_length=1, max_length=200) objective: str = Field(..., min_length=1, max_length=1000) @@ -10,17 +18,22 @@ class SituationalAnalysisRequest(BaseModel): observations: list[str] = Field(default_factory=list) constraints: list[str] = Field(default_factory=list) preferred_model: str | None = Field(default=None, max_length=200) + thinking: dict[str, Any] | None = None class SituationalAnalysisResponse(BaseModel): provider: str model: str content: str + content_blocks: list[AIContentBlock] = Field(default_factory=list) + text_blocks: list[str] = Field(default_factory=list) + thinking_blocks: list[str] = Field(default_factory=list) raw_response: dict[str, Any] = Field(default_factory=dict) class AIProviderStatusResponse(BaseModel): provider: str + api: str | None = None enabled: bool configured: bool model: str | None = None diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py index 5e07fbf1..27d1046e 100644 --- a/backend/app/schemas/ai.py +++ b/backend/app/schemas/ai.py @@ -3,6 +3,14 @@ from typing import Any from pydantic import BaseModel, Field +class AIContentBlock(BaseModel): + type: str + text: str | None = None + thinking: str | None = None + signature: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + class SituationalAnalysisRequest(BaseModel): title: str = Field(..., min_length=1, max_length=200) objective: str = Field(..., min_length=1, max_length=1000) @@ -10,17 +18,22 @@ class SituationalAnalysisRequest(BaseModel): observations: list[str] = Field(default_factory=list) constraints: list[str] = Field(default_factory=list) preferred_model: str | None = Field(default=None, max_length=200) + thinking: dict[str, Any] | None = None class SituationalAnalysisResponse(BaseModel): provider: str model: str content: str + content_blocks: list[AIContentBlock] = Field(default_factory=list) + text_blocks: list[str] = Field(default_factory=list) + thinking_blocks: list[str] = Field(default_factory=list) raw_response: dict[str, Any] = Field(default_factory=dict) class AIProviderStatusResponse(BaseModel): provider: str + api: str | None = None enabled: bool configured: bool model: str | None = None diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 8c0aabde..5faa479e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -165,7 +165,8 @@ async def test_ai_provider_status_with_auth(auth_headers): class _FakeAIProviderClient: async def get_status(self, request_id=None): return AIProviderStatusResponse( - provider="openai_compatible", + provider="minimax", + api="anthropic-messages", enabled=True, configured=True, model="test-model", @@ -193,6 +194,7 @@ async def test_ai_provider_status_with_auth(auth_headers): assert response.status_code == 200 data = response.json() assert "provider" in data + assert "api" in data assert "configured" in data finally: app.dependency_overrides.clear() @@ -207,6 +209,9 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers): provider="openai_compatible", model="test-model", content="1) 态势摘要: 测试返回", + content_blocks=[], + text_blocks=["1) 态势摘要: 测试返回"], + thinking_blocks=[], raw_response={"id": "mock-response"}, ) @@ -241,5 +246,8 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers): data = response.json() assert data["provider"] == "openai_compatible" assert data["content"] + assert "content_blocks" in data + assert "text_blocks" in data + assert "thinking_blocks" in data finally: app.dependency_overrides.clear() diff --git a/docs/aiprovider.md b/docs/aiprovider.md index 64227453..d82d75e7 100644 --- a/docs/aiprovider.md +++ b/docs/aiprovider.md @@ -31,22 +31,40 @@ The recommended default is: - timeout and lightweight retry - request tracing via `X-Request-ID` +This now follows an OpenClaw-like seam: + +- `AI_PROVIDER` identifies the vendor or logical provider +- `AI_PROVIDER_API` identifies the wire adapter + +That split makes MiniMax, Claude-compatible gateways, and self-hosted OpenAI-compatible services easier to model without overloading one config field. + ## Supported Providers -`aiprovider` currently supports: +`aiprovider` currently supports these provider identities: - `openai` -- `openai_compatible` - `anthropic` +- `minimax` +- `ollama` + +Supported request adapters: + +- `openai-completions` +- `anthropic-messages` +- `ollama-generate` + +Backward-compatible aliases still accepted: + +- `openai_compatible` - `anthropic_compatible` - `claude_compatible` -- `ollama` Provider mapping: -- `vLLM`, `LM Studio`, `One API`: `openai_compatible` -- `MiniMax`, Claude-compatible gateways: `claude_compatible` -- `Ollama`: `ollama` +- `vLLM`, `LM Studio`, `One API`: `AI_PROVIDER=openai`, `AI_PROVIDER_API=openai-completions` +- `MiniMax`: `AI_PROVIDER=minimax`, `AI_PROVIDER_API=anthropic-messages` +- Claude-compatible gateways: `AI_PROVIDER=anthropic`, `AI_PROVIDER_API=anthropic-messages` +- `Ollama`: `AI_PROVIDER=ollama`, `AI_PROVIDER_API=ollama-generate` ## API Surfaces @@ -137,9 +155,13 @@ Both backend and `aiprovider` return the same payload shape: ```json { - "provider": "openai_compatible", - "model": "gpt-4o-mini", + "provider": "minimax", + "api": "anthropic-messages", + "model": "MiniMax-M2.7", "content": "1) 态势摘要 ...", + "content_blocks": [], + "text_blocks": [], + "thinking_blocks": [], "raw_response": {} } ``` @@ -189,17 +211,37 @@ AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上 ### OpenAI-compatible example ```env -AI_PROVIDER=openai_compatible +AI_PROVIDER=openai +AI_PROVIDER_API=openai-completions AI_BASE_URL=http://127.0.0.1:8001/v1 AI_API_KEY=local-key AI_MODEL=your-local-model ``` -### Claude-compatible example +### MiniMax CN example ```env -AI_PROVIDER=claude_compatible -AI_BASE_URL=https://your-claude-compatible-endpoint.example.com +AI_PROVIDER=minimax +AI_PROVIDER_API=anthropic-messages +AI_BASE_URL=https://api.minimaxi.com/anthropic +AI_API_KEY=sk-cp-xxxxx +AI_MODEL=MiniMax-M2.7 +AI_MAX_TOKENS=1200 +AI_ANTHROPIC_VERSION=2023-06-01 +``` + +MiniMax note: + +- This follows the same Anthropic Messages request shape as the official MiniMax examples. +- For MiniMax, `aiprovider` now disables `thinking` by default unless the caller explicitly passes a `thinking` object. +- This mirrors OpenClaw's caution around MiniMax Anthropic-compatible behavior. + +### Anthropic-compatible example + +```env +AI_PROVIDER=anthropic +AI_PROVIDER_API=anthropic-messages +AI_BASE_URL=https://your-claude-compatible-endpoint.example.com/anthropic AI_API_KEY=your_api_key AI_MODEL=your-model AI_MAX_TOKENS=1200 @@ -210,6 +252,7 @@ AI_ANTHROPIC_VERSION=2023-06-01 ```env AI_PROVIDER=ollama +AI_PROVIDER_API=ollama-generate AI_BASE_URL=http://127.0.0.1:11434 AI_API_KEY= AI_MODEL=qwen2.5:7b From 7abf391c74fbebed7be0e9e0bc2286c1a00c72d0 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Tue, 7 Apr 2026 23:25:13 +0800 Subject: [PATCH 02/34] Harden startup retry and recovery flow --- README.md | 26 +++++ docs/CHANGELOG.md | 2 + planet.sh | 281 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 254 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 25665855..1df787fb 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,32 @@ 启动服务后访问: `http://localhost:8000/docs` +## 启动容错参数 + +`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。 + +可通过环境变量临时调整: + +```bash +# 例: 放宽 AI Provider 与数据库在网络抖动下的自愈次数 +AI_PROVIDER_START_MAX_RETRIES=5 \ +AI_PROVIDER_RETRY_INTERVAL=10 \ +DATABASE_START_MAX_RETRIES=5 \ +DATABASE_RETRY_INTERVAL=10 \ +./planet.sh restart +``` + +常用参数: + +- `DEPENDENCY_INSTALL_MAX_RETRIES` / `DEPENDENCY_INSTALL_RETRY_INTERVAL`: 控制 `uv sync`、`bun install` 的重试次数与间隔,默认 `3` 次、`5` 秒 +- `DATABASE_START_MAX_RETRIES` / `DATABASE_RETRY_INTERVAL`: 控制 `postgres`、`redis` 的启动/重启与健康检查自愈,默认 `3` 次、`5` 秒 +- `AI_PROVIDER_START_MAX_RETRIES` / `AI_PROVIDER_RETRY_INTERVAL`: 控制 `aiprovider` 的构建/启动与容器重启自愈,默认 `3` 次、`5` 秒 +- `BACKEND_MAX_RETRIES`: 控制后端进程启动重试次数,默认 `3` +- `FRONTEND_MAX_RETRIES`: 控制前端 dev server 启动重试次数,默认 `3` +- `BACKEND_HEALTH_CHECK_ATTEMPTS` / `BACKEND_HEALTH_CHECK_INTERVAL`: 控制后端 HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` 秒 +- `FRONTEND_HEALTH_CHECK_ATTEMPTS` / `FRONTEND_HEALTH_CHECK_INTERVAL`: 控制前端 HTTP 可访问检查等待次数与间隔,默认 `10` 次、`2` 秒 +- `AI_PROVIDER_HEALTH_CHECK_ATTEMPTS` / `AI_PROVIDER_HEALTH_CHECK_INTERVAL`: 控制 `aiprovider` HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` 秒 + ## AI 接口预留 项目现在采用“两层”设计: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0407a8de..301e72bb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -31,6 +31,8 @@ Released: 2026-04-07 - Improved backend-to-provider tracing by propagating `X-Request-ID` through the AI call chain and returning the same header from both backend and `aiprovider`. - Improved resilience by adding lightweight retry handling to both `backend -> aiprovider` and `aiprovider -> model provider` HTTP calls. - Improved operator workflow by folding `aiprovider` startup, health checks, restart support, and log viewing into [planet.sh](/home/ray/dev/linkong/planet/planet.sh). +- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by adding bounded retry and container-health self-recovery for dependency installs, database startup, `aiprovider` startup, and interactive PostgreSQL boot paths. +- Improved [README.md](/home/ray/dev/linkong/planet/README.md) by documenting the new `planet.sh` retry and health-check tuning environment variables with concrete override examples. - Improved container consistency by switching [backend/Dockerfile](/home/ray/dev/linkong/planet/backend/Dockerfile) and [aiprovider/Dockerfile](/home/ray/dev/linkong/planet/aiprovider/Dockerfile) to `uv sync` / `uv run`. ### Changed diff --git a/planet.sh b/planet.sh index b487a830..2e2ae5bc 100755 --- a/planet.sh +++ b/planet.sh @@ -14,8 +14,14 @@ NC='\033[0m' BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}" BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}" BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}" +DEPENDENCY_INSTALL_MAX_RETRIES="${DEPENDENCY_INSTALL_MAX_RETRIES:-3}" +DEPENDENCY_INSTALL_RETRY_INTERVAL="${DEPENDENCY_INSTALL_RETRY_INTERVAL:-5}" AI_PROVIDER_HEALTH_CHECK_ATTEMPTS="${AI_PROVIDER_HEALTH_CHECK_ATTEMPTS:-10}" AI_PROVIDER_HEALTH_CHECK_INTERVAL="${AI_PROVIDER_HEALTH_CHECK_INTERVAL:-2}" +AI_PROVIDER_START_MAX_RETRIES="${AI_PROVIDER_START_MAX_RETRIES:-3}" +AI_PROVIDER_RETRY_INTERVAL="${AI_PROVIDER_RETRY_INTERVAL:-5}" +DATABASE_START_MAX_RETRIES="${DATABASE_START_MAX_RETRIES:-3}" +DATABASE_RETRY_INTERVAL="${DATABASE_RETRY_INTERVAL:-5}" FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}" FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}" FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}" @@ -31,6 +37,33 @@ compose_up() { fi } +run_with_retry() { + local max_retries="$1" + local interval="$2" + local failure_message="$3" + local retry_label="$4" + shift 4 + + local attempt=1 + + while [ "$attempt" -le "$max_retries" ]; do + if "$@"; then + return 0 + fi + + if [ "$attempt" -eq "$max_retries" ]; then + echo -e "${RED}❌ ${failure_message}${NC}" + return 1 + fi + + echo -e "${YELLOW}⚠️ ${retry_label} 第 ${attempt}/${max_retries} 次失败,${interval} 秒后重试...${NC}" + sleep "$interval" + attempt=$((attempt + 1)) + done + + return 1 +} + ensure_uv_backend_deps() { echo -e "${BLUE}📦 检查后端 uv 环境...${NC}" @@ -43,7 +76,14 @@ ensure_uv_backend_deps() { if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then echo -e "${YELLOW}⚠️ 未检测到 .venv,正在执行 uv sync...${NC}" - uv sync --group dev + if ! run_with_retry \ + "$DEPENDENCY_INSTALL_MAX_RETRIES" \ + "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ + "uv 环境初始化失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ + "uv sync" \ + uv sync --group dev; then + exit 1 + fi fi if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then @@ -64,7 +104,14 @@ ensure_frontend_deps() { if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then echo -e "${YELLOW}⚠️ 前端依赖缺失,正在执行 bun install...${NC}" - bun install + if ! run_with_retry \ + "$DEPENDENCY_INSTALL_MAX_RETRIES" \ + "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ + "前端依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ + "bun install" \ + bun install; then + exit 1 + fi fi if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then @@ -93,6 +140,50 @@ wait_for_http() { return 1 } +wait_for_container_health() { + local container_name="$1" + local attempts="$2" + local interval="$3" + local service_name="$4" + local attempt=1 + local status + + while [ "$attempt" -le "$attempts" ]; do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)" + + if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then + return 0 + fi + + echo -e "${YELLOW}⏳ 等待${service_name}容器健康检查通过...${NC}" + sleep "$interval" + attempt=$((attempt + 1)) + done + + return 1 +} + +wait_for_database_health() { + wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" && + wait_for_container_health "planet_redis" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "Redis" +} + +wait_for_postgres_health() { + wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" +} + +start_database_services() { + docker start planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis +} + +restart_database_services() { + docker restart planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis +} + +start_postgres_service() { + docker start planet_postgres 2>/dev/null || compose_up up -d postgres +} + start_backend_with_retry() { local backend_port="$1" local retry=1 @@ -117,14 +208,33 @@ start_backend_with_retry() { start_ai_provider_service() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" + local retry=1 echo -e "${BLUE}🧠 启动 AI Provider...${NC}" - docker start planet_aiprovider 2>/dev/null || compose_up up -d aiprovider - if ! wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then - echo -e "${RED}❌ AI Provider 启动失败${NC}" - exit 1 - fi + while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do + if docker start planet_aiprovider 2>/dev/null || compose_up up -d aiprovider; then + if wait_for_container_health "planet_aiprovider" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" && + wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then + return 0 + fi + fi + + if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then + echo -e "${RED}❌ AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次${NC}" + docker logs --tail 20 planet_aiprovider 2>/dev/null || true + exit 1 + fi + + echo -e "${YELLOW}⚠️ AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}" + docker restart planet_aiprovider 2>/dev/null || true + sleep "$AI_PROVIDER_RETRY_INTERVAL" + retry=$((retry + 1)) + done + + echo -e "${RED}❌ AI Provider 启动失败${NC}" + docker logs --tail 20 planet_aiprovider 2>/dev/null || true + exit 1 } start_frontend_with_retry() { @@ -150,6 +260,114 @@ start_frontend_with_retry() { return 1 } +ensure_database_services_healthy() { + local retry=1 + + while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do + if start_database_services && wait_for_database_health; then + return 0 + fi + + if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then + echo -e "${RED}❌ 数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}" + docker logs --tail 20 planet_postgres 2>/dev/null || true + docker logs --tail 20 planet_redis 2>/dev/null || true + exit 1 + fi + + echo -e "${YELLOW}⚠️ 数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}" + docker restart planet_postgres planet_redis 2>/dev/null || true + sleep "$DATABASE_RETRY_INTERVAL" + retry=$((retry + 1)) + done +} + +ensure_postgres_service_healthy() { + local retry=1 + + while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do + if start_postgres_service && wait_for_postgres_health; then + return 0 + fi + + if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then + echo -e "${RED}❌ PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}" + docker logs --tail 20 planet_postgres 2>/dev/null || true + exit 1 + fi + + echo -e "${YELLOW}⚠️ PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}" + docker restart planet_postgres 2>/dev/null || true + sleep "$DATABASE_RETRY_INTERVAL" + retry=$((retry + 1)) + done +} + +restart_database_service() { + local retry=1 + + echo -e "${BLUE}🗄️ 重启数据库...${NC}" + + while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do + if restart_database_services && wait_for_database_health; then + sleep 3 + return 0 + fi + + if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then + echo -e "${RED}❌ 数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}" + docker logs --tail 20 planet_postgres 2>/dev/null || true + docker logs --tail 20 planet_redis 2>/dev/null || true + exit 1 + fi + + echo -e "${YELLOW}⚠️ 数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试...${NC}" + sleep "$DATABASE_RETRY_INTERVAL" + retry=$((retry + 1)) + done +} + +start_backend_service() { + local backend_port="$1" + local backend_port_requested="$2" + local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}" + + echo -e "${BLUE}🗄️ 启动数据库...${NC}" + ensure_database_services_healthy + sleep 3 + + start_ai_provider_service "$ai_provider_port" + + if [ "$backend_port_requested" -eq 1 ]; then + kill_port_if_requested "$backend_port" "后端" + fi + + echo -e "${BLUE}🔧 启动后端...${NC}" + ensure_uv_backend_deps + if ! start_backend_with_retry "$backend_port"; then + echo -e "${RED}❌ 后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次${NC}" + tail -10 /tmp/planet_backend.log + exit 1 + fi +} + +start_frontend_service() { + local frontend_port="$1" + local frontend_port_requested="$2" + + if [ "$frontend_port_requested" -eq 1 ]; then + kill_port_if_requested "$frontend_port" "前端" + fi + + echo -e "${BLUE}🌐 启动前端...${NC}" + ensure_frontend_deps + if ! start_frontend_with_retry "$frontend_port"; then + echo -e "${RED}❌ 前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次${NC}" + tail -10 /tmp/planet_frontend.log + exit 1 + fi +} + validate_port() { local port="$1" @@ -263,53 +481,6 @@ stop_frontend_service() { pkill -f "bun run dev" 2>/dev/null || true } -restart_database_service() { - echo -e "${BLUE}🗄️ 重启数据库...${NC}" - docker restart planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis - sleep 3 -} - -start_backend_service() { - local backend_port="$1" - local backend_port_requested="$2" - local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}" - - echo -e "${BLUE}🗄️ 启动数据库...${NC}" - docker start planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis - sleep 3 - - start_ai_provider_service "$ai_provider_port" - - if [ "$backend_port_requested" -eq 1 ]; then - kill_port_if_requested "$backend_port" "后端" - fi - - echo -e "${BLUE}🔧 启动后端...${NC}" - ensure_uv_backend_deps - if ! start_backend_with_retry "$backend_port"; then - echo -e "${RED}❌ 后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次${NC}" - tail -10 /tmp/planet_backend.log - exit 1 - fi -} - -start_frontend_service() { - local frontend_port="$1" - local frontend_port_requested="$2" - - if [ "$frontend_port_requested" -eq 1 ]; then - kill_port_if_requested "$frontend_port" "前端" - fi - - echo -e "${BLUE}🌐 启动前端...${NC}" - ensure_frontend_deps - if ! start_frontend_with_retry "$frontend_port"; then - echo -e "${RED}❌ 前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次${NC}" - tail -10 /tmp/planet_frontend.log - exit 1 - fi -} - create_user() { local username local password @@ -322,7 +493,7 @@ create_user() { ensure_uv_backend_deps echo -e "${BLUE}🗄️ 启动数据库...${NC}" - docker start planet_postgres 2>/dev/null || compose_up up -d postgres + ensure_postgres_service_healthy sleep 2 echo -e "${BLUE}👤 创建用户${NC}" From 981617ee80f60cee56775740e33cb42ca509a0cf Mon Sep 17 00:00:00 2001 From: rayd1o Date: Tue, 7 Apr 2026 23:51:44 +0800 Subject: [PATCH 03/34] Bump version to 0.23.1 --- VERSION | 2 +- docs/CHANGELOG.md | 17 + frontend/package.json | 2 +- frontend/src/index.css | 208 +++++++++++ frontend/src/pages/BGP/BGP.tsx | 636 ++++++++++++++++++--------------- pyproject.toml | 2 +- 6 files changed, 574 insertions(+), 293 deletions(-) diff --git a/VERSION b/VERSION index ca222b7c..610e2872 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.0 +0.23.1 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 301e72bb..ea0479e2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,23 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.23.1 + +Released: 2026-04-07 + +### Highlights + +- Fixed the BGP overview page so high-DPI and lower-height screens can keep the observation workspace visible within a single viewport. + +### Improved + +- Improved [BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) by switching the page to a height-aware shell, compact summary layout, and tabbed data workspace so observation tables retain the majority of the screen. +- Improved [index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by adding responsive BGP-specific compact spacing, denser table paddings, and an internal scroll region for collector coverage and event tables. + +### Fixed + +- Fixed the BGP observation page on small or high-scale displays where stacked stats and full-height blocks consumed too much vertical space, leaving only a couple of visible table rows. + ## 0.23.0 Released: 2026-04-07 diff --git a/frontend/package.json b/frontend/package.json index 75b87a17..658ce068 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.23.0", + "version": "0.23.1", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/src/index.css b/frontend/src/index.css index fff1a20e..d426010e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -483,6 +483,175 @@ body { background: transparent; } +.bgp-page { + min-height: 0; +} + +.bgp-page__header { + align-items: flex-start; +} + +.bgp-page__body { + gap: 10px; +} + +.bgp-page__body > .ant-space { + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +.bgp-page__body > .ant-space > .ant-space-item:last-child { + flex: 1 1 auto; + min-height: 0; + display: flex; +} + +.bgp-page__alert .ant-alert { + padding: 8px 12px; +} + +.bgp-page__alert .ant-alert-message { + font-size: 13px; +} + +.bgp-page__summary-card .ant-card-body { + padding: 10px 12px; +} + +.bgp-page__summary-item { + min-height: 64px; + padding: 8px 10px; + border-radius: 10px; + background: #f7f8fa; + border: 1px solid #eef1f5; +} + +.bgp-page__summary-label { + margin-bottom: 4px; + color: rgba(0, 0, 0, 0.45); + font-size: 12px; + line-height: 1.2; +} + +.bgp-page__summary-stat.ant-statistic { + line-height: 1; +} + +.bgp-page__summary-stat .ant-statistic-content { + font-size: 24px; + line-height: 1.1; +} + +.bgp-page__summary-stat .ant-statistic-content-value { + line-height: 1; +} + +.bgp-page__table-card, +.bgp-page__table-card .ant-card-body { + min-height: 0; + height: 100%; +} + +.bgp-page__table-card .ant-card-body { + display: flex; + flex-direction: column; + padding: 10px 12px; +} + +.bgp-page__table-region { + flex: 1 1 auto; + min-height: 0; + height: 100%; +} + +.bgp-page__tabs, +.bgp-page__tabs .ant-tabs-content-holder, +.bgp-page__tabs .ant-tabs-content, +.bgp-page__tabs .ant-tabs-tabpane { + min-height: 0; + height: 100%; +} + +.bgp-page__tabs { + display: flex; + flex-direction: column; +} + +.bgp-page__tabs .ant-tabs-nav { + flex: 0 0 auto; + margin-bottom: 8px; +} + +.bgp-page__tabs .ant-tabs-content-holder { + flex: 1 1 auto; +} + +.bgp-page__tabs .ant-tabs-tabpane { + display: flex; + flex-direction: column; +} + +.bgp-page__tabs .ant-table-wrapper { + flex: 1 1 auto; + min-height: 0; +} + +.bgp-page--compact .page-shell__body { + gap: 8px; +} + +.bgp-page--compact .bgp-page__alert .ant-alert { + padding: 6px 10px; +} + +.bgp-page--compact .bgp-page__alert .ant-alert-message { + font-size: 12px; +} + +.bgp-page--compact .bgp-page__summary-card .ant-card-body { + padding: 8px 10px; +} + +.bgp-page--compact .bgp-page__summary-item { + min-height: 56px; + padding: 6px 8px; +} + +.bgp-page--compact .bgp-page__summary-label { + margin-bottom: 2px; + font-size: 11px; +} + +.bgp-page--compact .bgp-page__summary-stat .ant-statistic-content { + font-size: 20px; +} + +.bgp-page--compact .bgp-page__table-card .ant-card-body { + padding: 8px 10px; +} + +.bgp-page--compact .bgp-page__tabs .ant-tabs-nav { + margin-bottom: 6px; +} + +.bgp-page--compact .bgp-page__tabs .ant-tabs-tab { + padding-top: 6px; + padding-bottom: 6px; +} + +.bgp-page--compact .bgp-page__tabs .ant-table-thead > tr > th { + font-size: 12px; + line-height: 1.2; +} + +.bgp-page--compact .table-scroll-region .ant-table-thead > tr > th, +.bgp-page--compact .table-scroll-region .ant-table-tbody > tr > td { + padding: 6px 8px !important; +} + .data-list-controls-shell { scrollbar-width: thin; scrollbar-color: rgba(148, 163, 184, 0.82) transparent; @@ -1056,6 +1225,22 @@ body { padding: 12px; } + .bgp-page__body { + gap: 10px; + } + + .bgp-page__summary-card .ant-card-body { + padding: 8px 10px; + } + + .bgp-page__summary-item { + min-height: 54px; + } + + .bgp-page__table-card .ant-card-body { + padding: 8px 10px; + } + .data-list-workspace { gap: 10px; } @@ -1112,6 +1297,29 @@ body { } @media (max-width: 640px) { + .bgp-page__header { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .bgp-page__stats-row { + margin-bottom: 0 !important; + } + + .bgp-page__summary-label { + font-size: 11px; + } + + .bgp-page__summary-stat .ant-statistic-content { + font-size: 18px; + } + + .bgp-page__tabs .ant-tabs-tab { + padding-left: 8px; + padding-right: 8px; + } + .data-list-summary-treemap { grid-template-columns: 1fr; } diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index 8dfc7a45..530dd071 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { Alert, Card, Col, Row, Space, Statistic, Table, Tag, Typography } from 'antd' +import { useEffect, useRef, useState } from 'react' +import { Alert, Card, Col, Row, Space, Statistic, Table, Tabs, Tag, Typography } from 'antd' import AppLayout from '../../components/AppLayout/AppLayout' import { formatDateTimeZhCN } from '../../utils/datetime' import { @@ -32,6 +32,9 @@ function BGP() { 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) useEffect(() => { const load = async () => { @@ -59,302 +62,355 @@ function BGP() { load() }, []) + useEffect(() => { + const updateViewportMode = () => { + if (typeof window === 'undefined') return + const compact = window.devicePixelRatio >= 1.5 || window.innerHeight <= 860 || window.innerWidth <= 1440 + setCompactViewport(compact) + } + + updateViewportMode() + window.addEventListener('resize', updateViewportMode) + return () => window.removeEventListener('resize', updateViewportMode) + }, []) + + useEffect(() => { + const updateTableHeight = () => { + const regionHeight = tableRegionRef.current?.offsetHeight || 0 + setTableHeight(Math.max(compactViewport ? 180 : 240, regionHeight - (compactViewport ? 40 : 52))) + } + + updateTableHeight() + + if (typeof ResizeObserver === 'undefined') { + return undefined + } + + const observer = new ResizeObserver(updateTableHeight) + if (tableRegionRef.current) observer.observe(tableRegionRef.current) + + return () => observer.disconnect() + }, [compactViewport, collectors.length, incidents.length, anomalies.length, events.length]) + + const summaryItems = [ + { label: '近24h事件', value: collectorSummary?.recent_24h_events || 0 }, + { label: '活跃观测站', value: collectorSummary?.active_collectors || 0 }, + { label: '观测前缀', value: collectorSummary?.observed_prefixes || eventSummary?.prefix_count || 0 }, + { label: '事件总数', value: incidentSummary?.total || 0 }, + { label: '活跃事件', value: incidentSummary?.by_status?.active || 0 }, + { label: '严重事件', value: incidentSummary?.by_severity?.critical || 0 }, + ] + return ( - -
- BGP观测 - 先看事件态势,再下钻到原子异常明细。 +
+
+
+ BGP观测 + 先看事件态势,再下钻到原子异常明细。 +
- +
+ + - - - - + + + {summaryItems.map((item) => ( + +
+
{item.label}
+ +
+ + ))} +
- - - - - - - - - - - -
- - - - + +
+ + rowKey="collector" + loading={loading} + dataSource={collectors} + pagination={{ pageSize: compactViewport ? 6 : 8 }} + scroll={{ x: 'max-content', 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', + 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: ( + + rowKey="id" + loading={loading} + dataSource={incidents} + pagination={{ pageSize: compactViewport ? 6 : 8 }} + scroll={{ x: 'max-content', 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) => {value}, + }, + { + 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', + }, + ]} + /> + ), + }, + { + key: 'anomalies', + label: '异常明细', + children: ( + + rowKey="id" + loading={loading} + dataSource={anomalies} + pagination={{ pageSize: compactViewport ? 8 : 10 }} + scroll={{ x: 'max-content', 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) => {value}, + }, + { + 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', + }, + ]} + /> + ), + }, + { + key: 'events', + label: '最近观测事件', + children: ( + + rowKey="id" + loading={loading} + dataSource={events} + pagination={{ pageSize: compactViewport ? 6 : 8 }} + scroll={{ x: 'max-content', 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}` : '-'), + }, + ]} + /> + ), + }, + ]} + /> +
- - - - - - - - - - - -
- - - - rowKey="collector" - loading={loading} - dataSource={collectors} - pagination={{ pageSize: 8 }} - 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', - 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 || '-' - }, - }, - ]} - /> - - - - - rowKey="id" - loading={loading} - dataSource={incidents} - pagination={{ pageSize: 8 }} - 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) => {value}, - }, - { - 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', - }, - ]} - /> - - - - - rowKey="id" - loading={loading} - dataSource={anomalies} - pagination={{ pageSize: 10 }} - 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) => {value}, - }, - { - 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', - }, - ]} - /> - - - - - rowKey="id" - loading={loading} - dataSource={events} - pagination={{ pageSize: 8 }} - 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}` : '-'), - }, - ]} - /> - -
+ +
+
) } diff --git a/pyproject.toml b/pyproject.toml index 621946fb..ca9b9828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.23.0" +version = "0.23.1" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ From f5308340afd94217ccef1fb3a3648fa720b5dc08 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Wed, 8 Apr 2026 02:49:29 +0800 Subject: [PATCH 04/34] Add force rerun recovery and polish CLI startup --- backend/app/api/v1/datasources.py | 96 +++++- backend/app/services/collectors/base.py | 114 +++++-- backend/app/services/scheduler.py | 56 +++- .../src/pages/DataSources/DataSources.tsx | 122 ++++++-- planet.sh | 293 ++++++++++++------ uv.lock | 2 +- 6 files changed, 546 insertions(+), 137 deletions(-) diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 22337662..1428e073 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func, select +from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.core.time import to_iso8601_utc @@ -11,10 +11,16 @@ from app.core.security import get_current_user from app.core.data_sources import get_data_sources_config from app.db.session import get_db from app.models.collected_data import CollectedData +from app.models.data_snapshot import DataSnapshot from app.models.datasource import DataSource from app.models.task import CollectionTask from app.models.user import User -from app.services.scheduler import get_latest_task_id_for_datasource, run_collector_now, sync_datasource_job +from app.services.scheduler import ( + cancel_running_collector_now, + get_latest_task_id_for_datasource, + run_collector_now, + sync_datasource_job, +) router = APIRouter() STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90 @@ -100,6 +106,70 @@ async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[Col return None +async def rollback_orphaned_running_task( + db: AsyncSession, + datasource: DataSource, + running_task: CollectionTask, +) -> None: + snapshot_result = await db.execute( + select(DataSnapshot) + .where( + DataSnapshot.datasource_id == datasource.id, + DataSnapshot.task_id == running_task.id, + ) + .order_by(DataSnapshot.id.desc()) + .limit(1) + ) + snapshot = snapshot_result.scalar_one_or_none() + + await db.execute(CollectedData.__table__.delete().where(CollectedData.task_id == running_task.id)) + + await db.execute( + text( + """ + UPDATE collected_data + SET is_current = FALSE + WHERE source = :source + """ + ), + {"source": datasource.source}, + ) + + if snapshot is not None: + snapshot.status = "cancelled" + snapshot.is_current = False + snapshot.completed_at = datetime.now(timezone.utc) + summary = dict(snapshot.summary or {}) + summary["rollback"] = True + summary["rollback_reason"] = "orphaned_running_task_after_backend_restart" + snapshot.summary = summary + + if snapshot.parent_snapshot_id is not None: + parent_snapshot = await db.get(DataSnapshot, snapshot.parent_snapshot_id) + if parent_snapshot: + parent_snapshot.is_current = True + await db.execute( + text( + """ + UPDATE collected_data + SET is_current = TRUE + WHERE snapshot_id = :snapshot_id + """ + ), + {"snapshot_id": snapshot.parent_snapshot_id}, + ) + + running_task.status = "cancelled" + running_task.phase = "cancelled" + running_task.completed_at = datetime.now(timezone.utc) + existing_error = (running_task.error_message or "").strip() + cancel_reason = "Cancelled after backend restart because the running task handle was lost; incomplete writes rolled back" + running_task.error_message = f"{existing_error}\n{cancel_reason}".strip() if existing_error else cancel_reason + datasource.last_status = "cancelled" + datasource.last_run_at = datetime.now(timezone.utc) + await db.commit() + + @router.get("") async def list_datasources( module: Optional[str] = None, @@ -346,6 +416,7 @@ async def get_datasource_stats( @router.post("/{source_id}/trigger") async def trigger_datasource( source_id: str, + force: bool = Query(False), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -356,6 +427,26 @@ async def trigger_datasource( if not datasource.is_active: raise HTTPException(status_code=400, detail="Data source is disabled") + running_task = await get_running_task(db, datasource.id) + if running_task is not None and not force: + raise HTTPException( + status_code=409, + detail={ + "reason": "running_task_in_progress", + "message": "当前采集任务尚未完成,重新触发会丢失本次未完成进度。是否强制重新采集?", + "task_id": running_task.id, + "phase": running_task.phase, + "progress": running_task.progress, + "records_processed": running_task.records_processed, + "total_records": running_task.total_records, + }, + ) + + if running_task is not None and force: + cancelled = await cancel_running_collector_now(datasource.source) + if not cancelled: + await rollback_orphaned_running_task(db, datasource, running_task) + previous_task_id = await get_latest_task_id_for_datasource(datasource.id) success = run_collector_now(datasource.source) if not success: @@ -375,6 +466,7 @@ async def trigger_datasource( "source_id": datasource.id, "task_id": task_id, "collector_name": datasource.source, + "force": force, "message": f"Collector '{datasource.source}' has been triggered", } diff --git a/backend/app/services/collectors/base.py b/backend/app/services/collectors/base.py index 64453974..fa2faaac 100644 --- a/backend/app/services/collectors/base.py +++ b/backend/app/services/collectors/base.py @@ -1,5 +1,6 @@ """Base collector class for all data sources""" +import asyncio from abc import ABC, abstractmethod from typing import Dict, List, Any, Optional from datetime import UTC, datetime @@ -166,6 +167,59 @@ class BaseCollector(ABC): await db.commit() return snapshot.id + async def _rollback_incomplete_run( + self, + db: AsyncSession, + *, + task_id: int, + snapshot_id: Optional[int], + reason: str, + ) -> None: + from app.models.collected_data import CollectedData + from app.models.data_snapshot import DataSnapshot + + await db.execute(CollectedData.__table__.delete().where(CollectedData.task_id == task_id)) + + parent_snapshot_id: Optional[int] = None + if snapshot_id is not None: + snapshot = await db.get(DataSnapshot, snapshot_id) + if snapshot: + parent_snapshot_id = snapshot.parent_snapshot_id + snapshot.status = "cancelled" + snapshot.is_current = False + snapshot.completed_at = datetime.now(UTC) + summary = dict(snapshot.summary or {}) + summary["rollback"] = True + summary["rollback_reason"] = reason + snapshot.summary = summary + + await db.execute( + text( + """ + UPDATE collected_data + SET is_current = FALSE + WHERE source = :source + """ + ), + {"source": self.name}, + ) + + if parent_snapshot_id is not None: + parent_snapshot = await db.get(DataSnapshot, parent_snapshot_id) + if parent_snapshot: + parent_snapshot.is_current = True + + await db.execute( + text( + """ + UPDATE collected_data + SET is_current = TRUE + WHERE snapshot_id = :snapshot_id + """ + ), + {"snapshot_id": parent_snapshot_id}, + ) + async def run(self, db: AsyncSession) -> Dict[str, Any]: """Full pipeline: fetch -> transform -> save""" from app.services.collectors.registry import collector_registry @@ -227,6 +281,21 @@ class BaseCollector(ABC): "records_processed": records_count, "execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(), } + except asyncio.CancelledError: + task.status = "cancelled" + task.phase = "cancelled" + task.error_message = "Collection cancelled by operator and rolled back" + task.completed_at = datetime.now(UTC) + if snapshot_id is not None: + await self._rollback_incomplete_run( + db, + task_id=task_id, + snapshot_id=snapshot_id, + reason="cancelled_by_operator", + ) + await db.commit() + await self._publish_task_update(force=True) + raise except Exception as e: task.status = "failed" task.phase = "failed" @@ -276,20 +345,34 @@ class BaseCollector(ABC): updated_count = 0 unchanged_count = 0 seen_entity_keys: set[str] = set() - previous_current_keys: set[str] = set() + progress_commit_interval = 1000 previous_current_result = await db.execute( - select(CollectedData.entity_key).where( + select(CollectedData) + .where( CollectedData.source == self.name, CollectedData.is_current == True, ) + .order_by(CollectedData.entity_key.asc(), CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc()) ) - previous_current_keys = {row[0] for row in previous_current_result.fetchall() if row[0]} + previous_current_records = previous_current_result.scalars().all() + previous_current_keys = {record.entity_key for record in previous_current_records if record.entity_key} + previous_current_map: dict[str, CollectedData] = {} + stale_previous_records: list[CollectedData] = [] + + for existing_record in previous_current_records: + entity_key = existing_record.entity_key + if not entity_key: + continue + if entity_key not in previous_current_map: + previous_current_map[entity_key] = existing_record + continue + stale_previous_records.append(existing_record) + + for stale_record in stale_previous_records: + stale_record.is_current = False for i, item in enumerate(data): - print( - f"DEBUG: Saving item {i}: name={item.get('name')}, metadata={item.get('metadata', 'NOT FOUND')}" - ) raw_metadata = item.get("metadata", {}) extra_data = build_dynamic_metadata( raw_metadata, @@ -318,20 +401,9 @@ class BaseCollector(ABC): previous_record = None if entity_key and entity_key not in seen_entity_keys: - result = await db.execute( - select(CollectedData) - .where( - CollectedData.source == self.name, - CollectedData.entity_key == entity_key, - CollectedData.is_current == True, - ) - .order_by(CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc()) - ) - previous_records = result.scalars().all() - if previous_records: - previous_record = previous_records[0] - for old_record in previous_records: - old_record.is_current = False + previous_record = previous_current_map.get(entity_key) + if previous_record is not None: + previous_record.is_current = False record = CollectedData( snapshot_id=snapshot_id, @@ -375,7 +447,7 @@ class BaseCollector(ABC): seen_entity_keys.add(entity_key) records_added += 1 - if i % 100 == 0: + if (i + 1) % progress_commit_interval == 0: await self.update_progress(i + 1, commit=True) if snapshot_id is not None: diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index ed0e45dd..0baf24be 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -19,6 +19,30 @@ logger = logging.getLogger(__name__) scheduler = AsyncIOScheduler() RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90 +RUNNING_COLLECTOR_TASKS: dict[str, asyncio.Task[Any]] = {} + + +def _collector_task_name(collector_name: str) -> str: + return f"collector:{collector_name}" + + +def get_running_collector_task(collector_name: str) -> asyncio.Task[Any] | None: + task = RUNNING_COLLECTOR_TASKS.get(collector_name) + if task is not None and not task.done(): + return task + + if task is not None and task.done(): + RUNNING_COLLECTOR_TASKS.pop(collector_name, None) + + target_name = _collector_task_name(collector_name) + for candidate in asyncio.all_tasks(): + if candidate.done(): + continue + if candidate.get_name() == target_name: + RUNNING_COLLECTOR_TASKS[collector_name] = candidate + return candidate + + return None async def _update_next_run_at(datasource: DataSource, session) -> None: @@ -133,6 +157,12 @@ async def run_collector_task(collector_name: str): datasource.last_status = task_result.get("status") await _update_next_run_at(datasource, db) logger.info("Collector %s completed: %s", collector_name, task_result) + except asyncio.CancelledError: + datasource.last_run_at = datetime.now(UTC) + datasource.last_status = "cancelled" + await db.commit() + logger.warning("Collector %s cancelled by operator", collector_name) + raise except Exception as exc: datasource.last_run_at = datetime.now(UTC) datasource.last_status = "failed" @@ -245,9 +275,31 @@ def run_collector_now(collector_name: str) -> bool: return False try: - asyncio.create_task(run_collector_task(collector_name)) + task = asyncio.create_task(run_collector_task(collector_name), name=_collector_task_name(collector_name)) + RUNNING_COLLECTOR_TASKS[collector_name] = task + + def _cleanup_task(done_task: asyncio.Task[Any]) -> None: + current = RUNNING_COLLECTOR_TASKS.get(collector_name) + if current is done_task: + RUNNING_COLLECTOR_TASKS.pop(collector_name, None) + + task.add_done_callback(_cleanup_task) logger.info("Triggered collector: %s", collector_name) return True except Exception as exc: logger.error("Failed to trigger collector %s: %s", collector_name, exc) - return False + return False + + +async def cancel_running_collector_now(collector_name: str) -> bool: + task = get_running_collector_task(collector_name) + if task is None or task.done(): + RUNNING_COLLECTOR_TASKS.pop(collector_name, None) + return False + + task.cancel() + try: + await task + except asyncio.CancelledError: + return True + return task.cancelled() diff --git a/frontend/src/pages/DataSources/DataSources.tsx b/frontend/src/pages/DataSources/DataSources.tsx index 70b65042..b560b44d 100644 --- a/frontend/src/pages/DataSources/DataSources.tsx +++ b/frontend/src/pages/DataSources/DataSources.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { - Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, + Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal, Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card } from 'antd' import { @@ -382,30 +382,92 @@ function DataSources() { return () => clearInterval(interval) }, [builtInSources, taskProgress, taskSocketConnected, fetchData]) + const triggerDatasource = async (id: number, options?: { force?: boolean }) => { + const force = options?.force ?? false + const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, { + params: { force }, + }) + + if (res.data.task_id) { + setTaskProgress(prev => ({ + ...prev, + [id]: { + task_id: res.data.task_id, + progress: 0, + is_running: true, + phase: 'queued', + status: 'running', + }, + })) + } else { + window.setTimeout(() => { + fetchData() + }, 800) + } + + fetchData() + return res + } + + const confirmForceTrigger = (id: number, taskInfo?: { + progress?: number | null + phase?: string | null + records_processed?: number | null + total_records?: number | null + message?: string + }) => { + const progressText = typeof taskInfo?.progress === 'number' ? `${Math.round(taskInfo.progress)}%` : '未知' + const phaseText = taskInfo?.phase || 'running' + const processedText = typeof taskInfo?.records_processed === 'number' + ? `${taskInfo.records_processed}${typeof taskInfo?.total_records === 'number' && taskInfo.total_records > 0 ? ` / ${taskInfo.total_records}` : ''}` + : '未知' + + Modal.confirm({ + title: '当前任务未完成', + content: ( +
+

{taskInfo?.message || '当前采集任务仍在运行,重新触发会丢失本次未完成进度。'}

+

当前阶段: {phaseText}

+

当前进度: {progressText}

+

已处理记录: {processedText}

+

确认后会强制取消当前采集,并回滚未完成写入,然后重新开始采集。

+
+ ), + okText: '强制重新采集', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: async () => { + try { + await triggerDatasource(id, { force: true }) + messageApi.success('已强制重新触发,未完成采集将回滚') + } catch (error: unknown) { + const err = error as { response?: { data?: { detail?: string | { message?: string } } } } + const detail = err.response?.data?.detail + messageApi.error(typeof detail === 'string' ? detail : detail?.message || '强制重新采集失败') + } + }, + }) + } + const handleTrigger = async (id: number) => { try { - const res = await axios.post(`/api/v1/datasources/${id}/trigger`) + await triggerDatasource(id) messageApi.success('任务已触发') - if (res.data.task_id) { - setTaskProgress(prev => ({ - ...prev, - [id]: { - task_id: res.data.task_id, - progress: 0, - is_running: true, - phase: 'queued', - status: 'running', - }, - })) - } else { - window.setTimeout(() => { - fetchData() - }, 800) - } - fetchData() } catch (error: unknown) { - const err = error as { response?: { data?: { detail?: string } } } - messageApi.error(err.response?.data?.detail || '触发失败') + const err = error as { response?: { status?: number; data?: { detail?: string | { + reason?: string + message?: string + progress?: number | null + phase?: string | null + records_processed?: number | null + total_records?: number | null + } } } } + const detail = err.response?.data?.detail + if (err.response?.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') { + confirmForceTrigger(id, detail) + return + } + messageApi.error(typeof detail === 'string' ? detail : detail?.message || '触发失败') } } @@ -527,12 +589,24 @@ function DataSources() { const handleUpdateSource = async () => { if (!viewingSource) return try { - await axios.post(`/api/v1/datasources/${viewingSource.id}/trigger`) + await triggerDatasource(viewingSource.id) messageApi.success('已触发更新') setViewDrawerVisible(false) } catch (error: unknown) { - const err = error as { response?: { data?: { detail?: string } } } - messageApi.error(err.response?.data?.detail || '更新失败') + const err = error as { response?: { status?: number; data?: { detail?: string | { + reason?: string + message?: string + progress?: number | null + phase?: string | null + records_processed?: number | null + total_records?: number | null + } } } } + const detail = err.response?.data?.detail + if (err.response?.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') { + confirmForceTrigger(viewingSource.id, detail) + return + } + messageApi.error(typeof detail === 'string' ? detail : detail?.message || '更新失败') } } diff --git a/planet.sh b/planet.sh index 2e2ae5bc..4f109889 100755 --- a/planet.sh +++ b/planet.sh @@ -5,11 +5,30 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' +RED='\033[38;5;203m' +GREEN='\033[38;5;114m' +YELLOW='\033[38;5;221m' +BLUE='\033[38;5;111m' +CYAN='\033[38;5;117m' +WHITE='\033[1;37m' +DIM='\033[2m' NC='\033[0m' +WAIT_SPINNER_FRAMES=( + '⠉⠁' + '⠈⠉' + '⠀⠙' + '⠀⠸' + '⠀⢰' + '⠀⣠' + '⢀⣀' + '⣀⡀' + '⣄⠀' + '⡆⠀' + '⠇⠀' + '⠋⠀' +) +WAIT_SPINNER_STEP=0 +WAIT_SPINNER_TICKS_PER_SECOND=8 BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}" BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}" @@ -37,6 +56,100 @@ compose_up() { fi } +render_wait_spinner() { + local message="$1" + local step="${2:-1}" + local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} )) + local frame="${WAIT_SPINNER_FRAMES[$frame_index]}" + printf "\r${DIM}·${NC} ${CYAN}%s${NC} ${WHITE}%s${NC}\033[K" "$frame" "$message" +} + +animate_wait_spinner() { + local message="$1" + local duration="${2:-1}" + local ticks=$(( duration * WAIT_SPINNER_TICKS_PER_SECOND )) + local tick=0 + + if [ "$ticks" -lt 1 ]; then + ticks=1 + fi + + while [ "$tick" -lt "$ticks" ]; do + WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) + render_wait_spinner "$message" "$WAIT_SPINNER_STEP" + sleep 0.125 + tick=$((tick + 1)) + done +} + +clear_wait_spinner() { + printf "\r\033[K" +} + +finish_wait_spinner() { + log_line "done" "$GREEN" "$1" +} + +log_line() { + local label="$1" + local color="$2" + local message="$3" + clear_wait_spinner + printf "${DIM}·${NC} ${color}%-4s${NC} ${WHITE}%s${NC}\n" "$label" "$message" +} + +log_step() { + log_line "run" "$BLUE" "$1" +} + +log_warn() { + log_line "warn" "$YELLOW" "$1" +} + +log_error() { + log_line "fail" "$RED" "$1" +} + +log_note() { + printf "${DIM} %s${NC}\n" "$1" +} + +log_success() { + log_line "done" "$GREEN" "$1" +} + +print_splash() { + clear_wait_spinner + printf "%b" "$CYAN" + cat <<'EOF' + ___ _ _ _ _ _ + |_ _|_ __ | |_ ___| | (_) __ _ ___ _ __ | |_ + | || '_ \| __/ _ \ | | |/ _` |/ _ \ '_ \| __| + | || | | | || __/ | | | (_| | __/ | | | |_ + |___|_| |_|\__\___|_|_|_|\__, |\___|_| |_|\__| + |___/ +EOF + printf "%b\n" "$NC" + printf "%b" "$BLUE" + cat <<'EOF' + ____ _ _ + | _ \| | __ _ _ __ ___ | |_ + | |_) | |/ _` | '_ \ / _ \| __| + | __/| | (_| | | | | __/| |_ + |_| |_|\__,_|_| |_|\___| \__| +EOF + printf "%b\n" "$NC" + printf "%b" "$WHITE" + cat <<'EOF' + ____ _ + | _ \| | __ _ _ __ + | |_) | |/ _` | '_ \ + | __/| | (_| | | | | + |_| |_|\__,_|_| |_| +EOF + printf "%b\n\n" "$NC" +} + run_with_retry() { local max_retries="$1" local interval="$2" @@ -52,12 +165,12 @@ run_with_retry() { fi if [ "$attempt" -eq "$max_retries" ]; then - echo -e "${RED}❌ ${failure_message}${NC}" + clear_wait_spinner + log_error "${failure_message}" return 1 fi - echo -e "${YELLOW}⚠️ ${retry_label} 第 ${attempt}/${max_retries} 次失败,${interval} 秒后重试...${NC}" - sleep "$interval" + animate_wait_spinner "${retry_label} 第 ${attempt}/${max_retries} 次失败,${interval} 秒后重试" "$interval" attempt=$((attempt + 1)) done @@ -65,17 +178,17 @@ run_with_retry() { } ensure_uv_backend_deps() { - echo -e "${BLUE}📦 检查后端 uv 环境...${NC}" + log_step "检查后端 uv 环境" if ! command -v uv >/dev/null 2>&1; then - echo -e "${RED}❌ 未找到 uv,请先安装 uv 并加入 PATH${NC}" + log_error "未找到 uv,请先安装 uv 并加入 PATH" exit 1 fi cd "$SCRIPT_DIR" if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then - echo -e "${YELLOW}⚠️ 未检测到 .venv,正在执行 uv sync...${NC}" + log_warn "未检测到 .venv,正在执行 uv sync" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ @@ -87,23 +200,23 @@ ensure_uv_backend_deps() { fi if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then - echo -e "${RED}❌ uv 环境初始化失败,未找到 .venv/bin/python${NC}" + log_error "uv 环境初始化失败,未找到 .venv/bin/python" exit 1 fi } ensure_frontend_deps() { - echo -e "${BLUE}📦 检查前端依赖...${NC}" + log_step "检查前端依赖" if ! command -v bun >/dev/null 2>&1; then - echo -e "${RED}❌ 未找到 bun,请先安装或加载 bun 到 PATH${NC}" + log_error "未找到 bun,请先安装或加载 bun 到 PATH" exit 1 fi cd "$SCRIPT_DIR/frontend" if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then - echo -e "${YELLOW}⚠️ 前端依赖缺失,正在执行 bun install...${NC}" + log_warn "前端依赖缺失,正在执行 bun install" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ @@ -115,7 +228,7 @@ ensure_frontend_deps() { fi if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then - echo -e "${RED}❌ 前端依赖安装失败,未找到 vite${NC}" + log_error "前端依赖安装失败,未找到 vite" exit 1 fi } @@ -129,14 +242,15 @@ wait_for_http() { while [ "$attempt" -le "$attempts" ]; do if curl -s "$url" > /dev/null 2>&1; then + finish_wait_spinner "${service_name}已就绪" return 0 fi - echo -e "${YELLOW}⏳ 等待${service_name}就绪...${NC}" - sleep "$interval" + animate_wait_spinner "等待${service_name}就绪" "$interval" attempt=$((attempt + 1)) done + clear_wait_spinner return 1 } @@ -152,14 +266,15 @@ wait_for_container_health() { status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)" if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then + finish_wait_spinner "${service_name}容器健康检查已通过" return 0 fi - echo -e "${YELLOW}⏳ 等待${service_name}容器健康检查通过...${NC}" - sleep "$interval" + animate_wait_spinner "等待${service_name}容器健康检查通过" "$interval" attempt=$((attempt + 1)) done + clear_wait_spinner return 1 } @@ -173,15 +288,15 @@ wait_for_postgres_health() { } start_database_services() { - docker start planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis + docker start planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1 } restart_database_services() { - docker restart planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis + docker restart planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1 } start_postgres_service() { - docker start planet_postgres 2>/dev/null || compose_up up -d postgres + docker start planet_postgres >/dev/null 2>&1 || compose_up up -d postgres >/dev/null 2>&1 } start_backend_with_retry() { @@ -198,11 +313,12 @@ start_backend_with_retry() { return 0 fi - echo -e "${YELLOW}⚠️ 后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}" kill "$BACKEND_PID" 2>/dev/null || true + animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL" retry=$((retry + 1)) done + clear_wait_spinner return 1 } @@ -210,10 +326,10 @@ start_ai_provider_service() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" local retry=1 - echo -e "${BLUE}🧠 启动 AI Provider...${NC}" + log_step "启动 AI Provider" while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do - if docker start planet_aiprovider 2>/dev/null || compose_up up -d aiprovider; then + if docker start planet_aiprovider >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then if wait_for_container_health "planet_aiprovider" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" && wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then return 0 @@ -221,18 +337,19 @@ start_ai_provider_service() { fi if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then - echo -e "${RED}❌ AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次${NC}" + clear_wait_spinner + log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次" docker logs --tail 20 planet_aiprovider 2>/dev/null || true exit 1 fi - echo -e "${YELLOW}⚠️ AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}" - docker restart planet_aiprovider 2>/dev/null || true - sleep "$AI_PROVIDER_RETRY_INTERVAL" + docker restart planet_aiprovider >/dev/null 2>&1 || true + animate_wait_spinner "AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$AI_PROVIDER_RETRY_INTERVAL" retry=$((retry + 1)) done - echo -e "${RED}❌ AI Provider 启动失败${NC}" + clear_wait_spinner + log_error "AI Provider 启动失败" docker logs --tail 20 planet_aiprovider 2>/dev/null || true exit 1 } @@ -252,11 +369,12 @@ start_frontend_with_retry() { return 0 fi - echo -e "${YELLOW}⚠️ 前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}" kill "$FRONTEND_PID" 2>/dev/null || true + animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL" retry=$((retry + 1)) done + clear_wait_spinner return 1 } @@ -269,15 +387,15 @@ ensure_database_services_healthy() { fi if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then - echo -e "${RED}❌ 数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}" + clear_wait_spinner + log_error "数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" docker logs --tail 20 planet_postgres 2>/dev/null || true docker logs --tail 20 planet_redis 2>/dev/null || true exit 1 fi - echo -e "${YELLOW}⚠️ 数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}" - docker restart planet_postgres planet_redis 2>/dev/null || true - sleep "$DATABASE_RETRY_INTERVAL" + docker restart planet_postgres planet_redis >/dev/null 2>&1 || true + animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL" retry=$((retry + 1)) done } @@ -291,14 +409,14 @@ ensure_postgres_service_healthy() { fi if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then - echo -e "${RED}❌ PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}" + clear_wait_spinner + log_error "PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" docker logs --tail 20 planet_postgres 2>/dev/null || true exit 1 fi - echo -e "${YELLOW}⚠️ PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}" - docker restart planet_postgres 2>/dev/null || true - sleep "$DATABASE_RETRY_INTERVAL" + docker restart planet_postgres >/dev/null 2>&1 || true + animate_wait_spinner "PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL" retry=$((retry + 1)) done } @@ -306,7 +424,7 @@ ensure_postgres_service_healthy() { restart_database_service() { local retry=1 - echo -e "${BLUE}🗄️ 重启数据库...${NC}" + log_step "重启数据库" while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do if restart_database_services && wait_for_database_health; then @@ -315,14 +433,14 @@ restart_database_service() { fi if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then - echo -e "${RED}❌ 数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}" + clear_wait_spinner + log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" docker logs --tail 20 planet_postgres 2>/dev/null || true docker logs --tail 20 planet_redis 2>/dev/null || true exit 1 fi - echo -e "${YELLOW}⚠️ 数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试...${NC}" - sleep "$DATABASE_RETRY_INTERVAL" + animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试" "$DATABASE_RETRY_INTERVAL" retry=$((retry + 1)) done } @@ -332,7 +450,7 @@ start_backend_service() { local backend_port_requested="$2" local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}" - echo -e "${BLUE}🗄️ 启动数据库...${NC}" + log_step "启动数据库" ensure_database_services_healthy sleep 3 @@ -342,10 +460,10 @@ start_backend_service() { kill_port_if_requested "$backend_port" "后端" fi - echo -e "${BLUE}🔧 启动后端...${NC}" + log_step "启动后端" ensure_uv_backend_deps if ! start_backend_with_retry "$backend_port"; then - echo -e "${RED}❌ 后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次${NC}" + log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次" tail -10 /tmp/planet_backend.log exit 1 fi @@ -359,10 +477,10 @@ start_frontend_service() { kill_port_if_requested "$frontend_port" "前端" fi - echo -e "${BLUE}🌐 启动前端...${NC}" + log_step "启动前端" ensure_frontend_deps if ! start_frontend_with_retry "$frontend_port"; then - echo -e "${RED}❌ 前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次${NC}" + log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次" tail -10 /tmp/planet_frontend.log exit 1 fi @@ -372,7 +490,7 @@ validate_port() { local port="$1" if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then - echo -e "${RED}❌ 非法端口: ${port}${NC}" + log_error "非法端口: ${port}" exit 1 fi } @@ -381,10 +499,10 @@ kill_port_if_requested() { local port="$1" local service_name="$2" - echo -e "${YELLOW}🧹 检测 ${service_name} 端口 ${port} 占用...${NC}" + log_warn "检测 ${service_name} 端口 ${port} 占用" if command -v fuser >/dev/null 2>&1 && fuser "${port}/tcp" >/dev/null 2>&1; then - echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}" + log_step "发现端口 ${port} 占用,正在终止" fuser -k "${port}/tcp" >/dev/null 2>&1 || true sleep 1 return 0 @@ -394,14 +512,14 @@ kill_port_if_requested() { local pids pids="$(lsof -ti tcp:"${port}" 2>/dev/null || true)" if [ -n "$pids" ]; then - echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}" + log_step "发现端口 ${port} 占用,正在终止" kill $pids 2>/dev/null || true sleep 1 return 0 fi fi - echo -e "${GREEN}✅ 端口 ${port} 未被占用${NC}" + log_success "端口 ${port} 未被占用" } parse_service_args() { @@ -447,7 +565,7 @@ parse_service_args() { shift 1 ;; *) - echo -e "${RED}❌ 未知参数: $1${NC}" + log_error "未知参数: $1" exit 1 ;; esac @@ -462,9 +580,9 @@ cleanup_exit_containers() { local exit_containers exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)" if [ -n "$exit_containers" ]; then - echo -e "${BLUE}🗑️ 清理残留 Exit 容器...${NC}" + log_step "清理残留 Exit 容器" echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true - echo -e "${GREEN}✅ 残留容器已清理${NC}" + log_success "残留容器已清理" fi } @@ -492,15 +610,15 @@ create_user() { ensure_uv_backend_deps - echo -e "${BLUE}🗄️ 启动数据库...${NC}" + log_step "启动数据库" ensure_postgres_service_healthy sleep 2 - echo -e "${BLUE}👤 创建用户${NC}" + log_step "创建用户" read -r -p "用户名: " username if [ -z "$username" ]; then - echo -e "${RED}❌ 用户名不能为空${NC}" + log_error "用户名不能为空" exit 1 fi @@ -516,17 +634,17 @@ create_user() { echo "" if [ -z "$password" ]; then - echo -e "${RED}❌ 密码不能为空${NC}" + log_error "密码不能为空" exit 1 fi if [ "${#password}" -lt 8 ]; then - echo -e "${RED}❌ 密码长度不能少于 8 位${NC}" + log_error "密码长度不能少于 8 位" exit 1 fi if [ "$password" != "$password_confirm" ]; then - echo -e "${RED}❌ 两次输入的密码不一致${NC}" + log_error "两次输入的密码不一致" exit 1 fi @@ -580,35 +698,36 @@ async def main(): asyncio.run(main()) PY - echo -e "${GREEN}✅ 用户创建成功${NC}" - echo " 用户名: ${username}" - echo " 角色: ${role}" - echo " 邮箱: ${email}" + log_success "用户创建成功" + log_note "用户名: ${username}" + log_note "角色: ${role}" + log_note "邮箱: ${email}" } start() { parse_service_args "$@" cleanup_exit_containers - echo -e "${BLUE}🚀 启动智能星球计划...${NC}" + print_splash + log_step "启动智能星球计划" start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" echo "" - echo -e "${GREEN}✅ 启动完成!${NC}" - echo " 前端: http://localhost:${FRONTEND_PORT}" - echo " 后端: http://localhost:${BACKEND_PORT}" - echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}" + log_success "启动完成" + log_note "前端: http://localhost:${FRONTEND_PORT}" + log_note "后端: http://localhost:${BACKEND_PORT}" + log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}" } stop() { - echo -e "${YELLOW}🛑 停止服务...${NC}" + log_warn "停止服务" stop_backend_service stop_ai_provider_service stop_frontend_service docker stop planet_postgres planet_redis 2>/dev/null || true - echo -e "${GREEN}✅ 已停止${NC}" + log_success "已停止" } restart() { @@ -622,7 +741,7 @@ restart() { return 0 fi - echo -e "${YELLOW}🔄 按需重启服务...${NC}" + log_warn "按需重启服务" if [ "$DATABASE_REQUESTED" -eq 1 ]; then restart_database_service @@ -647,43 +766,43 @@ restart() { fi echo "" - echo -e "${GREEN}✅ 重启完成!${NC}" + log_success "重启完成" if [ "$DATABASE_REQUESTED" -eq 1 ]; then - echo " 数据库: planet_postgres, planet_redis" + log_note "数据库: planet_postgres, planet_redis" fi if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then - echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}" + log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}" fi if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then - echo " 后端: http://localhost:${BACKEND_PORT}" + log_note "后端: http://localhost:${BACKEND_PORT}" fi if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then - echo " 前端: http://localhost:${FRONTEND_PORT}" + log_note "前端: http://localhost:${FRONTEND_PORT}" fi } health() { - echo "📊 容器状态:" + echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}" docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" echo "" - echo "🔍 服务状态:" + echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}" if curl -s http://localhost:8000/health > /dev/null 2>&1; then - echo -e " 后端: ${GREEN}✅ 运行中${NC}" + echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}" else - echo -e " 后端: ${RED}❌ 未运行${NC}" + echo -e "${DIM} 后端:${NC} ${RED}offline${NC}" fi if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then - echo -e " AI Provider: ${GREEN}✅ 运行中${NC}" + echo -e "${DIM} AI Provider:${NC} ${GREEN}online${NC}" else - echo -e " AI Provider: ${RED}❌ 未运行${NC}" + echo -e "${DIM} AI Provider:${NC} ${RED}offline${NC}" fi if curl -s http://localhost:3000 > /dev/null 2>&1; then - echo -e " 前端: ${GREEN}✅ 运行中${NC}" + echo -e "${DIM} 前端:${NC} ${GREEN}online${NC}" else - echo -e " 前端: ${RED}❌ 未运行${NC}" + echo -e "${DIM} 前端:${NC} ${RED}offline${NC}" fi } diff --git a/uv.lock b/uv.lock index 2604d004..0ff80823 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.23.0" +version = "0.23.1" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From da587398d946b324f3871980bc463e59de086bcc Mon Sep 17 00:00:00 2001 From: rayd1o Date: Wed, 8 Apr 2026 03:22:08 +0800 Subject: [PATCH 05/34] Refine datasource trigger flow and CLI output --- backend/app/services/collectors/base.py | 2 + .../src/pages/DataSources/DataSources.tsx | 188 +++++++++++++----- planet.sh | 51 +++-- 3 files changed, 158 insertions(+), 83 deletions(-) diff --git a/backend/app/services/collectors/base.py b/backend/app/services/collectors/base.py index fa2faaac..dcdbe36a 100644 --- a/backend/app/services/collectors/base.py +++ b/backend/app/services/collectors/base.py @@ -282,6 +282,7 @@ class BaseCollector(ABC): "execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(), } except asyncio.CancelledError: + await db.rollback() task.status = "cancelled" task.phase = "cancelled" task.error_message = "Collection cancelled by operator and rolled back" @@ -297,6 +298,7 @@ class BaseCollector(ABC): await self._publish_task_update(force=True) raise except Exception as e: + await db.rollback() task.status = "failed" task.phase = "failed" task.error_message = str(e) diff --git a/frontend/src/pages/DataSources/DataSources.tsx b/frontend/src/pages/DataSources/DataSources.tsx index b560b44d..9a667978 100644 --- a/frontend/src/pages/DataSources/DataSources.tsx +++ b/frontend/src/pages/DataSources/DataSources.tsx @@ -9,7 +9,7 @@ import { CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined, SyncOutlined, ClearOutlined, CopyOutlined } from '@ant-design/icons' -import axios from 'axios' +import axios, { type AxiosResponse } from 'axios' import AppLayout from '../../components/AppLayout/AppLayout' import { formatDateTimeZhCN } from '../../utils/datetime' import { useWebSocket } from '../../hooks/useWebSocket' @@ -60,6 +60,57 @@ interface BulkProgressBatch { items: Record } +type TriggerDatasourceConflict = { + reason?: string + message?: string + progress?: number | null + phase?: string | null + records_processed?: number | null + total_records?: number | null +} + +type TriggerDatasourceResult = + | { ok: true; response: AxiosResponse } + | { ok: false; status: number; detail?: string | TriggerDatasourceConflict } + +type DatasourceTaskStatus = { + is_running: boolean + task_id?: number | null + progress?: number | null + phase?: string | null + records_processed?: number | null + total_records?: number | null + status?: string | null +} + +const phaseProgressRanges: Record = { + queued: [0, 0], + fetching: [0, 30], + transforming: [30, 50], + saving: [50, 100], + completed: [100, 100], + success: [100, 100], + failed: [0, 0], + cancelled: [0, 0], +} + +function mapPhaseProgressToOverall( + phase?: string | null, + progress?: number | null, + status?: string | null, +): number { + if (status && status !== 'running') { + return status === 'success' ? 100 : 0 + } + + const normalizedPhase = phase || 'queued' + const [start, end] = phaseProgressRanges[normalizedPhase] ?? [0, 100] + if (start === end) return start + + const boundedProgress = Math.max(0, Math.min(100, progress ?? 0)) + return start + ((end - start) * boundedProgress) / 100 +} + function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgressBatch | null { if (!batch || batch.sourceIds.length === 0) { return null @@ -122,6 +173,7 @@ interface ViewDataSource { function DataSources() { const [messageApi, contextHolder] = message.useMessage() + const [modal, modalContextHolder] = Modal.useModal() const [activeTab, setActiveTab] = useState('builtin') const [builtInSources, setBuiltInSources] = useState([]) const [customSources, setCustomSources] = useState([]) @@ -173,17 +225,18 @@ function DataSources() { bulkProgressBatch.sourceIds.reduce((sum, sourceId) => { const item = bulkProgressBatch.items[sourceId] if (!item) return sum - if (item.status && item.status !== 'running') { - return sum + 100 - } - return sum + (item.progress || 0) + return sum + mapPhaseProgressToOverall(item.phase, item.progress, item.status) }, 0) / bulkProgressBatch.sourceIds.length ) : runningBuiltInSources.length > 0 ? Math.round( runningBuiltInSources.reduce((sum, source) => { const trackedTask = taskProgress[source.id] - return sum + (trackedTask?.progress ?? source.progress ?? 0) + return sum + mapPhaseProgressToOverall( + trackedTask?.phase ?? source.phase ?? 'queued', + trackedTask?.progress ?? source.progress ?? 0, + trackedTask?.status ?? (trackedTask?.is_running || source.is_running ? 'running' : source.last_status ?? null), + ) }, 0) / runningBuiltInSources.length ) : 0 @@ -249,7 +302,7 @@ function DataSources() { ...prev.items, [sourceId]: { task_id: payload.task_id ?? prev.items[sourceId]?.task_id ?? null, - progress: payload.status && payload.status !== 'running' ? 100 : (payload.progress ?? prev.items[sourceId]?.progress ?? 0), + progress: payload.progress ?? prev.items[sourceId]?.progress ?? 0, is_running: payload.status === 'running', phase: payload.phase ?? prev.items[sourceId]?.phase ?? null, status: payload.status ?? prev.items[sourceId]?.status ?? null, @@ -361,7 +414,7 @@ function DataSources() { if (!prev.sourceIds.includes(numericSourceId)) continue nextItems[numericSourceId] = { task_id: state.task_id, - progress: state.status && state.status !== 'running' ? 100 : state.progress, + progress: state.progress, is_running: state.is_running, phase: state.phase ?? null, status: state.status ?? null, @@ -386,8 +439,17 @@ function DataSources() { const force = options?.force ?? false const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, { params: { force }, + validateStatus: (status) => status < 500, }) + if (res.status >= 400) { + return { + ok: false, + status: res.status, + detail: res.data?.detail, + } satisfies TriggerDatasourceResult + } + if (res.data.task_id) { setTaskProgress(prev => ({ ...prev, @@ -406,7 +468,15 @@ function DataSources() { } fetchData() - return res + return { + ok: true, + response: res, + } satisfies TriggerDatasourceResult + } + + const fetchDatasourceTaskStatus = async (id: number) => { + const res = await axios.get(`/api/v1/datasources/${id}/task-status`) + return res.data } const confirmForceTrigger = (id: number, taskInfo?: { @@ -422,7 +492,7 @@ function DataSources() { ? `${taskInfo.records_processed}${typeof taskInfo?.total_records === 'number' && taskInfo.total_records > 0 ? ` / ${taskInfo.total_records}` : ''}` : '未知' - Modal.confirm({ + modal.confirm({ title: '当前任务未完成', content: (
@@ -437,38 +507,58 @@ function DataSources() { cancelText: '取消', okButtonProps: { danger: true }, onOk: async () => { - try { - await triggerDatasource(id, { force: true }) + const result = await triggerDatasource(id, { force: true }) + if (result.ok) { messageApi.success('已强制重新触发,未完成采集将回滚') - } catch (error: unknown) { - const err = error as { response?: { data?: { detail?: string | { message?: string } } } } - const detail = err.response?.data?.detail - messageApi.error(typeof detail === 'string' ? detail : detail?.message || '强制重新采集失败') + return } + + const detail = result.detail + messageApi.error(typeof detail === 'string' ? detail : detail?.message || '强制重新采集失败') }, }) } - const handleTrigger = async (id: number) => { - try { - await triggerDatasource(id) - messageApi.success('任务已触发') - } catch (error: unknown) { - const err = error as { response?: { status?: number; data?: { detail?: string | { - reason?: string - message?: string - progress?: number | null - phase?: string | null - records_processed?: number | null - total_records?: number | null - } } } } - const detail = err.response?.data?.detail - if (err.response?.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') { - confirmForceTrigger(id, detail) - return - } - messageApi.error(typeof detail === 'string' ? detail : detail?.message || '触发失败') + const triggerDatasourceWithPrecheck = async ( + id: number, + options?: { + successMessage: string + onSuccess?: () => void + errorMessage?: string + }, + ) => { + const taskStatus = await fetchDatasourceTaskStatus(id) + if (taskStatus.is_running) { + confirmForceTrigger(id, { + message: '当前采集任务尚未完成,重新触发会丢失本次未完成进度。是否强制重新采集?', + progress: taskStatus.progress ?? null, + phase: taskStatus.phase ?? null, + records_processed: taskStatus.records_processed ?? null, + total_records: taskStatus.total_records ?? null, + }) + return } + + const result = await triggerDatasource(id) + if (result.ok) { + messageApi.success(options?.successMessage || '任务已触发') + options?.onSuccess?.() + return + } + + const detail = result.detail + if (result.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') { + confirmForceTrigger(id, detail) + return + } + messageApi.error(typeof detail === 'string' ? detail : detail?.message || options?.errorMessage || '触发失败') + } + + const handleTrigger = async (id: number) => { + await triggerDatasourceWithPrecheck(id, { + successMessage: '任务已触发', + errorMessage: '触发失败', + }) } const handleTriggerAll = async () => { @@ -588,26 +678,13 @@ function DataSources() { const handleUpdateSource = async () => { if (!viewingSource) return - try { - await triggerDatasource(viewingSource.id) - messageApi.success('已触发更新') - setViewDrawerVisible(false) - } catch (error: unknown) { - const err = error as { response?: { status?: number; data?: { detail?: string | { - reason?: string - message?: string - progress?: number | null - phase?: string | null - records_processed?: number | null - total_records?: number | null - } } } } - const detail = err.response?.data?.detail - if (err.response?.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') { - confirmForceTrigger(viewingSource.id, detail) - return - } - messageApi.error(typeof detail === 'string' ? detail : detail?.message || '更新失败') - } + await triggerDatasourceWithPrecheck(viewingSource.id, { + successMessage: '已触发更新', + errorMessage: '更新失败', + onSuccess: () => { + setViewDrawerVisible(false) + }, + }) } const handleTest = async () => { @@ -1017,6 +1094,7 @@ function DataSources() { return ( {contextHolder} + {modalContextHolder}

数据源管理

diff --git a/planet.sh b/planet.sh index 4f109889..d2badfe9 100755 --- a/planet.sh +++ b/planet.sh @@ -165,7 +165,6 @@ run_with_retry() { fi if [ "$attempt" -eq "$max_retries" ]; then - clear_wait_spinner log_error "${failure_message}" return 1 fi @@ -337,7 +336,6 @@ start_ai_provider_service() { fi if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then - clear_wait_spinner log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次" docker logs --tail 20 planet_aiprovider 2>/dev/null || true exit 1 @@ -348,7 +346,6 @@ start_ai_provider_service() { retry=$((retry + 1)) done - clear_wait_spinner log_error "AI Provider 启动失败" docker logs --tail 20 planet_aiprovider 2>/dev/null || true exit 1 @@ -387,7 +384,6 @@ ensure_database_services_healthy() { fi if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then - clear_wait_spinner log_error "数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" docker logs --tail 20 planet_postgres 2>/dev/null || true docker logs --tail 20 planet_redis 2>/dev/null || true @@ -409,7 +405,6 @@ ensure_postgres_service_healthy() { fi if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then - clear_wait_spinner log_error "PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" docker logs --tail 20 planet_postgres 2>/dev/null || true exit 1 @@ -433,7 +428,6 @@ restart_database_service() { fi if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then - clear_wait_spinner log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" docker logs --tail 20 planet_postgres 2>/dev/null || true docker logs --tail 20 planet_redis 2>/dev/null || true @@ -809,25 +803,28 @@ health() { log() { case "$1" in -f|--frontend) - echo "📝 前端日志 (Ctrl+C 退出):" + log_step "查看前端日志" + log_note "按 Ctrl+C 退出" tail -f /tmp/planet_frontend.log ;; -b|--backend) - echo "📝 后端日志 (Ctrl+C 退出):" + log_step "查看后端日志" + log_note "按 Ctrl+C 退出" tail -f /tmp/planet_backend.log ;; -a|--ai-provider) - echo "📝 AI Provider 日志 (Ctrl+C 退出):" + log_step "查看 AI Provider 日志" + log_note "按 Ctrl+C 退出" docker logs -f planet_aiprovider ;; *) - echo "📝 最近日志:" - echo "--- 后端 ---" - tail -20 /tmp/planet_backend.log 2>/dev/null || echo "无日志" - echo "--- AI Provider ---" - docker logs --tail 20 planet_aiprovider 2>/dev/null || echo "无日志" - echo "--- 前端 ---" - tail -20 /tmp/planet_frontend.log 2>/dev/null || echo "无日志" + log_step "查看最近日志" + log_note "后端" + tail -20 /tmp/planet_backend.log 2>/dev/null || log_note "无日志" + log_note "AI Provider" + docker logs --tail 20 planet_aiprovider 2>/dev/null || log_note "无日志" + log_note "前端" + tail -20 /tmp/planet_frontend.log 2>/dev/null || log_note "无日志" ;; esac } @@ -854,17 +851,15 @@ case "$1" in log "$2" ;; *) - echo "用法: ./planet.sh {start|stop|restart|createuser|health|log}" - echo "" - echo "命令:" - echo " start 启动服务,可选: -b <后端端口> -f <前端端口> -a " - echo " stop 停止服务" - echo " restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d" - echo " createuser 交互创建用户" - echo " health 检查健康状态" - echo " log 查看日志" - echo " log -f 查看前端日志" - echo " log -b 查看后端日志" - echo " log -a 查看 AI Provider 日志" + log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}" + log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a " + log_note "stop 停止服务" + log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d" + log_note "createuser 交互创建用户" + log_note "health 检查健康状态" + log_note "log 查看日志" + log_note "log -f 查看前端日志" + log_note "log -b 查看后端日志" + log_note "log -a 查看 AI Provider 日志" ;; esac From 2da6ed166b84acc6e63c1049bfbfa4de34e27b72 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Wed, 8 Apr 2026 03:24:08 +0800 Subject: [PATCH 06/34] Bump version to 0.23.2 --- VERSION | 2 +- docs/CHANGELOG.md | 18 ++++++++++++++++++ frontend/package.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 610e2872..fda96dcf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.1 +0.23.2 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ea0479e2..976af428 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,24 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.23.2 + +Released: 2026-04-08 + +### Highlights + +- Hardened datasource retrigger handling so operators can safely force reruns without losing control of task state visibility or rollback behavior. + +### Improved + +- Improved [frontend/src/pages/DataSources/DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) by mapping phase-local task percentages into a continuous overall progress bar, pre-checking running task status before retriggering, and consolidating the trigger/force-confirm flow. +- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by polishing startup CLI output, reducing duplicated spinner cleanup, and standardizing log/help output around the newer terminal presentation. + +### Fixed + +- Fixed forced datasource reruns in [backend/app/services/collectors/base.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/base.py) by rolling back invalid SQLAlchemy session state before marking cancelled or failed task cleanup, preventing `PendingRollbackError` during operator-triggered cancellation. +- Fixed datasource trigger UX in [frontend/src/pages/DataSources/DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) so batch and single-source progress no longer jump straight to `100%` on frontend-side status coercion while the backend is still reporting real progress. + ## 0.23.1 Released: 2026-04-07 diff --git a/frontend/package.json b/frontend/package.json index 658ce068..0a77af8a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.23.1", + "version": "0.23.2", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/pyproject.toml b/pyproject.toml index ca9b9828..9833365e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.23.1" +version = "0.23.2" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 0ff80823..73a9107b 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.23.1" +version = "0.23.2" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From 2d43263b9edaf56b8dd352e2f389c029c44d0ad6 Mon Sep 17 00:00:00 2001 From: linkong Date: Wed, 8 Apr 2026 09:33:48 +0800 Subject: [PATCH 07/34] fix: tighten datasource trigger guards --- backend/app/api/v1/datasources.py | 92 +++++++++++++++++++++++++++---- backend/app/services/scheduler.py | 5 ++ backend/tests/test_api.py | 9 +++ 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 1428e073..d7176a02 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -93,16 +93,20 @@ async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[Col if now - started_at <= timedelta(minutes=STALE_RUNNING_TASK_TIMEOUT_MINUTES): return task - existing_error = (task.error_message or "").strip() - stale_reason = ( - f"Marked failed automatically after stale running timeout " - f"({STALE_RUNNING_TASK_TIMEOUT_MINUTES}m)" - ) - task.status = "failed" - task.phase = "failed" - task.completed_at = now - task.error_message = f"{existing_error}\n{stale_reason}".strip() if existing_error else stale_reason - await db.commit() + datasource = await db.get(DataSource, datasource_id) + if datasource is not None: + await fail_and_rollback_stale_running_task(db, datasource, task) + else: + existing_error = (task.error_message or "").strip() + stale_reason = ( + f"Marked failed automatically after stale running timeout " + f"({STALE_RUNNING_TASK_TIMEOUT_MINUTES}m)" + ) + task.status = "failed" + task.phase = "failed" + task.completed_at = now + task.error_message = f"{existing_error}\n{stale_reason}".strip() if existing_error else stale_reason + await db.commit() return None @@ -170,6 +174,73 @@ async def rollback_orphaned_running_task( await db.commit() +async def fail_and_rollback_stale_running_task( + db: AsyncSession, + datasource: DataSource, + running_task: CollectionTask, +) -> None: + snapshot_result = await db.execute( + select(DataSnapshot) + .where( + DataSnapshot.datasource_id == datasource.id, + DataSnapshot.task_id == running_task.id, + ) + .order_by(DataSnapshot.id.desc()) + .limit(1) + ) + snapshot = snapshot_result.scalar_one_or_none() + + await db.execute(CollectedData.__table__.delete().where(CollectedData.task_id == running_task.id)) + + await db.execute( + text( + """ + UPDATE collected_data + SET is_current = FALSE + WHERE source = :source + """ + ), + {"source": datasource.source}, + ) + + if snapshot is not None: + snapshot.status = "failed" + snapshot.is_current = False + snapshot.completed_at = datetime.now(timezone.utc) + summary = dict(snapshot.summary or {}) + summary["rollback"] = True + summary["rollback_reason"] = "stale_running_task_timeout" + snapshot.summary = summary + + if snapshot.parent_snapshot_id is not None: + parent_snapshot = await db.get(DataSnapshot, snapshot.parent_snapshot_id) + if parent_snapshot: + parent_snapshot.is_current = True + await db.execute( + text( + """ + UPDATE collected_data + SET is_current = TRUE + WHERE snapshot_id = :snapshot_id + """ + ), + {"snapshot_id": snapshot.parent_snapshot_id}, + ) + + existing_error = (running_task.error_message or "").strip() + stale_reason = ( + f"Marked failed automatically after stale running timeout " + f"({STALE_RUNNING_TASK_TIMEOUT_MINUTES}m); incomplete writes rolled back" + ) + running_task.status = "failed" + running_task.phase = "failed" + running_task.completed_at = datetime.now(timezone.utc) + running_task.error_message = f"{existing_error}\n{stale_reason}".strip() if existing_error else stale_reason + datasource.last_status = "failed" + datasource.last_run_at = datetime.now(timezone.utc) + await db.commit() + + @router.get("") async def list_datasources( module: Optional[str] = None, @@ -504,6 +575,7 @@ async def clear_datasource_data( async def get_task_status( source_id: str, task_id: Optional[int] = None, + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): datasource = await get_datasource_record(db, source_id) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 0baf24be..a16d84ff 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -274,6 +274,11 @@ def run_collector_now(collector_name: str) -> bool: logger.error("Collector not found: %s", collector_name) return False + existing_task = get_running_collector_task(collector_name) + if existing_task is not None and not existing_task.done(): + logger.warning("Collector %s is already running in-memory; skipping duplicate trigger", collector_name) + return False + try: task = asyncio.create_task(run_collector_task(collector_name), name=_collector_task_name(collector_name)) RUNNING_COLLECTOR_TASKS[collector_name] = task diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 5faa479e..4d79ee92 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -90,6 +90,15 @@ async def test_alerts_without_auth(): assert response.status_code == 401 +@pytest.mark.asyncio +async def test_datasource_task_status_without_auth(): + """Test datasource task-status endpoint requires authentication""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/datasources/1/task-status") + assert response.status_code == 401 + + @pytest.mark.asyncio async def test_alerts_endpoint_with_auth(auth_headers): """Test alerts endpoint with authentication""" From 8bd9d3437667935693407d5273bb0c84503f386f Mon Sep 17 00:00:00 2001 From: linkong Date: Wed, 8 Apr 2026 10:15:33 +0800 Subject: [PATCH 08/34] feat: configure collector endpoints and health plan --- backend/app/core/data_sources.py | 16 +- backend/app/core/data_sources.yaml | 34 ++ backend/app/services/collectors/celestrak.py | 4 +- backend/app/services/collectors/cloudflare.py | 30 ++ backend/app/services/collectors/epoch_ai.py | 2 +- .../app/services/collectors/fao_landing.py | 4 +- .../app/services/collectors/huggingface.py | 36 ++ backend/app/services/collectors/peeringdb.py | 35 +- backend/app/services/collectors/ris_live.py | 2 +- backend/app/services/collectors/spacetrack.py | 51 +- .../app/services/collectors/telegeography.py | 15 +- backend/app/services/collectors/top500.py | 7 +- docs/datasource-health-plan.md | 486 ++++++++++++++++++ planet.sh | 4 +- 14 files changed, 673 insertions(+), 53 deletions(-) create mode 100644 docs/datasource-health-plan.md diff --git a/backend/app/core/data_sources.py b/backend/app/core/data_sources.py index 0ec35942..4246052e 100644 --- a/backend/app/core/data_sources.py +++ b/backend/app/core/data_sources.py @@ -11,6 +11,7 @@ COLLECTOR_URL_KEYS = { "fao_landing_points": "fao.landing_point_url", "telegeography_cables": "telegeography.cable_url", "telegeography_landing": "telegeography.landing_point_url", + "telegeography_systems": "telegeography.cable_url", "huggingface_models": "huggingface.models_url", "huggingface_datasets": "huggingface.datasets_url", "huggingface_spaces": "huggingface.spaces_url", @@ -23,6 +24,7 @@ COLLECTOR_URL_KEYS = { "top500": "top500.url", "epoch_ai_gpu": "epoch_ai.gpu_clusters_url", "spacetrack_tle": "spacetrack.tle_query_url", + "celestrak_tle": "celestrak.base_url", "ris_live_bgp": "ris_live.url", "bgpstream_bgp": "bgpstream.url", "iptoasn_prefix_geo": "iptoasn.combined_url", @@ -41,18 +43,22 @@ class DataSourcesConfig: with open(config_path, "r") as f: self._yaml_config = yaml.safe_load(f) or {} - def get_yaml_url(self, collector_name: str) -> str: - key = COLLECTOR_URL_KEYS.get(collector_name, "") + def get_yaml_value(self, key: str): if not key: - return "" + return None parts = key.split(".") value = self._yaml_config for part in parts: if isinstance(value, dict): - value = value.get(part, "") + value = value.get(part) else: - return "" + return None + return value + + def get_yaml_url(self, collector_name: str) -> str: + key = COLLECTOR_URL_KEYS.get(collector_name, "") + value = self.get_yaml_value(key) return value if isinstance(value, str) else "" async def get_url(self, collector_name: str, db) -> str: diff --git a/backend/app/core/data_sources.yaml b/backend/app/core/data_sources.yaml index e1261be8..c4223f74 100644 --- a/backend/app/core/data_sources.yaml +++ b/backend/app/core/data_sources.yaml @@ -2,53 +2,87 @@ # All external data source URLs should be configured here arcgis: + # ArcGIS 海缆 GeoJSON 查询接口 cable_url: "https://services.arcgis.com/6DIQcwlPy8knb6sg/ArcGIS/rest/services/SubmarineCables/FeatureServer/2/query" + # ArcGIS 登陆点 GeoJSON 查询接口 landing_point_url: "https://services.arcgis.com/6DIQcwlPy8knb6sg/ArcGIS/rest/services/SubmarineCables/FeatureServer/1/query" + # ArcGIS 海缆与登陆点关联关系查询接口 cable_landing_relation_url: "https://services.arcgis.com/6DIQcwlPy8knb6sg/ArcGIS/rest/services/SubmarineCables/FeatureServer/3/query" fao: + # FAO 登陆点 CSV 下载地址 landing_point_url: "https://data.apps.fao.org/catalog/dataset/1b75ff21-92f2-4b96-9b7b-98e8aa65ad5d/resource/b6071077-d1d4-4e97-aa00-42e902847c87/download/landing-point-geo.csv" telegeography: + # TeleGeography 海缆/系统主数据源,当前使用 GitHub 镜像 JSON cable_url: "https://raw.githubusercontent.com/lintaojlu/submarine_cable_information/main/cable.json" + # TeleGeography 登陆点主数据源,当前使用 GitHub 镜像 JSON landing_point_url: "https://raw.githubusercontent.com/lintaojlu/submarine_cable_information/main/landing_point.json" + # TeleGeography 历史 API 存档,用于 cable collector 的 fallback + archived_cable_url: "https://web.archive.org/web/2024/https://www.submarinecablemap.com/api/v3/cable" + # TeleGeography 官网页面,用于 cable collector 的最终 HTML 抓取 fallback + live_map_url: "https://www.submarinecablemap.com" huggingface: + # Hugging Face 模型目录 API models_url: "https://huggingface.co/api/models" + # Hugging Face 数据集目录 API datasets_url: "https://huggingface.co/api/datasets" + # Hugging Face Spaces 目录 API spaces_url: "https://huggingface.co/api/spaces" cloudflare: + # Cloudflare Radar 设备类型摘要接口 radar_device_url: "https://api.cloudflare.com/client/v4/radar/http/summary/device_type" + # Cloudflare Radar 请求量时间序列接口 radar_traffic_url: "https://api.cloudflare.com/client/v4/radar/http/timeseries/requests" + # Cloudflare Radar 热点地理位置接口 radar_top_locations_url: "https://api.cloudflare.com/client/v4/radar/http/top/locations" peeringdb: + # PeeringDB IXP API ixp_url: "https://www.peeringdb.com/api/ix" + # PeeringDB Network API network_url: "https://www.peeringdb.com/api/net" + # PeeringDB Facility API facility_url: "https://www.peeringdb.com/api/fac" top500: + # TOP500 榜单页面,用于主表抓取 url: "https://top500.org/lists/top500/list/2025/11/" + # TOP500 站点根地址,用于拼详情页链接 + base_url: "https://top500.org" epoch_ai: + # Epoch AI GPU Cluster 页面 gpu_clusters_url: "https://epoch.ai/data/gpu-clusters" spacetrack: + # Space-Track 站点根地址,用于首页访问和登录地址推导 base_url: "https://www.space-track.org" + # Space-Track TLE 主查询接口 tle_query_url: "https://www.space-track.org/basicspacedata/query/class/gp/orderby/EPOCH%20desc/limit/1000/format/json" +celestrak: + # CelesTrak TLE 基础接口,collector 会在其后拼接 GROUP / FORMAT 参数 + base_url: "https://celestrak.org/NORAD/elements/gp.php" + ris_live: + # RIPE RIS Live 流式订阅地址 url: "https://ris-live.ripe.net/v1/stream/?format=json&client=planet-ris-live" bgpstream: + # CAIDA BGPStream Broker API url: "https://broker.bgpstream.caida.org/v2" iptoasn: + # IPtoASN prefix geography 合并数据下载地址 combined_url: "https://iptoasn.com/data/ip2asn-combined.tsv.gz" opengeofeed: + # OpenGeoFeed 公共 geofeed CSV public_csv_url: "https://opengeofeed.org/feed/public.csv" nro: + # NRO delegated stats 下载地址 delegated_stats_url: "https://ftp.ripe.net/pub/stats/ripencc/nro-stats/latest/nro-delegated-stats" diff --git a/backend/app/services/collectors/celestrak.py b/backend/app/services/collectors/celestrak.py index e6c5f749..6c82d4a2 100644 --- a/backend/app/services/collectors/celestrak.py +++ b/backend/app/services/collectors/celestrak.py @@ -21,7 +21,7 @@ class CelesTrakTLECollector(BaseCollector): @property def base_url(self) -> str: - return "https://celestrak.org/NORAD/elements/gp.php" + return self._resolved_url or "" async def fetch(self) -> List[Dict[str, Any]]: satellite_groups = [ @@ -40,7 +40,7 @@ class CelesTrakTLECollector(BaseCollector): async with httpx.AsyncClient(timeout=120.0) as client: for group in satellite_groups: try: - url = f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=json" + url = f"{self.base_url}?GROUP={group}&FORMAT=json" response = await client.get(url) if response.status_code == 200: diff --git a/backend/app/services/collectors/cloudflare.py b/backend/app/services/collectors/cloudflare.py index 4eb24015..bb7ab4cf 100644 --- a/backend/app/services/collectors/cloudflare.py +++ b/backend/app/services/collectors/cloudflare.py @@ -39,6 +39,16 @@ class CloudflareRadarDeviceCollector(HTTPCollector): if CLOUDFLARE_API_TOKEN: self.headers["Authorization"] = f"Bearer {CLOUDFLARE_API_TOKEN}" + @property + def request_url(self) -> str: + return self._resolved_url or self.base_url + + async def fetch(self) -> List[Dict[str, Any]]: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.get(self.request_url, headers=self.headers) + response.raise_for_status() + return self.parse_response(response.json()) + def parse_response(self, response: Dict[str, Any]) -> List[Dict[str, Any]]: """Parse Cloudflare Radar device type response""" data = [] @@ -87,6 +97,16 @@ class CloudflareRadarTrafficCollector(HTTPCollector): if CLOUDFLARE_API_TOKEN: self.headers["Authorization"] = f"Bearer {CLOUDFLARE_API_TOKEN}" + @property + def request_url(self) -> str: + return self._resolved_url or self.base_url + + async def fetch(self) -> List[Dict[str, Any]]: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.get(self.request_url, headers=self.headers) + response.raise_for_status() + return self.parse_response(response.json()) + def parse_response(self, response: Dict[str, Any]) -> List[Dict[str, Any]]: """Parse Cloudflare Radar traffic timeseries response""" data = [] @@ -135,6 +155,16 @@ class CloudflareRadarTopASCollector(HTTPCollector): if CLOUDFLARE_API_TOKEN: self.headers["Authorization"] = f"Bearer {CLOUDFLARE_API_TOKEN}" + @property + def request_url(self) -> str: + return self._resolved_url or self.base_url + + async def fetch(self) -> List[Dict[str, Any]]: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.get(self.request_url, headers=self.headers) + response.raise_for_status() + return self.parse_response(response.json()) + def parse_response(self, response: Dict[str, Any]) -> List[Dict[str, Any]]: """Parse Cloudflare Radar top locations response""" data = [] diff --git a/backend/app/services/collectors/epoch_ai.py b/backend/app/services/collectors/epoch_ai.py index 1fa813d6..b7b0c1a7 100644 --- a/backend/app/services/collectors/epoch_ai.py +++ b/backend/app/services/collectors/epoch_ai.py @@ -23,7 +23,7 @@ class EpochAIGPUCollector(BaseCollector): async def fetch(self) -> List[Dict[str, Any]]: """Fetch Epoch AI GPU clusters data from webpage""" - url = "https://epoch.ai/data/gpu-clusters" + url = self._resolved_url or "" async with httpx.AsyncClient(timeout=60.0) as client: response = await client.get(url) diff --git a/backend/app/services/collectors/fao_landing.py b/backend/app/services/collectors/fao_landing.py index f8c38570..2df6effa 100644 --- a/backend/app/services/collectors/fao_landing.py +++ b/backend/app/services/collectors/fao_landing.py @@ -18,11 +18,9 @@ class FAOLandingPointCollector(BaseCollector): frequency_hours = 168 data_type = "landing_point" - csv_url = "https://data.apps.fao.org/catalog/dataset/1b75ff21-92f2-4b96-9b7b-98e8aa65ad5d/resource/b6071077-d1d4-4e97-aa00-42e902847c87/download/landing-point-geo.csv" - async def fetch(self) -> List[Dict[str, Any]]: async with httpx.AsyncClient(timeout=60.0) as client: - response = await client.get(self.csv_url) + response = await client.get(self._resolved_url or "") response.raise_for_status() return self.parse_csv(response.text) diff --git a/backend/app/services/collectors/huggingface.py b/backend/app/services/collectors/huggingface.py index d4c0af55..fc012dfa 100644 --- a/backend/app/services/collectors/huggingface.py +++ b/backend/app/services/collectors/huggingface.py @@ -21,6 +21,18 @@ class HuggingFaceModelCollector(HTTPCollector): data_type = "model" base_url = "https://huggingface.co/api/models" + @property + def request_url(self) -> str: + return self._resolved_url or self.base_url + + async def fetch(self) -> List[Dict[str, Any]]: + from httpx import AsyncClient + + async with AsyncClient(timeout=60.0) as client: + response = await client.get(self.request_url, headers=self.headers) + response.raise_for_status() + return self.parse_response(response.json()) + def parse_response(self, response: Dict[str, Any]) -> List[Dict[str, Any]]: """Parse Hugging Face models API response""" data = [] @@ -63,6 +75,18 @@ class HuggingFaceDatasetCollector(HTTPCollector): data_type = "dataset" base_url = "https://huggingface.co/api/datasets" + @property + def request_url(self) -> str: + return self._resolved_url or self.base_url + + async def fetch(self) -> List[Dict[str, Any]]: + from httpx import AsyncClient + + async with AsyncClient(timeout=60.0) as client: + response = await client.get(self.request_url, headers=self.headers) + response.raise_for_status() + return self.parse_response(response.json()) + def parse_response(self, response: Dict[str, Any]) -> List[Dict[str, Any]]: """Parse Hugging Face datasets API response""" data = [] @@ -104,6 +128,18 @@ class HuggingFaceSpacesCollector(HTTPCollector): data_type = "space" base_url = "https://huggingface.co/api/spaces" + @property + def request_url(self) -> str: + return self._resolved_url or self.base_url + + async def fetch(self) -> List[Dict[str, Any]]: + from httpx import AsyncClient + + async with AsyncClient(timeout=60.0) as client: + response = await client.get(self.request_url, headers=self.headers) + response.raise_for_status() + return self.parse_response(response.json()) + def parse_response(self, response: Dict[str, Any]) -> List[Dict[str, Any]]: """Parse Hugging Face Spaces API response""" data = [] diff --git a/backend/app/services/collectors/peeringdb.py b/backend/app/services/collectors/peeringdb.py index 5510095c..e9ae2819 100644 --- a/backend/app/services/collectors/peeringdb.py +++ b/backend/app/services/collectors/peeringdb.py @@ -16,6 +16,7 @@ from typing import Dict, Any, List from datetime import UTC, datetime import httpx +from urllib.parse import urlencode from app.services.collectors.base import HTTPCollector @@ -38,9 +39,13 @@ class PeeringDBIXPCollector(HTTPCollector): "User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)", "Accept": "application/json", } - # API key is added to URL as query parameter - if PEERINGDB_API_KEY: - self.base_url = f"{self.base_url}?key={PEERINGDB_API_KEY}" + @property + def request_url(self) -> str: + base = self._resolved_url or self.base_url + if not PEERINGDB_API_KEY: + return base + separator = "&" if "?" in base else "?" + return f"{base}{separator}{urlencode({'key': PEERINGDB_API_KEY})}" async def fetch_with_retry( self, max_retries: int = 3, base_delay: float = 2.0 @@ -51,7 +56,7 @@ class PeeringDBIXPCollector(HTTPCollector): for attempt in range(max_retries): try: async with httpx.AsyncClient(timeout=60.0) as client: - response = await client.get(self.base_url, headers=self.headers) + response = await client.get(self.request_url, headers=self.headers) if response.status_code == 429: # Rate limited - wait and retry with exponential backoff @@ -141,8 +146,13 @@ class PeeringDBNetworkCollector(HTTPCollector): "User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)", "Accept": "application/json", } - if PEERINGDB_API_KEY: - self.base_url = f"{self.base_url}?key={PEERINGDB_API_KEY}" + @property + def request_url(self) -> str: + base = self._resolved_url or self.base_url + if not PEERINGDB_API_KEY: + return base + separator = "&" if "?" in base else "?" + return f"{base}{separator}{urlencode({'key': PEERINGDB_API_KEY})}" async def fetch_with_retry( self, max_retries: int = 3, base_delay: float = 2.0 @@ -153,7 +163,7 @@ class PeeringDBNetworkCollector(HTTPCollector): for attempt in range(max_retries): try: async with httpx.AsyncClient(timeout=60.0) as client: - response = await client.get(self.base_url, headers=self.headers) + response = await client.get(self.request_url, headers=self.headers) if response.status_code == 429: delay = base_delay * (2**attempt) @@ -244,8 +254,13 @@ class PeeringDBFacilityCollector(HTTPCollector): "User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)", "Accept": "application/json", } - if PEERINGDB_API_KEY: - self.base_url = f"{self.base_url}?key={PEERINGDB_API_KEY}" + @property + def request_url(self) -> str: + base = self._resolved_url or self.base_url + if not PEERINGDB_API_KEY: + return base + separator = "&" if "?" in base else "?" + return f"{base}{separator}{urlencode({'key': PEERINGDB_API_KEY})}" async def fetch_with_retry( self, max_retries: int = 3, base_delay: float = 2.0 @@ -256,7 +271,7 @@ class PeeringDBFacilityCollector(HTTPCollector): for attempt in range(max_retries): try: async with httpx.AsyncClient(timeout=60.0) as client: - response = await client.get(self.base_url, headers=self.headers) + response = await client.get(self.request_url, headers=self.headers) if response.status_code == 429: delay = base_delay * (2**attempt) diff --git a/backend/app/services/collectors/ris_live.py b/backend/app/services/collectors/ris_live.py index 38da086a..e39e9849 100644 --- a/backend/app/services/collectors/ris_live.py +++ b/backend/app/services/collectors/ris_live.py @@ -33,7 +33,7 @@ class RISLiveCollector(BaseCollector): def _fetch_via_stream(self) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] - stream_url = "https://ris-live.ripe.net/v1/stream/?format=json&client=planet-ris-live" + stream_url = self._resolved_url or "" subscribe = json.dumps( { "host": "rrc00", diff --git a/backend/app/services/collectors/spacetrack.py b/backend/app/services/collectors/spacetrack.py index bc960f6b..3104f083 100644 --- a/backend/app/services/collectors/spacetrack.py +++ b/backend/app/services/collectors/spacetrack.py @@ -7,6 +7,7 @@ API documentation: https://www.space-track.org/documentation import json from typing import Dict, Any, List import httpx +from urllib.parse import urlparse from app.services.collectors.base import BaseCollector from app.core.data_sources import get_data_sources_config @@ -21,12 +22,30 @@ class SpaceTrackTLECollector(BaseCollector): data_type = "satellite_tle" @property - def base_url(self) -> str: + def query_url(self) -> str: config = get_data_sources_config() if self._resolved_url: return self._resolved_url return config.get_yaml_url("spacetrack_tle") + @property + def site_root(self) -> str: + config = get_data_sources_config() + configured_root = config.get_yaml_value("spacetrack.base_url") + if isinstance(configured_root, str) and configured_root: + return configured_root.rstrip("/") + + parsed = urlparse(self.query_url) + return f"{parsed.scheme}://{parsed.netloc}".rstrip("/") + + @property + def login_url(self) -> str: + return f"{self.site_root}/ajaxauth/login" + + @property + def probe_url(self) -> str: + return f"{self.site_root}/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json" + async def fetch(self) -> List[Dict[str, Any]]: from app.core.config import settings @@ -47,13 +66,13 @@ class SpaceTrackTLECollector(BaseCollector): "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json, text/html, */*", "Accept-Language": "en-US,en;q=0.9", - "Referer": "https://www.space-track.org/", + "Referer": f"{self.site_root}/", }, ) as client: - await client.get("https://www.space-track.org/") + await client.get(f"{self.site_root}/") login_response = await client.post( - "https://www.space-track.org/ajaxauth/login", + self.login_url, data={ "identity": username, "password": password, @@ -69,7 +88,7 @@ class SpaceTrackTLECollector(BaseCollector): timeout=120.0, follow_redirects=True, ) as alt_client: - await alt_client.get("https://www.space-track.org/") + await alt_client.get(f"{self.site_root}/") form_data = { "username": username, @@ -77,7 +96,7 @@ class SpaceTrackTLECollector(BaseCollector): "query": "class/gp/NORAD_CAT_ID/25544/format/json", } alt_login = await alt_client.post( - "https://www.space-track.org/ajaxauth/login", + self.login_url, data={ "identity": username, "password": password, @@ -86,9 +105,7 @@ class SpaceTrackTLECollector(BaseCollector): print(f"SPACETRACK: Alt login status: {alt_login.status_code}") if alt_login.status_code == 200: - tle_response = await alt_client.get( - "https://www.space-track.org/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json" - ) + tle_response = await alt_client.get(self.probe_url) if tle_response.status_code == 200: data = tle_response.json() print(f"SPACETRACK: Received {len(data)} records via alt method") @@ -98,9 +115,7 @@ class SpaceTrackTLECollector(BaseCollector): print(f"SPACETRACK: Login failed, using sample data") return self._get_sample_data() - tle_response = await client.get( - "https://www.space-track.org/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json" - ) + tle_response = await client.get(self.probe_url) print(f"SPACETRACK: TLE query status: {tle_response.status_code}") if tle_response.status_code != 200: @@ -127,11 +142,11 @@ class SpaceTrackTLECollector(BaseCollector): }, ) as client: # First, visit the main page to get any cookies - await client.get("https://www.space-track.org/") + await client.get(f"{self.site_root}/") # Login to get session cookie login_response = await client.post( - "https://www.space-track.org/ajaxauth/login", + self.login_url, data={ "identity": username, "password": password, @@ -146,13 +161,7 @@ class SpaceTrackTLECollector(BaseCollector): return self._get_sample_data() # Query for TLE data (get first 1000 satellites) - tle_response = await client.get( - "https://www.space-track.org/basicspacedata/query" - "/class/gp" - "/orderby/EPOCH%20desc" - "/limit/1000" - "/format/json" - ) + tle_response = await client.get(self.query_url) print(f"SPACETRACK: TLE query status: {tle_response.status_code}") if tle_response.status_code != 200: diff --git a/backend/app/services/collectors/telegeography.py b/backend/app/services/collectors/telegeography.py index b3bd7c72..d6af4e21 100644 --- a/backend/app/services/collectors/telegeography.py +++ b/backend/app/services/collectors/telegeography.py @@ -11,6 +11,7 @@ from datetime import UTC, datetime from bs4 import BeautifulSoup import httpx +from app.core.data_sources import get_data_sources_config from app.services.collectors.base import BaseCollector @@ -24,15 +25,17 @@ class TeleGeographyCableCollector(BaseCollector): async def fetch(self) -> List[Dict[str, Any]]: """Fetch submarine cable data from Wayback Machine""" + config = get_data_sources_config() # Try multiple data sources sources = [ - # Wayback Machine archive of TeleGeography - "https://web.archive.org/web/2024/https://www.submarinecablemap.com/api/v3/cable", - # Alternative: Try scraping the page - "https://www.submarinecablemap.com", + self._resolved_url or "", + str(config.get_yaml_value("telegeography.archived_cable_url") or ""), + str(config.get_yaml_value("telegeography.live_map_url") or ""), ] for url in sources: + if not url: + continue try: async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: response = await client.get(url) @@ -161,7 +164,7 @@ class TeleGeographyLandingPointCollector(BaseCollector): async def fetch(self) -> List[Dict[str, Any]]: """Fetch landing point data from GitHub mirror""" - url = "https://raw.githubusercontent.com/lintaojlu/submarine_cable_information/main/landing_point.json" + url = self._resolved_url or "" async with httpx.AsyncClient(timeout=60.0) as client: response = await client.get(url) @@ -225,7 +228,7 @@ class TeleGeographyCableSystemCollector(BaseCollector): async def fetch(self) -> List[Dict[str, Any]]: """Fetch cable system data""" - url = "https://raw.githubusercontent.com/lintaojlu/submarine_cable_information/main/cable.json" + url = self._resolved_url or "" async with httpx.AsyncClient(timeout=60.0) as client: response = await client.get(url) diff --git a/backend/app/services/collectors/top500.py b/backend/app/services/collectors/top500.py index 9e21937d..547017d8 100644 --- a/backend/app/services/collectors/top500.py +++ b/backend/app/services/collectors/top500.py @@ -10,6 +10,7 @@ from typing import Dict, Any, List from bs4 import BeautifulSoup import httpx +from app.core.data_sources import get_data_sources_config from app.services.collectors.base import BaseCollector @@ -22,7 +23,7 @@ class TOP500Collector(BaseCollector): async def fetch(self) -> List[Dict[str, Any]]: """Fetch TOP500 list data and enrich each row with detail-page metadata.""" - url = "https://top500.org/lists/top500/list/2025/11/" + url = self._resolved_url or "" async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: response = await client.get(url) @@ -48,11 +49,13 @@ class TOP500Collector(BaseCollector): return await asyncio.gather(*(enrich(entry) for entry in entries)) def _extract_system_fields(self, system_cell) -> Dict[str, str]: + config = get_data_sources_config() + top500_base_url = config.get_yaml_value("top500.base_url") or "https://top500.org" link = system_cell.find("a") system_name = link.get_text(" ", strip=True) if link else system_cell.get_text(" ", strip=True) detail_url = "" if link and link.get("href"): - detail_url = f"https://top500.org{link.get('href')}" + detail_url = f"{str(top500_base_url).rstrip('/')}{link.get('href')}" manufacturer = "" if link and link.next_sibling: diff --git a/docs/datasource-health-plan.md b/docs/datasource-health-plan.md new file mode 100644 index 00000000..19946406 --- /dev/null +++ b/docs/datasource-health-plan.md @@ -0,0 +1,486 @@ +# Datasource Health Plan + +## Overview + +This document defines a phased plan for datasource health governance. + +The goal is to make collectors observable, diagnosable, and recoverable when upstream APIs change, while avoiding unsafe automatic mutation of repository defaults. + +The key principle is: + +- do not let runtime automation rewrite repository default config + +Instead, split responsibilities across: + +- default config +- runtime overrides +- health check records +- agent-generated repair proposals + + +## Problem Statement + +Collectors currently depend on third-party APIs, data downloads, mirrored JSON files, archive links, and web pages. + +These upstream dependencies can fail in several ways: + +- endpoint becomes unreachable +- endpoint still responds but schema changes +- content-type changes +- website shuts down or moves +- mirror link disappears +- HTML structure changes and scraping fails +- endpoint requires a new path or new host + +We want a system that can: + +- detect datasource health degradation early +- identify likely cause +- search for updated endpoints when reasonable +- apply safe runtime fixes without polluting default repo config +- preserve auditability and rollback + + +## Design Principles + +1. Default config is stable + +- `backend/app/core/data_sources.yaml` remains the repository baseline. +- It should be changed intentionally through normal development flow, not by autonomous runtime agents. + +2. Runtime fixes are isolated + +- Emergency or adaptive fixes should live in a runtime override layer. +- Overrides should be reversible and auditable. + +3. Deterministic checks come first + +- Use normal programmatic health checks before using LLMs. +- Only call an agent when deterministic checks indicate a meaningful failure. + +4. Agents suggest before they mutate + +- Agents should produce proposals with evidence and confidence. +- Application of a proposal should be controlled by policy. + +5. Every repair is attributable + +- Store what changed, why, who or what suggested it, and when it was applied. + + +## Configuration Layers + +Recommended runtime precedence: + +1. datasource endpoint override +2. datasource DB endpoint override +3. repository default YAML +4. collector internal fallback logic + +Definitions: + +- repository default YAML: + - `backend/app/core/data_sources.yaml` + - versioned baseline +- datasource DB endpoint override: + - existing `DataSourceConfig.endpoint` + - current runtime override entrypoint +- datasource endpoint override: + - a dedicated new override table + - used for health-repair and proposal application +- collector internal fallback logic: + - final defensive fallback + - should be minimized over time + + +## Recommended Architecture + +### 1. Deterministic Health Checks + +Each collector gets a health profile with checks such as: + +- endpoint resolves +- HTTP request succeeds +- status code is acceptable +- content-type is expected +- body parses successfully +- minimum structural fields exist +- sample item count is plausible +- latency is within threshold + +Output states: + +- `healthy` +- `degraded` +- `failed` +- `schema_changed` +- `rate_limited` +- `auth_required` + + +### 2. Agent-Assisted Repair Discovery + +Only triggered when deterministic health checks fail or return suspicious structure. + +Agent responsibilities: + +- search for current official endpoint or replacement path +- inspect likely upstream documentation or landing pages +- compare candidate endpoint output to collector expectations +- produce a repair proposal with confidence and evidence + +Agent should not directly modify repository defaults. + + +### 3. Safe Runtime Repair Application + +Repair proposals can be: + +- reviewed manually +- auto-applied only under strict low-risk policy + +Auto-apply should be limited to cases like: + +- same trusted domain +- highly similar response structure +- repeated successful verification +- confidence above threshold + + +## Phased Delivery Plan + +## Phase 1: Deterministic Health MVP + +Goal: + +- build health observability without automated repair + +Scope: + +- datasource health check task runner +- datasource health result persistence +- endpoint reachability + parse checks +- dashboard or API visibility into health status + +Deliverables: + +- health check service +- health check record table +- status endpoint +- scheduled or manual check trigger + +No agent usage yet. + + +## Phase 2: Agent Repair Proposals + +Goal: + +- let agent investigate failing sources and propose updated endpoints + +Scope: + +- invoke agent only when datasource health is `failed` or `schema_changed` +- web search + page inspection +- candidate endpoint extraction +- proposal persistence + +Deliverables: + +- repair proposal schema +- proposal generation pipeline +- confidence and evidence model +- operator review view or API + +Still no automatic config mutation. + + +## Phase 3: Runtime Overrides + +Goal: + +- allow approved proposals to take effect safely at runtime + +Scope: + +- add dedicated override storage +- runtime resolution prefers override over default config +- proposal application writes override only + +Deliverables: + +- endpoint override table +- override-aware resolution logic +- apply/reject endpoints +- rollback endpoint + +Repository default YAML remains untouched. + + +## Phase 4: Limited Auto-Apply + +Goal: + +- safely automate a narrow slice of low-risk repairs + +Scope: + +- policy engine for auto-apply +- same-domain or trusted-domain checks +- structure validation +- staged verification after apply + +Deliverables: + +- auto-apply rules +- audit logs +- automatic post-apply health verification +- auto-disable or rollback on regression + + +## Data Model Draft + +### datasource_health_checks + +Purpose: + +- store each health evaluation result + +Suggested fields: + +- `id` +- `datasource_id` +- `collector_name` +- `endpoint_checked` +- `status` +- `http_status` +- `content_type` +- `latency_ms` +- `sample_count` +- `error_message` +- `details` +- `checked_at` + +`details` can store structured diagnostic data such as: + +- parsed fields +- schema mismatch summary +- retry count +- exception class + + +### datasource_repair_proposals + +Purpose: + +- store agent-generated repair suggestions + +Suggested fields: + +- `id` +- `datasource_id` +- `collector_name` +- `old_endpoint` +- `candidate_endpoint` +- `reason` +- `confidence` +- `evidence_urls` +- `evidence_summary` +- `status` +- `created_by` +- `created_at` +- `reviewed_at` + +Suggested `status` values: + +- `proposed` +- `approved` +- `rejected` +- `applied` +- `expired` + + +### datasource_endpoint_overrides + +Purpose: + +- runtime endpoint override layer + +Suggested fields: + +- `id` +- `datasource_id` +- `collector_name` +- `endpoint` +- `reason` +- `source` +- `proposal_id` +- `enabled` +- `created_at` +- `updated_at` + +Suggested `source` values: + +- `manual` +- `health-agent` +- `migration` + + +## API Draft + +### Health + +- `GET /api/v1/datasources/health` +- `GET /api/v1/datasources/{id}/health` +- `POST /api/v1/datasources/{id}/health-check` +- `POST /api/v1/datasources/health-check-all` + +### Repair proposals + +- `GET /api/v1/datasources/{id}/repair-proposals` +- `POST /api/v1/datasources/{id}/repair-proposals/generate` +- `POST /api/v1/datasources/{id}/repair-proposals/{proposal_id}/approve` +- `POST /api/v1/datasources/{id}/repair-proposals/{proposal_id}/reject` +- `POST /api/v1/datasources/{id}/repair-proposals/{proposal_id}/apply` + +### Overrides + +- `GET /api/v1/datasources/{id}/overrides` +- `POST /api/v1/datasources/{id}/overrides` +- `PUT /api/v1/datasources/{id}/overrides/{override_id}` +- `DELETE /api/v1/datasources/{id}/overrides/{override_id}` + + +## Agent Contract Draft + +When deterministic health fails, the agent should receive: + +- datasource name +- collector name +- current endpoint +- current failure mode +- expected response shape summary +- known trusted domains + +Expected output: + +```json +{ + "status": "proposal", + "candidate_endpoint": "https://example.com/api/v2/data", + "confidence": 0.86, + "reason": "Official docs now point to v2 endpoint", + "evidence_urls": [ + "https://example.com/docs/api", + "https://example.com/changelog" + ], + "notes": "Response shape appears compatible after light field remapping" +} +``` + +The agent should never output "rewrite the default yaml" as its primary action. + + +## Risk Analysis + +### Risk: wrong endpoint chosen by agent + +Mitigation: + +- use trusted-domain allowlists +- require evidence URLs +- require confidence threshold +- add manual review for medium-risk sources + + +### Risk: endpoint responds but schema silently changed + +Mitigation: + +- deterministic schema checks +- parse and sample validation +- content-type checks +- collector-specific required fields + + +### Risk: automatic runtime override causes hidden drift + +Mitigation: + +- store all overrides explicitly +- mark source of override +- keep default YAML unchanged +- expose active overrides in API/UI + + +### Risk: persistent bad override breaks data collection + +Mitigation: + +- allow rollback +- keep parent/default endpoint visible +- re-run verification after apply +- auto-disable override on repeated failure + + +## Operational Policy Recommendations + +1. Do not auto-apply for high-value or high-fragility sources initially. + +2. Use manual approval for: + +- scraped HTML sources +- unofficial mirrors +- sources with auth or rate-limit complexity +- sources with legal or trust ambiguity + +3. Allow auto-apply only for: + +- same-domain version bumps +- obvious official migration paths +- repeated passing verification + +4. Expose health + proposal + override state together in one operator view. + + +## Suggested Implementation Order + +1. Phase 1 +- health result table +- deterministic checks +- API and UI visibility + +2. Phase 2 +- proposal table +- agent prompt/output contract +- proposal generation job + +3. Phase 3 +- runtime override table +- resolver precedence update +- apply/reject endpoints + +4. Phase 4 +- auto-apply rules +- rollback policy +- operator automation + + +## Out Of Scope For The First Iteration + +- direct automatic mutation of repository default YAML +- automatic git commits by repair agents +- unrestricted autonomous endpoint replacement +- fully generalized schema remapping engine + + +## Recommended First Milestone + +The first milestone should be: + +- deterministic datasource health checks +- persisted results +- manual visibility +- no automatic repair + +This gives immediate operational value with low risk, and prepares clean inputs for the later agent phase. diff --git a/planet.sh b/planet.sh index d2badfe9..57d4318b 100755 --- a/planet.sh +++ b/planet.sh @@ -129,7 +129,7 @@ print_splash() { |___|_| |_|\__\___|_|_|_|\__, |\___|_| |_|\__| |___/ EOF - printf "%b\n" "$NC" + # printf "%b\n" "$NC" printf "%b" "$BLUE" cat <<'EOF' ____ _ _ @@ -138,7 +138,7 @@ EOF | __/| | (_| | | | | __/| |_ |_| |_|\__,_|_| |_|\___| \__| EOF - printf "%b\n" "$NC" + # printf "%b\n" "$NC" printf "%b" "$WHITE" cat <<'EOF' ____ _ From d395769df6ad5de9003394489b2d590d23a3be54 Mon Sep 17 00:00:00 2001 From: linkong Date: Wed, 8 Apr 2026 12:49:49 +0800 Subject: [PATCH 09/34] docs: add agent runtime planning docs --- docs/agent-architecture-plan.md | 647 +++++++++++++++++++++++++ docs/agent-runtime-roadmap.md | 346 +++++++++++++ docs/datasource-health-stage2-tasks.md | 478 ++++++++++++++++++ 3 files changed, 1471 insertions(+) create mode 100644 docs/agent-architecture-plan.md create mode 100644 docs/agent-runtime-roadmap.md create mode 100644 docs/datasource-health-stage2-tasks.md diff --git a/docs/agent-architecture-plan.md b/docs/agent-architecture-plan.md new file mode 100644 index 00000000..9bfda21f --- /dev/null +++ b/docs/agent-architecture-plan.md @@ -0,0 +1,647 @@ +# Agent Architecture Plan + +## Overview + +This document defines the agent architecture for Planet. + +The architecture is intentionally broader than datasource health checking. + +It is designed to support both: + +- datasource health governance +- future situational-awareness workflows + +The core idea is to avoid building a one-off "repair broken API links" agent. + +Instead, Planet should grow a reusable agent runtime that can: + +- collect evidence +- evaluate signals +- reason over incomplete information +- generate proposals +- produce assessments +- execute limited actions under policy + + +## Design Goal + +Build an agent foundation that can evolve in this order: + +1. datasource health checks +2. datasource repair proposals +3. signal correlation +4. situational assessments +5. controlled runtime actions + +This means the architecture should treat datasource health as one use case of the larger agent system, not as the whole system. + + +## Core Principles + +1. Separate evidence from reasoning + +- raw signals should be gathered first +- deterministic checks should run before LLM reasoning + +2. Agents do not own the defaults + +- repository defaults remain human-owned +- agents operate on runtime state, proposals, and overrides + +3. Reasoning and action are different responsibilities + +- many agents should be read-only or propose-only +- only tightly controlled flows may apply changes + +4. Shared runtime, specialized roles + +- multiple agent roles should share the same object model and orchestration patterns +- health and situational-awareness agents should not invent incompatible payloads + +5. Auditability is mandatory + +- every proposal, assessment, and applied action should be attributable + + +## System Layers + +Planet agent architecture should be split into four layers. + +### 1. Signal Layer + +Purpose: + +- gather raw evidence from internal and external systems + +Example sources: + +- collector outputs +- datasource health checks +- logs +- snapshots +- alerts +- web search results +- scraped pages +- external APIs +- operator inputs + +Responsibilities: + +- fetch +- normalize +- timestamp +- tag with source and trust level + +This layer should not make high-level judgments. + + +### 2. Evaluation Layer + +Purpose: + +- perform deterministic analysis + +Examples: + +- reachability checks +- schema validation +- threshold checks +- time-window comparisons +- anomaly counters +- completeness checks + +Responsibilities: + +- classify signals into machine-readable findings +- attach deterministic evidence + +This layer should avoid LLM dependency whenever possible. + + +### 3. Reasoning Layer + +Purpose: + +- use LLMs when semantic interpretation or incomplete-information reasoning is needed + +Examples: + +- endpoint migration inference +- multi-source event correlation +- causality hypotheses +- ambiguity reduction +- assessment narrative generation +- action recommendation generation + +Responsibilities: + +- synthesize evidence +- produce hypotheses +- rank confidence +- explain reasoning boundaries + +This is the main place where `aiprovider` and web search are used. + + +### 4. Action Layer + +Purpose: + +- convert proposals or assessments into controlled system actions + +Examples: + +- create runtime override +- create proposal +- publish alert +- update operator task queue +- generate summary artifact +- trigger follow-up verification + +Responsibilities: + +- enforce policy +- enforce approval requirements +- verify post-action outcomes +- record audit trails + + +## Architecture Sketch + +```mermaid +flowchart TD + A["Collectors / Logs / Snapshots / External APIs"] --> B["Signal Layer"] + W["Web Search / Page Fetch / Docs"] --> B + B --> C["Evaluation Layer"] + C --> D["Findings"] + D --> E["Reasoning Layer (LLM + Tools)"] + E --> F["Proposals"] + E --> G["Assessments"] + F --> H["Action Layer"] + H --> I["Runtime Overrides / Alerts / Tasks"] + H --> J["Verification Loop"] + J --> B + + K["Policy Engine"] --> H + L["Audit / History Store"] --> H + L --> E + L --> C +``` + + +## Agent Roles + +The first version should define these logical roles. + +### 1. Health Agent + +Primary use case: + +- datasource health governance + +Inputs: + +- datasource metadata +- current endpoint +- latest health records +- latest failures +- deterministic findings + +Outputs: + +- health interpretation +- repair proposal +- confidence +- evidence references + +Typical action level: + +- propose-only + + +### 2. Correlation Agent + +Primary use case: + +- identify whether multiple signals describe the same event or related events + +Inputs: + +- findings from multiple collectors +- time windows +- region / ASN / prefix / cable relationships +- prior incidents + +Outputs: + +- grouped event candidates +- correlation rationale +- confidence per relationship + +Typical action level: + +- read-only + + +### 3. Assessment Agent + +Primary use case: + +- produce situational-awareness outputs + +Inputs: + +- grouped events +- findings +- current context +- historical context +- operator constraints + +Outputs: + +- structured assessment +- risk summary +- evidence-backed recommendations +- missing-information list + +Typical action level: + +- read-only or propose-only + + +### 4. Recovery Agent + +Primary use case: + +- carry low-risk proposals into controlled runtime actions + +Inputs: + +- approved proposal +- policy constraints +- trusted-domain rules +- verification checks + +Outputs: + +- applied override +- failed application +- rollback request + +Typical action level: + +- apply-limited + + +## Shared Object Model + +All agents should work on a shared object model. + +That prevents the health subsystem and situational-awareness subsystem from drifting into incompatible payloads. + +### Signal + +Represents a raw observed fact. + +Examples: + +- a datasource returned HTTP 404 +- a collector returned empty results +- BGP updates spiked in one region +- a known endpoint now redirects elsewhere + +Suggested shape: + +```json +{ + "id": "sig_123", + "type": "datasource.http_failure", + "source": "ris_live_bgp", + "occurred_at": "2026-04-08T10:00:00Z", + "severity": "medium", + "payload": {}, + "trust": 0.95 +} +``` + + +### Finding + +Represents a deterministic or semi-deterministic interpretation of one or more signals. + +Examples: + +- `schema_changed` +- `endpoint_unreachable` +- `data_volume_abnormally_low` +- `event_cluster_detected` + +Suggested shape: + +```json +{ + "id": "find_123", + "type": "datasource.schema_changed", + "source_ids": ["sig_123"], + "confidence": 0.92, + "evidence": [], + "details": {} +} +``` + + +### Proposal + +Represents a recommended action, not an already-applied action. + +Examples: + +- switch endpoint to new URL +- disable bad override +- escalate issue for manual review + +Suggested shape: + +```json +{ + "id": "prop_123", + "kind": "endpoint_override", + "target": "telegeography_cables", + "confidence": 0.84, + "reason": "Official docs now point to a new API path", + "payload": {}, + "evidence_urls": [], + "status": "proposed" +} +``` + + +### Assessment + +Represents a structured situational-awareness output for operators or downstream systems. + +Examples: + +- current network posture summary +- incident impact assessment +- risk and response recommendations + +Suggested shape: + +```json +{ + "id": "assess_123", + "scope": "regional-network", + "risk_level": "high", + "summary": "Regional routing instability is increasing.", + "key_risks": [], + "evidence": [], + "recommendations": [], + "missing_data": [] +} +``` + + +## State Machine + +The shared orchestration flow should look like this: + +```mermaid +stateDiagram-v2 + [*] --> Collect + Collect --> Validate + Validate --> Classify + Classify --> Reason + Reason --> Propose + Reason --> Assess + Propose --> Review + Review --> Apply + Apply --> Verify + Verify --> Archive + Assess --> Archive + Archive --> [*] +``` + +Definitions: + +- `Collect`: gather signals +- `Validate`: run deterministic checks +- `Classify`: create findings +- `Reason`: invoke LLM reasoning when needed +- `Propose`: create change proposals +- `Review`: policy or human approval +- `Apply`: perform limited runtime action +- `Verify`: confirm action effect +- `Archive`: store artifacts and decisions + + +## Permission Model + +Each agent role should be assigned one of these action levels. + +### `read-only` + +Allowed: + +- read signals +- search web +- fetch pages +- read internal state +- generate findings and assessments + +Not allowed: + +- mutate config +- write overrides +- change live runtime behavior + + +### `propose-only` + +Allowed: + +- everything in `read-only` +- create proposals +- create review tasks + +Not allowed: + +- apply live changes + + +### `apply-limited` + +Allowed: + +- everything in `propose-only` +- write approved runtime overrides +- trigger verification checks + +Not allowed: + +- mutate repository defaults +- make destructive data changes +- bypass policy engine + + +## Runtime Components + +The first durable architecture should introduce these components. + +### 1. Signal Store + +Stores normalized evidence and health outputs. + + +### 2. Finding Store + +Stores deterministic classifications that can be reused by multiple agents. + + +### 3. Proposal Store + +Stores recommended actions with evidence and confidence. + + +### 4. Assessment Store + +Stores structured situational-awareness outputs. + + +### 5. Policy Engine + +Decides: + +- whether agent may run +- whether proposal requires review +- whether proposal may auto-apply +- whether post-apply verification passed + + +### 6. Override Store + +Stores runtime-only configuration changes. + +This is where endpoint repairs should live. + + +## Relation To `aiprovider` + +`aiprovider` should remain the model gateway. + +It should not become the full agent runtime. + +Recommended split: + +- `aiprovider` + - provider adaptation + - prompt transport + - model execution + - protocol compatibility + +- agent runtime + - orchestration + - signal handling + - tool selection + - proposal generation + - policy and audit + +This keeps provider concerns and agent behavior concerns separate. + + +## Relation To Datasource Health + +Datasource health becomes one vertical slice of this architecture. + +Mapping: + +- signal: + - endpoint unreachable + - schema mismatch + - bad content type +- finding: + - `failed` + - `schema_changed` + - `moved_endpoint_suspected` +- proposal: + - runtime override suggestion +- assessment: + - datasource health summary for operators + + +## Relation To Situational Awareness + +Future situational-awareness capabilities should reuse the same flow: + +- raw telemetry becomes signals +- anomaly detection becomes findings +- LLM correlation becomes reasoning +- operator-facing output becomes assessments +- policy-approved mitigations become actions + +This lets the platform evolve from operational health governance into broader cyber/network posture workflows without changing the architecture. + + +## Suggested Delivery Sequence + +### Phase A + +- finalize shared object model +- implement health-oriented signal and finding storage + +### Phase B + +- implement Health Agent +- generate proposals only + +### Phase C + +- implement Assessment Agent +- expose structured assessments via API + +### Phase D + +- implement Correlation Agent +- support multi-source incident grouping + +### Phase E + +- implement Recovery Agent with policy-gated runtime actions + + +## Recommended First Build + +The first build should not try to implement every agent role. + +Recommended initial slice: + +- shared object model +- health signals +- health findings +- Health Agent +- proposal generation only + +This gives immediate value while preserving the longer-term architecture. + + +## Non-Goals For The First Iteration + +- repository YAML auto-rewrites +- unrestricted autonomous action +- full incident graph reasoning +- automatic large-scale remediation +- agent-owned configuration source of truth + + +## Summary + +Planet should treat agents as a reusable runtime for evidence, reasoning, proposals, and assessments. + +The datasource health use case is the first practical entrypoint, but the architecture should already assume future situational-awareness expansion. + +The safest path is: + +- deterministic checks first +- agent reasoning second +- proposals before actions +- runtime overrides instead of default mutation diff --git a/docs/agent-runtime-roadmap.md b/docs/agent-runtime-roadmap.md new file mode 100644 index 00000000..70fff821 --- /dev/null +++ b/docs/agent-runtime-roadmap.md @@ -0,0 +1,346 @@ +# Agent Runtime Roadmap + +## Overview + +This document connects three existing planning threads into one implementation roadmap: + +- `aiprovider` as the model gateway +- datasource health governance as the first practical agent use case +- situational awareness as the broader long-term target + +Related documents: + +- [aiprovider](/home/ray/dev/linkong/planet/docs/aiprovider.md) +- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/datasource-health-plan.md) +- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agent-architecture-plan.md) + + +## Big Picture + +Planet should evolve in layers: + +1. stable model gateway +2. deterministic health and evidence collection +3. agent runtime for reasoning and proposal generation +4. situational-awareness assessments and controlled actions + +This prevents the system from collapsing into a single giant "AI feature" with unclear boundaries. + + +## Architecture Overview + +```mermaid +flowchart TD + U["Frontend / Backend APIs / Operators"] --> B["Planet Backend"] + B --> H["Datasource Health Services"] + B --> R["Agent Runtime"] + R --> P["aiprovider"] + P --> M["OpenAI / Anthropic / MiniMax / Ollama / Local Models"] + + C["Collectors / Snapshots / Logs / Alerts / BGP Signals"] --> S["Signal Store"] + H --> S + S --> E["Evaluation Layer"] + E --> F["Findings"] + F --> R + + W["Web Search / Page Fetch / Docs Fetch"] --> R + R --> PR["Proposals"] + R --> AS["Assessments"] + + PR --> O["Runtime Overrides / Review Queue / Tasks"] + AS --> SA["Situational Awareness APIs / UI"] + + O --> V["Verification Loop"] + V --> S +``` + + +## Role Boundaries + +### `aiprovider` + +Responsibilities: + +- provider compatibility +- protocol adaptation +- auth and model transport +- request/response normalization + +Not responsible for: + +- agent orchestration +- business workflows +- datasource repair policy +- situational-awareness domain logic + + +### Backend + +Responsibilities: + +- stable business APIs +- auth and permissions +- task orchestration +- health records +- proposal and override persistence +- assessment exposure + + +### Agent Runtime + +Responsibilities: + +- consume findings and context +- invoke LLMs via `aiprovider` +- invoke tools such as web search +- create proposals +- create assessments +- route to policy-controlled action paths + + +## Delivery Sequence + +## Stage 1: Gateway Foundation + +Status: + +- already in place + +Delivered by current work: + +- `aiprovider` +- multi-provider compatibility +- backend AI facade +- MiniMax / Anthropic-compatible support +- request-id propagation + +Primary outcome: + +- the system already has a stable way to call models + + +## Stage 2: Datasource Health MVP + +Goal: + +- establish deterministic health observability + +Key work: + +- health check task runner +- health result table +- datasource health APIs +- UI visibility +- collector endpoint override precedence cleanup + +Primary outcome: + +- Planet knows which collectors are healthy before asking an LLM anything + + +## Stage 3: Health Agent + +Goal: + +- let the first agent role operate on health failures + +Key work: + +- convert health failures into signals/findings +- invoke agent only for failed or suspicious cases +- produce repair proposals with evidence and confidence + +Primary outcome: + +- Planet can suggest endpoint repairs without mutating defaults + + +## Stage 4: Runtime Repair Application + +Goal: + +- safely apply approved datasource repair proposals + +Key work: + +- override storage +- policy-gated apply flow +- verification after apply +- rollback path + +Primary outcome: + +- datasource repair becomes operationally useful without polluting repository defaults + + +## Stage 5: Situational Awareness Assessments + +Goal: + +- reuse the same runtime for broader operator-facing assessment + +Key work: + +- normalize telemetry and incident evidence into signals/findings +- build Assessment Agent +- expose structured assessments through backend APIs and UI + +Primary outcome: + +- LLM output becomes evidence-backed situational summary, not just ad hoc chat output + + +## Stage 6: Correlation and Controlled Actions + +Goal: + +- connect multiple sources into higher-level posture and event groupings + +Key work: + +- event correlation +- incident grouping +- recommendation scoring +- controlled action routing + +Primary outcome: + +- Planet becomes a true agent-assisted situational-awareness system + + +## Implementation Tracks + +These tracks can progress in parallel, but they should stay loosely coupled. + +### Track A: Config and Runtime Resolution + +Scope: + +- datasource defaults +- overrides +- runtime precedence +- audit trails + +First milestone: + +- health-safe override layer + + +### Track B: Health and Evidence + +Scope: + +- deterministic checks +- failure categorization +- signal and finding persistence + +First milestone: + +- datasource health record system + + +### Track C: Agent Runtime + +Scope: + +- shared object model +- orchestration flow +- prompt/tool pipeline +- policy integration + +First milestone: + +- Health Agent proposal pipeline + + +### Track D: Situational Awareness + +Scope: + +- assessment schema +- multi-source context assembly +- operator-facing outputs + +First milestone: + +- structured assessment API + + +## Shared Artifacts + +To avoid fragmentation, these artifacts should be shared across all future agent work. + +### Shared object model + +- `Signal` +- `Finding` +- `Proposal` +- `Assessment` + +### Shared orchestration flow + +- collect +- validate +- classify +- reason +- propose or assess +- review or apply +- verify +- archive + +### Shared policy model + +- read-only +- propose-only +- apply-limited + + +## Recommended Next Concrete Steps + +1. Build Stage 2 first + +- datasource health records +- deterministic checks +- no automatic repair + +2. Then build Stage 3 + +- Health Agent +- proposal generation only + +3. Then Stage 4 + +- override apply flow +- rollback and verification + +4. Only after that start Stage 5 + +- broader situational-awareness assessment workflows + + +## Why This Order + +Because situational-awareness quality depends on reliable upstream data. + +If datasource health is weak: + +- agent reasoning quality will degrade +- false explanations will increase +- assessment trust will drop + +So datasource health is not a side task. + +It is the first operational foundation for the later situational-awareness system. + + +## Summary + +Planet should be built as: + +- `aiprovider` for model access +- backend services for orchestration and persistence +- datasource health as the first evidence-governance layer +- agent runtime as the reusable reasoning core +- situational awareness as the long-term application layer + +That path keeps the architecture coherent and lets each phase produce useful functionality without forcing a rewrite later. diff --git a/docs/datasource-health-stage2-tasks.md b/docs/datasource-health-stage2-tasks.md new file mode 100644 index 00000000..ee71f4be --- /dev/null +++ b/docs/datasource-health-stage2-tasks.md @@ -0,0 +1,478 @@ +# Datasource Health Stage 2 Tasks + +## Goal + +Stage 2 focuses on the first practical operational layer: + +- deterministic datasource health checks +- persisted health results +- health visibility through API and UI +- no agent-assisted repair yet + +This stage should make Planet capable of answering: + +- which collectors are healthy +- which collectors are degraded +- which collectors are failing +- why they are failing at a basic deterministic level + + +## Scope + +Included: + +- datasource health data model +- deterministic health check service +- manual and scheduled health check triggers +- health result APIs +- frontend visibility + +Excluded: + +- LLM reasoning +- web-search-based repair proposals +- automatic endpoint rewriting +- runtime override application + + +## Delivery Target + +At the end of Stage 2, an operator should be able to: + +1. see health status for each collector +2. trigger a health check manually +3. inspect the latest failure reason +4. inspect the last checked endpoint +5. understand whether the problem is: + - unreachable + - auth-related + - rate-limit-related + - schema-related + - empty-data-related + + +## Work Breakdown + +## A. Data Model + +### A1. Add datasource health record table + +Create a new model, for example: + +- `backend/app/models/datasource_health_check.py` + +Suggested fields: + +- `id` +- `datasource_id` +- `collector_name` +- `endpoint_checked` +- `status` +- `http_status` +- `content_type` +- `latency_ms` +- `sample_count` +- `error_message` +- `details` +- `checked_at` + +Suggested status enum values: + +- `healthy` +- `degraded` +- `failed` +- `schema_changed` +- `rate_limited` +- `auth_required` +- `empty_result` + + +### A2. Add datasource health summary fields + +Option A: + +- keep summary only in the health check table + +Option B: + +- also add summary fields on `data_sources` + +Recommended first step: + +- do not mutate `data_sources` schema yet +- derive summary from the latest health record + + +### A3. Migration task + +Add migration for the health table. + +Deliverables: + +- migration file +- model registration + + +## B. Health Check Engine + +### B1. Define health check service + +Add a new service module, for example: + +- `backend/app/services/datasource_health.py` + +Responsibilities: + +- resolve effective endpoint +- execute deterministic check +- classify result +- persist health record + + +### B2. Define shared result schema + +Create a typed result object, for example: + +- `HealthCheckResult` + +Suggested fields: + +- `status` +- `endpoint_checked` +- `http_status` +- `content_type` +- `latency_ms` +- `sample_count` +- `error_message` +- `details` + + +### B3. Implement base deterministic checks + +Every datasource should go through a minimal baseline check: + +1. resolve endpoint +2. perform request +3. measure latency +4. inspect status code +5. inspect content type +6. inspect body shape + +Classification rules: + +- network error -> `failed` +- HTTP 401/403 -> `auth_required` +- HTTP 429 -> `rate_limited` +- HTTP 404/410 -> `failed` +- parse failure -> `schema_changed` +- zero or suspiciously empty results -> `empty_result` or `degraded` +- valid parse -> `healthy` + + +### B4. Add collector-aware adapters + +Some collectors do not use the same fetch semantics. + +Add adapter profiles such as: + +- `http_json` +- `http_csv` +- `html_scrape` +- `stream_probe` +- `auth_session_http` + +Initial mapping suggestion: + +- `huggingface`, `peeringdb`, `cloudflare` -> `http_json` +- `fao` -> `http_csv` +- `top500`, `epoch_ai`, `telegeography live_map` -> `html_scrape` +- `ris_live` -> `stream_probe` +- `spacetrack` -> `auth_session_http` + + +### B5. Add sample validation hooks + +For each adapter, add a lightweight validation rule. + +Examples: + +- JSON array length > 0 +- CSV rows > 1 +- HTML page contains expected table or script patterns +- stream source yields at least one valid event within timeout + + +## C. Persistence and Query Layer + +### C1. Save every check run + +Each health check should insert a record. + +Do not overwrite history in Stage 2. + + +### C2. Add latest-health query helpers + +Add helper functions to fetch: + +- latest health record by datasource +- latest failed health record +- recent health history + + +### C3. Optional retention policy + +For Stage 2, retention can be deferred. + +If desired, keep only: + +- last N records per datasource + + +## D. API Layer + +### D1. Add health list endpoint + +Suggested endpoint: + +- `GET /api/v1/datasources/health` + +Returns: + +- datasource id +- collector name +- current endpoint +- latest health status +- last checked time +- short reason + + +### D2. Add per-datasource health detail endpoint + +Suggested endpoint: + +- `GET /api/v1/datasources/{id}/health` + +Returns: + +- latest record +- recent history +- detailed classification fields + + +### D3. Add manual health trigger endpoint + +Suggested endpoint: + +- `POST /api/v1/datasources/{id}/health-check` + +Behavior: + +- run a health check now +- persist the result +- return the new record + + +### D4. Add bulk health trigger endpoint + +Suggested endpoint: + +- `POST /api/v1/datasources/health-check-all` + +Behavior: + +- enqueue or run health checks for all active datasources + + +## E. Scheduling + +### E1. Add health scheduler task + +Decide scheduling strategy. + +Recommended first version: + +- run collector jobs and health checks separately +- health checks run on a lower frequency + +Suggested frequency: + +- every 6h or 12h for most datasources +- optionally on-demand only in the very first cut + + +### E2. Prevent health check collision with collection + +Rules: + +- health checks should not disrupt active collection +- they should use light requests +- if a collector is currently running, health check may: + - skip + - or use a lightweight endpoint probe only + + +## F. Frontend + +### F1. Add health columns to datasource list + +Update: + +- `frontend/src/pages/DataSources/DataSources.tsx` + +Suggested new columns: + +- health status +- last checked +- reason summary + + +### F2. Add manual health check action + +Per datasource: + +- button or dropdown action: + - `健康检查` + + +### F3. Add health detail drawer or modal + +Show: + +- endpoint checked +- status +- HTTP status +- content type +- sample count +- error message +- last few results + + +### F4. Add basic visual language + +Suggested colors: + +- green -> healthy +- yellow -> degraded +- orange -> rate-limited / auth-required +- red -> failed / schema-changed + + +## G. Observability + +### G1. Structured logging + +Every health check should log: + +- datasource id +- collector name +- endpoint +- status +- latency +- failure class + + +### G2. Optional metrics + +If metrics are added later, useful counters include: + +- health checks total +- health checks failed +- schema changes detected +- rate limited checks + + +## H. Tests + +### H1. Unit tests + +Add tests for: + +- status classification +- content type classification +- adapter behavior +- latest-health query helpers + + +### H2. API tests + +Add tests for: + +- health endpoints require auth +- manual trigger endpoint works +- list endpoint returns latest status + + +### H3. Failure-path tests + +Add coverage for: + +- HTTP 404 +- HTTP 429 +- invalid JSON +- empty response +- parse mismatch + + +## Suggested File Plan + +Possible implementation files: + +- `backend/app/models/datasource_health_check.py` +- `backend/app/services/datasource_health.py` +- `backend/app/schemas/datasource_health.py` +- `backend/app/api/v1/datasource_health.py` +- migration file under the project migration system + +Likely touched existing files: + +- `backend/app/api/main.py` +- `frontend/src/pages/DataSources/DataSources.tsx` +- `backend/tests/test_api.py` + + +## Suggested Execution Order + +1. Add model and migration +2. Add service and result schema +3. Add deterministic adapters +4. Add manual trigger API +5. Add list/detail API +6. Add frontend visibility +7. Add scheduled checks +8. Expand tests + + +## Minimal First Milestone + +If we want the fastest useful slice, do this first: + +1. health table +2. deterministic check service +3. manual per-datasource health check API +4. latest health list API +5. frontend status badge column + +That is enough to start operating the system and will provide the input layer for Stage 3. + + +## Dependency On Later Stages + +Stage 2 outputs become direct inputs for Stage 3. + +Specifically: + +- failed or schema-changed health records become agent triggers +- health history becomes repair context +- endpoint_checked becomes proposal baseline + + +## Success Criteria + +Stage 2 is done when: + +- every active datasource can be health-checked deterministically +- the latest health state is visible in API and UI +- operators can manually trigger checks +- failures are categorized into stable machine-readable statuses +- no LLM is required for core health visibility From c8fe8cad594d26464a0f3ba682725b8b4528cad9 Mon Sep 17 00:00:00 2001 From: linkong Date: Wed, 8 Apr 2026 16:54:45 +0800 Subject: [PATCH 10/34] fix: refine planet startup script UX --- VERSION | 2 +- docs/CHANGELOG.md | 18 + docs/version-history.md | 3 +- frontend/package.json | 2 +- planet.sh | 742 ++++++++++++++++++++++++++++++++++------ 5 files changed, 656 insertions(+), 111 deletions(-) diff --git a/VERSION b/VERSION index fda96dcf..9e40e75c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.2 +0.23.3 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 976af428..4e1ad2e1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,24 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.23.3 + +Released: 2026-04-08 + +### Highlights + +- Refined `planet.sh` startup and restart presentation so service bring-up, health checks, and frontend dev-server readiness are easier to follow in real time. + +### Improved + +- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by standardizing the script on `zsh`, tightening frontend startup stage rendering, and making spinner-driven subtask feedback show up step by step instead of bunching at the end. +- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by clarifying health-check subtask copy, filtering noisy frontend startup lines, and aligning terminal output spacing across spinner and status rows. + +### Fixed + +- Fixed several `zsh` compatibility issues in [planet.sh](/home/ray/dev/linkong/planet/planet.sh), including reserved variable name collisions during `restart` and container health checks. +- Fixed [planet.sh](/home/ray/dev/linkong/planet/planet.sh) so frontend readiness animation no longer appears frozen while waiting for Vite startup and health probes. + ## 0.23.2 Released: 2026-04-08 diff --git a/docs/version-history.md b/docs/version-history.md index 7af737b9..ebc0b699 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.23.0` +- `dev` 当前开发分支历史推导到:`0.23.3` ## Timeline @@ -71,6 +71,7 @@ | `0.21.6` | bugfix | `dev` | `pending` | improve Earth legend generation, info-card interactions, and HUD messaging polish | | `0.22.9` | bugfix | `dev` | `6bfcd053` | simplify `planet.sh` readiness messaging and only show retry counts on actual restart | | `0.23.0` | feature | `dev` | `pending` | add dedicated `aiprovider` service, multi-protocol AI adapters, uv-only Python runtime, and AI Provider restart controls | +| `0.23.3` | bugfix | `dev` | `pending` | refine `planet.sh` zsh runtime compatibility, startup logging, and frontend readiness feedback | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 0a77af8a..f0bae64d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.23.2", + "version": "0.23.3", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/planet.sh b/planet.sh index 57d4318b..712a7a27 100755 --- a/planet.sh +++ b/planet.sh @@ -1,8 +1,9 @@ -#!/bin/bash +#!/usr/bin/env zsh set -e -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_PATH="${(%):-%N}" +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" cd "$SCRIPT_DIR" RED='\033[38;5;203m' @@ -15,20 +16,26 @@ DIM='\033[2m' NC='\033[0m' WAIT_SPINNER_FRAMES=( '⠉⠁' + '⠈⠁' '⠈⠉' - '⠀⠙' - '⠀⠸' - '⠀⢰' - '⠀⣠' + ' ⠙' + ' ⠸' + ' ⢰' + ' ⣠' '⢀⣀' '⣀⡀' - '⣄⠀' - '⡆⠀' - '⠇⠀' - '⠋⠀' + '⣄ ' + '⡆ ' + '⠇ ' + '⠋ ' + '⠋⠁' ) WAIT_SPINNER_STEP=0 WAIT_SPINNER_TICKS_PER_SECOND=8 +WAIT_SPINNER_DETAIL="" +WAIT_SPINNER_MESSAGE="" +WAIT_SESSION_ACTIVE=0 +HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-0.2}" BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}" BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}" @@ -44,10 +51,16 @@ DATABASE_RETRY_INTERVAL="${DATABASE_RETRY_INTERVAL:-5}" FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}" FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}" FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}" +PORT_RELEASE_ATTEMPTS="${PORT_RELEASE_ATTEMPTS:-45}" +PORT_RELEASE_INTERVAL="${PORT_RELEASE_INTERVAL:-1}" DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}" DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}" DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}" +FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}" +FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}" +FRONTEND_PID_FILE="/tmp/planet_frontend.pid" +# Shell / Docker helpers compose_up() { if docker compose version >/dev/null 2>&1; then docker compose "$@" @@ -56,12 +69,14 @@ compose_up() { fi } +# Terminal rendering helpers render_wait_spinner() { local message="$1" local step="${2:-1}" local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} )) local frame="${WAIT_SPINNER_FRAMES[$frame_index]}" - printf "\r${DIM}·${NC} ${CYAN}%s${NC} ${WHITE}%s${NC}\033[K" "$frame" "$message" + local detail="${WAIT_SPINNER_DETAIL:- }" + printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K\033[1A\r" "$frame" "$message" "$detail" >&2 } animate_wait_spinner() { @@ -83,19 +98,144 @@ animate_wait_spinner() { } clear_wait_spinner() { - printf "\r\033[K" + printf "\r\033[K\n\033[K\033[1A\r" >&2 +} + +start_wait_session() { + WAIT_SESSION_ACTIVE=1 + WAIT_SPINNER_MESSAGE="$1" + WAIT_SPINNER_DETAIL="" + WAIT_SPINNER_STEP=1 + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" +} + +resume_wait_session() { + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then + WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" + fi +} + +stop_wait_session() { + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then + clear_wait_spinner + fi + WAIT_SESSION_ACTIVE=0 + reset_wait_spinner_state +} + +close_wait_session_context() { + local owns_wait_session="$1" + + if [ "$owns_wait_session" -eq 1 ]; then + stop_wait_session + else + reset_wait_spinner_state + fi +} + +reset_wait_spinner_state() { + WAIT_SPINNER_DETAIL="" + WAIT_SPINNER_MESSAGE="" +} + +sanitize_wait_detail() { + local line="$1" + + line="$(printf "%s" "$line" | tr '\t' ' ' | tr -d '\r')" + line="$(printf "%s" "$line" | sed -E 's/\x1B\[[0-9;]*[[:alpha:]]//g')" + line="$(printf "%s" "$line" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')" + + if [ "${#line}" -gt 140 ]; then + line="${line:0:137}..." + fi + + printf "%s" "$line" +} + +set_wait_detail() { + WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")" + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then + WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" + fi +} + +run_command_with_spinner() { + local message="$1" + shift + + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then + set_wait_detail "$message" + fi + + "$@" & + local command_pid=$! + local exit_code=0 + + while kill -0 "$command_pid" 2>/dev/null; do + WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" + else + render_wait_spinner "$message" "$WAIT_SPINNER_STEP" + fi + sleep 0.125 + done + + wait "$command_pid" || exit_code=$? + return "$exit_code" +} + +resolve_bun_from_shell_configs() { + local shell_name="$1" + shift + + local config_file + local candidate="" + local bun_install="" + + for config_file in "$@"; do + [ -f "$config_file" ] || continue + + if grep -Eq 'BUN_INSTALL|\.bun/bin|bun ' "$config_file" 2>/dev/null; then + bun_install="$(sed -nE 's/^[[:space:]]*(export[[:space:]]+)?BUN_INSTALL=["'\'']?([^"'\'']+)["'\'']?/\2/p' "$config_file" | tail -n 1)" + + if [ -n "$bun_install" ]; then + bun_install="${bun_install/#\$HOME/$HOME}" + bun_install="${bun_install/#\~/$HOME}" + candidate="$bun_install/bin/bun" + if [ -x "$candidate" ]; then + FRONTEND_RUNTIME_BIN="$candidate" + FRONTEND_RUNTIME_SOURCE="${shell_name}-config" + return 0 + fi + fi + + candidate="$HOME/.bun/bin/bun" + if [ -x "$candidate" ]; then + FRONTEND_RUNTIME_BIN="$candidate" + FRONTEND_RUNTIME_SOURCE="${shell_name}-config" + return 0 + fi + fi + done + + return 1 } finish_wait_spinner() { log_line "done" "$GREEN" "$1" } +# Logging helpers log_line() { local label="$1" local color="$2" local message="$3" clear_wait_spinner printf "${DIM}·${NC} ${color}%-4s${NC} ${WHITE}%s${NC}\n" "$label" "$message" + resume_wait_session } log_step() { @@ -106,12 +246,16 @@ log_warn() { log_line "warn" "$YELLOW" "$1" } +log_halt() { + log_line "stopped" "$RED" "$1" +} + log_error() { log_line "fail" "$RED" "$1" } log_note() { - printf "${DIM} %s${NC}\n" "$1" + printf "${DIM} %s${NC}\n" "$1" } log_success() { @@ -129,7 +273,6 @@ print_splash() { |___|_| |_|\__\___|_|_|_|\__, |\___|_| |_|\__| |___/ EOF - # printf "%b\n" "$NC" printf "%b" "$BLUE" cat <<'EOF' ____ _ _ @@ -138,7 +281,6 @@ EOF | __/| | (_| | | | | __/| |_ |_| |_|\__,_|_| |_|\___| \__| EOF - # printf "%b\n" "$NC" printf "%b" "$WHITE" cat <<'EOF' ____ _ @@ -150,6 +292,7 @@ EOF printf "%b\n\n" "$NC" } +# Retry / dependency helpers run_with_retry() { local max_retries="$1" local interval="$2" @@ -160,7 +303,8 @@ run_with_retry() { local attempt=1 while [ "$attempt" -le "$max_retries" ]; do - if "$@"; then + if run_command_with_spinner "$retry_label" "$@"; then + WAIT_SPINNER_DETAIL="" return 0 fi @@ -177,7 +321,7 @@ run_with_retry() { } ensure_uv_backend_deps() { - log_step "检查后端 uv 环境" + set_wait_detail "检查后端 uv 环境" if ! command -v uv >/dev/null 2>&1; then log_error "未找到 uv,请先安装 uv 并加入 PATH" @@ -204,48 +348,124 @@ ensure_uv_backend_deps() { fi } -ensure_frontend_deps() { - log_step "检查前端依赖" +# Frontend runtime resolution +resolve_frontend_runtime() { + local shell_bin + local candidate - if ! command -v bun >/dev/null 2>&1; then - log_error "未找到 bun,请先安装或加载 bun 到 PATH" + if [ -n "$FRONTEND_RUNTIME_BIN" ] && [ -x "$FRONTEND_RUNTIME_BIN" ]; then + return 0 + fi + + set_wait_detail "读取 zsh 配置中的 bun" + if resolve_bun_from_shell_configs "zsh" "$HOME/.zshrc" "$HOME/.zprofile"; then + WAIT_SPINNER_DETAIL="已找到 zsh 配置中的 bun" + return 0 + fi + + if command -v zsh >/dev/null 2>&1; then + set_wait_detail "检查 zsh 中的 bun" + candidate="$(zsh -ic 'command -v bun 2>/dev/null || true' 2>/dev/null | tail -n 1)" + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + FRONTEND_RUNTIME_BIN="$candidate" + FRONTEND_RUNTIME_SOURCE="zsh" + WAIT_SPINNER_DETAIL="已找到 zsh 中的 bun" + return 0 + fi + fi + + for shell_bin in pwsh powershell; do + if command -v "$shell_bin" >/dev/null 2>&1; then + set_wait_detail "检查 powershell 中的 bun" + candidate="$("$shell_bin" -Command "(Get-Command bun -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)" 2>/dev/null | tail -n 1 | tr -d '\r')" + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + FRONTEND_RUNTIME_BIN="$candidate" + FRONTEND_RUNTIME_SOURCE="powershell" + WAIT_SPINNER_DETAIL="已找到 powershell 中的 bun" + return 0 + fi + fi + done + + if command -v bun >/dev/null 2>&1; then + set_wait_detail "检查当前环境中的 bun" + candidate="$(command -v bun)" + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + FRONTEND_RUNTIME_BIN="$candidate" + FRONTEND_RUNTIME_SOURCE="current-shell" + WAIT_SPINNER_DETAIL="已找到当前环境中的 bun" + return 0 + fi + fi + + return 1 +} + +ensure_frontend_deps() { + local owns_wait_session=0 + + if [ "$WAIT_SESSION_ACTIVE" -eq 0 ]; then + start_wait_session "检查前端依赖" + owns_wait_session=1 + else + set_wait_detail "检查前端依赖" + fi + + set_wait_detail "解析前端运行时" + if ! resolve_frontend_runtime; then + close_wait_session_context "$owns_wait_session" + log_error "未找到 bun,已按 zsh > powershell 顺序检查启动配置" exit 1 fi cd "$SCRIPT_DIR/frontend" + set_wait_detail "检查 vite 是否已安装" if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then - log_warn "前端依赖缺失,正在执行 bun install" + log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})" + set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "前端依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "bun install" \ - bun install; then + "$FRONTEND_RUNTIME_BIN" install; then exit 1 fi fi if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then + close_wait_session_context "$owns_wait_session" log_error "前端依赖安装失败,未找到 vite" exit 1 fi + + close_wait_session_context "$owns_wait_session" + if [ "$owns_wait_session" -eq 0 ]; then + resume_wait_session + fi } +# Health / readiness helpers wait_for_http() { local url="$1" local attempts="$2" local interval="$3" local service_name="$4" local attempt=1 + local wait_message="检查${service_name}健康检查" + local wait_detail_prefix="请求" + + set_wait_detail "${wait_detail_prefix} ${url} (1/${attempts})" while [ "$attempt" -le "$attempts" ]; do - if curl -s "$url" > /dev/null 2>&1; then + if curl -s --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1; then finish_wait_spinner "${service_name}已就绪" return 0 fi - animate_wait_spinner "等待${service_name}就绪" "$interval" + set_wait_detail "${wait_detail_prefix} ${url} (${attempt}/${attempts})" + animate_wait_spinner "${wait_message}" "$interval" attempt=$((attempt + 1)) done @@ -253,23 +473,127 @@ wait_for_http() { return 1 } +get_log_tail_line() { + local log_file="$1" + local fallback="$2" + local last_line="" + local filtered_line="" + + if [ -f "$log_file" ]; then + while IFS= read -r last_line; do + filtered_line="$(sanitize_wait_detail "$last_line")" + if [ -n "$filtered_line" ] && + [[ ! "$filtered_line" =~ ^\$[[:space:]] ]] && + [[ "$filtered_line" != "nohup: ignoring input" ]] && + [[ "$filtered_line" != The\ CJS\ build\ of\ Vite\'s\ Node\ API\ is\ deprecated.* ]]; then + printf "%s" "$filtered_line" + return 0 + fi + done < <(tail -n 20 "$log_file" 2>/dev/null | tac) + fi + + if [ -z "$last_line" ]; then + printf "%s" "$fallback" + return 0 + fi + + sanitize_wait_detail "$last_line" +} + +wait_for_frontend_ready() { + local frontend_port="$1" + local frontend_pid="$2" + local log_file="$3" + local attempts="$4" + local interval="$5" + local owns_wait_session=0 + local tick=0 + local max_ticks=$(( attempts * interval * WAIT_SPINNER_TICKS_PER_SECOND )) + local probe_every_ticks=$(( WAIT_SPINNER_TICKS_PER_SECOND / 2 )) + local detail_every_ticks=2 + local detail="" + + if [ "$max_ticks" -lt 1 ]; then + max_ticks=1 + fi + if [ "$probe_every_ticks" -lt 1 ]; then + probe_every_ticks=1 + fi + + if [ "$WAIT_SESSION_ACTIVE" -eq 0 ]; then + start_wait_session "启动前端服务" + owns_wait_session=1 + else + set_wait_detail "启动 Vite 开发服务器" + fi + + while [ "$tick" -lt "$max_ticks" ]; do + WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" + + if ! kill -0 "$frontend_pid" 2>/dev/null; then + detail="$(get_log_tail_line "$log_file" "前端进程已退出")" + WAIT_SPINNER_DETAIL="$detail" + clear_wait_spinner + if [ -n "$detail" ]; then + log_note "$detail" + fi + close_wait_session_context "$owns_wait_session" + return 1 + fi + + if [ $(( tick % detail_every_ticks )) -eq 0 ]; then + detail="$(get_log_tail_line "$log_file" "等待 Vite 输出启动日志")" + WAIT_SPINNER_DETAIL="$detail" + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" + fi + + if [ $(( tick % probe_every_ticks )) -eq 0 ] && + curl -s --max-time "$HTTP_CHECK_MAX_TIME" "http://localhost:${frontend_port}" > /dev/null 2>&1; then + if [ "$owns_wait_session" -eq 1 ]; then + stop_wait_session + finish_wait_spinner "前端已就绪" + else + reset_wait_spinner_state + fi + return 0 + fi + + sleep 0.125 + tick=$((tick + 1)) + done + + WAIT_SPINNER_DETAIL="$(get_log_tail_line "$log_file" "等待前端就绪超时")" + clear_wait_spinner + if [ -n "$WAIT_SPINNER_DETAIL" ]; then + log_note "$WAIT_SPINNER_DETAIL" + fi + close_wait_session_context "$owns_wait_session" + return 1 +} + wait_for_container_health() { local container_name="$1" local attempts="$2" local interval="$3" local service_name="$4" local attempt=1 - local status + local health_status + local wait_message="检查${service_name}容器健康检查" + local wait_detail_prefix="读取" + + set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (1/${attempts})" while [ "$attempt" -le "$attempts" ]; do - status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)" + health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)" - if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then + if [ "$health_status" = "healthy" ] || [ "$health_status" = "running" ]; then finish_wait_spinner "${service_name}容器健康检查已通过" return 0 fi - animate_wait_spinner "等待${service_name}容器健康检查通过" "$interval" + set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (${attempt}/${attempts})" + animate_wait_spinner "${wait_message}" "$interval" attempt=$((attempt + 1)) done @@ -277,6 +601,24 @@ wait_for_container_health() { return 1 } +fail_unreleased_port() { + local port="$1" + + if [ -z "$(collect_port_pids "$port" || true)" ]; then + log_error "端口 ${port} 当前环境内未发现占用进程,但端口仍不可用,请检查宿主机或外部环境占用" + else + log_error "端口 ${port} 清理失败,请检查占用进程" + if command -v lsof >/dev/null 2>&1; then + lsof -iTCP:"${port}" -sTCP:LISTEN -n -P 2>/dev/null || true + elif command -v ss >/dev/null 2>&1; then + ss -ltnp "( sport = :${port} )" 2>/dev/null || true + fi + fi + + exit 1 +} + +# Service startup helpers wait_for_database_health() { wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" && wait_for_container_health "planet_redis" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "Redis" @@ -298,12 +640,17 @@ start_postgres_service() { docker start planet_postgres >/dev/null 2>&1 || compose_up up -d postgres >/dev/null 2>&1 } +# Backend lifecycle helpers +cleanup_backend_processes() { + pkill -f "uvicorn" 2>/dev/null || true +} + start_backend_with_retry() { local backend_port="$1" local retry=1 while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do - pkill -f "uvicorn" 2>/dev/null || true + cleanup_backend_processes cd "$SCRIPT_DIR/backend" PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 & BACKEND_PID=$! @@ -324,8 +671,7 @@ start_backend_with_retry() { start_ai_provider_service() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" local retry=1 - - log_step "启动 AI Provider" + set_wait_detail "启动 AI Provider" while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do if docker start planet_aiprovider >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then @@ -351,30 +697,6 @@ start_ai_provider_service() { exit 1 } -start_frontend_with_retry() { - local frontend_port="$1" - local retry=1 - - while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do - pkill -f "vite" 2>/dev/null || true - pkill -f "bun run dev" 2>/dev/null || true - cd "$SCRIPT_DIR/frontend" - nohup bun run dev --port "$frontend_port" > /tmp/planet_frontend.log 2>&1 & - FRONTEND_PID=$! - - if wait_for_http "http://localhost:${frontend_port}" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL" "前端"; then - return 0 - fi - - kill "$FRONTEND_PID" 2>/dev/null || true - animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL" - retry=$((retry + 1)) - done - - clear_wait_spinner - return 1 -} - ensure_database_services_healthy() { local retry=1 @@ -418,8 +740,7 @@ ensure_postgres_service_healthy() { restart_database_service() { local retry=1 - - log_step "重启数据库" + set_wait_detail "重启数据库" while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do if restart_database_services && wait_for_database_health; then @@ -444,8 +765,9 @@ start_backend_service() { local backend_port_requested="$2" local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}" - log_step "启动数据库" + set_wait_detail "启动数据库" ensure_database_services_healthy + log_success "启动数据库已就绪" sleep 3 start_ai_provider_service "$ai_provider_port" @@ -454,7 +776,7 @@ start_backend_service() { kill_port_if_requested "$backend_port" "后端" fi - log_step "启动后端" + set_wait_detail "启动后端" ensure_uv_backend_deps if ! start_backend_with_retry "$backend_port"; then log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次" @@ -463,6 +785,210 @@ start_backend_service() { fi } +# Port / process helpers +validate_port() { + local port="$1" + + if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + log_error "非法端口: ${port}" + exit 1 + fi +} + +collect_port_pids() { + local port="$1" + local pids="" + local pid="" + + if command -v lsof >/dev/null 2>&1; then + pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)" + if [ -n "$pids" ]; then + printf "%s\n" "$pids" | awk '!seen[$0]++' + return 0 + fi + fi + + if command -v fuser >/dev/null 2>&1; then + pids="$(fuser "${port}/tcp" 2>/dev/null || true)" + if [ -n "$pids" ]; then + for pid in $pids; do + printf "%s\n" "$pid" + done | awk '!seen[$0]++' + return 0 + fi + fi + + if command -v ss >/dev/null 2>&1; then + pids="$(ss -ltnp "( sport = :${port} )" 2>/dev/null | sed -nE 's/.*pid=([0-9]+).*/\1/p' || true)" + if [ -n "$pids" ]; then + printf "%s\n" "$pids" | awk '!seen[$0]++' + return 0 + fi + fi + + return 1 +} + +terminate_process_tree() { + local signal="$1" + local pid="$2" + local child_pid + + [ -n "$pid" ] || return 0 + kill -0 "$pid" 2>/dev/null || return 0 + + if command -v pgrep >/dev/null 2>&1; then + while IFS= read -r child_pid; do + [ -n "$child_pid" ] || continue + terminate_process_tree "$signal" "$child_pid" + done < <(pgrep -P "$pid" 2>/dev/null || true) + fi + + kill "-${signal}" "$pid" 2>/dev/null || true +} + +can_bind_port() { + local port="$1" + python3 - "$port" <<'PY' >/dev/null 2>&1 +import socket +import sys + +port = int(sys.argv[1]) +sockets = [] +try: + for family, host in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")): + try: + sock = socket.socket(family) + sock.bind((host, port)) + sockets.append(sock) + except OSError: + raise +finally: + for sock in sockets: + try: + sock.close() + except OSError: + pass +PY +} + +wait_for_port_release() { + local port="$1" + local attempt=1 + + while [ "$attempt" -le "$PORT_RELEASE_ATTEMPTS" ]; do + if can_bind_port "$port"; then + return 0 + fi + sleep "$PORT_RELEASE_INTERVAL" + attempt=$((attempt + 1)) + done + + return 1 +} + +kill_port_if_requested() { + local port="$1" + local service_name="$2" + local pids="" + local pid="" + + log_warn "检测 ${service_name} 端口 ${port} 占用" + + pids="$(collect_port_pids "$port" || true)" + + if [ -z "$pids" ] && can_bind_port "$port"; then + log_success "端口 ${port} 未被占用" + return 0 + fi + + log_step "发现端口 ${port} 占用,正在终止" + for pid in $pids; do + terminate_process_tree TERM "$pid" + done + + if wait_for_port_release "$port"; then + log_success "端口 ${port} 已释放" + return 0 + fi + + log_warn "端口 ${port} 仍被占用,正在强制终止" + pids="$(collect_port_pids "$port" || true)" + for pid in $pids; do + terminate_process_tree KILL "$pid" + done + + if wait_for_port_release "$port"; then + log_success "端口 ${port} 已释放" + return 0 + fi + + fail_unreleased_port "$port" +} + +frontend_log_indicates_port_conflict() { + local log_file="$1" + + [ -f "$log_file" ] || return 1 + grep -q "Port .* is already in use" "$log_file" 2>/dev/null +} + +# Frontend lifecycle helpers +cleanup_frontend_processes() { + local frontend_port="${1:-$DEFAULT_FRONTEND_PORT}" + local tracked_pid="" + + if [ -f "$FRONTEND_PID_FILE" ]; then + tracked_pid="$(cat "$FRONTEND_PID_FILE" 2>/dev/null || true)" + if [ -n "$tracked_pid" ]; then + terminate_process_tree TERM "$tracked_pid" + sleep 1 + terminate_process_tree KILL "$tracked_pid" + fi + rm -f "$FRONTEND_PID_FILE" + fi + + pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite --port ${frontend_port} --strictPort" 2>/dev/null || true + pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite" 2>/dev/null || true + pkill -f "bun run dev --port ${frontend_port}" 2>/dev/null || true + pkill -f "bun run dev" 2>/dev/null || true +} + +start_frontend_with_retry() { + local frontend_port="$1" + local frontend_port_requested="${2:-0}" + local retry=1 + + while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do + cleanup_frontend_processes "$frontend_port" + if ! wait_for_port_release "$frontend_port"; then + fail_unreleased_port "$frontend_port" + fi + cd "$SCRIPT_DIR/frontend" + : > /tmp/planet_frontend.log + nohup "$FRONTEND_RUNTIME_BIN" run dev --port "$frontend_port" --strictPort > /tmp/planet_frontend.log 2>&1 & + FRONTEND_PID=$! + printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE" + + if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "/tmp/planet_frontend.log" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL"; then + return 0 + fi + + cleanup_frontend_processes "$frontend_port" + reset_wait_spinner_state + + if [ "$frontend_port_requested" -eq 1 ] && frontend_log_indicates_port_conflict "/tmp/planet_frontend.log"; then + kill_port_if_requested "$frontend_port" "前端" + fi + + animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL" + retry=$((retry + 1)) + done + + clear_wait_spinner + return 1 +} + start_frontend_service() { local frontend_port="$1" local frontend_port_requested="$2" @@ -471,51 +997,24 @@ start_frontend_service() { kill_port_if_requested "$frontend_port" "前端" fi - log_step "启动前端" + start_wait_session "检查前端依赖" ensure_frontend_deps - if ! start_frontend_with_retry "$frontend_port"; then + stop_wait_session + log_success "前端依赖已就绪" + + start_wait_session "启动前端服务" + set_wait_detail "启动 Vite 开发服务器" + if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then + stop_wait_session log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次" tail -10 /tmp/planet_frontend.log exit 1 fi + stop_wait_session + log_success "前端已就绪" } -validate_port() { - local port="$1" - - if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then - log_error "非法端口: ${port}" - exit 1 - fi -} - -kill_port_if_requested() { - local port="$1" - local service_name="$2" - - log_warn "检测 ${service_name} 端口 ${port} 占用" - - if command -v fuser >/dev/null 2>&1 && fuser "${port}/tcp" >/dev/null 2>&1; then - log_step "发现端口 ${port} 占用,正在终止" - fuser -k "${port}/tcp" >/dev/null 2>&1 || true - sleep 1 - return 0 - fi - - if command -v lsof >/dev/null 2>&1; then - local pids - pids="$(lsof -ti tcp:"${port}" 2>/dev/null || true)" - if [ -n "$pids" ]; then - log_step "发现端口 ${port} 占用,正在终止" - kill $pids 2>/dev/null || true - sleep 1 - return 0 - fi - fi - - log_success "端口 ${port} 未被占用" -} - +# Argument parsing / command helpers parse_service_args() { BACKEND_PORT="$DEFAULT_BACKEND_PORT" FRONTEND_PORT="$DEFAULT_FRONTEND_PORT" @@ -580,17 +1079,43 @@ cleanup_exit_containers() { fi } +stop_container_if_running() { + local container_name="$1" + local service_name="$2" + local container_status="" + + container_status="$(docker inspect --format '{{.State.Status}}' "$container_name" 2>/dev/null || true)" + if [ "$container_status" = "running" ]; then + docker stop "$container_name" >/dev/null 2>&1 || true + log_halt "${service_name}已停止" + fi +} + stop_backend_service() { - pkill -f "uvicorn" 2>/dev/null || true + if pgrep -f "uvicorn" >/dev/null 2>&1; then + cleanup_backend_processes + log_halt "后端服务已停止" + fi } stop_ai_provider_service() { - docker stop planet_aiprovider 2>/dev/null || true + stop_container_if_running "planet_aiprovider" "AI Provider" } stop_frontend_service() { - pkill -f "vite" 2>/dev/null || true - pkill -f "bun run dev" 2>/dev/null || true + if pgrep -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite|bun run dev" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then + cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT" + if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then + cleanup_frontend_processes "$FRONTEND_PORT" + fi + log_halt "前端服务已停止" + return 0 + fi + + cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT" + if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then + cleanup_frontend_processes "$FRONTEND_PORT" + fi } create_user() { @@ -698,21 +1223,21 @@ PY log_note "邮箱: ${email}" } +# Top-level commands start() { parse_service_args "$@" cleanup_exit_containers print_splash - log_step "启动智能星球计划" start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" - echo "" log_success "启动完成" - log_note "前端: http://localhost:${FRONTEND_PORT}" - log_note "后端: http://localhost:${BACKEND_PORT}" - log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}" + log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" + log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" + log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs" + log_note "智能星球AI: http://localhost:${AI_PROVIDER_PORT}/chat" } stop() { @@ -720,7 +1245,8 @@ stop() { stop_backend_service stop_ai_provider_service stop_frontend_service - docker stop planet_postgres planet_redis 2>/dev/null || true + stop_container_if_running "planet_postgres" "PostgreSQL" + stop_container_if_running "planet_redis" "Redis" log_success "已停止" } From 5639546990f137515fa336b804acfc925af113a5 Mon Sep 17 00:00:00 2001 From: linkong Date: Wed, 8 Apr 2026 17:48:32 +0800 Subject: [PATCH 11/34] fix: stabilize bgp overview table viewport --- VERSION | 2 +- docs/CHANGELOG.md | 20 +++++++ frontend/package.json | 2 +- frontend/src/index.css | 95 +++++++++++++++++++++++++++++++--- frontend/src/pages/BGP/BGP.tsx | 42 ++++++++++----- pyproject.toml | 2 +- uv.lock | 2 +- 7 files changed, 141 insertions(+), 24 deletions(-) diff --git a/VERSION b/VERSION index 9e40e75c..40a6dfed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.3 +0.23.4 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4e1ad2e1..74842b7c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,26 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.23.4 + +Released: 2026-04-08 + +### Highlights + +- Fixed the BGP overview workspace so summary cards and tabular data now share viewport space more predictably, keeping the table header visible while preserving internal horizontal and vertical scrolling. + +### Improved + +- Improved [BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) by switching BGP tables from frontend pagination to in-table scrolling, setting explicit horizontal scroll baselines per tab, and reshaping the summary cards into a desktop two-row layout with a compact single-row horizontal strip on tighter screens. +- Improved [index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by hardening the BGP table layout chain from card body to Ant Design table body, so width overflow stays inside the table region and compact summary cards no longer consume unnecessary vertical space above the workspace. +- Improved release consistency by aligning [VERSION](/home/ray/dev/linkong/planet/VERSION), [pyproject.toml](/home/ray/dev/linkong/planet/pyproject.toml), [frontend/package.json](/home/ray/dev/linkong/planet/frontend/package.json), and [uv.lock](/home/ray/dev/linkong/planet/uv.lock) on the same `0.23.4` bugfix version. + +### Fixed + +- Fixed the BGP collector/events tables so shrinking the browser window no longer clips the table card frame without exposing a usable horizontal scrollbar. +- Fixed the BGP table region so the Ant Design header row is no longer obscured by an overgrown table body after the page switched to full-height internal scrolling. +- Fixed the BGP summary area so medium and large screens no longer collapse the six KPI cards into overly narrow single-row tiles. + ## 0.23.3 Released: 2026-04-08 diff --git a/frontend/package.json b/frontend/package.json index f0bae64d..6b306618 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.23.3", + "version": "0.23.4", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/src/index.css b/frontend/src/index.css index d426010e..606c1ec5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -495,16 +495,24 @@ body { gap: 10px; } -.bgp-page__body > .ant-space { +.bgp-page__body > .ant-space, +.bgp-page__stack { flex: 1 1 auto; display: flex; flex-direction: column; overflow: hidden; + min-width: 0; min-height: 0; } -.bgp-page__body > .ant-space > .ant-space-item:last-child { +.bgp-page__stack > .ant-space-item:first-child, +.bgp-page__stack > .ant-space-item:nth-child(2) { + flex: 0 0 auto; +} + +.bgp-page__stack > .ant-space-item:last-child { flex: 1 1 auto; + min-width: 0; min-height: 0; display: flex; } @@ -549,21 +557,56 @@ body { line-height: 1; } +.bgp-page__summary-grid--compact { + flex-wrap: nowrap !important; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 4px; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.82) transparent; +} + +.bgp-page__summary-grid--compact::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.bgp-page__summary-grid--compact::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.82); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.bgp-page__summary-grid--compact::-webkit-scrollbar-track { + background: transparent; +} + .bgp-page__table-card, .bgp-page__table-card .ant-card-body { + min-width: 0; min-height: 0; + width: 100%; height: 100%; } +.bgp-page__table-card { + flex: 1 1 55%; + min-height: 55%; +} + .bgp-page__table-card .ant-card-body { display: flex; flex-direction: column; padding: 10px 12px; + overflow: hidden; } .bgp-page__table-region { flex: 1 1 auto; + min-width: 0; min-height: 0; + width: 100%; height: 100%; } @@ -571,7 +614,9 @@ body { .bgp-page__tabs .ant-tabs-content-holder, .bgp-page__tabs .ant-tabs-content, .bgp-page__tabs .ant-tabs-tabpane { + min-width: 0; min-height: 0; + width: 100%; height: 100%; } @@ -587,16 +632,50 @@ body { .bgp-page__tabs .ant-tabs-content-holder { flex: 1 1 auto; + overflow: hidden; } .bgp-page__tabs .ant-tabs-tabpane { display: flex; flex-direction: column; + overflow: hidden; } .bgp-page__tabs .ant-table-wrapper { flex: 1 1 auto; + min-width: 0; min-height: 0; + width: 100%; +} + +.bgp-page__tabs .ant-spin-nested-loading, +.bgp-page__tabs .ant-spin-container, +.bgp-page__tabs .ant-table, +.bgp-page__tabs .ant-table-container { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; +} + +.bgp-page__tabs .ant-table-container, +.bgp-page__tabs .ant-table-content, +.bgp-page__tabs .ant-table-body { + overflow-x: auto !important; +} + +.bgp-page__tabs .ant-table-header { + flex: 0 0 auto; +} + +.bgp-page__tabs .ant-table-body { + flex: 1 1 auto; + min-height: 0; + height: 0 !important; + max-height: none !important; } .bgp-page--compact .page-shell__body { @@ -612,21 +691,21 @@ body { } .bgp-page--compact .bgp-page__summary-card .ant-card-body { - padding: 8px 10px; + padding: 10px 12px; } .bgp-page--compact .bgp-page__summary-item { - min-height: 56px; - padding: 6px 8px; + min-height: 64px; + padding: 8px 10px; } .bgp-page--compact .bgp-page__summary-label { - margin-bottom: 2px; - font-size: 11px; + margin-bottom: 4px; + font-size: 12px; } .bgp-page--compact .bgp-page__summary-stat .ant-statistic-content { - font-size: 20px; + font-size: 24px; } .bgp-page--compact .bgp-page__table-card .ant-card-body { diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index 530dd071..bb62cac1 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -65,7 +65,7 @@ function BGP() { useEffect(() => { const updateViewportMode = () => { if (typeof window === 'undefined') return - const compact = window.devicePixelRatio >= 1.5 || window.innerHeight <= 860 || window.innerWidth <= 1440 + const compact = window.innerHeight <= 860 || window.innerWidth <= 1440 setCompactViewport(compact) } @@ -112,7 +112,12 @@ function BGP() {
- + - + {summaryItems.map((item) => ( - +
{item.label}
@@ -146,8 +161,8 @@ function BGP() { rowKey="collector" loading={loading} dataSource={collectors} - pagination={{ pageSize: compactViewport ? 6 : 8 }} - scroll={{ x: 'max-content', y: tableHeight }} + pagination={false} + scroll={{ x: 1240, y: tableHeight }} tableLayout="fixed" columns={[ { @@ -191,6 +206,7 @@ function BGP() { { 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(' / ') || '' @@ -209,8 +225,8 @@ function BGP() { rowKey="id" loading={loading} dataSource={incidents} - pagination={{ pageSize: compactViewport ? 6 : 8 }} - scroll={{ x: 'max-content', y: tableHeight }} + pagination={false} + scroll={{ x: 1660, y: tableHeight }} tableLayout="fixed" columns={[ { @@ -280,6 +296,7 @@ function BGP() { { title: '摘要', dataIndex: 'summary', + width: 320, }, ]} /> @@ -293,8 +310,8 @@ function BGP() { rowKey="id" loading={loading} dataSource={anomalies} - pagination={{ pageSize: compactViewport ? 8 : 10 }} - scroll={{ x: 'max-content', y: tableHeight }} + pagination={false} + scroll={{ x: 1380, y: tableHeight }} tableLayout="fixed" columns={[ { @@ -348,6 +365,7 @@ function BGP() { { title: '摘要', dataIndex: 'summary', + width: 320, }, ]} /> @@ -361,8 +379,8 @@ function BGP() { rowKey="id" loading={loading} dataSource={events} - pagination={{ pageSize: compactViewport ? 6 : 8 }} - scroll={{ x: 'max-content', y: tableHeight }} + pagination={false} + scroll={{ x: 980, y: tableHeight }} tableLayout="fixed" columns={[ { diff --git a/pyproject.toml b/pyproject.toml index 9833365e..ccf57a26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.23.2" +version = "0.23.4" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 73a9107b..73db0fef 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.23.2" +version = "0.23.4" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From ef65acd49cccb751b52e35355eacc79860156e66 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Thu, 9 Apr 2026 00:35:38 +0800 Subject: [PATCH 12/34] Add AI playground and frontend layout guidelines --- README.md | 16 + VERSION | 2 +- docker-compose.yml | 2 + docs/CHANGELOG.md | 29 ++ docs/ai-playground-development-plan.md | 318 +++++++++++++ docs/frontend-layout-guidelines.md | 168 +++++++ frontend/package.json | 2 +- frontend/src/App.tsx | 2 + .../src/components/AppLayout/AppLayout.tsx | 2 + frontend/src/index.css | 448 ++++++++++++++++++ frontend/src/pages/Playground/Playground.tsx | 350 ++++++++++++++ planet.sh | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 14 files changed, 1340 insertions(+), 5 deletions(-) create mode 100644 docs/ai-playground-development-plan.md create mode 100644 docs/frontend-layout-guidelines.md create mode 100644 frontend/src/pages/Playground/Playground.tsx diff --git a/README.md b/README.md index 1df787fb..3fe1507d 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,22 @@ AI_PROVIDER_SERVICE_TOKEN=change_me - [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) - [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md) +- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) +- [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) + +## 前端页面布局规范 + +管理后台页面默认遵循“单屏工作区”原则: + +- 页头、摘要区、主工作区应在一屏内形成稳定结构 +- 主表格 / 主图表 / 主分析区应占据页面主要可视空间 +- 模块内容超出时优先在卡片、表格、标签页内部滚动 +- 不依赖整页纵向撑开来容纳主要工作区 + +当前推荐参考实现: + +- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) +- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) ## License diff --git a/VERSION b/VERSION index 40a6dfed..2094a100 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.4 +0.24.0 diff --git a/docker-compose.yml b/docker-compose.yml index 89ab58ce..329ecff7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: . dockerfile: aiprovider/Dockerfile + env_file: + - ./aiprovider/.env container_name: planet_aiprovider ports: - "8010:8010" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 74842b7c..ea2a10cb 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.0 + +Released: 2026-04-09 + +### Highlights + +- Added a dedicated `AI Playground` admin entry so operators can validate provider connectivity and run controlled situational-analysis prompts from the main frontend without introducing a separate UI service. +- Established a first explicit frontend layout rulebook centered on single-screen workspaces, internal module scrolling, and BGP-style page composition for future admin pages. + +### Added + +- Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance. +- Added [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling. +- Added [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion. + +### Improved + +- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx) and [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx) by wiring `AI Playground` into the main admin navigation and route tree instead of pointing operators to a nonexistent `aiprovider` chat page. +- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by replacing the incorrect `localhost:8010/chat` closeout link with the frontend `AI Playground` entry. +- Improved [docker-compose.yml](/home/ray/dev/linkong/planet/docker-compose.yml) by attaching `./aiprovider/.env` to the `aiprovider` service so provider identity, model, and credentials actually reach the running container. +- Improved [README.md](/home/ray/dev/linkong/planet/README.md) by linking the new frontend layout guidance and AI Playground development plan. +- Improved [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by refining the Playground workspace into a notebook-friendly left-sidebar plus right-tabbed layout, reusing thin scrollbars, and making provider/help/result regions degrade more gracefully under constrained height. + +### Fixed + +- Fixed the local AI status flow so provider configuration no longer appeared permanently `disabled / not configured` merely because `aiprovider/.env` was not mounted into the container. +- Fixed repeated `Provider 状态` refetching when switching away from and back to `/playground` by caching the last known provider status within the browser session until the operator explicitly refreshes it. +- Fixed several Playground layout regressions where auxiliary panels could push the result area out of view or clip provider details without exposing internal scrolling. + ## 0.23.4 Released: 2026-04-08 diff --git a/docs/ai-playground-development-plan.md b/docs/ai-playground-development-plan.md new file mode 100644 index 00000000..6568da12 --- /dev/null +++ b/docs/ai-playground-development-plan.md @@ -0,0 +1,318 @@ +# AI Playground Development Plan + +## 目标 + +这份计划用于统一 `aiprovider`、`backend AI facade`、`Playground` 页面,以及后续 `BGP / 告警 / 数据源健康` 等 AI 入口的演进方向。 + +当前原则: + +- `aiprovider` 继续作为独立模型网关 +- `backend` 继续作为稳定业务入口 +- `frontend` 负责测试台和业务 UI +- 先做“可控、可验证、可解释”的 AI 能力,再逐步引入 agent/tool calling + +## 当前已完成 + +### 1. AI 网关基础层 + +已完成: + +- 独立 `aiprovider` 服务 +- `backend -> aiprovider -> model provider` 调用链 +- `provider/status` 与 `situational-awareness/analyze` 稳定接口 +- `X-Request-ID` 透传 +- 轻量超时与重试 +- MiniMax / Anthropic-compatible / OpenAI-compatible / Ollama 适配 + +相关文件: + +- [backend/app/api/v1/ai.py](/home/ray/dev/linkong/planet/backend/app/api/v1/ai.py) +- [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py) +- [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py) +- [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py) +- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) + +### 2. 本地运行与配置打通 + +已完成: + +- `planet.sh` 启动链路纳入 `aiprovider` +- `planet.sh` 启动完成后输出 Playground 入口 +- `docker-compose.yml` 为 `aiprovider` 加入 `env_file` +- `backend/.env` 与 `aiprovider/.env` 两侧 service token 对齐 +- `Playground` 状态缓存,避免页面切换时每次都重新请求 provider 状态 + +相关文件: + +- [planet.sh](/home/ray/dev/linkong/planet/planet.sh) +- [docker-compose.yml](/home/ray/dev/linkong/planet/docker-compose.yml) +- [backend/.env.example](/home/ray/dev/linkong/planet/backend/.env.example) +- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) + +### 3. Playground UI 基础版 + +已完成: + +- 新增前端路由 `/playground` +- 左侧 `Provider 状态 + 测试说明` +- 右侧 `请求 / 结果` Tabs +- `Provider 状态` 支持手动刷新 +- `测试说明` 支持折叠 +- 内部区域采用细滚动条 +- 页面布局开始遵循“单屏工作区 + 模块内部滚动”规范 + +相关文件: + +- [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) +- [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx) +- [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx) +- [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) + +### 4. 前端布局规范沉淀 + +已完成: + +- 把“一屏工作区、主模块优先、模块内部滚动”的规范文档化 +- 明确 `BGP` 页面为当前参考实现 + +相关文件: + +- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) +- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) + +## 当前限制 + +### 1. Playground 还是 prompt playground,不是 agent playground + +当前 `Playground` 的 `观察项 / 目标 / 约束条件` 都是人工输入。 + +模型现在拿到的是: + +- 你手工输入的结构化字段 +- 后端传递的少量静态上下文 + +模型现在拿不到: + +- 实时 BGP 事件 +- 真实告警列表 +- 数据源健康状态 +- 自动检索结果 +- tool calling / skills / 自主取数 + +### 2. `situational-awareness/analyze` 还是通用提示词接口 + +当前更适合: + +- 测试链路 +- 测试模型输出风格 +- 验证不同 provider 是否正常返回 + +当前还不适合: + +- 直接当真实态势系统主入口 +- 让用户手工维护长期分析模板 +- 代替专用业务研判接口 + +### 3. 还没有可验证的真实业务输入注入 + +目前最缺的是: + +- 从业务系统自动整理“事实输入” +- 再把这些事实喂给 AI + +而不是继续让用户在 Playground 手工输入真实事件摘要。 + +## 短期计划 + +### Phase A: Playground 收敛为稳定测试台 + +目标: + +- 保持 Playground 简洁可用 +- 不再继续堆“高级参数” + +工作项: + +- 继续微调左侧 `Provider 状态` 与 `测试说明` 的空间策略 +- 保持 `请求 / 结果` 为单一主工作区 +- 不引入盲填式高级字段 +- 统一滚动条、卡片、溢出行为 + +完成标准: + +- 笔记本视口下依然可用 +- 各模块标题可见 +- 主要阅读区始终是右侧 Tabs + +### Phase B: BGP AI 简报 + +目标: + +- 不再依赖手工填写“观察项” +- 让系统自动把真实 BGP 数据注入 AI + +建议实现: + +- 新增专用后端接口,例如: + - `POST /api/v1/ai/bgp/brief` +- 后端自动读取: + - incidents summary + - anomalies + - recent events + - collector coverage summary +- 后端将结构化事实注入 `context / observations` +- 前端在 BGP 页面增加“生成 AI 简报” + +完成标准: + +- 用户不需要手工录入 BGP 观察项 +- AI 输出能明确区分“事实”和“研判” + +### Phase C: 告警 / 数据源健康 AI 简报 + +目标: + +- 复用同样模式,扩展到其他模块 + +建议入口: + +- `Alerts` 页面:异常与告警摘要 +- `DataSources` 页面:采集失败与健康状态总结 + +原则: + +- 每个业务页优先做“专用 AI 简报” +- 不优先做“万能大聊天框” + +## 中期计划 + +### 1. Assessment Layer + +目标: + +- 不只返回自由文本 +- 返回结构化的 assessment + +建议输出字段: + +- summary +- key_risks +- evidence +- recommendations +- confidence +- missing_data + +这样后续才能: + +- 持久化 +- 回看 +- 对比不同时间的 AI 结论 +- 在 Earth / Dashboard / BGP 页面稳定展示 + +### 2. Evidence-first Runtime + +目标: + +- 所有 AI 分析先取真实数据,再调模型 + +原则: + +- 先 evidence +- 再 prompt +- 最后才是自由生成 + +优先要做的不是更强聊天,而是: + +- 更稳定的数据注入 +- 更一致的事实模板 +- 更清晰的结果结构 + +### 3. 按页面提供专用入口 + +目标: + +- 让 AI 成为业务视图的一部分,而不是孤立 playground + +优先顺序建议: + +1. `BGP` AI 简报 +2. `Alerts` AI 简报 +3. `DataSources` 健康研判 +4. `Dashboard` 总览总结 + +## 长期计划 + +### 1. Tool Calling / Agent Runtime + +只有在以下基础稳定后再推进: + +- 数据源健康信号稳定 +- BGP / Alerts / Datasource evidence 注入稳定 +- assessment 结构稳定 + +长期可做能力: + +- AI 调用受控工具查询业务数据 +- AI 调用检索/web search 做外部验证 +- AI 生成建议而不是直接修改系统 +- 审核后触发受控动作 + +### 2. 受控动作与闭环 + +潜在方向: + +- 根据健康异常生成修复建议 +- 根据态势变化生成处理建议 +- 进入 review queue +- 审批后执行 +- 验证结果并形成闭环 + +### 3. 多模块统一 AI 体验 + +长期目标不是一个孤立 Playground,而是: + +- 每个业务页都有自己的 AI 入口 +- 共享统一的 backend AI facade +- 共享统一的 assessment 结构 +- 共享统一的 evidence 注入与审计链路 + +## 设计决策总结 + +### 为什么保留 `aiprovider` + +因为它已经很好地承担了: + +- provider 适配 +- 协议兼容 +- service token 边界 +- 独立重启与部署 + +因此短期内不建议把它并回 `backend`。 + +### 为什么 Playground 不做成万能聊天页 + +因为当前更需要的是: + +- 稳定测试链路 +- 可验证业务输入 +- 专用分析入口 + +而不是一个泛化但没有真实数据支撑的聊天框。 + +### 为什么优先做专用 AI 简报 + +因为: + +- 数据可以自动注入 +- 用户心智更清晰 +- 输出更容易结构化 +- 更容易校验事实与研判是否一致 + +## 下一步建议 + +按优先级建议接下来这样做: + +1. 稳住 `Playground` 当前布局,不再大幅重做 +2. 在 `BGP` 页面新增专用 “AI 简报” 入口 +3. 后端新增 `BGP brief` 专用接口,自动注入真实数据 +4. 把 AI 输出逐步从自由文本升级为结构化 assessment diff --git a/docs/frontend-layout-guidelines.md b/docs/frontend-layout-guidelines.md new file mode 100644 index 00000000..bf35c337 --- /dev/null +++ b/docs/frontend-layout-guidelines.md @@ -0,0 +1,168 @@ +# Frontend Layout Guidelines + +本项目后台页面默认遵循“单屏工作区”布局规范。目标不是让页面永远不溢出,而是确保在常见桌面视口下: + +- 页面主结构能在一屏内看清 +- 用户能同时看到页头、摘要区和主工作区 +- 超出的内容在模块内部滚动,而不是把整页纵向撑爆 + +当前推荐参考实现: + +- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) +- [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) + +## 核心原则 + +### 1. 页面优先保证一屏工作区 + +管理页默认采用: + +- 页头:标题、说明、主要操作 +- 主工作区:统计卡、表格、图表、列表、标签页 + +推荐结构: + +```tsx + +
+
...
+
...
+
+
+``` + +页面总高度应被限制在 `AppLayout` 内容区内,而不是继续让整个页面自然向下增长。 + +### 2. 滚动优先发生在模块内部 + +如果表格、日志、长列表、图表明细超出空间: + +- 让卡片内部滚动 +- 让表格内部滚动 +- 让标签页内容区内部滚动 + +不要默认依赖整个页面滚动去“解决”空间问题。 + +### 3. 主工作区必须拿到主要空间 + +页面里最重要的模块必须是视觉和空间上的主角。通常应保证: + +- 页头始终可见 +- 摘要区高度被控制 +- 主表格 / 主图表 / 主分析区占据 50% 以上可视高度 + +如果一个页面有多个大模块,优先顺序是: + +1. 先压缩说明区和摘要区 +2. 再把次级模块收进标签页或切换视图 +3. 最后才考虑继续增加整页滚动 + +### 4. 小屏幕和高缩放必须进入紧凑模式 + +在窗口高度较低、宽度较窄、或系统缩放较高时,应主动切换紧凑布局,例如: + +- 缩小卡片 padding +- 缩小表头和单元格间距 +- 将摘要区改为更紧凑的单行/横向滚动布局 +- 将次级模块移入标签页、抽屉、折叠区 + +紧凑模式的目标是保持可用,不是单纯把文字和控件一股脑缩小。 + +### 5. overflow 责任必须明确 + +页面中的大块内容必须明确: + +- 谁负责占满剩余高度 +- 谁负责裁剪 +- 谁负责滚动 + +常见要求: + +- 父容器链路需要 `min-height: 0` +- 工作区容器通常需要 `display: flex` +- 真正的滚动节点要显式 `overflow: auto` + +## 推荐实现模式 + +### 页面骨架 + +优先复用项目里已有的通用结构: + +- `.dashboard-content-inner` +- `.page-shell` +- `.page-shell__header` +- `.page-shell__body` +- `.table-scroll-region` + +不要每个页面都重新发明一套完全不同的高度和滚动语义。 + +### 表格工作区 + +推荐模式: + +```tsx + +
+ + + +``` + +要求: + +- 表格尽量在卡片内部滚动 +- `scroll.y` 应来自实际可用高度估算,而不是完全静态的魔法数字 +- 父容器链路要保证 header、body、content 的 overflow 都在表格内部闭合 + +### 多模块页面 + +如果一个页面同时有: + +- 摘要卡 +- 表格 +- 异常明细 +- 最近事件 + +不建议简单纵向堆叠全部模块。优先使用: + +- 顶部摘要 + 底部单一主工作区 +- 标签页切换多个次级数据视图 +- 左右分栏,并保证每栏内部独立滚动 + +## 不推荐的做法 + +以下模式默认视为不符合本项目页面规范: + +- 依赖整页纵向滚动来显示主要工作区 +- 一个页面纵向堆 3 到 4 个大卡片,每个都想完整展示 +- 表格没有内部滚动,导致缩放后只能看到 1 到 2 行数据 +- 父容器缺少 `min-height: 0`,导致内部滚动失效 +- 只做视觉缩小,不处理真正的空间分配 + +## 页面验收检查清单 + +提交前至少检查: + +- 页头、摘要区、主工作区能否同时出现 +- 主工作区是否拿到了页面中最多的高度 +- 表格或明细溢出时,滚动条是否出现在模块内部 +- 浏览器缩放到 `125%` / `150%` 时是否仍可用 +- 低高度窗口下是否还保有合理的可见内容行数 +- Tabs、Card、Table 在 overflow 时是否仍可操作 + +## 落地顺序 + +后续新增或重构后台页时,优先按这个顺序设计: + +1. 先定义主工作区 +2. 再确定哪些模块必须常驻可见 +3. 最后再做样式和视觉层次 + +简单说: + +- 先保证空间分配正确 +- 再处理滚动边界 +- 最后再做美化 diff --git a/frontend/package.json b/frontend/package.json index 6b306618..3e9f19df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.23.4", + "version": "0.24.0", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d4457f0a..a5a8c337 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import DataList from './pages/DataList/DataList' import Earth from './pages/Earth/Earth' import Settings from './pages/Settings/Settings' import BGP from './pages/BGP/BGP' +import Playground from './pages/Playground/Playground' function App() { const { token } = useAuthStore() @@ -26,6 +27,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 83fe521e..1820a7d9 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -7,6 +7,7 @@ import { SettingOutlined, BarChartOutlined, DeploymentUnitOutlined, + RobotOutlined, MenuUnfoldOutlined, MenuFoldOutlined, } from '@ant-design/icons' @@ -34,6 +35,7 @@ function AppLayout({ children }: AppLayoutProps) { { key: '/datasources', icon: , label: '数据源' }, { key: '/data', icon: , label: '采集数据' }, { key: '/bgp', icon: , label: 'BGP观测' }, + { key: '/playground', icon: , label: 'AI Playground' }, { key: '/users', icon: , label: '用户管理' }, { key: '/settings', icon: , label: '系统配置' }, ] diff --git a/frontend/src/index.css b/frontend/src/index.css index 606c1ec5..efcce359 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -183,6 +183,454 @@ body { min-height: 0; } +.playground-page__grid { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + align-items: stretch; +} + +.playground-page__body { + overflow: hidden; +} + +.playground-shell { + flex: 1 1 auto; + min-height: 0; + display: flex; + gap: 12px; +} + +.playground-shell__sidebar { + flex: 0 0 340px; + min-width: 300px; + max-width: 360px; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 12px; +} + +.playground-card { + width: 100%; + min-height: 0; + display: flex; + flex-direction: column; + border-radius: 16px; +} + +.playground-card .ant-card-head { + min-height: 56px; + padding: 0 20px; + flex: 0 0 auto; +} + +.playground-card .ant-card-head-title { + padding: 14px 0; +} + +.playground-card__icon-button { + color: #64748b; +} + +.playground-card__icon-button:hover { + color: #1677ff !important; + background: rgba(22, 119, 255, 0.08) !important; +} + +.playground-card .ant-card-body { + flex: 1 1 auto; + min-height: 0; + padding: 16px 20px 14px; +} + +.playground-card__scroll { + height: 100%; + overflow: auto; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.88) transparent; +} + +.playground-card__scroll::-webkit-scrollbar, +.playground-result__content::-webkit-scrollbar, +.playground-result__blocks-scroll::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.playground-card__scroll::-webkit-scrollbar-thumb, +.playground-result__content::-webkit-scrollbar-thumb, +.playground-result__blocks-scroll::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.82); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.playground-card__scroll::-webkit-scrollbar-thumb:hover, +.playground-result__content::-webkit-scrollbar-thumb:hover, +.playground-result__blocks-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.9); + background-clip: padding-box; +} + +.playground-card__scroll::-webkit-scrollbar-track, +.playground-result__content::-webkit-scrollbar-track, +.playground-result__blocks-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.playground-card--provider { + flex: 1 1 auto; + min-height: 180px; +} + +.playground-card--provider .ant-card-body { + overflow: hidden; +} + +.playground-card--help { + flex: 1 1 auto; +} + +.playground-card--workspace { + flex: 1 1 auto; +} + +.playground-card--workspace .ant-card-body, +.playground-card--result .ant-card-body { + overflow: hidden; +} + +.playground-tabs, +.playground-tabs .ant-tabs-content-holder, +.playground-tabs .ant-tabs-content, +.playground-tabs .ant-tabs-tabpane { + height: 100%; + min-height: 0; +} + +.playground-tabs { + display: flex; + flex-direction: column; +} + +.playground-tabs .ant-tabs-nav { + flex: 0 0 auto; + margin-bottom: 12px; +} + +.playground-tabs .ant-tabs-content-holder { + flex: 1 1 auto; + overflow: hidden; +} + +.playground-tabpane { + height: 100%; + min-height: 0; + overflow: auto; + padding-right: 2px; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.88) transparent; +} + +.playground-tabpane::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.playground-tabpane::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.82); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.playground-tabpane::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.9); + background-clip: padding-box; +} + +.playground-tabpane::-webkit-scrollbar-track { + background: transparent; +} + +.playground-form__grid { + display: grid; + gap: 12px; +} + +.playground-form__grid--bottom { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.playground-form__grid .ant-form-item { + min-width: 0; +} + +.playground-tabpane .ant-form { + padding-bottom: 4px; +} + +.playground-form__actions { + display: flex; + gap: 12px; +} + +.playground-card--request .ant-form-item { + margin-bottom: 8px; +} + +.playground-card--request .ant-form-item-label { + padding-bottom: 4px; +} + +.playground-card--request .ant-form-item-label > label { + font-size: 13px; +} + +.playground-card--result { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; +} + +.playground-card--result .ant-card-body { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +.playground-provider-panel { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.playground-kv { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + align-items: start; + column-gap: 14px; + row-gap: 4px; +} + +.playground-kv > .ant-typography:first-child { + flex: 0 0 auto; + white-space: nowrap; +} + +.playground-kv > .ant-typography:last-child, +.playground-kv .playground-status-tags { + min-width: 0; + justify-self: end; + text-align: right; +} + +.playground-card--provider .ant-card-body { + padding-top: 18px; + padding-bottom: 16px; +} + +.playground-card--provider .playground-kv + .playground-kv { + margin-top: 2px; +} + +.playground-kv--top { + align-items: start; +} + +.playground-status-tags { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.playground-code-block { + white-space: pre-wrap; + word-break: break-all; +} + +.playground-note { + border-radius: 12px; +} + +.playground-help { + flex: 0 0 auto; + min-height: 0; + border-radius: 16px; + overflow: hidden; +} + +.playground-help--collapsed { + flex: 0 0 auto; +} + +.playground-help--expanded { + flex: 1 0 auto; +} + +.playground-help.ant-collapse { + display: flex; + flex-direction: column; +} + +.playground-help > .ant-collapse-item { + display: flex; + flex-direction: column; + min-height: 0; +} + +.playground-help .ant-collapse-header { + padding: 14px 20px !important; + align-items: center !important; + flex: 0 0 auto; +} + +.playground-help .ant-collapse-content { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.playground-help .ant-collapse-content-box { + height: 100%; + overflow: auto; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.88) transparent; + padding: 12px 16px 16px !important; +} + +.playground-help .ant-collapse-content-box::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.playground-help .ant-collapse-content-box::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.82); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.playground-help .ant-collapse-content-box::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.9); + background-clip: padding-box; +} + +.playground-help .ant-collapse-content-box::-webkit-scrollbar-track { + background: transparent; +} + +.playground-note.ant-alert { + padding: 10px 12px; +} + +.playground-note .ant-alert-icon { + margin-inline-end: 10px; + font-size: 16px; +} + +.playground-note .ant-alert-message { + font-size: 13px; + font-weight: 600; + line-height: 1.5; +} + +.playground-note .ant-alert-description { + font-size: 12px; + margin-top: 6px; + line-height: 1.5; +} + +.playground-result__loading { + flex: 1 1 auto; + min-height: 180px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; +} + +.playground-result__stack { + flex: 1 1 auto; + min-height: 0; +} + +.playground-result__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.playground-result__content, +.playground-result__blocks .ant-card { + border-radius: 14px; + background: #0f172a; + color: #e2e8f0; +} + +.playground-result__content { + flex: 1 1 auto; + min-height: 0; + padding: 16px; + overflow: auto; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.88) transparent; +} + +.playground-result__content pre, +.playground-result__blocks pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + line-height: 1.6; +} + +.playground-result__blocks { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.playground-result__blocks-scroll { + max-height: 180px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.88) transparent; +} + +@media (max-width: 1200px) { + .playground-page__header { + align-items: flex-start; + } + + .playground-form__grid--bottom { + grid-template-columns: 1fr; + } + + .playground-shell { + flex-direction: column; + } + + .playground-shell__sidebar { + flex: 0 0 auto; + min-width: 0; + max-width: none; + } +} + .data-source-tabs-shell, .data-source-tabs, diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx new file mode 100644 index 00000000..684b540e --- /dev/null +++ b/frontend/src/pages/Playground/Playground.tsx @@ -0,0 +1,350 @@ +import { useEffect, useState } from 'react' +import axios from 'axios' +import { SyncOutlined } from '@ant-design/icons' +import { + Alert, + Button, + Card, + Collapse, + Form, + Input, + Space, + Spin, + Tabs, + Tag, + Typography, + message, +} from 'antd' +import AppLayout from '../../components/AppLayout/AppLayout' + +const { Title, Text } = Typography +const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1' +const PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY = 'playground-provider-status' + +interface AIProviderStatus { + provider: string + api?: string | null + enabled: boolean + configured: boolean + model?: string | null + base_url?: string | null +} + +interface AIContentBlock { + type: string + text?: string | null + thinking?: string | null +} + +interface SituationalAnalysisResponse { + provider: string + model: string + content: string + content_blocks: AIContentBlock[] + text_blocks: string[] + thinking_blocks: string[] + raw_response: Record +} + +interface PlaygroundFormValues { + title: string + objective: string + observations?: string + constraints?: string +} + +function splitLines(value?: string) { + return (value || '') + .split('\n') + .map((item) => item.trim()) + .filter(Boolean) +} + +function Playground() { + const [form] = Form.useForm() + const [messageApi, contextHolder] = message.useMessage() + const [statusLoading, setStatusLoading] = useState(false) + const [analyzing, setAnalyzing] = useState(false) + const [providerStatus, setProviderStatus] = useState(null) + const [analysis, setAnalysis] = useState(null) + const [activeTab, setActiveTab] = useState<'request' | 'result'>('request') + const [helpExpanded, setHelpExpanded] = useState(true) + + const loadProviderStatus = async (force = false) => { + if (!force) { + const cached = sessionStorage.getItem(PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY) + if (cached) { + try { + setProviderStatus(JSON.parse(cached) as AIProviderStatus) + return + } catch { + sessionStorage.removeItem(PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY) + } + } + } + + setStatusLoading(true) + try { + const res = await axios.get(`${API_BASE_URL}/ai/provider/status`) + setProviderStatus(res.data) + sessionStorage.setItem(PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY, JSON.stringify(res.data)) + } catch { + messageApi.error('AI Provider 状态获取失败') + } finally { + setStatusLoading(false) + } + } + + useEffect(() => { + void loadProviderStatus() + }, []) + + const handleAnalyze = async (values: PlaygroundFormValues) => { + setAnalyzing(true) + setActiveTab('result') + try { + const res = await axios.post( + `${API_BASE_URL}/ai/situational-awareness/analyze`, + { + title: values.title.trim(), + objective: values.objective.trim(), + observations: splitLines(values.observations), + constraints: splitLines(values.constraints), + context: { + source: 'playground', + }, + }, + ) + setAnalysis(res.data) + messageApi.success('分析已完成') + } catch { + messageApi.error('分析失败,请检查 AI Provider 配置或稍后再试') + setActiveTab('request') + } finally { + setAnalyzing(false) + } + } + + return ( + + {contextHolder} +
+
+
+ AI Playground + + 在前端测试 AI 能力,但实际请求仍统一经由主后端转发到 AI Provider。 + +
+
+ +
+
+
+ } + onClick={() => void loadProviderStatus(true)} + aria-label="刷新 Provider 状态" + className="playground-card__icon-button" + /> + )} + > +
+
+ + {providerStatus ? ( +
+
+ Provider + {providerStatus.provider || '-'} +
+
+ 模型 + {providerStatus.model || '-'} +
+
+ API + {providerStatus.api || '-'} +
+
+ 状态 +
+ + {providerStatus.enabled ? 'enabled' : 'disabled'} + + + {providerStatus.configured ? 'configured' : 'not configured'} + +
+
+
+ Base URL + + {providerStatus.base_url || '-'} + +
+
+ ) : ( + + )} +
+
+
+
+ + setHelpExpanded(Array.isArray(keys) ? keys.includes('help') : keys === 'help')} + items={[ + { + key: 'help', + label: '测试说明', + children: ( + + 当前链路: + frontend /playground + {' -> '} + backend /api/v1/ai/* + {' -> '} + aiprovider /v1/* + + )} + /> + ), + }, + ]} + /> +
+ + + setActiveTab(key as 'request' | 'result')} + className="playground-tabs" + items={[ + { + key: 'request', + label: '请求', + children: ( +
+
+ + + + + + + + +
+ + + + + + + +
+ +
+ + +
+ +
+ ), + }, + { + key: 'result', + label: '结果', + children: ( +
+ {analyzing ? ( +
+ + AI 正在生成结果... +
+ ) : analysis ? ( + +
+ {analysis.provider} + {analysis.model} +
+
+
{analysis.content}
+
+ {analysis.text_blocks.length ? ( +
+ 文本块 +
+ {analysis.text_blocks.map((block, index) => ( + +
{block}
+
+ ))} +
+
+ ) : null} +
+ ) : ( + + )} +
+ ), + }, + ]} + /> +
+
+
+
+
+ ) +} + +export default Playground diff --git a/planet.sh b/planet.sh index 712a7a27..7f7df5e8 100755 --- a/planet.sh +++ b/planet.sh @@ -1236,8 +1236,8 @@ start() { log_success "启动完成" log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" + log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground" log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs" - log_note "智能星球AI: http://localhost:${AI_PROVIDER_PORT}/chat" } stop() { diff --git a/pyproject.toml b/pyproject.toml index ccf57a26..32deb601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.23.4" +version = "0.24.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 73db0fef..7ef15c78 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.23.4" +version = "0.24.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From 34d94a6b6bfe788d7714f94456ec263afcca52ea Mon Sep 17 00:00:00 2001 From: rayd1o Date: Thu, 9 Apr 2026 00:37:02 +0800 Subject: [PATCH 13/34] Redirect login success to admin --- frontend/src/pages/Login/Login.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/pages/Login/Login.tsx b/frontend/src/pages/Login/Login.tsx index 8eccc5a9..74f07bda 100644 --- a/frontend/src/pages/Login/Login.tsx +++ b/frontend/src/pages/Login/Login.tsx @@ -1,10 +1,12 @@ import { useState } from 'react' import { Input, Button, Form, message } from 'antd' import { UserOutlined, LockOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router-dom' import { useAuthStore } from '../../stores/auth' function Login() { const [loading, setLoading] = useState(false) + const navigate = useNavigate() const { login } = useAuthStore() const onFinish = async (values: { username: string; password: string }) => { @@ -12,6 +14,7 @@ function Login() { try { await login(values.username, values.password) message.success('登录成功') + navigate('/admin', { replace: true }) } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } message.error(err.response?.data?.detail || '登录失败') From c4ea918fac2ca72b22e2de0f74d3e16f37838d44 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Thu, 9 Apr 2026 02:12:22 +0800 Subject: [PATCH 14/34] fix: refine earth hud structure and bun startup flow - refactor the /earth HUD into class-first CSS layers with dedicated base, hud, and toolbar responsibilities - clean up Earth markup and runtime DOM hooks so shared panel, toolbar, tooltip, and status classes stay consistent after dynamic updates - keep HUD scaling configurable through extracted constants and remove leftover legacy panel/toolbar styling paths - make planet.sh self-bootstrap Bun and uv by prepending local runtime bins to PATH and auto-installing missing tools in fresh environments - document Bun as the frontend package manager of record and sync repository version metadata to 0.24.1 --- README.md | 18 + VERSION | 2 +- docs/CHANGELOG.md | 31 + docs/version-history.md | 4 +- frontend/package.json | 3 +- frontend/public/earth/css/base.css | 834 ++---------------- .../public/earth/css/coordinates-display.css | 43 +- frontend/public/earth/css/earth-stats.css | 54 +- frontend/public/earth/css/hud.css | 170 ++++ frontend/public/earth/css/info-panel.css | 196 +--- frontend/public/earth/css/legend.css | 65 +- frontend/public/earth/css/toolbar.css | 498 +++++++++++ frontend/public/earth/index.html | 194 ++-- frontend/public/earth/js/constants.js | 7 + frontend/public/earth/js/controls.js | 6 +- frontend/public/earth/js/legend.js | 2 +- frontend/public/earth/js/main.js | 118 ++- frontend/public/earth/js/ui.js | 90 +- planet.sh | 90 +- project_context.md | 3 + pyproject.toml | 2 +- rules.md | 3 + scripts/bootstrap-dev.sh | 5 +- uv.lock | 2 +- 24 files changed, 1248 insertions(+), 1192 deletions(-) create mode 100644 frontend/public/earth/css/hud.css create mode 100644 frontend/public/earth/css/toolbar.css diff --git a/README.md b/README.md index 3fe1507d..303a67d9 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,13 @@ | Axios | HTTP 客户端 | | Socket.io-client | WebSocket 客户端 | | ECharts | 统计图表 | +| Bun | 前端包管理与脚本运行 | + +前端工程统一使用 Bun: + +- 安装依赖使用 `bun install` +- 运行脚本使用 `bun run + + - - + -
-
-

智能星球计划

+
+
+

智能星球计划

现实层宇宙全息感知系统 卫星 · 海底光缆 · 算力基础设施
- -
-
-
- - -
- -
- - - - -
- -
- -
- - 100%重置缩放到100% - +
+ + 100%重置缩放到100% +
- -
-
-

坐标信息

-
- 经度: - 0.00° +
+

坐标信息

+
+ 经度: + 0.00°
-
- 纬度: - 0.00° +
+ 纬度: + 0.00°
-
缩放: 1.0x
-
鼠标位置: 无
+
缩放: 1.0x
+
鼠标位置: 无
-
-

线缆图例

+
+

线缆图例

-
+
Americas II
-
+
AU Aleutian A
-
+
AU Aleutian B
-
+
其他电缆
-
-

地球信息

-
- 电缆系统: - 0个 +
+

地球信息

+
+ 电缆系统: + 0个
-
- 状态: - - +
+ 状态: + -
-
- 登陆点: - 0个 +
+ 登陆点: + 0个
-
- 地形: - 开启 +
+ 地形: + 开启
-
- 卫星: - 0 颗 +
+ 卫星: + 0 颗
-
- BGP事件: - 0 条 +
+ BGP事件: + 0 条
-
- 观测站: - 0 个 +
+ 观测站: + 0 个
-
- BGP态势: - 暂无观测数据 +
+ BGP态势: + 暂无观测数据
-
- 视角距离: - 300 km +
+ 视角距离: + 300 km
-
- 纹理质量: - 8K 卫星图 +
+ 纹理质量: + 8K 卫星图
-
-
-
正在初始化全球态势数据...
-
同步卫星、海底光缆、登陆点与BGP态势数据
+
+
+
正在初始化全球态势数据...
+
同步卫星、海底光缆、登陆点与BGP态势数据
- -
+
+
diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 05008c6b..e7a10b6b 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -12,6 +12,13 @@ export const CONFIG = { dragRotationScaleMax: 2.0, }; +export const HUD_CONFIG = { + scaleReferenceWidth: 1920, + scaleReferenceHeight: 1080, + minScale: 0.7, + maxScale: 1, +}; + // Earth coordinate constants export const EARTH_CONFIG = { tilt: 23.5, // earth tilt angle (degrees) diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index ea07bf13..22b58736 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -76,7 +76,7 @@ function setFloatingMenuOpen(group, shouldOpen) { } function setButtonTooltip(button, text) { - const tooltip = button?.querySelector(".tooltip"); + const tooltip = button?.querySelector(".earth-toolbar-tooltip"); if (tooltip) { tooltip.textContent = text; } @@ -566,7 +566,7 @@ function updateRotateUI() { if (btn) { btn.classList.toggle("active", autoRotate); btn.classList.toggle("is-stopped", !autoRotate); - const tooltip = btn.querySelector(".tooltip"); + const tooltip = btn.querySelector(".earth-toolbar-tooltip"); if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转"; } } @@ -599,7 +599,7 @@ function updateLayoutUI(container) { const btn = document.getElementById("layout-toggle"); if (btn) { btn.classList.toggle("active", layoutExpanded); - const tooltip = btn.querySelector(".tooltip"); + const tooltip = btn.querySelector(".earth-toolbar-tooltip"); const nextLabel = layoutExpanded ? "恢复布局" : "最大化布局"; btn.title = nextLabel; if (tooltip) tooltip.textContent = nextLabel; diff --git a/frontend/public/earth/js/legend.js b/frontend/public/earth/js/legend.js index 94e44535..45f9fc35 100644 --- a/frontend/public/earth/js/legend.js +++ b/frontend/public/earth/js/legend.js @@ -61,7 +61,7 @@ function renderLegend(mode) { .join(""); legend.innerHTML = ` -

${config.title}

+

${config.title}

${itemsHtml}
`; } diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 653be064..d757be95 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -1,7 +1,7 @@ import * as THREE from "three"; import { createNoise3D } from "simplex-noise"; -import { CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js"; +import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js"; import { vector3ToLatLon, screenToEarthCoords } from "./utils.js"; import { showStatusMessage, @@ -174,6 +174,7 @@ const cleanupFns = []; const DRAG_SMOOTHING_FACTOR = 0.18; const INERTIA_DAMPING = 0.92; const INERTIA_MIN_VELOCITY = 0.00008; +const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测"; const HUD_INTERACTIVE_SELECTORS = [ "#info-panel", "#info-panel *", @@ -195,6 +196,17 @@ function bindListener(target, eventName, handler, options) { ); } +function getViewportAspect() { + return window.innerWidth / window.innerHeight; +} + +function syncRendererViewport() { + if (!camera || !renderer) return; + camera.aspect = getViewportAspect(); + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +} + function isEventOnHud(event) { const target = event?.target; if (!(target instanceof Element)) return false; @@ -520,6 +532,47 @@ function showBGPCollectorInfo(marker) { }); } +function getBGPStatusText(bgpResult) { + if (bgpResult.totalCount > 0) { + return `${bgpResult.totalCount} 起活跃事件`; + } + if (bgpResult.anomalyCount > 0) { + return `${bgpResult.anomalyCount} 条活跃异常`; + } + return "当前无活跃事件"; +} + +function updateBGPHud(bgpResult) { + const bgpBtn = document.getElementById("toggle-bgp"); + if (bgpBtn) { + bgpBtn.classList.add("active"); + const tooltip = bgpBtn.querySelector(".earth-toolbar-tooltip"); + if (tooltip) { + tooltip.textContent = ACTIVE_BGP_TOOLTIP_TEXT; + } + } + + const bgpCountEl = document.getElementById("bgp-anomaly-count"); + if (bgpCountEl) { + bgpCountEl.textContent = `${bgpResult.totalCount} 起`; + } + + const bgpCollectorEl = document.getElementById("bgp-collector-count"); + if (bgpCollectorEl) { + bgpCollectorEl.textContent = `${bgpResult.collectorCount} 个`; + } + + const bgpStatusEl = document.getElementById("bgp-status-summary"); + if (bgpStatusEl) { + bgpStatusEl.textContent = getBGPStatusText(bgpResult); + } +} + +function clearSelectionAndInfo() { + clearLockedObject(); + hideInfoCard(); +} + function getBGPRelatedCableNames(marker) { const items = Array.isArray(marker?.userData?.related_cables) ? marker.userData.related_cables @@ -655,7 +708,7 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount()) const satBtn = document.getElementById("toggle-satellites"); if (satBtn) { satBtn.classList.toggle("active", enabled); - const tooltip = satBtn.querySelector(".tooltip"); + const tooltip = satBtn.querySelector(".earth-toolbar-tooltip"); if (tooltip) tooltip.textContent = enabled ? "隐藏卫星" : "显示卫星"; } @@ -669,7 +722,7 @@ function updateCableToggleUi(enabled) { const cableBtn = document.getElementById("toggle-cables"); if (cableBtn) { cableBtn.classList.toggle("active", enabled); - const tooltip = cableBtn.querySelector(".tooltip"); + const tooltip = cableBtn.querySelector(".earth-toolbar-tooltip"); if (tooltip) tooltip.textContent = enabled ? "隐藏线缆" : "显示线缆"; } @@ -799,11 +852,12 @@ export function init() { destroyed = false; initialized = true; simplex = createNoise3D(); + updateHudScale(); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera( 75, - window.innerWidth / window.innerHeight, + getViewportAspect(), 0.1, 1000, ); @@ -815,7 +869,7 @@ export function init() { alpha: false, powerPreference: "high-performance", }); - renderer.setSize(window.innerWidth, window.innerHeight); + syncRendererViewport(); renderer.setClearColor(0x0a0a1a, 1); renderer.setPixelRatio(window.devicePixelRatio); @@ -898,8 +952,7 @@ async function loadData(showWhiteSphere = false) { : "同步卫星、海底光缆、登陆点与BGP态势数据", ); setLoading(true); - clearLockedObject(); - hideInfoCard(); + clearSelectionAndInfo(); if (showWhiteSphere && earth.material) { earthTexture = earth.material.map; @@ -915,29 +968,7 @@ async function loadData(showWhiteSphere = false) { clearBGPData(earth); const bgpResult = await loadBGPAnomalies(scene, earth); toggleBGP(true); - const bgpBtn = document.getElementById("toggle-bgp"); - if (bgpBtn) { - bgpBtn.classList.add("active"); - const tooltip = bgpBtn.querySelector(".tooltip"); - if (tooltip) tooltip.textContent = "隐藏BGP观测"; - } - const bgpCountEl = document.getElementById("bgp-anomaly-count"); - if (bgpCountEl) { - bgpCountEl.textContent = `${bgpResult.totalCount} 起`; - } - const bgpCollectorEl = document.getElementById("bgp-collector-count"); - if (bgpCollectorEl) { - bgpCollectorEl.textContent = `${bgpResult.collectorCount} 个`; - } - const bgpStatusEl = document.getElementById("bgp-status-summary"); - if (bgpStatusEl) { - bgpStatusEl.textContent = - bgpResult.totalCount > 0 - ? `${bgpResult.totalCount} 起活跃事件` - : bgpResult.anomalyCount > 0 - ? `${bgpResult.anomalyCount} 条活跃异常` - : "当前无活跃事件"; - } + updateBGPHud(bgpResult); return bgpResult; })(), ]); @@ -997,8 +1028,7 @@ export async function setCablesEnabled(enabled) { } if (!enabled) { - clearLockedObject(); - hideInfoCard(); + clearSelectionAndInfo(); disableCables(); showStatusMessage("线缆已隐藏", "info"); return 0; @@ -1032,8 +1062,7 @@ export async function setSatellitesEnabled(enabled) { } if (!enabled) { - clearLockedObject(); - hideInfoCard(); + clearSelectionAndInfo(); disableSatellites(); return 0; } @@ -1078,11 +1107,24 @@ function setupEventListeners() { bindListener(renderer.domElement, "click", handleClick); } +function updateHudScale() { + const widthScale = window.innerWidth / HUD_CONFIG.scaleReferenceWidth; + const heightScale = window.innerHeight / HUD_CONFIG.scaleReferenceHeight; + const nextScale = THREE.MathUtils.clamp( + Math.min(widthScale, heightScale), + HUD_CONFIG.minScale, + HUD_CONFIG.maxScale, + ); + + document.documentElement.style.setProperty( + "--hud-scale", + nextScale.toFixed(3), + ); +} + function onWindowResize() { - if (!camera || !renderer) return; - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - renderer.setSize(window.innerWidth, window.innerHeight); + updateHudScale(); + syncRendererViewport(); } function getFrontFacingCables(cableLines) { diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js index d472a18f..480a4117 100644 --- a/frontend/public/earth/js/ui.js +++ b/frontend/public/earth/js/ui.js @@ -3,12 +3,18 @@ let statusTimeoutId = null; let statusHideTimeoutId = null; let statusReplayTimeoutId = null; +const STATUS_BASE_CLASS = "earth-status-message"; -// Show status message -export function showStatusMessage(message, type = "info") { - const statusEl = document.getElementById("status-message"); - if (!statusEl) return; +function getElement(id) { + return document.getElementById(id); +} +function setElementDisplay(element, visible, displayValue = "block") { + if (!element) return; + element.style.display = visible ? displayValue : "none"; +} + +function clearStatusTimers() { if (statusTimeoutId) { clearTimeout(statusTimeoutId); statusTimeoutId = null; @@ -23,18 +29,26 @@ export function showStatusMessage(message, type = "info") { clearTimeout(statusReplayTimeoutId); statusReplayTimeoutId = null; } +} + +// Show status message +export function showStatusMessage(message, type = "info") { + const statusEl = getElement("status-message"); + if (!statusEl) return; + + clearStatusTimers(); const startShow = () => { statusEl.textContent = message; - statusEl.className = `status-message ${type}`; - statusEl.style.display = "block"; + statusEl.className = `${STATUS_BASE_CLASS} ${type}`; + setElementDisplay(statusEl, true); statusEl.offsetHeight; statusEl.classList.add("visible"); statusTimeoutId = setTimeout(() => { statusEl.classList.remove("visible"); statusHideTimeoutId = setTimeout(() => { - statusEl.style.display = "none"; + setElementDisplay(statusEl, false); statusEl.textContent = ""; statusHideTimeoutId = null; }, 280); @@ -56,9 +70,9 @@ export function showStatusMessage(message, type = "info") { // Update coordinates display export function updateCoordinatesDisplay(lat, lon, alt = 0) { - const longitudeEl = document.getElementById("longitude-value"); - const latitudeEl = document.getElementById("latitude-value"); - const mouseCoordsEl = document.getElementById("mouse-coords"); + const longitudeEl = getElement("longitude-value"); + const latitudeEl = getElement("latitude-value"); + const mouseCoordsEl = getElement("mouse-coords"); if (longitudeEl) longitudeEl.textContent = lon.toFixed(2) + "°"; if (latitudeEl) latitudeEl.textContent = lat.toFixed(2) + "°"; @@ -70,10 +84,10 @@ export function updateCoordinatesDisplay(lat, lon, alt = 0) { // Update zoom display export function updateZoomDisplay(zoomLevel, distance) { const percent = Math.round(zoomLevel * 100); - const zoomValueEl = document.getElementById("zoom-value"); - const zoomLevelEl = document.getElementById("zoom-level"); - const slider = document.getElementById("zoom-slider"); - const cameraDistanceEl = document.getElementById("camera-distance"); + const zoomValueEl = getElement("zoom-value"); + const zoomLevelEl = getElement("zoom-level"); + const slider = getElement("zoom-slider"); + const cameraDistanceEl = getElement("camera-distance"); if (zoomValueEl) zoomValueEl.textContent = percent + "%"; if (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%"; @@ -83,13 +97,13 @@ export function updateZoomDisplay(zoomLevel, distance) { // Update earth stats export function updateEarthStats(stats) { - const cableCountEl = document.getElementById("cable-count"); - const landingPointCountEl = document.getElementById("landing-point-count"); - const bgpAnomalyCountEl = document.getElementById("bgp-anomaly-count"); - const bgpCollectorCountEl = document.getElementById("bgp-collector-count"); - const bgpStatusSummaryEl = document.getElementById("bgp-status-summary"); - const terrainStatusEl = document.getElementById("terrain-status"); - const textureQualityEl = document.getElementById("texture-quality"); + const cableCountEl = getElement("cable-count"); + const landingPointCountEl = getElement("landing-point-count"); + const bgpAnomalyCountEl = getElement("bgp-anomaly-count"); + const bgpCollectorCountEl = getElement("bgp-collector-count"); + const bgpStatusSummaryEl = getElement("bgp-status-summary"); + const terrainStatusEl = getElement("terrain-status"); + const textureQualityEl = getElement("texture-quality"); if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0; if (landingPointCountEl) @@ -108,14 +122,14 @@ export function updateEarthStats(stats) { // Show/hide loading export function setLoading(loading) { - const loadingEl = document.getElementById("loading"); + const loadingEl = getElement("loading"); if (!loadingEl) return; - loadingEl.style.display = loading ? "block" : "none"; + setElementDisplay(loadingEl, loading); } export function setLoadingMessage(title, subtitle = "") { - const titleEl = document.getElementById("loading-title"); - const subtitleEl = document.getElementById("loading-subtitle"); + const titleEl = getElement("loading-title"); + const subtitleEl = getElement("loading-subtitle"); if (titleEl) { titleEl.textContent = title; @@ -128,48 +142,46 @@ export function setLoadingMessage(title, subtitle = "") { // Show tooltip export function showTooltip(x, y, content) { - const tooltip = document.getElementById("tooltip"); + const tooltip = getElement("tooltip"); if (!tooltip) return; tooltip.innerHTML = content; tooltip.style.left = x + "px"; tooltip.style.top = y + "px"; - tooltip.style.display = "block"; + setElementDisplay(tooltip, true); } // Hide tooltip export function hideTooltip() { - const tooltip = document.getElementById("tooltip"); + const tooltip = getElement("tooltip"); if (tooltip) { - tooltip.style.display = "none"; + setElementDisplay(tooltip, false); } } // Show error message export function showError(message) { - const errorEl = document.getElementById("error-message"); + const errorEl = getElement("error-message"); if (!errorEl) return; errorEl.textContent = message; - errorEl.style.display = "block"; + setElementDisplay(errorEl, true); } // Hide error message export function hideError() { - const errorEl = document.getElementById("error-message"); + const errorEl = getElement("error-message"); if (errorEl) { - errorEl.style.display = "none"; + setElementDisplay(errorEl, false); errorEl.textContent = ""; } } export function clearUiState() { - if (statusTimeoutId) { - clearTimeout(statusTimeoutId); - statusTimeoutId = null; - } + clearStatusTimers(); - const statusEl = document.getElementById("status-message"); + const statusEl = getElement("status-message"); if (statusEl) { - statusEl.style.display = "none"; + statusEl.className = STATUS_BASE_CLASS; + setElementDisplay(statusEl, false); statusEl.textContent = ""; } diff --git a/planet.sh b/planet.sh index 7f7df5e8..a3ab5e6f 100755 --- a/planet.sh +++ b/planet.sh @@ -60,6 +60,24 @@ FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}" FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}" FRONTEND_PID_FILE="/tmp/planet_frontend.pid" +prepend_path_once() { + local path_entry="$1" + [ -n "$path_entry" ] || return 0 + [ -d "$path_entry" ] || return 0 + + case ":$PATH:" in + *":$path_entry:"*) ;; + *) export PATH="$path_entry:$PATH" ;; + esac +} + +ensure_local_runtime_bins_on_path() { + prepend_path_once "$HOME/.local/bin" + prepend_path_once "$HOME/.bun/bin" +} + +ensure_local_runtime_bins_on_path + # Shell / Docker helpers compose_up() { if docker compose version >/dev/null 2>&1; then @@ -320,12 +338,62 @@ run_with_retry() { return 1 } +install_uv_if_needed() { + if command -v uv >/dev/null 2>&1; then + return 0 + fi + + set_wait_detail "未找到 uv,准备自动安装" + mkdir -p "$HOME/.local/bin" + + if ! run_with_retry \ + "$DEPENDENCY_INSTALL_MAX_RETRIES" \ + "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ + "uv 安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ + "安装 uv" \ + sh -c 'curl -LsSf https://astral.sh/uv/install.sh | sh'; then + exit 1 + fi + + ensure_local_runtime_bins_on_path + + if ! command -v uv >/dev/null 2>&1; then + log_error "uv 安装完成后仍未找到命令,请检查 ~/.local/bin" + exit 1 + fi +} + +install_bun_if_needed() { + if command -v bun >/dev/null 2>&1 || [ -x "$HOME/.bun/bin/bun" ]; then + ensure_local_runtime_bins_on_path + return 0 + fi + + set_wait_detail "未找到 bun,准备自动安装" + mkdir -p "$HOME/.bun" + + if ! run_with_retry \ + "$DEPENDENCY_INSTALL_MAX_RETRIES" \ + "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ + "bun 安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ + "安装 bun" \ + sh -c 'curl -fsSL https://bun.sh/install | bash'; then + exit 1 + fi + + ensure_local_runtime_bins_on_path + + if [ ! -x "$HOME/.bun/bin/bun" ] && ! command -v bun >/dev/null 2>&1; then + log_error "bun 安装完成后仍未找到命令,请检查 ~/.bun/bin" + exit 1 + fi +} + ensure_uv_backend_deps() { set_wait_detail "检查后端 uv 环境" if ! command -v uv >/dev/null 2>&1; then - log_error "未找到 uv,请先安装 uv 并加入 PATH" - exit 1 + install_uv_if_needed fi cd "$SCRIPT_DIR" @@ -357,6 +425,15 @@ resolve_frontend_runtime() { return 0 fi + ensure_local_runtime_bins_on_path + + if [ -x "$HOME/.bun/bin/bun" ]; then + FRONTEND_RUNTIME_BIN="$HOME/.bun/bin/bun" + FRONTEND_RUNTIME_SOURCE="local-install" + WAIT_SPINNER_DETAIL="已找到 ~/.bun/bin/bun" + return 0 + fi + set_wait_detail "读取 zsh 配置中的 bun" if resolve_bun_from_shell_configs "zsh" "$HOME/.zshrc" "$HOME/.zprofile"; then WAIT_SPINNER_DETAIL="已找到 zsh 配置中的 bun" @@ -413,9 +490,12 @@ ensure_frontend_deps() { set_wait_detail "解析前端运行时" if ! resolve_frontend_runtime; then - close_wait_session_context "$owns_wait_session" - log_error "未找到 bun,已按 zsh > powershell 顺序检查启动配置" - exit 1 + install_bun_if_needed + if ! resolve_frontend_runtime; then + close_wait_session_context "$owns_wait_session" + log_error "未找到 bun,自动安装后仍无法解析运行时" + exit 1 + fi fi cd "$SCRIPT_DIR/frontend" diff --git a/project_context.md b/project_context.md index ce1d4f98..df8cf6de 100644 --- a/project_context.md +++ b/project_context.md @@ -40,6 +40,7 @@ - **State Management:** React Query - **Real-time:** Socket.io-client - **Charts:** ECharts +- **Package Manager / Runner:** Bun 1 ### Visualization (UE5) - **Engine:** Unreal Engine 5.3+ @@ -210,6 +211,8 @@ bun run build bun run preview ``` +前端统一使用 Bun,不使用 `npm`、`pnpm`、`yarn`。 + ### Docker ```bash docker-compose up -d diff --git a/pyproject.toml b/pyproject.toml index 32deb601..6141523c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.24.0" +version = "0.24.1" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/rules.md b/rules.md index 99705a68..9f16df9a 100644 --- a/rules.md +++ b/rules.md @@ -157,6 +157,9 @@ git fetch origin && git rebase origin/main - Prefer well-maintained, widely-used libraries - Pin dependency versions in `pyproject.toml`, `uv.lock`, and `package.json` - Review security advisories with `pip-audit` and `bun audit` +- Frontend package management and script execution use **Bun only** +- Frontend commands must use `bun install` / `bun run ...` +- **NEVER** use `npm` / `pnpm` / `yarn` for the frontend project - **NEVER** add unknown packages --- diff --git a/scripts/bootstrap-dev.sh b/scripts/bootstrap-dev.sh index 3f0da561..d0fabe68 100755 --- a/scripts/bootstrap-dev.sh +++ b/scripts/bootstrap-dev.sh @@ -106,7 +106,10 @@ print_next_steps() { echo "下一步建议:" echo " 1. cd \"$ROOT_DIR\"" echo " 2. uv run pytest backend/tests/test_api.py -q -s" - echo " 3. ./planet.sh start" + echo " 3. cd \"$ROOT_DIR/frontend\" && bun run build" + echo " 4. cd \"$ROOT_DIR\" && ./planet.sh start" + echo "" + echo "前端统一使用 Bun,不使用 npm/pnpm/yarn。" } main() { diff --git a/uv.lock b/uv.lock index 7ef15c78..da25f7ce 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.24.0" +version = "0.24.1" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From 39f90bd575ca9b1ba55a88983f1b9e0ccd832165 Mon Sep 17 00:00:00 2001 From: linkong Date: Thu, 9 Apr 2026 15:23:10 +0800 Subject: [PATCH 15/34] fix: polish playground routing and bundle splits --- VERSION | 2 +- docs/CHANGELOG.md | 20 +++++++ docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/src/App.tsx | 56 ++++++++++++-------- frontend/src/index.css | 42 ++++++--------- frontend/src/pages/Playground/Playground.tsx | 35 ++++++------ frontend/vite.config.ts | 32 +++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 10 files changed, 126 insertions(+), 70 deletions(-) diff --git a/VERSION b/VERSION index 48b91fd8..8b95abd9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.1 +0.24.2 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3304dd88..b050dcb3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,26 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.24.2 + +Released: 2026-04-09 + +### Highlights + +- Fixed the public-entry and AI diagnostics regressions introduced during the recent frontend routing and playground work, while also reducing the main frontend bundle by switching to route-level lazy loading and more targeted vendor chunking. + +### Improved + +- Improved [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx) by lazy-loading admin pages and large workspaces through `React.lazy()` plus `Suspense`, so the initial frontend entry no longer pulls every route into the first bundle. +- Improved [frontend/vite.config.ts](/home/ray/dev/linkong/planet/frontend/vite.config.ts) by adding targeted manual chunking for React, icon, network, and Earth-related vendor dependencies instead of leaving everything in one monolithic application bundle. +- Improved [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by adding a shared route-loading state and making the Playground help panel size to its content instead of stretching to fill the sidebar. + +### Fixed + +- Fixed [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx) so anonymous visits to `/` once again reach the public Earth entry through the existing `/ -> /earth` redirect instead of being intercepted by the login screen. +- Fixed [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) so cached provider status is shown immediately but still refreshed from the backend, avoiding stale diagnostics after `.env` or provider changes within the same browser tab. +- Fixed [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) so the `测试说明` card no longer over-expands and now fits its content more naturally in the sidebar. + ## 0.24.1 Released: 2026-04-09 diff --git a/docs/version-history.md b/docs/version-history.md index 83b26d9c..922dded0 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.24.1` +- `dev` 当前开发分支历史推导到:`0.24.2` ## Timeline @@ -74,6 +74,7 @@ | `0.23.3` | bugfix | `dev` | `pending` | refine `planet.sh` zsh runtime compatibility, startup logging, and frontend readiness feedback | | `0.24.0` | feature | `dev` | `pending` | add AI Playground entry, provider diagnostics, and frontend layout guidance | | `0.24.1` | bugfix | `dev` | `pending` | refactor Earth HUD into class-first CSS layers, unify Bun-only frontend tooling guidance, and auto-bootstrap Bun/uv in `planet.sh` | +| `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 | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 6ae13209..95656b0d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.24.1", + "version": "0.24.2", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a5a8c337..a99042dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,36 +1,48 @@ +import { Suspense, lazy } from 'react' +import { Spin } from 'antd' import { Routes, Route, Navigate } from 'react-router-dom' import { useAuthStore } from './stores/auth' import Login from './pages/Login/Login' -import Dashboard from './pages/Dashboard/Dashboard' -import Users from './pages/Users/Users' -import DataSources from './pages/DataSources/DataSources' -import DataList from './pages/DataList/DataList' -import Earth from './pages/Earth/Earth' -import Settings from './pages/Settings/Settings' -import BGP from './pages/BGP/BGP' -import Playground from './pages/Playground/Playground' + +const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard')) +const Users = lazy(() => import('./pages/Users/Users')) +const DataSources = lazy(() => import('./pages/DataSources/DataSources')) +const DataList = lazy(() => import('./pages/DataList/DataList')) +const Earth = lazy(() => import('./pages/Earth/Earth')) +const Settings = lazy(() => import('./pages/Settings/Settings')) +const BGP = lazy(() => import('./pages/BGP/BGP')) +const Playground = lazy(() => import('./pages/Playground/Playground')) function App() { const { token } = useAuthStore() - const isEarthPage = window.location.pathname === '/earth' + const publicPaths = new Set(['/', '/earth']) + const isPublicRoute = publicPaths.has(window.location.pathname) - if (!token && !isEarthPage) { + if (!token && !isPublicRoute) { return } return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + +
+ )} + > + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) } diff --git a/frontend/src/index.css b/frontend/src/index.css index efcce359..bb495c92 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -14,6 +14,13 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } +.app-route-loading { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + .login-container { min-height: 100vh; display: flex; @@ -472,7 +479,7 @@ body { } .playground-help--expanded { - flex: 1 0 auto; + flex: 0 0 auto; } .playground-help.ant-collapse { @@ -493,38 +500,21 @@ body { } .playground-help .ant-collapse-content { - flex: 1 1 auto; + flex: 0 0 auto; min-height: 0; - overflow: hidden; + overflow: visible; } .playground-help .ant-collapse-content-box { - height: 100%; - overflow: auto; - scrollbar-width: thin; - scrollbar-color: rgba(148, 163, 184, 0.88) transparent; + height: auto; + overflow: visible; padding: 12px 16px 16px !important; } -.playground-help .ant-collapse-content-box::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -.playground-help .ant-collapse-content-box::-webkit-scrollbar-thumb { - background: rgba(148, 163, 184, 0.82); - border-radius: 999px; - border: 2px solid transparent; - background-clip: padding-box; -} - -.playground-help .ant-collapse-content-box::-webkit-scrollbar-thumb:hover { - background: rgba(100, 116, 139, 0.9); - background-clip: padding-box; -} - -.playground-help .ant-collapse-content-box::-webkit-scrollbar-track { - background: transparent; +.playground-help__content { + display: flex; + flex-direction: column; + gap: 12px; } .playground-note.ant-alert { diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx index 684b540e..38c22231 100644 --- a/frontend/src/pages/Playground/Playground.tsx +++ b/frontend/src/pages/Playground/Playground.tsx @@ -76,7 +76,6 @@ function Playground() { if (cached) { try { setProviderStatus(JSON.parse(cached) as AIProviderStatus) - return } catch { sessionStorage.removeItem(PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY) } @@ -207,22 +206,24 @@ function Playground() { key: 'help', label: '测试说明', children: ( - - 当前链路: - frontend /playground - {' -> '} - backend /api/v1/ai/* - {' -> '} - aiprovider /v1/* - - )} - /> +
+ + 当前链路: + frontend /playground + {' -> '} + backend /api/v1/ai/* + {' -> '} + aiprovider /v1/* + + )} + /> +
), }, ]} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b4323c34..2a61f691 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -72,4 +72,36 @@ export default defineConfig({ entries: ['src/main.tsx'], exclude: ['satellite.js'], }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes('node_modules')) { + return + } + + if (id.includes('/@ant-design/icons/')) { + return 'icons-vendor' + } + + if ( + id.includes('/react/') || + id.includes('/react-dom/') || + id.includes('/react-router-dom/') || + id.includes('/scheduler/') + ) { + return 'react-vendor' + } + + if (id.includes('/axios/')) { + return 'network-vendor' + } + + if (id.includes('/three/') || id.includes('/simplex-noise/') || id.includes('/satellite.js/')) { + return 'earth-vendor' + } + }, + }, + }, + }, }) diff --git a/pyproject.toml b/pyproject.toml index 6141523c..94ce09c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.24.1" +version = "0.24.2" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index da25f7ce..dc11f79c 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.24.1" +version = "0.24.2" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From 306ba7f85044bd7a72d344f0960a48b2a4e92d1c Mon Sep 17 00:00:00 2001 From: linkong Date: Thu, 9 Apr 2026 16:38:55 +0800 Subject: [PATCH 16/34] fix: expand playground diagnostics and compose fallback --- VERSION | 2 +- docs/CHANGELOG.md | 19 ++ docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/src/index.css | 43 +++++ frontend/src/pages/Playground/Playground.tsx | 173 ++++++++++++++++-- planet.sh | 174 +++++++++++++++++-- pyproject.toml | 2 +- uv.lock | 2 +- 9 files changed, 394 insertions(+), 26 deletions(-) diff --git a/VERSION b/VERSION index 8b95abd9..63082344 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.2 +0.24.3 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b050dcb3..ae56525a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,25 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.24.3 + +Released: 2026-04-09 + +### Highlights + +- Extended `AI Playground` from a minimal prompt form into a more repeatable diagnostics workspace, and hardened `planet.sh` so AI Provider restarts can rebuild changed images and expose clearer Compose fallback behavior. + +### Improved + +- Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) by adding preset scenarios, response metadata, `thinking` block visibility, raw JSON inspection, and copy actions so the page works more like a proper AI diagnostics console. +- Improved [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by styling Playground presets, result metadata, raw response sections, and responsive action groups without breaking the single-screen workspace layout. +- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) so AI Provider restarts detect code/config changes, rebuild the image when needed, and surface which Compose path is being used during image and container operations. + +### Fixed + +- Fixed the AI Provider restart path in [planet.sh](/home/ray/dev/linkong/planet/planet.sh) so code changes inside `aiprovider/` no longer stay hidden behind an old container image after `restart -a`. +- Fixed Compose error reporting in [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by making the script explicitly show `docker compose v2` first, then `docker-compose v1`, and only fail after both execution paths are exhausted. + ## 0.24.2 Released: 2026-04-09 diff --git a/docs/version-history.md b/docs/version-history.md index 922dded0..4d86120a 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.24.2` +- `dev` 当前开发分支历史推导到:`0.24.3` ## Timeline @@ -75,6 +75,7 @@ | `0.24.0` | feature | `dev` | `pending` | add AI Playground entry, provider diagnostics, and frontend layout guidance | | `0.24.1` | bugfix | `dev` | `pending` | refactor Earth HUD into class-first CSS layers, unify Bun-only frontend tooling guidance, and auto-bootstrap Bun/uv in `planet.sh` | | `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 | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 95656b0d..7c56e834 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.24.2", + "version": "0.24.3", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/src/index.css b/frontend/src/index.css index bb495c92..a390ee7b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -380,6 +380,23 @@ body { padding-bottom: 4px; } +.playground-preset-strip { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 14px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + +.playground-preset-strip__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .playground-form__actions { display: flex; gap: 12px; @@ -555,10 +572,23 @@ body { .playground-result__meta { display: flex; + align-items: center; + justify-content: space-between; flex-wrap: wrap; gap: 8px; } +.playground-result__meta-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.playground-result__descriptions .ant-descriptions-view { + border-radius: 14px; + overflow: hidden; +} + .playground-result__content, .playground-result__blocks .ant-card { border-radius: 14px; @@ -591,6 +621,13 @@ body { gap: 12px; } +.playground-result__blocks-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + .playground-result__blocks-scroll { max-height: 180px; overflow: auto; @@ -619,6 +656,12 @@ body { min-width: 0; max-width: none; } + + .playground-result__meta, + .playground-result__blocks-head { + align-items: stretch; + flex-direction: column; + } } diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx index 38c22231..17660e2a 100644 --- a/frontend/src/pages/Playground/Playground.tsx +++ b/frontend/src/pages/Playground/Playground.tsx @@ -1,11 +1,12 @@ import { useEffect, useState } from 'react' import axios from 'axios' -import { SyncOutlined } from '@ant-design/icons' +import { CopyOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons' import { Alert, Button, Card, Collapse, + Descriptions, Form, Input, Space, @@ -53,6 +54,51 @@ interface PlaygroundFormValues { constraints?: string } +interface PlaygroundPreset { + key: string + label: string + values: PlaygroundFormValues +} + +interface AnalysisRunMeta { + requestId: string | null + durationMs: number | null + completedAt: string | null +} + +const PLAYGROUND_PRESETS: PlaygroundPreset[] = [ + { + key: 'bgp-brief', + label: 'BGP 简报', + values: { + title: 'BGP 告警态势简报', + objective: '总结当前告警的主要风险、优先级与建议动作。', + observations: '出现新的高危告警\n部分观测站最近 24h 事件增多', + constraints: '结论要简洁\n优先给出操作建议', + }, + }, + { + key: 'datasource-health', + label: '数据源健康', + values: { + title: '采集器健康检查说明', + objective: '判断当前采集器失败是否属于上游接口失效、限流、结构变更或临时波动。', + observations: '最近 3 次任务失败\n部分数据源响应时间抬升\n个别接口返回结构不稳定', + constraints: '区分事实与推断\n先给排障优先级', + }, + }, + { + key: 'link-smoke', + label: '链路探测', + values: { + title: 'AI 链路探测', + objective: '验证 backend -> aiprovider -> model provider 调用链路是否正常。', + observations: '当前从 Playground 发起测试\n希望确认 provider 配置和返回结构正常', + constraints: '输出简洁\n包含一段明确结论', + }, + }, +] + function splitLines(value?: string) { return (value || '') .split('\n') @@ -67,6 +113,12 @@ function Playground() { const [analyzing, setAnalyzing] = useState(false) const [providerStatus, setProviderStatus] = useState(null) const [analysis, setAnalysis] = useState(null) + const [analysisMeta, setAnalysisMeta] = useState({ + requestId: null, + durationMs: null, + completedAt: null, + }) + const [providerStatusUpdatedAt, setProviderStatusUpdatedAt] = useState(null) const [activeTab, setActiveTab] = useState<'request' | 'result'>('request') const [helpExpanded, setHelpExpanded] = useState(true) @@ -87,6 +139,7 @@ function Playground() { const res = await axios.get(`${API_BASE_URL}/ai/provider/status`) setProviderStatus(res.data) sessionStorage.setItem(PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY, JSON.stringify(res.data)) + setProviderStatusUpdatedAt(new Date().toLocaleString()) } catch { messageApi.error('AI Provider 状态获取失败') } finally { @@ -99,6 +152,7 @@ function Playground() { }, []) const handleAnalyze = async (values: PlaygroundFormValues) => { + const startedAt = performance.now() setAnalyzing(true) setActiveTab('result') try { @@ -115,6 +169,11 @@ function Playground() { }, ) setAnalysis(res.data) + setAnalysisMeta({ + requestId: typeof res.headers['x-request-id'] === 'string' ? res.headers['x-request-id'] : null, + durationMs: Math.round(performance.now() - startedAt), + completedAt: new Date().toLocaleString(), + }) messageApi.success('分析已完成') } catch { messageApi.error('分析失败,请检查 AI Provider 配置或稍后再试') @@ -124,6 +183,31 @@ function Playground() { } } + const handleApplyPreset = (preset: PlaygroundPreset) => { + form.setFieldsValue(preset.values) + setActiveTab('request') + } + + const handleCopyResult = async () => { + if (!analysis?.content) return + try { + await navigator.clipboard.writeText(analysis.content) + messageApi.success('结果已复制') + } catch { + messageApi.error('复制失败,请稍后再试') + } + } + + const handleCopyRawResponse = async () => { + if (!analysis) return + try { + await navigator.clipboard.writeText(JSON.stringify(analysis.raw_response, null, 2)) + messageApi.success('原始响应已复制') + } catch { + messageApi.error('复制失败,请稍后再试') + } + } + return ( {contextHolder} @@ -188,6 +272,10 @@ function Playground() { {providerStatus.base_url || '-'}
+
+ 最后同步 + {providerStatusUpdatedAt || '-'} +
) : ( @@ -223,6 +311,13 @@ function Playground() { )} /> +
), }, @@ -241,15 +336,20 @@ function Playground() { label: '请求', children: (
+
+ 快速预设 +
+ {PLAYGROUND_PRESETS.map((preset) => ( + + ))} +
+
@@ -308,9 +413,30 @@ function Playground() { ) : analysis ? (
- {analysis.provider} - {analysis.model} +
+ {analysis.provider} + {analysis.model} +
+ + + +
+
{analysis.content}
@@ -326,6 +452,31 @@ function Playground() {
) : null} + {analysis.thinking_blocks.length ? ( +
+ Thinking Blocks +
+ {analysis.thinking_blocks.map((block, index) => ( + +
{block}
+
+ ))} +
+
+ ) : null} +
+
+ Raw Response + +
+
+ +
{JSON.stringify(analysis.raw_response, null, 2)}
+
+
+
) : ( /dev/null 2>&1 +} + +compose_v1_available() { + command -v docker-compose >/dev/null 2>&1 +} + +report_missing_compose() { + clear_wait_spinner + log_error "未检测到可用的 Docker Compose" + log_note "优先需要 Docker Compose v2 (`docker compose`)" + log_note "如果当前机器仍只装有旧版 `docker-compose`,也请确认该命令可执行" + log_note "推荐安装 Docker Compose v2 插件:" + log_note ' mkdir -p ~/.docker/cli-plugins' + log_note ' curl -SL https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose' + log_note ' chmod +x ~/.docker/cli-plugins/docker-compose' + log_note ' docker compose version' + return 1 +} + compose_up() { - if docker compose version >/dev/null 2>&1; then - docker compose "$@" - else - docker-compose "$@" + local args=("$@") + local arg_text="$*" + + if compose_v2_available; then + set_wait_detail "正在用 docker compose v2 执行: ${arg_text}" + if docker compose "${args[@]}"; then + return 0 + fi + log_warn "docker compose v2 执行失败,回退到 docker-compose v1" fi + + if compose_v1_available; then + set_wait_detail "正在用 docker-compose v1 执行: ${arg_text}" + if docker-compose "${args[@]}"; then + return 0 + fi + clear_wait_spinner + log_error "docker compose v2 与 docker-compose v1 均执行失败" + log_note "最后尝试的命令: docker-compose ${arg_text}" + return 1 + fi + + report_missing_compose +} + +compose_supports_build() { + compose_up build --help >/dev/null 2>&1 } # Terminal rendering helpers @@ -338,6 +385,102 @@ run_with_retry() { return 1 } +compute_ai_provider_build_fingerprint() { + ( + cd "$SCRIPT_DIR" || exit 1 + tar -cf - \ + aiprovider \ + pyproject.toml \ + uv.lock \ + docker-compose.yml \ + docker-compose.simple.yml 2>/dev/null + ) | sha256sum | awk '{print $1}' +} + +read_ai_provider_build_stamp() { + [ -f "$AI_PROVIDER_BUILD_STAMP_FILE" ] || return 1 + tr -d '[:space:]' < "$AI_PROVIDER_BUILD_STAMP_FILE" +} + +write_ai_provider_build_stamp() { + local fingerprint="$1" + printf "%s\n" "$fingerprint" > "$AI_PROVIDER_BUILD_STAMP_FILE" +} + +ai_provider_image_exists() { + docker image inspect "$AI_PROVIDER_IMAGE_NAME" >/dev/null 2>&1 +} + +list_ai_provider_containers() { + docker ps -aq --filter "name=planet_aiprovider" 2>/dev/null || true +} + +remove_ai_provider_containers() { + local container_ids + container_ids="$(list_ai_provider_containers)" + [ -n "$container_ids" ] || return 0 + echo "$container_ids" | xargs -r docker rm -f >/dev/null 2>&1 || true +} + +run_ai_provider_container_manually() { + local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" + local env_file_args=() + + if [ -f "$SCRIPT_DIR/aiprovider/.env" ]; then + env_file_args=(--env-file "$SCRIPT_DIR/aiprovider/.env") + fi + + docker run -d \ + --name "$AI_PROVIDER_CONTAINER_NAME" \ + "${env_file_args[@]}" \ + -p "${ai_provider_port}:8010" \ + "$AI_PROVIDER_IMAGE_NAME" >/dev/null +} + +recreate_ai_provider_container() { + local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" + + remove_ai_provider_containers + + if compose_up up -d aiprovider >/dev/null 2>&1; then + return 0 + fi + + run_ai_provider_container_manually "$ai_provider_port" +} + +ensure_ai_provider_image_current() { + local current_fingerprint="" + local previous_fingerprint="" + + current_fingerprint="$(compute_ai_provider_build_fingerprint)" + previous_fingerprint="$(read_ai_provider_build_stamp || true)" + + if ai_provider_image_exists && [ -n "$previous_fingerprint" ] && [ "$current_fingerprint" = "$previous_fingerprint" ]; then + AI_PROVIDER_RECREATE_REQUIRED=0 + return 0 + fi + + set_wait_detail "检测到 AI Provider 代码或配置变化,重建镜像" + + if ! compose_supports_build; then + log_error "当前 docker compose 不支持 build,请检查 Docker / Compose 环境" + exit 1 + fi + + if ! run_with_retry \ + "$AI_PROVIDER_START_MAX_RETRIES" \ + "$AI_PROVIDER_RETRY_INTERVAL" \ + "AI Provider 镜像构建失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次" \ + "构建 AI Provider 镜像" \ + compose_up build aiprovider; then + exit 1 + fi + + write_ai_provider_build_stamp "$current_fingerprint" + AI_PROVIDER_RECREATE_REQUIRED=1 +} + install_uv_if_needed() { if command -v uv >/dev/null 2>&1; then return 0 @@ -753,9 +896,19 @@ start_ai_provider_service() { local retry=1 set_wait_detail "启动 AI Provider" + ensure_ai_provider_image_current + while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do - if docker start planet_aiprovider >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then - if wait_for_container_health "planet_aiprovider" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" && + if [ "$AI_PROVIDER_RECREATE_REQUIRED" -eq 1 ]; then + if recreate_ai_provider_container "$ai_provider_port"; then + AI_PROVIDER_RECREATE_REQUIRED=0 + if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" && + wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then + return 0 + fi + fi + elif docker start "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then + if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" && wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then return 0 fi @@ -763,17 +916,18 @@ start_ai_provider_service() { if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次" - docker logs --tail 20 planet_aiprovider 2>/dev/null || true + docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true exit 1 fi - docker restart planet_aiprovider >/dev/null 2>&1 || true + remove_ai_provider_containers + AI_PROVIDER_RECREATE_REQUIRED=1 animate_wait_spinner "AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$AI_PROVIDER_RETRY_INTERVAL" retry=$((retry + 1)) done log_error "AI Provider 启动失败" - docker logs --tail 20 planet_aiprovider 2>/dev/null || true + docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true exit 1 } @@ -1179,7 +1333,7 @@ stop_backend_service() { } stop_ai_provider_service() { - stop_container_if_running "planet_aiprovider" "AI Provider" + stop_container_if_running "$AI_PROVIDER_CONTAINER_NAME" "AI Provider" } stop_frontend_service() { diff --git a/pyproject.toml b/pyproject.toml index 94ce09c3..7f7ddc93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.24.2" +version = "0.24.3" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index dc11f79c..926b27e4 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.24.2" +version = "0.24.3" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From abe0b5c11b2ce55d5c48f9a223d5a6217511397b Mon Sep 17 00:00:00 2001 From: linkong Date: Thu, 9 Apr 2026 16:52:21 +0800 Subject: [PATCH 17/34] fix: polish ai provider rebuild feedback --- VERSION | 2 +- aiprovider/provider_service.py | 2 +- docs/CHANGELOG.md | 17 ++++++++++++++ docs/version-history.md | 3 ++- frontend/package.json | 2 +- planet.sh | 42 ++++++++++++++++++++++++++++------ pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 59 insertions(+), 13 deletions(-) diff --git a/VERSION b/VERSION index 63082344..4b8c9a55 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.3 +0.24.4 diff --git a/aiprovider/provider_service.py b/aiprovider/provider_service.py index 0bdbc6bb..282506da 100644 --- a/aiprovider/provider_service.py +++ b/aiprovider/provider_service.py @@ -319,7 +319,6 @@ class ProviderService: ) ) return blocks - def _extract_anthropic_content(self, payload: dict[str, Any]) -> str: content = payload.get("content") if isinstance(content, str): @@ -359,6 +358,7 @@ class ProviderService: ) return blocks + def _extract_ollama_content(self, payload: dict[str, Any]) -> str: response = payload.get("response") if isinstance(response, str): diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ae56525a..cb3fc110 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,23 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.24.4 + +Released: 2026-04-09 + +### Highlights + +- Refined the `planet.sh` AI Provider rebuild UX so image rebuilds now feel like first-class scripted tasks, with clearer stage boundaries and cleaner fallback messaging instead of leaking raw Compose output into the terminal. + +### Improved + +- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by keeping AI Provider image build logs in a temporary file, surfacing stage-specific detail copy for `docker compose v2` and `docker-compose v1`, and showing an explicit success line when the image rebuild finishes before container health checks begin. + +### Fixed + +- Fixed [planet.sh](/home/ray/dev/linkong/planet/planet.sh) so the AI Provider image rebuild stage no longer dumps raw Compose build output into the main spinner flow during normal successful runs. +- Fixed [planet.sh](/home/ray/dev/linkong/planet/planet.sh) so the `构建 AI Provider 镜像` phase now ends with an explicit completion signal instead of visually blending into the subsequent container health-check phase. + ## 0.24.3 Released: 2026-04-09 diff --git a/docs/version-history.md b/docs/version-history.md index 4d86120a..6cd063ac 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.24.3` +- `dev` 当前开发分支历史推导到:`0.24.4` ## Timeline @@ -76,6 +76,7 @@ | `0.24.1` | bugfix | `dev` | `pending` | refactor Earth HUD into class-first CSS layers, unify Bun-only frontend tooling guidance, and auto-bootstrap Bun/uv in `planet.sh` | | `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 | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 7c56e834..ff6012de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.24.3", + "version": "0.24.4", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/planet.sh b/planet.sh index fe3b7d86..5ced3165 100755 --- a/planet.sh +++ b/planet.sh @@ -60,6 +60,7 @@ FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}" FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}" FRONTEND_PID_FILE="/tmp/planet_frontend.pid" AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256" +AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log" AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}" AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}" AI_PROVIDER_RECREATE_REQUIRED=0 @@ -131,7 +132,13 @@ compose_up() { } compose_supports_build() { - compose_up build --help >/dev/null 2>&1 + if compose_v2_available; then + docker compose build --help >/dev/null 2>&1 && return 0 + fi + if compose_v1_available; then + docker-compose build --help >/dev/null 2>&1 && return 0 + fi + return 1 } # Terminal rendering helpers @@ -468,19 +475,40 @@ ensure_ai_provider_image_current() { exit 1 fi - if ! run_with_retry \ - "$AI_PROVIDER_START_MAX_RETRIES" \ - "$AI_PROVIDER_RETRY_INTERVAL" \ - "AI Provider 镜像构建失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次" \ - "构建 AI Provider 镜像" \ - compose_up build aiprovider; then + : > "$AI_PROVIDER_BUILD_LOG_FILE" + + if ! build_ai_provider_image_with_fallback; then + clear_wait_spinner + log_error "AI Provider 镜像构建失败" + tail -20 "$AI_PROVIDER_BUILD_LOG_FILE" 2>/dev/null || true exit 1 fi + log_success "AI Provider 镜像已构建" write_ai_provider_build_stamp "$current_fingerprint" AI_PROVIDER_RECREATE_REQUIRED=1 } +build_ai_provider_image_with_fallback() { + if compose_v2_available; then + set_wait_detail "使用 Docker Compose v2 构建 AI Provider 镜像" + if run_command_with_spinner "构建 AI Provider 镜像" sh -c "docker compose build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"; then + return 0 + fi + log_warn "Docker Compose v2 构建失败,回退到 docker-compose v1" + fi + + if compose_v1_available; then + set_wait_detail "使用 docker-compose v1 构建 AI Provider 镜像" + if run_command_with_spinner "构建 AI Provider 镜像" sh -c "docker-compose build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"; then + return 0 + fi + return 1 + fi + + report_missing_compose +} + install_uv_if_needed() { if command -v uv >/dev/null 2>&1; then return 0 diff --git a/pyproject.toml b/pyproject.toml index 7f7ddc93..3d7d703e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.24.3" +version = "0.24.4" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 926b27e4..6107b29e 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.24.3" +version = "0.24.4" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From ed898aef9c498f744f3ecada90af05715666639d Mon Sep 17 00:00:00 2001 From: rayd1o Date: Thu, 9 Apr 2026 23:08:31 +0800 Subject: [PATCH 18/34] docs: add docker compose and buildx upgrade guide - add a standalone docs guide for reinstalling Docker, Compose, and Buildx from the official Ubuntu repository - refine planet.sh compose/build diagnostics so docker compose is the primary wording and missing buildx / fallback cases report more accurately - keep the startup failure output aligned with the existing terminal tone while reducing misleading compose-version messaging --- docs/docker-compose-buildx-upgrade.md | 105 +++++++++++++++++++++ planet.sh | 130 ++++++++++++++++++++++---- 2 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 docs/docker-compose-buildx-upgrade.md diff --git a/docs/docker-compose-buildx-upgrade.md b/docs/docker-compose-buildx-upgrade.md new file mode 100644 index 00000000..000c0556 --- /dev/null +++ b/docs/docker-compose-buildx-upgrade.md @@ -0,0 +1,105 @@ +# Docker + Compose + Buildx 升级教程 + +流程:删除旧版 -> 安装新版 -> 验证 + +--- + +# 1. 删除旧版本 + +## 删除 apt 安装的旧包 + +```bash +sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc +``` + +--- + +## 删除系统中的 `docker-compose`(V1) + +```bash +sudo rm -f "$(which docker-compose 2>/dev/null)" +``` + +--- + +## 查找并删除手动安装的 Buildx 插件 + +```bash +docker info | sed -n '/Plugins:/,/^ Server:/p' | grep -A2 buildx +``` + +从输出中获取 `Path`,然后执行: + +```bash +rm -f +``` + +--- + +## 清理无用依赖 + +```bash +sudo apt autoremove -y +``` + +--- + +# 2. 安装 Docker 官方版本 + +包含 Docker Engine、Docker Compose 插件、Docker Buildx 插件。 + +## 安装依赖 + +```bash +sudo apt update +sudo apt install -y ca-certificates curl gnupg +``` + +--- + +## 添加 Docker GPG key + +```bash +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ + sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg +``` + +--- + +## 添加官方仓库 + +```bash +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ +sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +``` + +--- + +## 安装 Docker + Compose + Buildx + +```bash +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +``` + +--- + +# 3. 验证安装 + +```bash +docker --version +docker compose version +docker buildx version +``` + +--- + +# 4. 常用命令 + +```bash +docker compose up -d +docker compose down +docker buildx build . +``` diff --git a/planet.sh b/planet.sh index 5ced3165..4b9be282 100755 --- a/planet.sh +++ b/planet.sh @@ -84,7 +84,7 @@ ensure_local_runtime_bins_on_path() { ensure_local_runtime_bins_on_path # Shell / Docker helpers -compose_v2_available() { +compose_available() { docker compose version >/dev/null 2>&1 } @@ -95,9 +95,9 @@ compose_v1_available() { report_missing_compose() { clear_wait_spinner log_error "未检测到可用的 Docker Compose" - log_note "优先需要 Docker Compose v2 (`docker compose`)" + log_note '优先使用 `docker compose`' log_note "如果当前机器仍只装有旧版 `docker-compose`,也请确认该命令可执行" - log_note "推荐安装 Docker Compose v2 插件:" + log_note "推荐安装 Docker Compose 插件:" log_note ' mkdir -p ~/.docker/cli-plugins' log_note ' curl -SL https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose' log_note ' chmod +x ~/.docker/cli-plugins/docker-compose' @@ -105,16 +105,53 @@ report_missing_compose() { return 1 } +buildx_version() { + docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//' +} + +buildx_meets_minimum() { + local current_version="$1" + local required_version="$2" + + [ -n "$current_version" ] || return 1 + [ -n "$required_version" ] || return 1 + + if [ "$(printf '%s\n%s\n' "$required_version" "$current_version" | sort -V | head -n 1)" = "$required_version" ]; then + return 0 + fi + + return 1 +} + +log_buildx_diagnostics_if_needed() { + local current_buildx_version="" + local required_buildx_version="0.17.0" + + current_buildx_version="$(buildx_version)" + + if [ -z "$current_buildx_version" ]; then + log_note "未检测到 docker buildx,AI Provider 镜像构建依赖 buildx。" + log_note "请先执行: docker buildx version" + return 0 + fi + + if ! buildx_meets_minimum "$current_buildx_version" "$required_buildx_version"; then + log_note "检测到 docker buildx 当前版本为 v${current_buildx_version},但 compose build 需要 v${required_buildx_version} 或更高。" + log_note "可先升级 buildx 插件,再重新执行 ./planet.sh start" + log_note "请先执行: docker buildx version" + fi +} + compose_up() { local args=("$@") local arg_text="$*" - if compose_v2_available; then - set_wait_detail "正在用 docker compose v2 执行: ${arg_text}" + if compose_available; then + set_wait_detail "正在用 docker compose 执行: ${arg_text}" if docker compose "${args[@]}"; then return 0 fi - log_warn "docker compose v2 执行失败,回退到 docker-compose v1" + log_warn "docker compose 执行失败,回退到 docker-compose v1" fi if compose_v1_available; then @@ -123,7 +160,7 @@ compose_up() { return 0 fi clear_wait_spinner - log_error "docker compose v2 与 docker-compose v1 均执行失败" + log_error "docker compose 与 docker-compose v1 均执行失败" log_note "最后尝试的命令: docker-compose ${arg_text}" return 1 fi @@ -132,7 +169,7 @@ compose_up() { } compose_supports_build() { - if compose_v2_available; then + if compose_available; then docker compose build --help >/dev/null 2>&1 && return 0 fi if compose_v1_available; then @@ -392,6 +429,58 @@ run_with_retry() { return 1 } +docker_daemon_available() { + docker info >/dev/null 2>&1 +} + +log_docker_daemon_diagnostics() { + local docker_service_status="" + local docker_socket_status="" + local service_hint="sudo systemctl enable --now docker.socket && sudo systemctl restart docker" + + docker_service_status="$(systemctl is-active docker 2>/dev/null || true)" + docker_socket_status="$(systemctl is-active docker.socket 2>/dev/null || true)" + + if [ -S /var/run/docker.sock ] && [ ! -r /var/run/docker.sock ]; then + log_note "检测到 /var/run/docker.sock 存在,但当前用户无法访问。" + log_note "请确认当前用户已加入 docker 组,并重新登录会话。" + return 0 + fi + + if [ "$docker_service_status" = "active" ]; then + log_note "Docker CLI 可执行,但当前会话仍无法连接 daemon。" + log_note "请先执行: docker info" + return 0 + fi + + if [ "$docker_service_status" = "failed" ] || [ "$docker_socket_status" = "failed" ]; then + log_note "检测到 Docker daemon 当前未正常运行。" + log_note "可先执行: ${service_hint}" + log_note "如仍失败,继续查看: journalctl -u docker.service -n 50 --no-pager" + return 0 + fi + + if [ "$docker_socket_status" != "active" ]; then + log_note "检测到 Docker socket 未激活,daemon 可能无法通过 systemd socket activation 启动。" + log_note "可先执行: ${service_hint}" + return 0 + fi + + log_note "当前无法连接 Docker daemon,请先执行: docker info" + log_note "若 daemon 未启动,可尝试: ${service_hint}" +} + +print_database_failure_diagnostics() { + if ! docker_daemon_available; then + log_note "数据库容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。" + log_docker_daemon_diagnostics + return 0 + fi + + docker logs --tail 20 planet_postgres 2>/dev/null || true + docker logs --tail 20 planet_redis 2>/dev/null || true +} + compute_ai_provider_build_fingerprint() { ( cd "$SCRIPT_DIR" || exit 1 @@ -481,6 +570,7 @@ ensure_ai_provider_image_current() { clear_wait_spinner log_error "AI Provider 镜像构建失败" tail -20 "$AI_PROVIDER_BUILD_LOG_FILE" 2>/dev/null || true + log_buildx_diagnostics_if_needed exit 1 fi log_success "AI Provider 镜像已构建" @@ -490,12 +580,17 @@ ensure_ai_provider_image_current() { } build_ai_provider_image_with_fallback() { - if compose_v2_available; then - set_wait_detail "使用 Docker Compose v2 构建 AI Provider 镜像" + if compose_available; then + set_wait_detail "使用 docker compose 构建 AI Provider 镜像" if run_command_with_spinner "构建 AI Provider 镜像" sh -c "docker compose build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"; then return 0 fi - log_warn "Docker Compose v2 构建失败,回退到 docker-compose v1" + if compose_v1_available; then + log_warn "docker compose 构建失败,回退到 docker-compose v1" + else + log_warn "docker compose 构建失败,当前环境未安装 docker-compose v1,无法继续回退" + return 1 + fi fi if compose_v1_available; then @@ -969,8 +1064,7 @@ ensure_database_services_healthy() { if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then log_error "数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" - docker logs --tail 20 planet_postgres 2>/dev/null || true - docker logs --tail 20 planet_redis 2>/dev/null || true + print_database_failure_diagnostics exit 1 fi @@ -990,7 +1084,12 @@ ensure_postgres_service_healthy() { if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then log_error "PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" - docker logs --tail 20 planet_postgres 2>/dev/null || true + if ! docker_daemon_available; then + log_note "PostgreSQL 容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。" + log_docker_daemon_diagnostics + else + docker logs --tail 20 planet_postgres 2>/dev/null || true + fi exit 1 fi @@ -1012,8 +1111,7 @@ restart_database_service() { if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" - docker logs --tail 20 planet_postgres 2>/dev/null || true - docker logs --tail 20 planet_redis 2>/dev/null || true + print_database_failure_diagnostics exit 1 fi From 83839b8b11fbbc1887084b2537b19f5ec5550c95 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Fri, 10 Apr 2026 00:33:01 +0800 Subject: [PATCH 19/34] 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( +
    + {items.map((item, itemIndex) => ( +
  • {renderInlineMarkdown(item)}
  • + ))} +
, + ) + 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。打开页面时默认展示最近一次简报,也可以从历史列表切换查看。 +
+
+
+
+ + + {tableHeaderCells.map((cell, cellIndex) => ( + + ))} + + + + {bodyRows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
{renderInlineMarkdown(cell)}
{renderInlineMarkdown(cell)}
+
, + ) + continue + } + const paragraphLines: string[] = [] while (index < lines.length && lines[index].trim()) { paragraphLines.push(lines[index].trim()) @@ -165,3 +220,23 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender return
{nodes}
} + +function parseTableRow(line: string): string[] | null { + if (!line.includes('|')) { + return null + } + + const normalized = line.replace(/^\|/, '').replace(/\|$/, '') + const cells = normalized.split('|').map((cell) => cell.trim()) + return cells.length > 0 ? cells : null +} + +function parseTableDivider(line: string): string[] | null { + const cells = parseTableRow(line) + if (!cells || cells.length === 0) { + return null + } + + const isDivider = cells.every((cell) => /^:?-{3,}:?$/.test(cell)) + return isDivider ? cells : null +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 9d75a680..9f44d7ef 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1006,11 +1006,45 @@ body { font-size: 13px; } +.bgp-page__brief-tabpane { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + padding-right: 4px; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.88) transparent; +} + +.bgp-page__brief-tabpane::-webkit-scrollbar { + width: 8px; +} + +.bgp-page__brief-tabpane::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(148, 163, 184, 0.88); +} + +.bgp-page__brief-tabpane::-webkit-scrollbar-track { + background: transparent; +} + .bgp-page__brief-card { - display: flex; - flex-direction: column; - gap: 14px; - min-height: 100%; + display: block; +} + +.bgp-page__brief-body { + display: grid; + gap: 12px; +} + +.bgp-page__brief-content { + min-height: 360px; + overflow: visible; + 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); } .bgp-page__brief-head { @@ -1045,24 +1079,16 @@ body { gap: 12px; } +.bgp-page__brief-card > * + * { + margin-top: 14px; +} + .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; @@ -1103,7 +1129,9 @@ body { .markdown-renderer ul, .markdown-renderer ol, .markdown-renderer blockquote, -.markdown-renderer pre { +.markdown-renderer pre, +.markdown-renderer hr, +.markdown-renderer__table-wrap { margin: 0 0 0.9em; } @@ -1132,6 +1160,43 @@ body { color: #e2e8f0; } +.markdown-renderer hr { + border: 0; + border-top: 1px solid rgba(15, 23, 42, 0.12); +} + +.markdown-renderer__table-wrap { + overflow-x: auto; +} + +.markdown-renderer__table { + width: 100%; + border-collapse: collapse; + min-width: 520px; + font-size: 13px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 10px; +} + +.markdown-renderer__table th, +.markdown-renderer__table td { + padding: 10px 12px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); +} + +.markdown-renderer__table th { + font-weight: 600; + color: #111827; + background: rgba(248, 250, 252, 0.95); +} + +.markdown-renderer__table tbody tr:last-child td { + border-bottom: 0; +} + .markdown-renderer code { padding: 0.08em 0.32em; border-radius: 6px; diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index 1233e0db..ac47edc9 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -519,60 +519,62 @@ function BGP() { } const briefTabContent = ( -
-
-
- BGP AI 简报 -
- 每次生成都会保存为 Markdown。打开页面时默认展示最近一次简报,也可以从历史列表切换查看。 +
+
+
+
+ BGP AI 简报 +
+ 每次生成都会保存为 Markdown。打开页面时默认展示最近一次简报,也可以从历史列表切换查看。 +
+
+
+ void handleBriefSelectionChange(value)} - disabled={briefLoading || briefDetailLoading || briefOptions.length === 0} - /> - -
-
- {briefLoading || briefDetailLoading ? ( -
- - - {briefLoading ? '正在整理 BGP 事实并生成简报...' : '正在加载已保存的 BGP 简报...'} - -
- ) : brief ? ( - - - {brief.provider || '-'} - {brief.model || '-'} - {brief.request_id || '-'} - - {formatDateTimeZhCN(brief.generated_at)} - - -
- + {briefLoading || briefDetailLoading ? ( +
+ + + {briefLoading ? '正在整理 BGP 事实并生成简报...' : '正在加载已保存的 BGP 简报...'} +
- - ) : ( -
- 还没有生成简报。点击右侧按钮,用当前 BGP 真实数据生成并保存第一份 Markdown 简报。 -
- )} + ) : brief ? ( +
+ + {brief.provider || '-'} + {brief.model || '-'} + {brief.request_id || '-'} + + {formatDateTimeZhCN(brief.generated_at)} + + +
+ +
+
+ ) : ( +
+ 还没有生成简报。点击右侧按钮,用当前 BGP 真实数据生成并保存第一份 Markdown 简报。 +
+ )} +
) diff --git a/pyproject.toml b/pyproject.toml index fc75212f..1981ac88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.24.5" +version = "0.24.6" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index e99f3d62..b5626750 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.24.5" +version = "0.24.6" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From fbb6adfbf5675dc9054dff44868ec6828a888ed2 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Fri, 10 Apr 2026 03:08:39 +0800 Subject: [PATCH 21/34] docs: formalize release workflow and frontend layout guardrails --- .codex/skills/release-workflow/SKILL.md | 78 +++++++++++++ VERSION | 2 +- docs/CHANGELOG.md | 22 +--- docs/frontend-layout-guidelines.md | 141 ++++++++++++++++++++++++ docs/version-history.md | 3 +- frontend/package.json | 2 +- pyproject.toml | 2 +- rules.md | 54 +++++++++ uv.lock | 2 +- 9 files changed, 284 insertions(+), 22 deletions(-) create mode 100644 .codex/skills/release-workflow/SKILL.md diff --git a/.codex/skills/release-workflow/SKILL.md b/.codex/skills/release-workflow/SKILL.md new file mode 100644 index 00000000..512f6169 --- /dev/null +++ b/.codex/skills/release-workflow/SKILL.md @@ -0,0 +1,78 @@ +--- +name: release-workflow +description: Use when the user asks to release, bump version, update changelog/version files, or commit/push a repository release for the Planet repo. Applies the repo's versioning rules, updates all required version-bearing files, updates changelog/version-history, runs minimal relevant validation, and then commits/pushes when requested. +--- + +# Release Workflow + +Use this skill for release-oriented work in this repository. + +## When To Use + +- The user asks to `发版` +- The user asks to bump a version +- The user asks to update `CHANGELOG`, `version-history`, or version files as part of a release +- The user asks to commit/push a release or a publishable bugfix/feature bundle + +Do not use this skill for ordinary commits that are not being released. + +## Versioning Rules + +- `feature` -> bump `+0.1.0` +- `bugfix` -> bump `+0.0.1` +- `docs`, `maintenance`, and `refactor` do not bump by default unless the user explicitly wants a release + +When intent is mixed, prefer the user’s stated release intent. If they ask to release a bugfix bundle, use a patch bump. + +## Required Files + +Every release bump must update these files together: + +- `/home/ray/dev/linkong/planet/VERSION` +- `/home/ray/dev/linkong/planet/frontend/package.json` +- `/home/ray/dev/linkong/planet/pyproject.toml` +- `/home/ray/dev/linkong/planet/uv.lock` +- `/home/ray/dev/linkong/planet/docs/CHANGELOG.md` +- `/home/ray/dev/linkong/planet/docs/version-history.md` + +## Workflow + +1. Inspect the current worktree and current version. +2. Decide the release type from the user request: + - feature + - bugfix + - release without code changes +3. Compute the next version. +4. Update all required version-bearing files. +5. Add a concise but specific changelog entry: + - highlights + - important added/improved/fixed items + - mention the highest-signal files only +6. Update `docs/version-history.md`: + - current dev version + - new timeline row with summary +7. Run the smallest relevant validation available. +8. Before commit, verify the target version is present in all required files. +9. If the user asked for commit/push: + - stage the release files and code changes + - commit with a conventional message + - push to the requested branch, usually `dev` + +## Validation Guidance + +- Prefer scope-matched validation over broad expensive checks +- Typical examples: + - Python backend edits: `python3 -m py_compile ...` + - Frontend edits: use the project-standard frontend build/check if available +- If the environment prevents a check, say that explicitly in the final summary + +## Release Checklist + +Before closing the task, confirm: + +- version bump applied consistently +- changelog updated +- version history updated +- generated/runtime artifacts are not accidentally staged +- validation status recorded +- commit and push completed if requested diff --git a/VERSION b/VERSION index b9c51a1a..c9731ff4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.6 +0.24.7 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3db0c156..d899d47d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,34 +7,22 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` -## 0.24.5 +## 0.24.7 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. +- Formalized repository release hygiene and frontend layout guardrails so repeated versioning chores and recurring layout regressions now have explicit repo-level rules instead of living only in conversation context. ### 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. +- Added [release-workflow/SKILL.md](/home/ray/dev/linkong/planet/.codex/skills/release-workflow/SKILL.md), defining the repository release workflow for version bumps, changelog/version-history updates, minimal validation, and commit/push sequencing. ### 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. +- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks. +- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.” ## 0.24.6 diff --git a/docs/frontend-layout-guidelines.md b/docs/frontend-layout-guidelines.md index bf35c337..81b5c96d 100644 --- a/docs/frontend-layout-guidelines.md +++ b/docs/frontend-layout-guidelines.md @@ -82,6 +82,145 @@ - 工作区容器通常需要 `display: flex` - 真正的滚动节点要显式 `overflow: auto` +### 6. 卡片不能被压到不可读 + +历史上我们反复踩到的问题不是“没有滚动条”,而是: + +- 卡片被 `flex` 压缩得只剩一小条可视区域 +- 文字能渲染,但读不完整 +- 内容其实存在,却被 `overflow: hidden` 裁掉 + +因此后续约束是: + +- 先保证卡片有可读的最小高度 +- 如果继续压缩会影响阅读,就切换成内部滚动 +- 不要为了“保持一屏”而把正文、表格、描述区压成无法阅读的条状区域 + +### 7. Tabs 不是天然安全的布局容器 + +历史上 Tabs 相关回归非常多,典型问题包括: + +- 隐藏 tab pane 因为自定义 `display: flex` 而重新露出来 +- 所有 tab 被强行套用同一套高度/overflow 规则 +- 表格 tab 能工作,但 markdown / help / diagnostics tab 被压坏 + +因此约束是: + +- `Tabs` 里的每类内容都要单独定义自己的布局策略 +- 表格 tab 可以是“固定高度 + 内部滚动” +- 文档/Markdown tab 更适合“tab pane 自身滚动 + 内容正常文档流” +- 如果覆盖组件库样式,必须同时检查 hidden 状态是否仍然成立 + +### 8. 摘要区优先进入紧凑模式,而不是挤压正文 + +历史经验表明,最容易被误处理的是顶部摘要卡: + +- 它们经常为了“都放下”被强行压窄 +- 然后正文、表格、AI 结果区一起失去主空间 + +后续统一约束: + +- 小屏或高缩放时,摘要卡优先: + - 降低 padding + - 改成横向滚动 + - 改成更紧凑的网格 +- 不要优先牺牲主工作区的可视面积 + +### 9. 长文档类内容优先保证阅读体验 + +像下面这些内容,不能直接套用“表格工作区”的逻辑: + +- AI 简报 +- 运行日志 +- 原始 JSON +- 帮助说明 +- 多段描述性文本 + +这些区域应该优先满足: + +- 标题和元信息稳定可见 +- 正文有明确的最小可读高度 +- 正文滚动策略单独定义 +- 支持 Markdown 表格、分隔线、引用、代码块等结构 + +### 10. 高度关键路径要少包一层 + +历史上不少滚动问题不是组件本身错,而是多包了一层之后: + +- 高度链路断掉 +- `min-height: 0` 没传下去 +- `overflow` 责任被吃掉 + +因此: + +- 对高度关键区域,优先使用最直接的 DOM 结构 +- 使用 `Space`、额外包装 `div`、第三方布局容器时,要确认它们不会改变滚动和高度语义 +- 如果一个区域已经出现“内容明明有,但只剩一条缝”,优先怀疑中间包装层 + +## 历史坑位总结 + +从 Earth、Playground、BGP、DataSources 这些页面的 bugfix 可以归纳出几类高频坑: + +### 1. 用 `overflow: hidden` 掩盖布局问题 + +表面上看页面“整齐了”,实际上会导致: + +- 内容被裁掉 +- tab 内容只剩一条缝 +- 面板明明渲染成功,但用户看不见 + +正确做法: + +- 让真正的内容节点滚动 +- 不要让上层容器无差别裁剪所有子内容 + +### 2. 把所有 tab 当成同一种内容 + +表格、Markdown、帮助卡、日志流的空间需求完全不同。 + +正确做法: + +- 表格:固定工作区 + 内部滚动 +- 文档:普通流式内容 + pane 级滚动 +- 侧边说明:内容驱动高度,不强行拉满 + +### 3. 只做视觉缩小,不做空间重分配 + +这会导致: + +- 卡片文字被截断 +- 表格只剩 1 到 2 行 +- 按钮和筛选区挤成一团 + +正确做法: + +- 紧凑模式优先重排 +- 横向滚动摘要区 +- 折叠/收纳次级模块 + +### 4. 父容器高度链不完整 + +这是最常见的内部滚动失效原因。 + +检查顺序: + +1. 外层是否真的有确定高度 +2. flex 父容器是否带了 `min-height: 0` +3. 真正滚动节点是否明确 `overflow: auto` +4. 中间包装层是否偷偷改了布局语义 + +### 5. UI 状态和显示状态不同步 + +Earth 相关改动里反复出现: + +- 图层隐藏了,但 hover/lock 还在 +- tooltip 还在显示旧对象 +- legend 没跟着切换 + +这类约束同样适用于后台页面: + +- 被隐藏、卸载、切换出视图的内容,不应继续保留活跃交互状态 + ## 推荐实现模式 ### 页面骨架 @@ -149,9 +288,11 @@ - 页头、摘要区、主工作区能否同时出现 - 主工作区是否拿到了页面中最多的高度 - 表格或明细溢出时,滚动条是否出现在模块内部 +- 卡片是否被压缩到文字显示不完整;如果会,是否已经切换为内部滚动 - 浏览器缩放到 `125%` / `150%` 时是否仍可用 - 低高度窗口下是否还保有合理的可见内容行数 - Tabs、Card、Table 在 overflow 时是否仍可操作 +- 非表格 tab(Markdown、帮助说明、日志)是否有独立且合理的滚动策略 ## 落地顺序 diff --git a/docs/version-history.md b/docs/version-history.md index 1704fa3d..bab08169 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.24.6` +- `dev` 当前开发分支历史推导到:`0.24.7` ## Timeline @@ -79,6 +79,7 @@ | `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 | | `0.24.6` | bugfix | `dev` | `pending` | batch datasource and visualization hot-path queries, fix BGP collector JSON extraction, and rebuild the BGP AI brief tab layout and markdown rendering | +| `0.24.7` | bugfix | `dev` | `pending` | formalize release workflow and frontend layout constraints with repo rules and a reusable release skill | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index c6b2db44..0be6b0df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.24.6", + "version": "0.24.7", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/pyproject.toml b/pyproject.toml index 1981ac88..ede4323f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.24.6" +version = "0.24.7" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/rules.md b/rules.md index cce03fa5..68c78346 100644 --- a/rules.md +++ b/rules.md @@ -239,3 +239,57 @@ class BaseCollector: - Python-side post-filtering - repeated summary queries that can be merged - repeated per-request recomputation that should be cached or aggregated once + +--- + +## Release Workflow - MANDATORY + +- When the user asks to `发版`, `bump version`, `release`, or `推送发布类改动`, treat it as a release workflow, not a plain commit +- Apply repository versioning rules consistently: + - `feature` -> `+0.1.0` + - `bugfix` -> `+0.0.1` + - `docs / maintenance / refactor` do **NOT** bump version unless the user explicitly wants a release anyway +- A release bump **MUST** update all version-bearing files together: + - `VERSION` + - `frontend/package.json` + - `pyproject.toml` + - `uv.lock` +- A release bump **MUST** update release records together: + - `docs/CHANGELOG.md` + - `docs/version-history.md` +- Before committing a release, verify the target version appears consistently in all required files +- Before pushing a release, run the smallest relevant validation available for the changed scope and report what was or was not validated +- If runtime output directories are part of the feature flow, confirm they are ignored appropriately so release commits do not accidentally include generated artifacts +- If asked to commit/push release work, do **NOT** skip changelog or version-history updates just because the code changes are small +- Use the repo skill at `/home/ray/dev/linkong/planet/.codex/skills/release-workflow/SKILL.md` whenever performing a release workflow for this repository + +--- + +## Frontend Layout - MANDATORY + +- Backend/admin pages must be designed as a `single-screen workspace` first, not as a long vertically stacked document +- In common desktop viewports, users should be able to see: + - page header + - summary/controls + - the main work area +- The main work area must get the majority of the available height; secondary cards must not crowd it out +- If a card or panel would be compressed until text, controls, or tables become unreadable, stop shrinking it and give that region an internal scrollbar instead +- On small screens, high browser zoom, or reduced viewport height, switch to a compact mode or horizontal summary scrolling before allowing important content to be crushed +- Overflow ownership must be explicit: + - parent height chain must be valid + - height-constrained flex parents need `min-height: 0` + - only the intended scroll node should own `overflow: auto` +- Do **NOT** rely on `overflow: hidden` as the final fix for a crowded layout unless another child container is explicitly responsible for scrolling +- For tabs: + - hidden tab panes must stay hidden + - do not override library hidden-pane selectors in a way that makes inactive content visible + - each tab must define its own scroll strategy instead of inheriting a one-size-fits-all table layout +- For long-form content such as AI briefs, logs, markdown, raw JSON, or help text: + - prefer normal document flow inside the content block + - if height is constrained, use a stable minimum readable height plus scrolling + - do not let flex compression collapse the readable area into a thin strip +- Avoid brittle viewport sizing: + - prefer `height: 100%` chains over naive `100vh/100vw` usage in embedded/admin shells + - verify layouts under browser zoom `125%` and `150%` +- Avoid using wrapper components with implicit layout behavior, such as `Space`, for height-critical scroll regions unless their generated DOM is fully accounted for +- Any UI state that hides data or a layer must also reconcile related hover/lock/tooltip/selection state so hidden content is not still “active” in the UI diff --git a/uv.lock b/uv.lock index b5626750..862e6a28 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.24.6" +version = "0.24.7" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From 60f5ff9babd16f161d2f6bbf4278cf89278da0fc Mon Sep 17 00:00:00 2001 From: rayd1o Date: Fri, 10 Apr 2026 03:57:13 +0800 Subject: [PATCH 22/34] fix: move bgp brief markdown into a modal workspace --- VERSION | 2 +- docs/CHANGELOG.md | 16 +++++++ docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/src/index.css | 85 ++++++++++++++++++++++++++++++---- frontend/src/pages/BGP/BGP.tsx | 49 +++++++++++++++----- pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 135 insertions(+), 26 deletions(-) diff --git a/VERSION b/VERSION index c9731ff4..1f1cd637 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.7 +0.24.8 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d899d47d..58318156 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,22 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.24.8 + +Released: 2026-04-10 + +### Highlights + +- Refined the BGP AI brief operator workflow so the tab now stays compact and metadata-focused, while the full Markdown brief opens in a bounded modal that respects the repo’s single-screen layout rules. + +### Improved + +- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) by turning the brief action row into a clearer `历史简报下拉框 + 查看 + 生成` flow, keeping inline metadata visible in the tab while moving full Markdown reading into a dedicated modal workspace. + +### Fixed + +- Fixed [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) and [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) so the BGP brief modal no longer spills below the viewport; long brief content now scrolls inside the modal body instead of extending past the visible screen. + ## 0.24.7 Released: 2026-04-10 diff --git a/docs/version-history.md b/docs/version-history.md index bab08169..08eb09c4 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.24.7` +- `dev` 当前开发分支历史推导到:`0.24.8` ## Timeline @@ -80,6 +80,7 @@ | `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 | | `0.24.6` | bugfix | `dev` | `pending` | batch datasource and visualization hot-path queries, fix BGP collector JSON extraction, and rebuild the BGP AI brief tab layout and markdown rendering | | `0.24.7` | bugfix | `dev` | `pending` | formalize release workflow and frontend layout constraints with repo rules and a reusable release skill | +| `0.24.8` | bugfix | `dev` | `pending` | move BGP brief markdown into a dedicated modal, keep tab content metadata-focused, and constrain modal scrolling to the viewport | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 0be6b0df..bedfea5d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.24.7", + "version": "0.24.8", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/src/index.css b/frontend/src/index.css index 9f44d7ef..b31073a5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1038,15 +1038,6 @@ body { gap: 12px; } -.bgp-page__brief-content { - min-height: 360px; - overflow: visible; - 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); -} - .bgp-page__brief-head { display: flex; align-items: flex-start; @@ -1058,10 +1049,20 @@ body { display: flex; align-items: center; gap: 12px; + margin-left: auto; + min-width: 0; } .bgp-page__brief-select { - min-width: 260px; + width: 320px; + min-width: 240px; +} + +.bgp-page__brief-buttons { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 12px; } .bgp-page__brief-subtitle { @@ -1071,6 +1072,22 @@ body { line-height: 1.5; } +@media (max-width: 960px) { + .bgp-page__brief-head { + flex-direction: column; + } + + .bgp-page__brief-actions { + width: 100%; + margin-left: 0; + } + + .bgp-page__brief-select { + flex: 1 1 auto; + width: auto; + } +} + .bgp-page__brief-loading, .bgp-page__brief-empty { min-height: 72px; @@ -1089,6 +1106,54 @@ body { padding: 8px 12px; } +.bgp-page__brief-modal-body { + max-height: calc(100vh - 180px); + overflow: auto; + padding-right: 6px; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.88) transparent; +} + +.bgp-page__brief-modal { + max-width: min(920px, calc(100vw - 32px)); +} + +.bgp-page__brief-modal .ant-modal-content { + max-height: calc(100vh - 48px); + overflow: hidden; +} + +.bgp-page__brief-modal .ant-modal-body { + overflow: hidden; +} + +.bgp-page__brief-modal-body::-webkit-scrollbar { + width: 8px; +} + +.bgp-page__brief-modal-body::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(148, 163, 184, 0.88); +} + +.bgp-page__brief-modal-body::-webkit-scrollbar-track { + background: transparent; +} + +@media (max-width: 768px) { + .bgp-page__brief-modal { + max-width: calc(100vw - 20px); + } + + .bgp-page__brief-modal .ant-modal-content { + max-height: calc(100vh - 20px); + } + + .bgp-page__brief-modal-body { + max-height: calc(100vh - 156px); + } +} + .markdown-renderer { color: #262626; font-size: 13px; diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index ac47edc9..2eb83801 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -6,6 +6,7 @@ import { Card, Col, Descriptions, + Modal, Row, Select, Space, @@ -343,6 +344,7 @@ function BGP() { const [briefOptions, setBriefOptions] = useState([]) const [selectedBriefId, setSelectedBriefId] = useState(null) const [briefListLoaded, setBriefListLoaded] = useState(false) + const [briefModalOpen, setBriefModalOpen] = useState(false) const tableRegionRef = useRef(null) useEffect(() => { @@ -537,14 +539,22 @@ function BGP() { onChange={(value) => void handleBriefSelectionChange(value)} disabled={briefLoading || briefDetailLoading || briefOptions.length === 0} /> - +
+ + +
@@ -565,9 +575,6 @@ function BGP() { {formatDateTimeZhCN(brief.generated_at)} -
- -
) : (
@@ -706,6 +713,26 @@ function BGP() {
+ + setBriefModalOpen(false)} + footer={null} + width={920} + className="bgp-page__brief-modal" + style={{ top: 24 }} + styles={{ body: { paddingTop: 12 } }} + destroyOnHidden + > + {brief ? ( +
+ +
+ ) : ( + 当前没有可查看的简报内容。 + )} +
) diff --git a/pyproject.toml b/pyproject.toml index ede4323f..077071d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.24.7" +version = "0.24.8" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 862e6a28..2b1bcbb1 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.24.7" +version = "0.24.8" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From 89a71e6f294e4060abc1621242e6eb98fac32017 Mon Sep 17 00:00:00 2001 From: linkong Date: Fri, 10 Apr 2026 15:57:34 +0800 Subject: [PATCH 23/34] feat: ship persistent ai playground and alerts foundation --- README.md | 1 + backend/app/api/v1/ai.py | 184 ++- backend/app/api/v1/alerts.py | 9 +- backend/app/api/v1/dashboard.py | 2 +- backend/app/db/session.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/playground_message.py | 40 + backend/app/models/playground_session.py | 27 + backend/app/schemas/ai.py | 114 ++ backend/app/schemas/alert.py | 5 + backend/app/services/alert_ai_brief.py | 103 ++ backend/app/services/bgp_ai_brief.py | 138 +- backend/app/services/bgp_ai_brief_store.py | 15 +- backend/app/services/bgp_enrichment.py | 7 + .../app/services/playground_chat_service.py | 678 +++++++++ .../app/services/playground_session_store.py | 72 + .../services/situational_alert_ai_brief.py | 174 +++ backend/tests/test_api.py | 296 +++- docs/situational-awareness-foundation-plan.md | 309 +++++ frontend/src/App.tsx | 9 + .../src/components/AppLayout/AppLayout.tsx | 84 +- frontend/src/index.css | 685 ++++++++- frontend/src/pages/Alerts/Alerts.tsx | 263 +--- frontend/src/pages/Alerts/BGPAlerts.tsx | 264 ++++ .../src/pages/Alerts/SituationalAlerts.tsx | 202 +++ frontend/src/pages/Alerts/SystemAlerts.tsx | 303 ++++ frontend/src/pages/BGP/BGP.tsx | 57 + frontend/src/pages/Playground/Playground.tsx | 1227 ++++++++++++----- .../services/situational-awareness/index.ts | 5 - .../situational-awareness/mock-gateway.ts | 103 -- .../services/situational-awareness/types.ts | 43 + 31 files changed, 4754 insertions(+), 669 deletions(-) create mode 100644 backend/app/models/playground_message.py create mode 100644 backend/app/models/playground_session.py create mode 100644 backend/app/schemas/alert.py create mode 100644 backend/app/services/alert_ai_brief.py create mode 100644 backend/app/services/playground_chat_service.py create mode 100644 backend/app/services/playground_session_store.py create mode 100644 backend/app/services/situational_alert_ai_brief.py create mode 100644 docs/situational-awareness-foundation-plan.md create mode 100644 frontend/src/pages/Alerts/BGPAlerts.tsx create mode 100644 frontend/src/pages/Alerts/SituationalAlerts.tsx create mode 100644 frontend/src/pages/Alerts/SystemAlerts.tsx delete mode 100644 frontend/src/services/situational-awareness/mock-gateway.ts diff --git a/README.md b/README.md index 303a67d9..6ce6ab59 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,7 @@ AI_PROVIDER_SERVICE_TOKEN=change_me - [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md) - [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) - [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) +- [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md) ## 前端页面布局规范 diff --git a/backend/app/api/v1/ai.py b/backend/app/api/v1/ai.py index 364db973..2900ceb7 100644 --- a/backend/app/api/v1/ai.py +++ b/backend/app/api/v1/ai.py @@ -8,12 +8,25 @@ from app.db.session import get_db from app.models.user import User from app.schemas.ai import ( AIProviderStatusResponse, + AlertBriefRequest, + AlertBriefResponse, BGPBriefRequest, BGPBriefRecordResponse, BGPBriefRecordSummary, + PlaygroundMessageActionResponse, + PlaygroundMessageCreateRequest, + PlaygroundMessageEditRequest, + PlaygroundMessageResendRequest, + PlaygroundMessageStopRequest, + PlaygroundSessionResponse, + PlaygroundSessionUpsertRequest, + PlaygroundThreadResponse, + SituationalAlertBriefRequest, + SituationalAlertBriefResponse, SituationalAnalysisRequest, SituationalAnalysisResponse, ) +from app.services.alert_ai_brief import build_alert_brief_request 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 ( @@ -22,6 +35,18 @@ from app.services.bgp_ai_brief_store import ( list_bgp_brief_records, save_bgp_brief_record, ) +from app.services.playground_session_store import ( + get_playground_session, + upsert_playground_session, +) +from app.services.playground_chat_service import ( + create_turn, + edit_user_message, + get_thread, + resend_turn, + stop_message, +) +from app.services.situational_alert_ai_brief import build_situational_alert_brief_request router = APIRouter() @@ -51,6 +76,101 @@ async def analyze_situational_awareness( return await provider_client.analyze(payload, request_id=request_id) +@router.get("/playground/thread", response_model=PlaygroundThreadResponse | None) +async def get_playground_thread( + session_key: str = "default", + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await get_thread( + db, + user_id=current_user.id, + session_key=session_key, + ) + + +@router.get("/playground/session", response_model=PlaygroundSessionResponse | None) +async def get_saved_playground_session( + session_key: str = "default", + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await get_playground_session( + db, + user_id=current_user.id, + session_key=session_key, + ) + + +@router.put("/playground/session", response_model=PlaygroundSessionResponse) +async def save_playground_session( + payload: PlaygroundSessionUpsertRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await upsert_playground_session( + db, + user_id=current_user.id, + payload=payload, + ) + + +@router.post("/playground/messages", response_model=PlaygroundMessageActionResponse) +async def create_playground_message( + payload: PlaygroundMessageCreateRequest, + current_user: User = Depends(get_current_user), + provider_client: AIProviderClient = Depends(get_ai_provider_client), + db: AsyncSession = Depends(get_db), +): + return await create_turn( + db, + user_id=current_user.id, + payload=payload, + provider_client=provider_client, + ) + + +@router.post("/playground/messages/stop", response_model=PlaygroundMessageActionResponse) +async def stop_playground_message( + payload: PlaygroundMessageStopRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await stop_message( + db, + user_id=current_user.id, + payload=payload, + ) + + +@router.post("/playground/messages/resend", response_model=PlaygroundMessageActionResponse) +async def resend_playground_message( + payload: PlaygroundMessageResendRequest, + current_user: User = Depends(get_current_user), + provider_client: AIProviderClient = Depends(get_ai_provider_client), + db: AsyncSession = Depends(get_db), +): + return await resend_turn( + db, + user_id=current_user.id, + payload=payload, + provider_client=provider_client, + ) + + +@router.post("/playground/messages/edit", response_model=PlaygroundMessageActionResponse) +async def edit_playground_message( + payload: PlaygroundMessageEditRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await edit_user_message( + db, + user_id=current_user.id, + payload=payload, + ) + + @router.get("/bgp/briefs", response_model=list[BGPBriefRecordSummary]) async def list_saved_bgp_briefs( current_user: User = Depends(get_current_user), @@ -88,7 +208,7 @@ async def analyze_bgp_brief( 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( + brief_request, facts, context = await build_bgp_brief_request( db, incident_limit=payload.incident_limit, anomaly_limit=payload.anomaly_limit, @@ -98,4 +218,64 @@ async def analyze_bgp_brief( 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) + return save_bgp_brief_record( + analysis, + request_id=request_id, + facts=facts, + context=context, + ) + + +@router.post("/alerts/brief", response_model=AlertBriefResponse) +async def analyze_alert_brief( + payload: AlertBriefRequest, + 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, facts, context = await build_alert_brief_request( + db, + alert_limit=payload.alert_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 AlertBriefResponse( + **analysis.model_dump(), + title=brief_request.title, + objective=brief_request.objective, + facts=facts, + context=context, + ) + + +@router.post("/situational-alerts/brief", response_model=SituationalAlertBriefResponse) +async def analyze_situational_alert_brief( + payload: SituationalAlertBriefRequest, + 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, facts, context = await build_situational_alert_brief_request(db) + brief_request.preferred_model = payload.preferred_model + brief_request.thinking = payload.thinking + + analysis = await provider_client.analyze(brief_request, request_id=request_id) + return SituationalAlertBriefResponse( + **analysis.model_dump(), + title=brief_request.title, + objective=brief_request.objective, + facts=facts, + context=context, + ) diff --git a/backend/app/api/v1/alerts.py b/backend/app/api/v1/alerts.py index c53c046c..f780c203 100644 --- a/backend/app/api/v1/alerts.py +++ b/backend/app/api/v1/alerts.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select, func, case from sqlalchemy.ext.asyncio import AsyncSession @@ -9,6 +9,7 @@ from app.db.session import get_db from app.models.user import User from app.core.security import get_current_user from app.models.alert import Alert, AlertSeverity, AlertStatus +from app.schemas.alert import AlertResolutionRequest router = APIRouter() @@ -77,7 +78,7 @@ async def acknowledge_alert( @router.post("/{alert_id}/resolve") async def resolve_alert( alert_id: int, - resolution: str, + payload: AlertResolutionRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -85,12 +86,12 @@ async def resolve_alert( alert = result.scalar_one_or_none() if not alert: - return {"error": "Alert not found"} + raise HTTPException(status_code=404, detail="Alert not found") alert.status = AlertStatus.RESOLVED alert.resolved_by = current_user.id alert.resolved_at = datetime.now(UTC) - alert.resolution_notes = resolution + alert.resolution_notes = payload.resolution await db.commit() return {"message": "Alert resolved", "alert": alert.to_dict()} diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 7379422a..6596af71 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends -from sqlalchemy import select, func, text +from sqlalchemy import case, select, func, text from sqlalchemy.ext.asyncio import AsyncSession from app.db.session import get_db diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 66368051..4aed0860 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -95,6 +95,8 @@ async def init_db(): import app.models.bgp_observation # noqa: F401 import app.models.collected_data # noqa: F401 import app.models.system_setting # noqa: F401 + import app.models.playground_session # noqa: F401 + import app.models.playground_message # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 30c52b05..ebc4d6ca 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,8 @@ from app.models.bgp_anomaly import BGPAnomaly from app.models.bgp_incident import BGPIncident from app.models.bgp_observation import BGPObservation from app.models.system_setting import SystemSetting +from app.models.playground_session import PlaygroundSession +from app.models.playground_message import PlaygroundMessage __all__ = [ "User", diff --git a/backend/app/models/playground_message.py b/backend/app/models/playground_message.py new file mode 100644 index 00000000..ce85de8c --- /dev/null +++ b/backend/app/models/playground_message.py @@ -0,0 +1,40 @@ +from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.sql import func + +from app.db.session import Base + + +class PlaygroundMessage(Base): + __tablename__ = "playground_messages" + + id = Column(Integer, primary_key=True, autoincrement=True) + public_id = Column(String(64), unique=True, index=True, nullable=False) + session_id = Column(Integer, ForeignKey("playground_sessions.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + parent_message_id = Column(Integer, ForeignKey("playground_messages.id", ondelete="SET NULL"), nullable=True) + role = Column(String(20), nullable=False) + kind = Column(String(20), nullable=False, default="message") + status = Column(String(20), nullable=False, default="done") + title = Column(String(255), nullable=True) + content = Column(Text, nullable=False, default="") + thinking_content = Column(Text, nullable=False, default="") + meta = Column(JSON, nullable=False, default=list) + provider = Column(String(100), nullable=True) + model = Column(String(200), nullable=True) + request_id = Column(String(100), nullable=True) + raw_response = Column(JSON, nullable=False, default=dict) + content_blocks = Column(JSON, nullable=False, default=list) + text_blocks = Column(JSON, nullable=False, default=list) + thinking_blocks = Column(JSON, nullable=False, default=list) + sort_order = Column(Integer, nullable=False, default=0, index=True) + is_visible = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/playground_session.py b/backend/app/models/playground_session.py new file mode 100644 index 00000000..0343127f --- /dev/null +++ b/backend/app/models/playground_session.py @@ -0,0 +1,27 @@ +from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.sql import func + +from app.db.session import Base + + +class PlaygroundSession(Base): + __tablename__ = "playground_sessions" + __table_args__ = ( + UniqueConstraint("user_id", "session_key", name="uq_playground_sessions_user_session_key"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + session_key = Column(String(100), nullable=False, default="default") + title = Column(String(200), nullable=False, default="Playground 会话") + state = Column(JSON, nullable=False, default={}) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + def __repr__(self): + return f"" diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py index 3bcdac9c..37e02f45 100644 --- a/backend/app/schemas/ai.py +++ b/backend/app/schemas/ai.py @@ -29,6 +29,17 @@ class BGPBriefRequest(BaseModel): thinking: dict[str, Any] | None = None +class AlertBriefRequest(BaseModel): + alert_limit: int = Field(default=8, ge=1, le=20) + preferred_model: str | None = Field(default=None, max_length=200) + thinking: dict[str, Any] | None = None + + +class SituationalAlertBriefRequest(BaseModel): + preferred_model: str | None = Field(default=None, max_length=200) + thinking: dict[str, Any] | None = None + + class SituationalAnalysisResponse(BaseModel): provider: str model: str @@ -50,6 +61,22 @@ class BGPBriefRecordSummary(BaseModel): class BGPBriefRecordResponse(BGPBriefRecordSummary): content_markdown: str + facts: list[str] = Field(default_factory=list) + context: dict[str, Any] = Field(default_factory=dict) + + +class AlertBriefResponse(SituationalAnalysisResponse): + title: str + objective: str + facts: list[str] = Field(default_factory=list) + context: dict[str, Any] = Field(default_factory=dict) + + +class SituationalAlertBriefResponse(SituationalAnalysisResponse): + title: str + objective: str + facts: list[str] = Field(default_factory=list) + context: dict[str, Any] = Field(default_factory=dict) class AIProviderStatusResponse(BaseModel): @@ -59,3 +86,90 @@ class AIProviderStatusResponse(BaseModel): configured: bool model: str | None = None base_url: str | None = None + + +class PlaygroundSessionState(BaseModel): + messages: list[dict[str, Any]] = Field(default_factory=list) + selectedPresetKey: str = Field(default="bgp-brief", max_length=100) + title: str = Field(default="", max_length=200) + objective: str = Field(default="", max_length=1000) + constraints: str = Field(default="") + inputValue: str = Field(default="") + analysis: dict[str, Any] | None = None + latestAnalysisMessageId: str | None = Field(default=None, max_length=200) + analysisMeta: dict[str, Any] = Field(default_factory=dict) + helpExpanded: bool = True + + +class PlaygroundSessionUpsertRequest(BaseModel): + session_key: str = Field(default="default", min_length=1, max_length=100) + title: str | None = Field(default=None, max_length=200) + state: PlaygroundSessionState + + +class PlaygroundMessageRecord(BaseModel): + id: str + role: str + kind: str = "message" + status: str = "done" + title: str | None = None + content: str = "" + thinking_content: str = "" + meta: list[str] = Field(default_factory=list) + markdown: bool = True + provider: str | None = None + model: str | None = None + request_id: str | None = None + raw_response: dict[str, Any] = Field(default_factory=dict) + content_blocks: list[dict[str, Any]] = Field(default_factory=list) + text_blocks: list[str] = Field(default_factory=list) + thinking_blocks: list[str] = Field(default_factory=list) + parent_message_id: str | None = None + created_at: str + updated_at: str + + +class PlaygroundSessionResponse(BaseModel): + id: str + session_key: str + title: str + state: PlaygroundSessionState + created_at: str + updated_at: str + + +class PlaygroundThreadResponse(BaseModel): + session: PlaygroundSessionResponse + messages: list[PlaygroundMessageRecord] = Field(default_factory=list) + + +class PlaygroundMessageCreateRequest(BaseModel): + session_key: str = Field(default="default", min_length=1, max_length=100) + title: str = Field(..., min_length=1, max_length=200) + objective: str = Field(..., min_length=1, max_length=1000) + constraints: str = Field(default="") + input: str = Field(..., min_length=1) + selected_preset_key: str = Field(default="bgp-brief", max_length=100) + help_expanded: bool = True + + +class PlaygroundMessageActionResponse(BaseModel): + session: PlaygroundSessionResponse + messages: list[PlaygroundMessageRecord] = Field(default_factory=list) + active_message_id: str | None = None + + +class PlaygroundMessageStopRequest(BaseModel): + session_key: str = Field(default="default", min_length=1, max_length=100) + message_id: str = Field(..., min_length=1, max_length=64) + + +class PlaygroundMessageResendRequest(BaseModel): + session_key: str = Field(default="default", min_length=1, max_length=100) + user_message_id: str = Field(..., min_length=1, max_length=64) + + +class PlaygroundMessageEditRequest(BaseModel): + session_key: str = Field(default="default", min_length=1, max_length=100) + user_message_id: str = Field(..., min_length=1, max_length=64) + content: str = Field(..., min_length=1) diff --git a/backend/app/schemas/alert.py b/backend/app/schemas/alert.py new file mode 100644 index 00000000..f8cb4e7e --- /dev/null +++ b/backend/app/schemas/alert.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + + +class AlertResolutionRequest(BaseModel): + resolution: str = Field(..., min_length=1, max_length=1000) diff --git a/backend/app/services/alert_ai_brief.py b/backend/app/services/alert_ai_brief.py new file mode 100644 index 00000000..6051fa61 --- /dev/null +++ b/backend/app/services/alert_ai_brief.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections import Counter +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.alert import Alert, AlertSeverity, AlertStatus +from app.schemas.ai import AlertBriefRequest, SituationalAnalysisRequest + + +def _format_counter(counter: Counter[str], empty_text: str = "无") -> str: + if not counter: + return empty_text + return ",".join(f"{key} {value}" for key, value in counter.items()) + + +async def build_alert_brief_request( + db: AsyncSession, + *, + alert_limit: int = 8, +) -> tuple[SituationalAnalysisRequest, list[str], dict[str, Any]]: + recent_alerts_result = await db.execute( + select(Alert) + .order_by(Alert.created_at.desc(), Alert.id.desc()) + .limit(max(alert_limit, 1)) + ) + total_result = await db.execute(select(func.count(Alert.id))) + active_result = await db.execute(select(func.count(Alert.id)).where(Alert.status == AlertStatus.ACTIVE)) + acknowledged_result = await db.execute( + select(func.count(Alert.id)).where(Alert.status == AlertStatus.ACKNOWLEDGED) + ) + resolved_result = await db.execute(select(func.count(Alert.id)).where(Alert.status == AlertStatus.RESOLVED)) + + recent_alerts = recent_alerts_result.scalars().all() + total_alerts = total_result.scalar() or 0 + active_alerts = active_result.scalar() or 0 + acknowledged_alerts = acknowledged_result.scalar() or 0 + resolved_alerts = resolved_result.scalar() or 0 + + severity_counts = Counter((item.severity.value if item.severity else "unknown") for item in recent_alerts) + status_counts = Counter((item.status.value if item.status else "unknown") for item in recent_alerts) + datasource_counts = Counter((item.datasource_name or "未命名数据源") for item in recent_alerts) + active_datasource_counts = Counter( + (item.datasource_name or "未命名数据源") + for item in recent_alerts + if item.status == AlertStatus.ACTIVE + ) + + facts = [ + f"告警总量 {total_alerts} 条,其中 active {active_alerts} 条、acknowledged {acknowledged_alerts} 条、resolved {resolved_alerts} 条。", + f"最近告警严重度分布:{_format_counter(severity_counts)}。", + f"最近告警状态分布:{_format_counter(status_counts)}。", + f"最近告警数据源分布:{_format_counter(Counter(dict(datasource_counts.most_common(6))))}。", + ] + + if active_datasource_counts: + facts.append( + "当前待处理告警主要集中在:" + + _format_counter(Counter(dict(active_datasource_counts.most_common(5)))) + + "。" + ) + + if recent_alerts: + facts.append( + "最近告警摘录:" + + ";".join( + [ + f"{item.datasource_name or '未命名数据源'} / {item.severity.value if item.severity else '-'} / {item.status.value if item.status else '-'} / {item.message or '-'}" + for item in recent_alerts[:6] + ] + ) + ) + + context = { + "source": "alerts", + "total_alerts": total_alerts, + "active_alerts": active_alerts, + "acknowledged_alerts": acknowledged_alerts, + "resolved_alerts": resolved_alerts, + "severity_distribution": dict(severity_counts), + "status_distribution": dict(status_counts), + "top_datasources": dict(datasource_counts.most_common(6)), + "top_active_datasources": dict(active_datasource_counts.most_common(5)), + } + + return ( + SituationalAnalysisRequest( + title="告警态势 AI 简报", + objective="基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。", + observations=facts, + constraints=[ + "明确区分事实、推断与建议。", + "优先指出仍处于 active 状态且高严重度的告警簇。", + "不要把 acknowledged 或 resolved 告警误判成当前仍在扩大。", + "如果证据不足,请明确指出缺失的上下文。", + ], + context=context, + ), + facts, + context, + ) diff --git a/backend/app/services/bgp_ai_brief.py b/backend/app/services/bgp_ai_brief.py index 998b440e..b492deac 100644 --- a/backend/app/services/bgp_ai_brief.py +++ b/backend/app/services/bgp_ai_brief.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import Counter +from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +12,7 @@ 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 +from app.services.bgp_enrichment import lookup_prefix_geography def _format_counter(counter: dict[str, int], empty_text: str = "无") -> str: @@ -30,13 +32,59 @@ def _severity_rank(value: str | None) -> int: return order.get((value or "").lower(), 99) +def _normalize_geo_key(country: str | None, city: str | None) -> str: + if city and country: + return f"{city}, {country}" + return city or country or "未知区域" + + +def _top_counter_items(counter: Counter[str], limit: int = 5) -> dict[str, int]: + return {name: count for name, count in counter.most_common(limit) if name} + + +def _collect_incident_regions(incidents: list[BGPIncident]) -> Counter[str]: + counter: Counter[str] = Counter() + for item in incidents: + for region in item.affected_regions or []: + if not isinstance(region, dict): + continue + counter[_normalize_geo_key(region.get("country"), region.get("city"))] += 1 + return counter + + +def _collect_collector_regions(collectors: list[dict[str, Any]]) -> Counter[str]: + counter: Counter[str] = Counter() + for item in collectors: + counter[_normalize_geo_key(item.get("country"), item.get("city"))] += int(item.get("recent_24h_observation_count") or 0) + return counter + + +def _format_geo_evidence(prefix_geographies: dict[str, dict[str, Any]], limit: int = 6) -> str: + if not prefix_geographies: + return "没有命中 prefix geography 证据。" + + rows = [] + for prefix, item in list(prefix_geographies.items())[:limit]: + region = _normalize_geo_key(item.get("country"), item.get("city")) + source = item.get("source") or item.get("geography_mode") or "unknown" + as_hint = item.get("asn") + as_name = item.get("as_name") + as_text = "" + if as_hint: + as_text = f" / ASN AS{as_hint}" + if as_name: + as_text += f" ({as_name})" + rows.append(f"{prefix} -> {region} / 来源 {source}{as_text}") + return ";".join(rows) + + async def build_bgp_brief_request( db: AsyncSession, *, incident_limit: int = 5, anomaly_limit: int = 6, collector_limit: int = 5, -) -> SituationalAnalysisRequest: +) -> tuple[SituationalAnalysisRequest, list[str], dict[str, int | str | dict[str, int]]]: incidents_result = await db.execute( select(BGPIncident) .order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc()) @@ -68,6 +116,7 @@ async def build_bgp_brief_request( 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) + incident_region_counts = _collect_incident_regions(incidents) top_collectors = sorted( active_collectors, @@ -77,6 +126,29 @@ async def build_bgp_brief_request( str(item["collector"]), ), )[: max(collector_limit, 1)] + collector_region_counts = _collect_collector_regions(top_collectors) + + prefix_candidates = sorted( + { + prefix + for item in incidents + for prefix in (item.affected_prefixes or []) + if prefix + } + | {item.prefix for item in anomalies if item.prefix} + ) + prefix_geographies = await lookup_prefix_geography(db, prefix_candidates) if prefix_candidates else {} + geography_region_counts = Counter( + _normalize_geo_key(item.get("country"), item.get("city")) + for item in prefix_geographies.values() + if item.get("country") or item.get("city") + ) + hotspot_region_counts = geography_region_counts + incident_region_counts + collector_bias_regions = [ + region + for region, count in collector_region_counts.most_common(3) + if count > hotspot_region_counts.get(region, 0) + ] observations_lines: list[str] = [ f"当前共有 {total_incidents} 起 BGP incidents、{total_anomalies} 条 anomalies、{total_observations} 条原始观测事件。", @@ -88,6 +160,27 @@ async def build_bgp_brief_request( f"观测事件类型分布:{_format_counter(dict(event_type_counts.most_common(6)))}。", ] + if hotspot_region_counts: + observations_lines.append( + "区域热点事实层:" + + _format_counter(_top_counter_items(hotspot_region_counts, limit=5), empty_text="无明显区域聚集") + + "。" + ) + + if prefix_geographies: + observations_lines.append("Prefix geography 证据:" + _format_geo_evidence(prefix_geographies)) + + if collector_bias_regions: + observations_lines.append( + "观测偏差提示:重点观测站最近 24h 活跃度更集中在 " + + "、".join(collector_bias_regions) + + ",这些区域的事件升温结论需要结合 prefix geography 与 affected regions 交叉验证。" + ) + elif top_collectors: + observations_lines.append( + "观测偏差提示:当前未发现明显高于区域热点事实层的单一观测站集中区域,但仍需区分 collector coverage 与真实区域风险。" + ) + if incidents: observations_lines.append( "最近 incident 摘要:" + ";".join( @@ -124,24 +217,43 @@ async def build_bgp_brief_request( ) ) + 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)), + "region_hotspots": _top_counter_items(hotspot_region_counts, limit=6), + "incident_regions": _top_counter_items(incident_region_counts, limit=6), + "collector_bias_regions": collector_bias_regions, + "prefix_geography_sources": dict( + Counter(str(item.get("source") or "unknown") for item in prefix_geographies.values()).most_common(5) + ), + "prefix_geography_sample": { + prefix: { + "country": item.get("country"), + "city": item.get("city"), + "source": item.get("source"), + "asn": item.get("asn"), + "as_name": item.get("as_name"), + } + for prefix, item in list(prefix_geographies.items())[:8] + }, + } + return SituationalAnalysisRequest( title="BGP 态势 AI 简报", - objective="基于当前 BGP incidents、anomalies、原始观测事件与观测站覆盖情况,生成一份面向操作员的简明态势简报,突出当前风险、证据和优先动作。", + objective="基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。", observations=observations_lines, constraints=[ "明确区分事实、推断与建议。", "优先指出需要立即关注的高严重度 incident 或异常模式。", + "需要单独指出哪些区域结论来自 prefix geography / affected regions,哪些可能受 collector coverage 偏差影响。", "结论应服务值班排障,不要写成泛泛的模型演示文案。", "如果证据不足,要明确指出缺失数据。", ], - 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)), - }, - ) + context=context, + ), observations_lines, context diff --git a/backend/app/services/bgp_ai_brief_store.py b/backend/app/services/bgp_ai_brief_store.py index 66eb2302..90658060 100644 --- a/backend/app/services/bgp_ai_brief_store.py +++ b/backend/app/services/bgp_ai_brief_store.py @@ -4,6 +4,7 @@ import json from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path +from typing import Any from uuid import uuid4 from app.core.config import ROOT_DIR @@ -25,6 +26,8 @@ class _StoredBrief: request_id: str | None generated_at: str content_markdown: str + facts: list[str] + context: dict[str, Any] path: Path @@ -33,7 +36,7 @@ def _ensure_storage_dir() -> Path: return _BRIEF_STORAGE_DIR -def _build_metadata_line(metadata: dict[str, str | None]) -> str: +def _build_metadata_line(metadata: dict[str, Any]) -> str: return f"{_METADATA_PREFIX}{json.dumps(metadata, ensure_ascii=False)}{_METADATA_SUFFIX}" @@ -62,6 +65,8 @@ def _parse_brief_file(path: Path) -> _StoredBrief | None: 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"), + facts=list(metadata.get("facts") or []), + context=dict(metadata.get("context") or {}), path=path, ) @@ -104,6 +109,8 @@ def get_bgp_brief_record(brief_id: str) -> BGPBriefRecordResponse | None: request_id=parsed.request_id, generated_at=parsed.generated_at, content_markdown=parsed.content_markdown, + facts=parsed.facts, + context=parsed.context, ) @@ -118,6 +125,8 @@ def save_bgp_brief_record( analysis: SituationalAnalysisResponse, *, request_id: str | None, + facts: list[str] | None = None, + context: dict[str, Any] | None = None, generated_at: datetime | None = None, ) -> BGPBriefRecordResponse: created_at = generated_at or datetime.now(UTC) @@ -131,6 +140,8 @@ def save_bgp_brief_record( "model": analysis.model, "request_id": request_id, "generated_at": created_at.isoformat(), + "facts": facts or [], + "context": context or {}, } markdown_text = f"{_build_metadata_line(metadata)}\n\n{analysis.content.rstrip()}\n" @@ -144,4 +155,6 @@ def save_bgp_brief_record( request_id=request_id, generated_at=created_at.isoformat(), content_markdown=analysis.content, + facts=facts or [], + context=context or {}, ) diff --git a/backend/app/services/bgp_enrichment.py b/backend/app/services/bgp_enrichment.py index cc98d7c7..2df3b1d3 100644 --- a/backend/app/services/bgp_enrichment.py +++ b/backend/app/services/bgp_enrichment.py @@ -231,6 +231,13 @@ async def _lookup_prefix_geography( return results +async def lookup_prefix_geography( + db: AsyncSession, + prefix_values: list[str], +) -> dict[str, dict[str, Any]]: + return await _lookup_prefix_geography(db, prefix_values) + + async def enrich_bgp_events_for_batch( db: AsyncSession, *, diff --git a/backend/app/services/playground_chat_service.py b/backend/app/services/playground_chat_service.py new file mode 100644 index 00000000..9ce32970 --- /dev/null +++ b/backend/app/services/playground_chat_service.py @@ -0,0 +1,678 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from datetime import UTC, datetime +from time import perf_counter +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import async_session_factory +from app.models.playground_message import PlaygroundMessage +from app.models.playground_session import PlaygroundSession +from app.schemas.ai import ( + PlaygroundMessageEditRequest, + PlaygroundMessageActionResponse, + PlaygroundMessageCreateRequest, + PlaygroundMessageRecord, + PlaygroundMessageResendRequest, + PlaygroundMessageStopRequest, + PlaygroundSessionResponse, + PlaygroundSessionState, + PlaygroundSessionUpsertRequest, + PlaygroundThreadResponse, + SituationalAnalysisRequest, +) +from app.services.ai_client import AIProviderClient +from app.services.playground_session_store import _to_response as session_to_response +from app.services.playground_session_store import upsert_playground_session + +STREAM_CHUNK_SIZE = 24 +STREAM_INTERVAL_SECONDS = 0.08 +THINKING_PREVIEW_SECONDS = 2.6 + + +class _ActiveRun: + def __init__(self, task: asyncio.Task[None]) -> None: + self.task = task + self.stop_requested = asyncio.Event() + + +_ACTIVE_RUNS: dict[str, _ActiveRun] = {} + + +def _message_to_record(message: PlaygroundMessage, parent_public_id: str | None = None) -> PlaygroundMessageRecord: + return PlaygroundMessageRecord( + id=message.public_id, + role=message.role, + kind=message.kind, + status=message.status, + title=message.title, + content=message.content or "", + thinking_content=message.thinking_content or "", + meta=list(message.meta or []), + markdown=message.role != "system", + provider=message.provider, + model=message.model, + request_id=message.request_id, + raw_response=dict(message.raw_response or {}), + content_blocks=list(message.content_blocks or []), + text_blocks=list(message.text_blocks or []), + thinking_blocks=list(message.thinking_blocks or []), + parent_message_id=parent_public_id, + created_at=message.created_at.isoformat(), + updated_at=message.updated_at.isoformat(), + ) + + +async def _ensure_session( + db: AsyncSession, + *, + user_id: int, + session_key: str, + title: str, + state: PlaygroundSessionState | None = None, +) -> PlaygroundSession: + result = await db.execute( + select(PlaygroundSession).where( + PlaygroundSession.user_id == user_id, + PlaygroundSession.session_key == session_key, + ) + ) + session = result.scalar_one_or_none() + if session is not None: + if title: + session.title = title[:200] + if state is not None: + session.state = state.model_dump(mode="json") + await db.flush() + await db.refresh(session) + return session + + payload = PlaygroundSessionUpsertRequest( + session_key=session_key, + title=title[:200], + state=state or PlaygroundSessionState(title=title[:200]), + ) + await upsert_playground_session(db, user_id=user_id, payload=payload) + result = await db.execute( + select(PlaygroundSession).where( + PlaygroundSession.user_id == user_id, + PlaygroundSession.session_key == session_key, + ) + ) + return result.scalar_one() + + +async def _list_visible_messages( + db: AsyncSession, + *, + session_id: int, +) -> list[PlaygroundMessage]: + result = await db.execute( + select(PlaygroundMessage) + .where( + PlaygroundMessage.session_id == session_id, + PlaygroundMessage.is_visible.is_(True), + ) + .order_by(PlaygroundMessage.sort_order.asc(), PlaygroundMessage.id.asc()) + ) + return list(result.scalars().all()) + + +async def _build_thread_response( + db: AsyncSession, + *, + session: PlaygroundSession, +) -> PlaygroundThreadResponse: + messages = await _list_visible_messages(db, session_id=session.id) + id_map = {item.id: item.public_id for item in messages} + return PlaygroundThreadResponse( + session=session_to_response(session), + messages=[_message_to_record(item, id_map.get(item.parent_message_id)) for item in messages], + ) + + +async def get_thread( + db: AsyncSession, + *, + user_id: int, + session_key: str, +) -> PlaygroundThreadResponse | None: + result = await db.execute( + select(PlaygroundSession).where( + PlaygroundSession.user_id == user_id, + PlaygroundSession.session_key == session_key, + ) + ) + session = result.scalar_one_or_none() + if session is None: + return None + return await _build_thread_response(db, session=session) + + +def _collect_constraints(raw_constraints: str) -> list[str]: + return [item.strip() for item in raw_constraints.split("\n") if item.strip()] + + +async def _next_sort_order(db: AsyncSession, session_id: int) -> int: + result = await db.execute( + select(func.max(PlaygroundMessage.sort_order)).where(PlaygroundMessage.session_id == session_id) + ) + current = result.scalar_one_or_none() + return int(current or 0) + + +async def _set_session_state( + db: AsyncSession, + *, + session: PlaygroundSession, + payload: PlaygroundMessageCreateRequest, +) -> PlaygroundSession: + session.state = PlaygroundSessionState( + messages=[], + selectedPresetKey=payload.selected_preset_key, + title=payload.title, + objective=payload.objective, + constraints=payload.constraints, + inputValue="", + analysis=None, + latestAnalysisMessageId=None, + analysisMeta={}, + helpExpanded=payload.help_expanded, + ).model_dump(mode="json") + session.title = payload.title[:200] + await db.flush() + await db.refresh(session) + return session + + +async def create_turn( + db: AsyncSession, + *, + user_id: int, + payload: PlaygroundMessageCreateRequest, + provider_client: AIProviderClient, +) -> PlaygroundMessageActionResponse: + session = await _ensure_session( + db, + user_id=user_id, + session_key=payload.session_key, + title=payload.title, + state=PlaygroundSessionState( + selectedPresetKey=payload.selected_preset_key, + title=payload.title, + objective=payload.objective, + constraints=payload.constraints, + inputValue="", + helpExpanded=payload.help_expanded, + ), + ) + session = await _set_session_state(db, session=session, payload=payload) + base_order = await _next_sort_order(db, session.id) + + user_message = PlaygroundMessage( + public_id=uuid4().hex, + session_id=session.id, + user_id=user_id, + role="user", + kind="message", + status="done", + title=payload.selected_preset_key, + content=payload.input, + meta=[payload.title], + sort_order=base_order + 10, + ) + assistant_message = PlaygroundMessage( + public_id=uuid4().hex, + session_id=session.id, + user_id=user_id, + parent_message_id=None, + role="assistant", + kind="thinking", + status="pending", + title="AI 回应", + content="", + thinking_content="", + meta=[], + sort_order=base_order + 20, + ) + db.add(user_message) + await db.flush() + assistant_message.parent_message_id = user_message.id + db.add(assistant_message) + await db.flush() + await db.refresh(user_message) + await db.refresh(assistant_message) + await db.commit() + await db.refresh(session) + await db.refresh(user_message) + await db.refresh(assistant_message) + + task = asyncio.create_task( + _run_assistant_message( + user_id=user_id, + session_id=session.id, + session_key=payload.session_key, + user_message_id=user_message.id, + assistant_message_id=assistant_message.id, + payload=payload, + provider_client=provider_client, + ) + ) + _ACTIVE_RUNS[assistant_message.public_id] = _ActiveRun(task) + + thread = await _build_thread_response(db, session=session) + return PlaygroundMessageActionResponse( + session=thread.session, + messages=thread.messages, + active_message_id=assistant_message.public_id, + ) + + +async def _create_assistant_retry_turn( + db: AsyncSession, + *, + user_id: int, + session: PlaygroundSession, + user_message: PlaygroundMessage, + payload: PlaygroundMessageCreateRequest, + provider_client: AIProviderClient, +) -> PlaygroundMessageActionResponse: + base_order = await _next_sort_order(db, session.id) + assistant_message = PlaygroundMessage( + public_id=uuid4().hex, + session_id=session.id, + user_id=user_id, + parent_message_id=user_message.id, + role="assistant", + kind="thinking", + status="pending", + title="AI 回应", + content="", + thinking_content="", + meta=[], + sort_order=base_order + 10, + ) + db.add(assistant_message) + await db.flush() + await db.commit() + await db.refresh(session) + await db.refresh(assistant_message) + + task = asyncio.create_task( + _run_assistant_message( + user_id=user_id, + session_id=session.id, + session_key=payload.session_key, + user_message_id=user_message.id, + assistant_message_id=assistant_message.id, + payload=payload, + provider_client=provider_client, + ) + ) + _ACTIVE_RUNS[assistant_message.public_id] = _ActiveRun(task) + + thread = await _build_thread_response(db, session=session) + return PlaygroundMessageActionResponse( + session=thread.session, + messages=thread.messages, + active_message_id=assistant_message.public_id, + ) + + +async def stop_message( + db: AsyncSession, + *, + user_id: int, + payload: PlaygroundMessageStopRequest, +) -> PlaygroundMessageActionResponse: + result = await db.execute( + select(PlaygroundSession).where( + PlaygroundSession.user_id == user_id, + PlaygroundSession.session_key == payload.session_key, + ) + ) + session = result.scalar_one_or_none() + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") + + result = await db.execute( + select(PlaygroundMessage).where( + PlaygroundMessage.user_id == user_id, + PlaygroundMessage.public_id == payload.message_id, + PlaygroundMessage.is_visible.is_(True), + ) + ) + message = result.scalar_one_or_none() + if message is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground message not found") + + if message.status not in {"pending", "thinking", "answering"}: + thread = await _build_thread_response(db, session=session) + return PlaygroundMessageActionResponse( + session=thread.session, + messages=thread.messages, + active_message_id=None, + ) + + active_run = _ACTIVE_RUNS.get(message.public_id) + if active_run is not None: + active_run.stop_requested.set() + active_run.task.cancel() + + message.status = "stopped" + if "已手动停止生成" not in (message.meta or []): + message.meta = [*(message.meta or []), "已手动停止生成"] + await db.flush() + await db.commit() + await db.refresh(message) + + thread = await _build_thread_response(db, session=session) + return PlaygroundMessageActionResponse( + session=thread.session, + messages=thread.messages, + active_message_id=None, + ) + + +async def resend_turn( + db: AsyncSession, + *, + user_id: int, + payload: PlaygroundMessageResendRequest, + provider_client: AIProviderClient, +) -> PlaygroundMessageActionResponse: + result = await db.execute( + select(PlaygroundSession).where( + PlaygroundSession.user_id == user_id, + PlaygroundSession.session_key == payload.session_key, + ) + ) + session = result.scalar_one_or_none() + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") + + result = await db.execute( + select(PlaygroundMessage).where( + PlaygroundMessage.user_id == user_id, + PlaygroundMessage.public_id == payload.user_message_id, + PlaygroundMessage.role == "user", + PlaygroundMessage.is_visible.is_(True), + ) + ) + user_message = result.scalar_one_or_none() + if user_message is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User message not found") + + later_messages = await db.execute( + select(PlaygroundMessage).where( + PlaygroundMessage.session_id == session.id, + PlaygroundMessage.sort_order > user_message.sort_order, + PlaygroundMessage.is_visible.is_(True), + ) + ) + for item in later_messages.scalars().all(): + item.is_visible = False + if item.status in {"pending", "thinking", "answering"}: + active_run = _ACTIVE_RUNS.get(item.public_id) + if active_run is not None: + active_run.stop_requested.set() + active_run.task.cancel() + await db.flush() + await db.commit() + + session_state = PlaygroundSessionState.model_validate(session.state or {}) + create_payload = PlaygroundMessageCreateRequest( + session_key=payload.session_key, + title=session_state.title or session.title, + objective=session_state.objective or "继续当前对话", + constraints=session_state.constraints or "", + input=user_message.content, + selected_preset_key=session_state.selectedPresetKey or "bgp-brief", + help_expanded=session_state.helpExpanded, + ) + return await _create_assistant_retry_turn( + db, + user_id=user_id, + session=session, + user_message=user_message, + payload=create_payload, + provider_client=provider_client, + ) + + +async def edit_user_message( + db: AsyncSession, + *, + user_id: int, + payload: PlaygroundMessageEditRequest, +) -> PlaygroundMessageActionResponse: + result = await db.execute( + select(PlaygroundSession).where( + PlaygroundSession.user_id == user_id, + PlaygroundSession.session_key == payload.session_key, + ) + ) + session = result.scalar_one_or_none() + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") + + result = await db.execute( + select(PlaygroundMessage).where( + PlaygroundMessage.user_id == user_id, + PlaygroundMessage.public_id == payload.user_message_id, + PlaygroundMessage.role == "user", + PlaygroundMessage.is_visible.is_(True), + ) + ) + user_message = result.scalar_one_or_none() + if user_message is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User message not found") + + user_message.content = payload.content.strip() + await db.flush() + await db.commit() + await db.refresh(user_message) + + thread = await _build_thread_response(db, session=session) + return PlaygroundMessageActionResponse( + session=thread.session, + messages=thread.messages, + active_message_id=None, + ) + + +async def _append_meta_if_missing(db: AsyncSession, message_id: int, meta_line: str) -> None: + result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == message_id)) + message = result.scalar_one_or_none() + if message is None: + return + if meta_line not in (message.meta or []): + message.meta = [*(message.meta or []), meta_line] + await db.flush() + + +async def _should_stop(message_public_id: str) -> bool: + active_run = _ACTIVE_RUNS.get(message_public_id) + return active_run.stop_requested.is_set() if active_run is not None else False + + +async def _mark_message_state( + db: AsyncSession, + *, + message_id: int, + **updates, +) -> PlaygroundMessage: + result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == message_id)) + message = result.scalar_one() + for key, value in updates.items(): + setattr(message, key, value) + await db.flush() + await db.refresh(message) + return message + + +def _build_conversation_history(messages: Sequence[PlaygroundMessage], current_user_message_id: int) -> list[dict]: + history: list[dict] = [] + for item in messages: + if item.id >= current_user_message_id: + break + if item.role == "system": + continue + history.append( + { + "role": item.role, + "kind": item.kind or "message", + "title": item.title, + "content": item.content or "", + } + ) + return history[-8:] + + +async def _run_assistant_message( + *, + user_id: int, + session_id: int, + session_key: str, + user_message_id: int, + assistant_message_id: int, + payload: PlaygroundMessageCreateRequest, + provider_client: AIProviderClient, +) -> None: + request_id = str(uuid4()) + started_at = perf_counter() + assistant_public_id: str | None = None + try: + async with async_session_factory() as db: + session = await db.get(PlaygroundSession, session_id) + user_message = await db.get(PlaygroundMessage, user_message_id) + assistant_message = await db.get(PlaygroundMessage, assistant_message_id) + if session is None or user_message is None or assistant_message is None: + return + assistant_public_id = assistant_message.public_id + + visible_messages = await _list_visible_messages(db, session_id=session_id) + conversation_history = _build_conversation_history(visible_messages, user_message_id) + + request_payload = SituationalAnalysisRequest( + title=payload.title, + objective=payload.objective, + observations=[item.strip() for item in payload.input.split("\n") if item.strip()], + constraints=_collect_constraints(payload.constraints), + context={ + "source": "playground", + "preset": payload.selected_preset_key, + "conversation_history": conversation_history, + "history_size": len(conversation_history), + }, + thinking={"type": "enabled"}, + ) + + analysis = await provider_client.analyze(request_payload, request_id=request_id) + + async with async_session_factory() as db: + assistant_message = await _mark_message_state( + db, + message_id=assistant_message_id, + status="thinking" if analysis.thinking_blocks else "answering", + title=f"{analysis.provider} / {analysis.model}", + provider=analysis.provider, + model=analysis.model, + request_id=request_id, + raw_response=analysis.raw_response, + content_blocks=[item.model_dump(mode="json") for item in analysis.content_blocks], + text_blocks=analysis.text_blocks, + thinking_blocks=analysis.thinking_blocks, + thinking_content="\n\n".join(analysis.thinking_blocks).strip(), + ) + session = await db.get(PlaygroundSession, session_id) + if session is not None: + session_state = PlaygroundSessionState.model_validate(session.state or {}) + session.state = session_state.model_copy( + update={ + "latestAnalysisMessageId": assistant_message.public_id, + "analysis": analysis.model_dump(mode="json"), + } + ).model_dump(mode="json") + await db.flush() + await db.commit() + + if assistant_public_id and analysis.thinking_blocks: + await asyncio.sleep(THINKING_PREVIEW_SECONDS) + if await _should_stop(assistant_public_id): + return + + content = analysis.content or "" + cursor = 0 + while cursor < len(content): + if assistant_public_id and await _should_stop(assistant_public_id): + return + cursor = min(len(content), cursor + STREAM_CHUNK_SIZE) + async with async_session_factory() as db: + await _mark_message_state( + db, + message_id=assistant_message_id, + status="answering", + content=content[:cursor], + ) + await db.commit() + await asyncio.sleep(STREAM_INTERVAL_SECONDS) + + duration_ms = round((perf_counter() - started_at) * 1000) + async with async_session_factory() as db: + assistant_message = await _mark_message_state( + db, + message_id=assistant_message_id, + status="done", + content=content, + meta=[ + f"Request ID: {request_id}", + f"耗时: {duration_ms} ms", + f"完成时间: {datetime.now(UTC).astimezone().isoformat()}", + ], + ) + session = await db.get(PlaygroundSession, session_id) + if session is not None: + session_state = PlaygroundSessionState.model_validate(session.state or {}) + session.state = session_state.model_copy( + update={ + "latestAnalysisMessageId": assistant_message.public_id, + "analysis": analysis.model_dump(mode="json"), + "analysisMeta": { + "requestId": request_id, + "durationMs": duration_ms, + "completedAt": datetime.now().isoformat(), + }, + } + ).model_dump(mode="json") + await db.flush() + await db.commit() + except asyncio.CancelledError: + async with async_session_factory() as db: + result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id)) + message = result.scalar_one_or_none() + if message is not None and message.status in {"pending", "thinking", "answering"}: + message.status = "stopped" + if "已手动停止生成" not in (message.meta or []): + message.meta = [*(message.meta or []), "已手动停止生成"] + await db.flush() + await db.commit() + raise + except Exception as exc: + async with async_session_factory() as db: + result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id)) + message = result.scalar_one_or_none() + if message is not None: + message.status = "error" + message.content = message.content or "分析失败,请检查 AI Provider 配置或稍后再试。" + message.meta = [*(message.meta or []), f"错误: {type(exc).__name__}"] + await db.flush() + await db.commit() + finally: + if assistant_public_id: + _ACTIVE_RUNS.pop(assistant_public_id, None) diff --git a/backend/app/services/playground_session_store.py b/backend/app/services/playground_session_store.py new file mode 100644 index 00000000..873d90fa --- /dev/null +++ b/backend/app/services/playground_session_store.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.playground_session import PlaygroundSession +from app.schemas.ai import ( + PlaygroundSessionResponse, + PlaygroundSessionState, + PlaygroundSessionUpsertRequest, +) + + +def _to_response(record: PlaygroundSession) -> PlaygroundSessionResponse: + return PlaygroundSessionResponse( + id=str(record.id), + session_key=record.session_key, + title=record.title, + state=PlaygroundSessionState.model_validate(record.state or {}), + created_at=record.created_at.isoformat(), + updated_at=record.updated_at.isoformat(), + ) + + +async def get_playground_session( + db: AsyncSession, + *, + user_id: int, + session_key: str = "default", +) -> PlaygroundSessionResponse | None: + result = await db.execute( + select(PlaygroundSession).where( + PlaygroundSession.user_id == user_id, + PlaygroundSession.session_key == session_key, + ) + ) + record = result.scalar_one_or_none() + if record is None: + return None + return _to_response(record) + + +async def upsert_playground_session( + db: AsyncSession, + *, + user_id: int, + payload: PlaygroundSessionUpsertRequest, +) -> PlaygroundSessionResponse: + result = await db.execute( + select(PlaygroundSession).where( + PlaygroundSession.user_id == user_id, + PlaygroundSession.session_key == payload.session_key, + ) + ) + record = result.scalar_one_or_none() + title = (payload.title or payload.state.title or "Playground 会话").strip()[:200] or "Playground 会话" + + if record is None: + record = PlaygroundSession( + user_id=user_id, + session_key=payload.session_key, + title=title, + state=payload.state.model_dump(mode="json"), + ) + db.add(record) + else: + record.title = title + record.state = payload.state.model_dump(mode="json") + + await db.flush() + await db.refresh(record) + return _to_response(record) diff --git a/backend/app/services/situational_alert_ai_brief.py b/backend/app/services/situational_alert_ai_brief.py new file mode 100644 index 00000000..e672669f --- /dev/null +++ b/backend/app/services/situational_alert_ai_brief.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from collections import Counter +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.alert import Alert, AlertSeverity, AlertStatus +from app.models.bgp_anomaly import BGPAnomaly +from app.models.bgp_incident import BGPIncident +from app.schemas.ai import SituationalAnalysisRequest +from app.services.bgp_ai_brief_store import get_latest_bgp_brief_record + + +def _format_pairs(pairs: list[tuple[str, int]], empty_text: str = "无") -> str: + if not pairs: + return empty_text + return ",".join(f"{key} {value}" for key, value in pairs if key) + + +async def build_situational_alert_brief_request( + db: AsyncSession, +) -> tuple[SituationalAnalysisRequest, list[str], dict[str, Any]]: + total_alerts_result = await db.execute(select(func.count(Alert.id))) + active_alerts_result = await db.execute( + select(func.count(Alert.id)).where(Alert.status == AlertStatus.ACTIVE) + ) + alert_severity_result = await db.execute( + select(Alert.severity, func.count(Alert.id)) + .where(Alert.status == AlertStatus.ACTIVE) + .group_by(Alert.severity) + ) + alert_source_result = await db.execute( + select(Alert.datasource_name, func.count(Alert.id)) + .where(Alert.status == AlertStatus.ACTIVE) + .group_by(Alert.datasource_name) + .order_by(func.count(Alert.id).desc()) + .limit(6) + ) + recent_alerts_result = await db.execute( + select(Alert) + .order_by(Alert.created_at.desc(), Alert.id.desc()) + .limit(6) + ) + + total_incidents_result = await db.execute(select(func.count(BGPIncident.id))) + active_incidents_result = await db.execute( + select(func.count(BGPIncident.id)).where(BGPIncident.status == "active") + ) + bgp_severity_result = await db.execute( + select(BGPIncident.severity, func.count(BGPIncident.id)) + .where(BGPIncident.status == "active") + .group_by(BGPIncident.severity) + ) + bgp_region_counter: Counter[str] = Counter() + recent_incidents_result = await db.execute( + select(BGPIncident) + .order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc()) + .limit(5) + ) + + total_anomalies_result = await db.execute(select(func.count(BGPAnomaly.id))) + active_anomalies_result = await db.execute( + select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == "active") + ) + anomaly_type_result = await db.execute( + select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id)) + .where(BGPAnomaly.status == "active") + .group_by(BGPAnomaly.anomaly_type) + .order_by(func.count(BGPAnomaly.id).desc()) + .limit(6) + ) + + recent_incidents = recent_incidents_result.scalars().all() + for incident in recent_incidents: + for region in incident.affected_regions or []: + if not isinstance(region, dict): + continue + label = ", ".join(part for part in [region.get("city"), region.get("country")] if part) or "未知区域" + bgp_region_counter[label] += 1 + + latest_bgp_brief = get_latest_bgp_brief_record() + active_alert_severities = [ + (item[0].value if isinstance(item[0], AlertSeverity) else str(item[0]), item[1]) + for item in alert_severity_result.fetchall() + if item[0] + ] + active_bgp_severities = [ + (str(item[0]), item[1]) + for item in bgp_severity_result.fetchall() + if item[0] + ] + active_anomaly_types = [(str(item[0]), item[1]) for item in anomaly_type_result.fetchall() if item[0]] + active_alert_sources = [ + (str(item[0] or "未命名数据源"), item[1]) + for item in alert_source_result.fetchall() + ] + + facts = [ + ( + f"系统告警侧:总告警 {total_alerts_result.scalar() or 0} 条,active {active_alerts_result.scalar() or 0} 条;" + f"活跃告警严重度分布为 {_format_pairs(active_alert_severities)}。" + ), + ( + f"BGP态势侧:累计 incidents {total_incidents_result.scalar() or 0} 条,active incidents {active_incidents_result.scalar() or 0} 条;" + f"活跃 incidents 严重度分布为 {_format_pairs(active_bgp_severities)}。" + ), + ( + f"BGP异常侧:累计 anomalies {total_anomalies_result.scalar() or 0} 条,active anomalies {active_anomalies_result.scalar() or 0} 条;" + f"活跃 anomaly 类型分布为 {_format_pairs(active_anomaly_types)}。" + ), + ] + + if active_alert_sources: + facts.append(f"当前系统告警主要集中在:{_format_pairs(active_alert_sources)}。") + if bgp_region_counter: + facts.append(f"BGP近期高风险区域线索:{_format_pairs(bgp_region_counter.most_common(5))}。") + + recent_alerts = recent_alerts_result.scalars().all() + if recent_alerts: + facts.append( + "最近系统告警摘录:" + + ";".join( + [ + f"{alert.datasource_name or '未命名数据源'} / {alert.severity.value if alert.severity else '-'} / {alert.status.value if alert.status else '-'} / {alert.message or '-'}" + for alert in recent_alerts + ] + ) + ) + + if recent_incidents: + facts.append( + "最近BGP事件摘录:" + + ";".join( + [ + f"{incident.incident_type} / {incident.severity} / {incident.status} / {incident.summary}" + for incident in recent_incidents + ] + ) + ) + + if latest_bgp_brief: + facts.append( + f"最近一份 BGP AI 简报生成于 {latest_bgp_brief.generated_at},模型 {latest_bgp_brief.model},可作为当前态势的补充说明。" + ) + + context = { + "source": "situational-alerts", + "active_system_alerts": active_alerts_result.scalar() or 0, + "active_system_alert_severities": dict(active_alert_severities), + "top_system_alert_sources": dict(active_alert_sources), + "active_bgp_incidents": active_incidents_result.scalar() or 0, + "active_bgp_incident_severities": dict(active_bgp_severities), + "active_bgp_anomalies": active_anomalies_result.scalar() or 0, + "active_bgp_anomaly_types": dict(active_anomaly_types), + "bgp_hot_regions": dict(bgp_region_counter.most_common(5)), + "latest_bgp_brief_id": latest_bgp_brief.id if latest_bgp_brief else None, + "latest_bgp_brief_generated_at": latest_bgp_brief.generated_at if latest_bgp_brief else None, + } + + request = SituationalAnalysisRequest( + title="态势告警 AI 简报", + objective="综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。", + observations=facts, + constraints=[ + "明确区分事实、推断与建议。", + "优先指出仍在 active 状态的系统告警与 BGP 风险是否存在联动。", + "不要把单一数据源的局部异常夸大成全局态势。", + "如果证据不足,请明确写出仍缺哪些模块或区域信息。", + ], + context=context, + ) + return request, facts, context diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 4d79ee92..2822bf82 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -10,7 +10,12 @@ from app.core.config import settings from app.core.security import create_access_token from app.db.session import get_db from app.models.user import User -from app.schemas.ai import AIProviderStatusResponse, SituationalAnalysisResponse +from app.schemas.ai import ( + AIProviderStatusResponse, + PlaygroundSessionResponse, + PlaygroundSessionState, + SituationalAnalysisResponse, +) @pytest.fixture @@ -258,5 +263,294 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers): assert "content_blocks" in data assert "text_blocks" in data assert "thinking_blocks" in data + + +@pytest.mark.asyncio +async def test_get_playground_session_with_auth(auth_headers): + """Test playground session restore endpoint.""" + + def override_get_current_user(): + return User( + id=1, + username="testuser", + email="test@example.com", + password_hash="hashed", + role="admin", + is_active=True, + ) + + async def override_get_db(): + yield AsyncMock() + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + get_db: override_get_db, + } + transport = ASGITransport(app=app) + try: + with patch( + "app.api.v1.ai.get_playground_session", + new=AsyncMock( + return_value=PlaygroundSessionResponse( + id="1", + session_key="default", + title="Playground 会话", + state=PlaygroundSessionState( + messages=[{"id": "msg-1", "role": "user", "content": "hello"}], + title="测试标题", + objective="测试目标", + ), + created_at="2026-04-10T00:00:00+00:00", + updated_at="2026-04-10T00:00:00+00:00", + ) + ), + ): + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/ai/playground/session", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["session_key"] == "default" + assert data["state"]["messages"][0]["content"] == "hello" + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_save_playground_session_with_auth(auth_headers): + """Test playground session save endpoint.""" + + def override_get_current_user(): + return User( + id=1, + username="testuser", + email="test@example.com", + password_hash="hashed", + role="admin", + is_active=True, + ) + + async def override_get_db(): + yield AsyncMock() + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + get_db: override_get_db, + } + transport = ASGITransport(app=app) + try: + with patch( + "app.api.v1.ai.upsert_playground_session", + new=AsyncMock( + return_value=PlaygroundSessionResponse( + id="1", + session_key="default", + title="测试标题", + state=PlaygroundSessionState( + messages=[{"id": "msg-1", "role": "user", "content": "hello"}], + title="测试标题", + objective="测试目标", + ), + created_at="2026-04-10T00:00:00+00:00", + updated_at="2026-04-10T00:00:00+00:00", + ) + ), + ): + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.put( + "/api/v1/ai/playground/session", + headers=auth_headers, + json={ + "session_key": "default", + "title": "测试标题", + "state": { + "messages": [{"id": "msg-1", "role": "user", "content": "hello"}], + "selectedPresetKey": "bgp-brief", + "title": "测试标题", + "objective": "测试目标", + "constraints": "", + "inputValue": "", + "analysis": None, + "latestAnalysisMessageId": None, + "analysisMeta": {}, + "helpExpanded": True, + }, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["title"] == "测试标题" + assert data["state"]["objective"] == "测试目标" + finally: + app.dependency_overrides.clear() + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_ai_bgp_brief_endpoint_persists_fact_snapshot(auth_headers): + class _FakeAIProviderClient: + async def analyze(self, _payload, request_id=None): + return SituationalAnalysisResponse( + provider="minimax", + model="MiniMax-M2.5", + content="# BGP AI 简报\n\n事实摘要:测试", + content_blocks=[], + text_blocks=["# BGP AI 简报\n\n事实摘要:测试"], + thinking_blocks=[], + raw_response={"id": "mock-bgp-brief"}, + ) + + def override_get_current_user(): + return User( + id=1, + username="testuser", + email="test@example.com", + password_hash="hashed", + role="admin", + is_active=True, + ) + + async def override_get_db(): + yield AsyncMock() + + async def _fake_build_bgp_brief_request(_db, **_kwargs): + request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest( + title="BGP 态势 AI 简报", + objective="生成值班简报", + observations=["事实A", "事实B"], + constraints=["不要编造"], + context={"incident_total": 2, "active_collectors": 3}, + ) + return request_payload, ["事实A", "事实B"], {"incident_total": 2, "active_collectors": 3} + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + __import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(), + get_db: override_get_db, + } + transport = ASGITransport(app=app) + try: + with patch("app.api.v1.ai.build_bgp_brief_request", side_effect=_fake_build_bgp_brief_request): + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/api/v1/ai/bgp/brief", headers=auth_headers, json={}) + assert response.status_code == 200 + data = response.json() + assert data["facts"] == ["事实A", "事实B"] + assert data["context"]["incident_total"] == 2 + assert data["content_markdown"] + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_ai_alert_brief_endpoint_with_auth(auth_headers): + class _FakeAIProviderClient: + async def analyze(self, _payload, request_id=None): + return SituationalAnalysisResponse( + provider="minimax", + model="MiniMax-M2.7", + content="事实摘要:告警测试。风险研判:告警测试。建议动作:告警测试。", + content_blocks=[], + text_blocks=["事实摘要:告警测试。风险研判:告警测试。建议动作:告警测试。"], + thinking_blocks=[], + raw_response={"id": "mock-alert-brief"}, + ) + + def override_get_current_user(): + return User( + id=1, + username="testuser", + email="test@example.com", + password_hash="hashed", + role="admin", + is_active=True, + ) + + async def override_get_db(): + yield AsyncMock() + + async def _fake_build_alert_brief_request(_db, **_kwargs): + request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest( + title="告警态势 AI 简报", + objective="输出告警简报", + observations=["告警事实A", "告警事实B"], + constraints=["不要编造"], + context={"active_alerts": 3, "top_datasources": {"bgp": 2}}, + ) + return request_payload, ["告警事实A", "告警事实B"], {"active_alerts": 3, "top_datasources": {"bgp": 2}} + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + __import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(), + get_db: override_get_db, + } + transport = ASGITransport(app=app) + try: + with patch("app.api.v1.ai.build_alert_brief_request", side_effect=_fake_build_alert_brief_request): + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/api/v1/ai/alerts/brief", headers=auth_headers, json={}) + assert response.status_code == 200 + data = response.json() + assert data["title"] == "告警态势 AI 简报" + assert data["facts"] == ["告警事实A", "告警事实B"] + assert data["context"]["active_alerts"] == 3 + assert data["content"] + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_ai_situational_alert_brief_endpoint_with_auth(auth_headers): + class _FakeAIProviderClient: + async def analyze(self, _payload, request_id=None): + return SituationalAnalysisResponse( + provider="minimax", + model="MiniMax-M2.7", + content="事实摘要:态势测试。风险研判:态势测试。建议动作:态势测试。", + content_blocks=[], + text_blocks=["事实摘要:态势测试。风险研判:态势测试。建议动作:态势测试。"], + thinking_blocks=[], + raw_response={"id": "mock-situational-brief"}, + ) + + def override_get_current_user(): + return User( + id=1, + username="testuser", + email="test@example.com", + password_hash="hashed", + role="admin", + is_active=True, + ) + + async def override_get_db(): + yield AsyncMock() + + async def _fake_build_situational_alert_brief_request(_db): + request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest( + title="态势告警 AI 简报", + objective="输出态势告警简报", + observations=["态势事实A", "态势事实B"], + constraints=["不要编造"], + context={"active_system_alerts": 2, "active_bgp_incidents": 1}, + ) + return request_payload, ["态势事实A", "态势事实B"], {"active_system_alerts": 2, "active_bgp_incidents": 1} + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + __import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(), + get_db: override_get_db, + } + transport = ASGITransport(app=app) + try: + with patch("app.api.v1.ai.build_situational_alert_brief_request", side_effect=_fake_build_situational_alert_brief_request): + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/api/v1/ai/situational-alerts/brief", headers=auth_headers, json={}) + assert response.status_code == 200 + data = response.json() + assert data["title"] == "态势告警 AI 简报" + assert data["facts"] == ["态势事实A", "态势事实B"] + assert data["context"]["active_system_alerts"] == 2 + assert data["content"] finally: app.dependency_overrides.clear() diff --git a/docs/situational-awareness-foundation-plan.md b/docs/situational-awareness-foundation-plan.md new file mode 100644 index 00000000..f0fa368e --- /dev/null +++ b/docs/situational-awareness-foundation-plan.md @@ -0,0 +1,309 @@ +# Situational Awareness Foundation Plan + +## 定位 + +当前这套 AI 能力应被视为 `态势感知服务底座`,而不是完整的态势感知产品。 + +也就是说,现阶段的目标不是: + +- 做一个“什么都能分析”的万能 AI 页面 +- 让模型在证据不足时替代人工研判 +- 过早把页面做成完整指挥大屏 + +现阶段真正要做的是: + +- 先把 `model gateway / backend facade / evidence injection / page-specific brief` 这几层边界搭稳 +- 让系统能够在已有证据上稳定地产出“可读、可回看、可扩展”的摘要 +- 为后续更强的数据联动、agent 推理和 assessment 结构化输出预留好接口与数据模型 + +## 当前现实约束 + +### 1. 数据维度不足 + +目前系统能提供的主要证据仍集中在: + +- BGP incidents / anomalies / events +- collector coverage +- datasource health / platform alerts +- prefix geography 的部分归属信息 + +当前明显还缺: + +- 流量异常与业务指标 +- 电商、支付、物流等业务侧指标 +- 更丰富的资产、链路、区域、行业画像 +- 外部舆情、公告、运营商状态、基础设施事件等背景信息 + +这意味着: + +- 模型现在可以做“基于现有证据的摘要与归纳” +- 但还不能可靠地做“跨维度因果研判” + +### 2. 维度之间联动还弱 + +目前不同模块之间更多是“并列展示”,还不是“强关联分析”: + +- 系统告警和 BGP 事件还没有统一事件模型 +- collector bias 与真实区域热度还没有完全剥离 +- datasource health 与 BGP 风险、业务影响之间还没有稳定映射 + +这意味着: + +- 当前更适合做 `brief / overview / operator notes` +- 还不适合过度承诺“自动态势判断” + +### 3. 结构化 assessment 还未成为主输出 + +虽然已经有 BGP brief、系统告警 brief、态势告警 brief,但目前主输出仍偏向: + +- 文本摘要 +- facts/context 附带证据 + +后续真正要服务态势感知,需要更稳定的结构化输出,例如: + +- summary +- key risks +- evidence +- confidence +- recommendations +- missing data + +## 当前基座已经具备的能力 + +### 1. AI 调用边界已经明确 + +- `aiprovider` 负责模型协议与 provider 兼容 +- `backend` 负责业务 API、证据整合和鉴权 +- `frontend` 负责页面入口与结果展示 + +### 2. 页面级 AI 入口已经开始成型 + +当前已经有或正在收口的入口: + +- `Playground` + - 用于链路验证与 provider 诊断 +- `BGP AI 简报` + - 用于 BGP 事实摘要和区域风险归纳 +- `Alerts` + - 用于系统告警、BGP 告警、态势告警三类入口 + +### 3. 证据优先的方向已经建立 + +已经不再只依赖人工在 Playground 中手填 prompt,系统开始具备: + +- 从真实业务数据生成事实输入 +- 保存 facts/context 快照 +- 回看 AI 输出时同时回看证据 + +这一步非常关键,因为它决定后面能否从“玩具 demo”走向“有运维价值的系统”。 + +## 近期收尾建议 + +这些事情都属于“底座收口”,值得做,但不应该再继续重产品包装。 + +### 1. 统一 Alerts 页面 + +已采用: + +- 一个 `Alerts` 页面 +- 三个 tab: + - `系统告警` + - `BGP 告警` + - `态势告警` + +收尾重点: + +- 保持 tab 的文案、摘要卡和 AI 简报交互一致 +- 不额外扩展成多个独立二级页面 + +### 2. 保持 Playground 为测试台 + +原则: + +- Playground 只承担链路验证、provider 状态诊断、请求结果观察 +- 不继续堆“万能业务分析器”式交互 + +### 3. 把 brief 能力当服务能力而不是页面特效 + +页面现在能看到按钮和结果,这很好,但更重要的是: + +- 后端接口稳定 +- facts/context 可追踪 +- 输出结构后续可升级 + +### 4. 导航结构先收口,不继续平铺一级菜单 + +随着后续能力扩展,系统很可能继续新增: + +- 海缆 +- 算力中心 +- 战争信息 +- 电商分析 +- 其他专题观测页 + +如果继续把这些入口全部平铺在左侧一级菜单中,会带来两个问题: + +- 一级菜单过长,用户难以判断先进入哪个上下文 +- `观测页 / 告警页 / 研判页 / 运维页` 的职责边界会被混在一起 + +因此近期应明确采用分组导航,而不是继续扩展平铺菜单。 + +推荐的导航分组如下: + +- `总览` + - 仪表盘 + - Earth +- `专题观测` + - BGP 观测 + - 采集数据 + - 后续可扩展:海缆、算力中心、战争信息、电商分析 +- `告警与研判` + - Alerts +- `运维与配置` + - 数据源 + - AI Playground + - 用户管理 + - 系统配置 + +这套结构的含义是: + +- `专题观测` 页面负责看某个维度本身 +- `Alerts` 负责跨模块风险与值班工作台 +- `Playground` 保持为测试台,不挤占业务导航语义 + +短期收尾时,应优先重组现有入口,而不是继续增加新的一级菜单。 + +## 后续路线 + +## Phase 1:服务底座稳固 + +目标: + +- 不追求“更炫的 AI 页面” +- 先把当前接口、证据、存储和页面入口收稳 + +工作项: + +- 统一页面级 AI 入口模式 +- 统一 brief response schema +- 保证 facts/context 在前后端都可回看 +- 继续清理 mock 和临时分支逻辑 + +完成标准: + +- 每个 AI 入口都是真实链路 +- 每个 AI 结果都能追溯到证据输入 + +## Phase 2:Evidence-first Assessment + +目标: + +- 从“文本摘要”升级成“结构化 assessment” + +工作项: + +- 为 brief/assessment 定义统一 schema +- 固化: + - summary + - key_risks + - evidence + - confidence + - recommendations + - missing_data +- 页面以结构化区块展示,而不只是大段文本 + +完成标准: + +- AI 输出可持久化、可比较、可审计 + +## Phase 3:多维证据接入 + +目标: + +- 让“态势感知”真正拥有更多维度,而不是只靠 BGP 与系统告警 + +优先接入方向: + +- datasource health findings +- 流量或业务指标 +- 区域/资产/链路映射 +- 外部事件与公告 +- 业务垂直数据,例如电商分析相关指标 + +完成标准: + +- AI 能基于多个维度做交叉说明 +- 不再只围绕单一模块自说自话 + +## Phase 4:Correlation Layer + +目标: + +- 不同来源的信号不再只是并列,而是形成统一的事件关联 + +工作项: + +- 统一 signal/finding 模型 +- 跨模块事件聚合 +- 证据来源权重 +- collector bias 与真实热度分离 + +完成标准: + +- 系统能回答“这些异常是不是同一件事” +- 系统能回答“哪些结论只是观测偏差” + +## Phase 5:Agent-assisted Situational Awareness + +目标: + +- 在证据足够的前提下,再让 agent 负责更复杂的推理与建议 + +工作项: + +- 复用现有 agent runtime 规划 +- 引入 web search / docs fetch / repair proposal 等能力 +- 但始终坚持: + - evidence first + - proposal before action + - no silent mutation of defaults + +完成标准: + +- agent 成为证据驱动的分析层 +- 而不是一个“万能猜测层” + +## 设计原则 + +### 1. 先底座,后产品化 + +先把服务链路和证据模型做好,再做更大的页面表达。 + +### 2. 先证据,后判断 + +事实输入应先稳定,再让模型做归纳。 + +### 3. 先专用 brief,后统一态势层 + +先让各业务页有各自可信的 AI 入口,再考虑统一态势页。 + +### 4. 先 proposal,后自动动作 + +涉及修复、覆盖、写配置、调任务的动作,都应经过 proposal 和审计。 + +## 当前建议结论 + +对现在这个项目,最合理的定位是: + +- `Playground` 是测试台 +- `BGP / Alerts` 是第一批业务 AI 入口 +- `aiprovider + backend AI facade + evidence snapshots` 是核心服务底座 + +现阶段不需要追求“已经具备完整态势感知能力”。 + +现阶段真正的成功标准是: + +- 这套底座可用 +- 可回看 +- 可扩展 +- 不自欺欺人 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a99042dd..83dec72f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,14 @@ import { Suspense, lazy } from 'react' + import { Spin } from 'antd' import { Routes, Route, Navigate } from 'react-router-dom' + import { useAuthStore } from './stores/auth' import Login from './pages/Login/Login' +const SystemAlerts = lazy(() => import('./pages/Alerts/SystemAlerts')) +const BGPAlerts = lazy(() => import('./pages/Alerts/BGPAlerts')) +const SituationalAlerts = lazy(() => import('./pages/Alerts/SituationalAlerts')) const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard')) const Users = lazy(() => import('./pages/Users/Users')) const DataSources = lazy(() => import('./pages/DataSources/DataSources')) @@ -37,6 +42,10 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 1820a7d9..5a11422f 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -1,6 +1,7 @@ -import { ReactNode, useState } from 'react' +import { ReactNode, useMemo, useState } from 'react' import { Layout, Menu, Typography, Button, Space } from 'antd' import { + AlertOutlined, DashboardOutlined, DatabaseOutlined, UserOutlined, @@ -10,8 +11,13 @@ import { RobotOutlined, MenuUnfoldOutlined, MenuFoldOutlined, + GlobalOutlined, + AppstoreOutlined, + ToolOutlined, + InboxOutlined, } from '@ant-design/icons' import { useLocation, useNavigate } from 'react-router-dom' +import type { ItemType, MenuItemType } from 'antd/es/menu/interface' import { useAuthStore } from '../../stores/auth' import packageJson from '../../../package.json' @@ -27,19 +33,64 @@ function AppLayout({ children }: AppLayoutProps) { const navigate = useNavigate() const { user, logout } = useAuthStore() const [collapsed, setCollapsed] = useState(false) + const [openKeys, setOpenKeys] = useState(['collection']) const showBanner = true const appVersion = `v${packageJson.version}` - const menuItems = [ - { key: '/admin', icon: , label: '仪表盘' }, - { key: '/datasources', icon: , label: '数据源' }, - { key: '/data', icon: , label: '采集数据' }, - { key: '/bgp', icon: , label: 'BGP观测' }, - { key: '/playground', icon: , label: 'AI Playground' }, - { key: '/users', icon: , label: '用户管理' }, - { key: '/settings', icon: , label: '系统配置' }, + const menuItems: ItemType[] = [ + { + key: 'overview', + icon: , + label: '总览', + children: [ + { key: '/admin', icon: , label: '仪表盘' }, + { key: '/earth', icon: , label: 'Earth' }, + ], + }, + { + key: 'collection', + icon: , + label: '采集与数据', + children: [ + { key: '/datasources', icon: , label: '数据源' }, + { key: '/data', icon: , label: '采集数据' }, + ], + }, + { + key: 'observability', + icon: , + label: '专题观测', + children: [ + { key: '/bgp', icon: , label: 'BGP观测' }, + ], + }, + { + key: 'alerts', + icon: , + label: '告警与研判', + children: [ + { key: '/alerts/system', icon: , label: '系统告警' }, + { key: '/alerts/bgp', icon: , label: 'BGP 告警' }, + { key: '/alerts/situational', icon: , label: '态势告警' }, + ], + }, + { + key: 'ops', + icon: , + label: '运维与配置', + children: [ + { key: '/playground', icon: , label: 'AI Playground' }, + { key: '/users', icon: , label: '用户管理' }, + { key: '/settings', icon: , label: '系统配置' }, + ], + }, ] + const selectedKey = useMemo(() => { + if (location.pathname === '/') return '/earth' + return location.pathname + }, [location.pathname]) + return ( { + setCollapsed(nextCollapsed) + if (nextCollapsed) { + setOpenKeys([]) + } + }} className="dashboard-sider" >
@@ -69,10 +125,14 @@ function AppLayout({ children }: AppLayoutProps) { { + setOpenKeys(keys as string[]) + }} onClick={({ key }) => { - if (key !== location.pathname) { + if (key !== selectedKey) { navigate(key) } }} diff --git a/frontend/src/index.css b/frontend/src/index.css index b31073a5..6d8b0f8a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -190,6 +190,11 @@ body { min-height: 0; } +.playground-page { + height: 100%; + min-height: 0; +} + .playground-page__grid { flex: 1 1 auto; min-height: 0; @@ -198,11 +203,15 @@ body { } .playground-page__body { + flex: 1 1 auto; + min-height: 0; + display: flex; overflow: hidden; } .playground-shell { flex: 1 1 auto; + height: 100%; min-height: 0; display: flex; gap: 12px; @@ -303,6 +312,7 @@ body { .playground-card--workspace { flex: 1 1 auto; + height: 100%; } .playground-card--workspace .ant-card-body, @@ -310,6 +320,405 @@ body { overflow: hidden; } +.playground-chat { + flex: 1 1 auto; + min-height: 0; +} + +.playground-chat .ant-card-body { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px 18px 18px; + min-height: 0; + overflow: hidden; +} + +.playground-chat__messages { + flex: 1 1 auto; + min-height: 0; + height: 100%; + overflow: auto; + display: flex; + flex-direction: column; + gap: 14px; + padding-right: 4px; + overscroll-behavior: contain; +} + +.playground-chat__messages-shell { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.playground-chat__scroll-bottom.ant-btn { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 12px; + width: 38px; + height: 38px; + border-radius: 999px; + border: 1px solid rgba(15, 23, 42, 0.1); + background: rgba(255, 255, 255, 0.94); + color: #0f172a; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16); + backdrop-filter: blur(8px); +} + +.playground-chat__scroll-bottom.ant-btn:hover, +.playground-chat__scroll-bottom.ant-btn:focus { + background: #ffffff !important; + color: #0f766e !important; + border-color: rgba(15, 118, 110, 0.2) !important; +} + +.playground-chat__composer { + flex: 0 0 auto; + border-top: 1px solid rgba(148, 163, 184, 0.18); + padding-top: 14px; +} + +.playground-chat__input-wrap { + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.98)); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08); + padding: 12px; +} + +.playground-chat__input.ant-input { + border: 0; + box-shadow: none; + padding: 6px 2px 2px; + resize: none; + background: transparent; + margin-top: 10px; +} + +.playground-chat__input.ant-input:focus, +.playground-chat__input.ant-input-focused { + box-shadow: none; +} + +.playground-chat__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(148, 163, 184, 0.16); +} + +.playground-chat__hints { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.playground-chat__send-button.ant-btn { + width: 40px; + height: 40px; + border-radius: 999px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: #0f172a; + color: #f8fafc; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16); +} + +.playground-chat__send-button.ant-btn:hover, +.playground-chat__send-button.ant-btn:focus { + background: #111827 !important; + color: #ffffff !important; + border-color: transparent !important; +} + +.playground-chat__send-button--stop.ant-btn { + background: #7f1d1d; +} + +.playground-chat__send-button--stop.ant-btn:hover, +.playground-chat__send-button--stop.ant-btn:focus { + background: #991b1b !important; +} + +.playground-message { + display: flex; + align-items: flex-end; + gap: 10px; +} + +.playground-message--system, +.playground-message--assistant { + justify-content: flex-start; +} + +.playground-message--user { + flex-direction: row-reverse; +} + +.playground-message__avatar { + flex: 0 0 auto; +} + +.playground-message__body { + display: flex; + flex-direction: column; + min-width: 0; + max-width: min(100%, 760px); +} + +.playground-message__body--editing { + width: min(100%, 760px); + flex: 0 1 min(100%, 760px); +} + +.playground-message__avatar-inner { + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.14); +} + +.playground-message__avatar-inner--assistant, +.playground-message__avatar-inner--system { + background: linear-gradient(135deg, #0f766e, #14b8a6) !important; + color: #f8fafc !important; +} + +.playground-message__avatar-inner--user { + background: linear-gradient(135deg, #0f172a, #334155) !important; + color: #f8fafc !important; +} + +.playground-message__bubble { + width: 100%; + border-radius: 18px; + padding: 14px 16px; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08); + border: 1px solid rgba(148, 163, 184, 0.18); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.98)); +} + +.playground-message--assistant .playground-message__bubble { + background: + linear-gradient(180deg, rgba(232, 244, 255, 0.95), rgba(240, 249, 255, 0.98)); + border-color: rgba(56, 189, 248, 0.26); +} + +.playground-message--system .playground-message__bubble { + background: + linear-gradient(180deg, rgba(255, 250, 235, 0.98), rgba(254, 249, 195, 0.72)); + border-color: rgba(245, 158, 11, 0.24); +} + +.playground-message--user .playground-message__bubble { + background: + linear-gradient(180deg, rgba(15, 118, 110, 0.94), rgba(13, 148, 136, 0.96)); + border-color: rgba(15, 118, 110, 0.28); + color: #f8fafc; +} + +.playground-message__bubble--loading { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.playground-message__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.playground-message__head .ant-tag { + margin-inline-end: 0; +} + +.playground-message__phase { + margin-bottom: 8px; +} + +.playground-message__phase .ant-typography { + font-size: 12px; + color: #64748b; +} + +.playground-message__thinking { + margin-bottom: 10px; + padding: 10px 12px; + border-radius: 12px; + border: 1px dashed rgba(148, 163, 184, 0.35); + background: rgba(255, 255, 255, 0.45); +} + +.playground-message__thinking .ant-typography { + display: block; + margin-bottom: 6px; + font-size: 12px; +} + +.playground-message--user .playground-message__head .ant-typography, +.playground-message--user .playground-message__content { + color: #f8fafc; +} + +.playground-message__content { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.65; +} + +.playground-message__editor { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.playground-message__editor-input, +.playground-message__editor-input.ant-input, +.playground-message__editor-input.ant-input-affix-wrapper, +.playground-message__editor .ant-input-textarea, +.playground-message__editor .ant-input-textarea-show-count { + display: block; + width: 100%; + min-width: 0; +} + +.playground-message__editor-input.ant-input, +.playground-message__editor .ant-input-textarea textarea.ant-input { + border-radius: 14px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.94); + color: #0f172a; + width: 100% !important; + min-width: 0; +} + +.playground-message__editor-actions { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} + +.playground-message__editor-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; + justify-content: flex-start; + flex: 1 1 auto; +} + +.playground-message__editor-meta .ant-tag { + margin-inline-end: 0; +} + +.playground-message__editor-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + flex: 0 0 auto; + margin-left: auto; +} + +.playground-message__editor-buttons .ant-btn { + border-radius: 999px; + padding-inline: 12px; + font-size: 12px; + line-height: 1; + min-width: 56px; +} + +.playground-message__editor-buttons .ant-btn-default { + border-color: rgba(148, 163, 184, 0.3); + color: #475569; + background: rgba(255, 255, 255, 0.82); +} + +.playground-message__editor-buttons .ant-btn-default:hover, +.playground-message__editor-buttons .ant-btn-default:focus { + color: #0f172a !important; + border-color: rgba(100, 116, 139, 0.34) !important; + background: rgba(255, 255, 255, 0.92) !important; +} + +.playground-message__editor-buttons .ant-btn-primary { + border-color: transparent; + background: linear-gradient(135deg, #0f172a, #334155); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22); +} + +.playground-message__editor-buttons .ant-btn-primary:hover, +.playground-message__editor-buttons .ant-btn-primary:focus { + background: linear-gradient(135deg, #020617, #1e293b) !important; + border-color: transparent !important; +} + +.playground-message__markdown { + font-size: 13px; + line-height: 1.7; +} + +.playground-message__markdown .markdown-renderer { + color: inherit; +} + +.playground-message__markdown .markdown-renderer > :last-child { + margin-bottom: 0; +} + +.playground-message__markdown .markdown-renderer code { + background: rgba(15, 23, 42, 0.08); +} + +.playground-message--assistant .playground-message__markdown .markdown-renderer blockquote { + background: rgba(255, 255, 255, 0.42); +} + +.playground-message__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.playground-message__detail-action { + margin-top: 8px; +} + +.playground-message__detail-action .ant-btn { + padding-inline: 0; +} + +.playground-message__actions { + display: flex; + align-items: center; + gap: 2px; + margin-top: 6px; + padding-inline: 6px; +} + +.playground-message__actions .ant-btn { + color: #64748b; +} + +.playground-message__actions .ant-btn:hover { + color: #0f172a !important; + background: rgba(148, 163, 184, 0.12) !important; +} + .playground-tabs, .playground-tabs .ant-tabs-content-holder, .playground-tabs .ant-tabs-content, @@ -397,6 +806,30 @@ body { gap: 8px; } +.playground-preset-strip--chat { + gap: 8px; + margin-bottom: 14px; +} + +.playground-preset-strip__actions .ant-tag-checkable { + margin-inline-end: 0; + margin-bottom: 4px; + border-radius: 999px; + padding: 4px 12px; +} + +.playground-preset-strip__actions .ant-tag-checkable-checked { + background: linear-gradient(135deg, #0f766e, #14b8a6); + color: #f8fafc; + border-color: transparent; +} + +.playground-preset-strip__actions .ant-tag-checkable:not(.ant-tag-checkable-checked) { + background: rgba(15, 118, 110, 0.08); + border-color: rgba(15, 118, 110, 0.16); + color: #0f766e; +} + .playground-form__actions { display: flex; gap: 12px; @@ -534,6 +967,11 @@ body { gap: 12px; } +.playground-service-modal__body { + display: grid; + gap: 12px; +} + .playground-note.ant-alert { padding: 10px 12px; } @@ -621,6 +1059,21 @@ body { gap: 12px; } +.playground-chat__messages::-webkit-scrollbar { + width: 8px; +} + +.playground-chat__messages::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.82); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.playground-chat__messages::-webkit-scrollbar-track { + background: transparent; +} + .playground-result__blocks-head { display: flex; align-items: center; @@ -638,6 +1091,52 @@ body { scrollbar-color: rgba(148, 163, 184, 0.88) transparent; } +.playground-result__blocks-scroll--raw { + max-height: 240px; +} + +.playground-result-modal .ant-modal-content { + overflow: hidden; +} + +.playground-result-modal__body { + display: flex; + flex-direction: column; + gap: 14px; + height: min(76vh, 760px); + min-height: 0; +} + +.playground-result-modal__head { + flex: 0 0 auto; + display: grid; + gap: 12px; +} + +.playground-result-modal__content { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding-right: 6px; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.88) transparent; +} + +.playground-result-modal__content::-webkit-scrollbar { + width: 8px; +} + +.playground-result-modal__content::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.82); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.playground-result-modal__content::-webkit-scrollbar-track { + background: transparent; +} + @media (max-width: 1200px) { .playground-page__header { align-items: flex-start; @@ -652,9 +1151,7 @@ body { } .playground-shell__sidebar { - flex: 0 0 auto; - min-width: 0; - max-width: none; + display: none; } .playground-result__meta, @@ -662,6 +1159,23 @@ body { align-items: stretch; flex-direction: column; } + + .playground-chat__actions { + align-items: center; + flex-direction: row; + justify-content: space-between; + gap: 10px; + } + + .playground-chat__hints { + flex: 1 1 auto; + min-width: 0; + } + + .playground-chat__send-button.ant-btn { + margin-left: auto; + flex: 0 0 auto; + } } @@ -1072,7 +1586,71 @@ body { line-height: 1.5; } +.alerts-page__body { + overflow: hidden; +} + +.alerts-page__tabs, +.alerts-page__tabs .ant-tabs-content-holder, +.alerts-page__tabs .ant-tabs-content, +.alerts-page__tabs .ant-tabs-tabpane { + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; +} + +.alerts-page__tabs { + display: flex; + flex-direction: column; +} + +.alerts-page__tabs .ant-tabs-nav { + flex: 0 0 auto; + margin-bottom: 8px; +} + +.alerts-page__tabs .ant-tabs-content-holder { + flex: 1 1 auto; + overflow: hidden; +} + +.alerts-page__tabs .ant-tabs-tabpane { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.alerts-tab-panel { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: auto; + padding-right: 4px; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.88) transparent; +} + +.alerts-tab-panel__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.alerts-tab-panel__subtitle { + margin-top: 4px; + color: rgba(0, 0, 0, 0.45); + font-size: 13px; + line-height: 1.5; +} + @media (max-width: 960px) { + .alerts-tab-panel__head { + flex-direction: column; + align-items: stretch; + } + .bgp-page__brief-head { flex-direction: column; } @@ -1100,6 +1678,22 @@ body { margin-top: 14px; } +.bgp-page__brief-facts { + display: grid; + gap: 8px; +} + +.bgp-page__brief-fact-list { + display: grid; + gap: 8px; +} + +.bgp-page__brief-fact-item { + display: flex; + align-items: flex-start; + gap: 8px; +} + .bgp-page__brief-meta .ant-descriptions-view { background: #f7f8fa; border-radius: 12px; @@ -1107,6 +1701,8 @@ body { } .bgp-page__brief-modal-body { + display: grid; + gap: 14px; max-height: calc(100vh - 180px); overflow: auto; padding-right: 6px; @@ -1114,6 +1710,89 @@ body { scrollbar-color: rgba(148, 163, 184, 0.88) transparent; } +.bgp-page__brief-evidence { + display: grid; + gap: 12px; +} + +.bgp-page__brief-evidence-card { + border-radius: 12px; +} + +.alerts-brief-drawer { + display: flex; + flex-direction: column; + gap: 12px; +} + +.alerts-brief-drawer__loading { + min-height: 160px; + display: flex; + align-items: center; + justify-content: center; +} + +.alerts-brief-fact { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.alerts-brief-content { + white-space: pre-wrap; + margin-bottom: 0 !important; +} + +.system-alerts-page__body, +.bgp-alerts-page__body, +.situational-alerts-page__body { + overflow: hidden; +} + +.system-alerts-page__stack, +.bgp-alerts-page__stack, +.situational-alerts-page__stack { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.system-alerts-page__table-card, +.bgp-alerts-page__table-card { + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +.system-alerts-page__table-card .ant-card-body, +.bgp-alerts-page__table-card .ant-card-body { + min-width: 0; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.system-alerts-page__table-region, +.bgp-alerts-page__table-region { + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +.bgp-alerts-page__tabs, +.bgp-alerts-page__tabs .ant-tabs-content-holder, +.bgp-alerts-page__tabs .ant-tabs-content, +.bgp-alerts-page__tabs .ant-tabs-tabpane { + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; +} + .bgp-page__brief-modal { max-width: min(920px, calc(100vw - 32px)); } diff --git a/frontend/src/pages/Alerts/Alerts.tsx b/frontend/src/pages/Alerts/Alerts.tsx index eeb55004..833454ae 100644 --- a/frontend/src/pages/Alerts/Alerts.tsx +++ b/frontend/src/pages/Alerts/Alerts.tsx @@ -1,221 +1,66 @@ -import { useEffect, useState } from 'react' -import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptions } from 'antd' -import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons' -import { useAuthStore } from '../../stores/auth' -import AppLayout from '../../components/AppLayout/AppLayout' -import { formatDateTimeZhCN } from '../../utils/datetime' +import { useMemo } from 'react' -interface Alert { - id: number - severity: 'critical' | 'warning' | 'info' - status: 'active' | 'acknowledged' | 'resolved' - datasource_name: string - message: string - created_at: string - acknowledged_at?: string - resolved_at?: string -} +import { AlertOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons' +import { Tabs, Typography } from 'antd' +import { useSearchParams } from 'react-router-dom' + +import AppLayout from '../../components/AppLayout/AppLayout' +import { BGPAlertsPanel } from './BGPAlerts' +import { SituationalAlertsPanel } from './SituationalAlerts' +import { SystemAlertsPanel } from './SystemAlerts' + +const { Title, Text } = Typography + +const ALERT_TABS = [ + { + key: 'system', + label: '系统告警', + icon: , + children: , + }, + { + key: 'bgp', + label: 'BGP 告警', + icon: , + children: , + }, + { + key: 'situational', + label: '态势告警', + icon: , + children: , + }, +] function Alerts() { - const { token } = useAuthStore() - const [alerts, setAlerts] = useState([]) - const [loading, setLoading] = useState(false) - const [selectedAlert, setSelectedAlert] = useState(null) - const [detailVisible, setDetailVisible] = useState(false) - - const fetchAlerts = async () => { - setLoading(true) - try { - const res = await fetch('/api/v1/alerts', { - headers: { Authorization: `Bearer ${token}` }, - }) - const data = await res.json() - setAlerts(data.data || []) - } catch (error) { - console.error('Failed to fetch alerts:', error) - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchAlerts() - }, [token]) - - const handleAcknowledge = async (alertId: number) => { - try { - await fetch(`/api/v1/alerts/${alertId}/acknowledge`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - }) - fetchAlerts() - } catch (error) { - console.error('Failed to acknowledge alert:', error) - } - } - - const handleResolve = async (alertId: number) => { - try { - await fetch(`/api/v1/alerts/${alertId}/resolve`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify({ resolution: '已处理' }), - }) - fetchAlerts() - } catch (error) { - console.error('Failed to resolve alert:', error) - } - } - - const columns = [ - { title: 'ID', dataIndex: 'id', key: 'id', width: 60 }, - { - title: '级别', - dataIndex: 'severity', - key: 'severity', - render: (s: string) => { - const colors: Record = { critical: 'error', warning: 'warning', info: 'blue' } - const icons: Record = { - critical: , - warning: , - info: , - } - return ( - - {s === 'critical' ? '严重' : s === 'warning' ? '警告' : '信息'} - - ) - }, - }, - { - title: '状态', - dataIndex: 'status', - key: 'status', - render: (s: string) => { - const colors: Record = { active: 'red', acknowledged: 'orange', resolved: 'green' } - return ( - - {s === 'active' ? '待处理' : s === 'acknowledged' ? '已确认' : '已解决'} - - ) - }, - }, - { title: '数据源', dataIndex: 'datasource_name', key: 'datasource_name' }, - { title: '消息', dataIndex: 'message', key: 'message', ellipsis: true }, - { - title: '时间', - dataIndex: 'created_at', - key: 'created_at', - render: (t: string) => formatDateTimeZhCN(t), - }, - { - title: '操作', - key: 'action', - render: (_: unknown, record: Alert) => ( - - {record.status === 'active' && ( - - )} - {record.status !== 'resolved' && ( - - )} - - - ), - }, - ] - - const stats = alerts.reduce( - (acc, alert) => { - if (alert.status === 'active') { - acc[alert.severity]++ - } - return acc - }, - { critical: 0, warning: 0, info: 0 } as Record + const [searchParams, setSearchParams] = useSearchParams() + const requestedTab = searchParams.get('tab') || 'system' + const activeTab = useMemo( + () => (ALERT_TABS.some((item) => item.key === requestedTab) ? requestedTab : 'system'), + [requestedTab], ) return ( - - - - } - /> - - - - - } - /> - - - - - } /> - - - - - } onClick={fetchAlerts}>刷新} - > -
- +
+
+
+ 告警工作台 + + 统一查看系统告警、BGP 告警和跨模块态势告警。主工作区保持单屏,具体证据和 AI 简报在各个 Tab 内处理。 + +
- - setDetailVisible(false)} - footer={null} - width={600} - > - {selectedAlert && ( - - {selectedAlert.id} - - - {selectedAlert.severity} - - - - - {selectedAlert.status} - - - {selectedAlert.datasource_name} - {selectedAlert.message} - {formatDateTimeZhCN(selectedAlert.created_at)} - {selectedAlert.acknowledged_at && ( - - {formatDateTimeZhCN(selectedAlert.acknowledged_at)} - - )} - {selectedAlert.resolved_at && ( - - {formatDateTimeZhCN(selectedAlert.resolved_at)} - - )} - - )} - +
+ setSearchParams({ tab: key })} + items={ALERT_TABS} + /> +
+
) } diff --git a/frontend/src/pages/Alerts/BGPAlerts.tsx b/frontend/src/pages/Alerts/BGPAlerts.tsx new file mode 100644 index 00000000..adb0ab88 --- /dev/null +++ b/frontend/src/pages/Alerts/BGPAlerts.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useState } from 'react' + +import { ReloadOutlined, RobotOutlined } from '@ant-design/icons' +import { + Alert, + Button, + Card, + Col, + Descriptions, + Modal, + Row, + Space, + Spin, + Statistic, + Table, + Tabs, + Tag, + Typography, + message, + type TableColumnsType, +} from 'antd' + +import AppLayout from '../../components/AppLayout/AppLayout' +import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness' +import { getSituationalAwarenessGateway } from '../../services/situational-awareness' +import { formatDateTimeZhCN } from '../../utils/datetime' + +const { Text } = Typography + +const gateway = getSituationalAwarenessGateway() + +function severityColor(severity: string) { + if (severity === 'critical') return 'red' + if (severity === 'high') return 'orange' + if (severity === 'medium') return 'gold' + return 'blue' +} + +export function BGPAlertsPanel() { + const [messageApi, contextHolder] = message.useMessage() + const [loading, setLoading] = useState(false) + const [incidents, setIncidents] = useState([]) + const [anomalies, setAnomalies] = useState([]) + const [briefLoading, setBriefLoading] = useState(false) + const [briefModalOpen, setBriefModalOpen] = useState(false) + const [brief, setBrief] = useState(null) + + const loadData = async () => { + setLoading(true) + try { + const [incidentRows, anomalyRows] = await Promise.all([ + gateway.getBGPIncidents(50), + gateway.getBGPAnomalies(80), + ]) + setIncidents(incidentRows) + setAnomalies(anomalyRows) + } catch (error) { + console.error('Failed to load BGP alerts:', error) + messageApi.error('BGP 告警加载失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void loadData() + }, []) + + const summary = useMemo( + () => ({ + activeIncidents: incidents.filter((item) => item.status === 'active').length, + criticalIncidents: incidents.filter((item) => item.severity === 'critical').length, + activeAnomalies: anomalies.filter((item) => item.status === 'active').length, + highRiskAnomalies: anomalies.filter((item) => ['critical', 'high'].includes(item.severity)).length, + }), + [anomalies, incidents], + ) + + const handleGenerateBrief = async () => { + setBriefModalOpen(true) + setBriefLoading(true) + try { + const record = await gateway.generateBGPBrief() + setBrief(record) + messageApi.success('BGP AI 简报已生成') + } catch (error) { + console.error('Failed to generate BGP brief:', error) + messageApi.error('BGP AI 简报生成失败') + } finally { + setBriefLoading(false) + } + } + + 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: 'status', + width: 120, + render: (value: string) => {value}, + }, + { + title: '影响前缀', + dataIndex: 'affected_prefixes', + width: 220, + render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'), + }, + { 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: 'status', + width: 120, + render: (value: string) => {value}, + }, + { + title: '前缀', + dataIndex: 'prefix', + width: 200, + render: (value: string | null) => value || '-', + }, + { title: '摘要', dataIndex: 'summary', width: 320 }, + ] + + return ( + +
+ {contextHolder} + +
+
+ BGP 告警 +
把 BGP incidents 与 anomalies 当作告警工作台来快速筛查控制平面风险。
+
+ + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + columns={incidentColumns} + dataSource={incidents} + loading={loading} + pagination={false} + rowKey="id" + scroll={{ x: 1200, y: 480 }} + tableLayout="fixed" + /> + + ), + }, + { + key: 'anomalies', + label: 'BGP 异常', + children: ( +
+ + columns={anomalyColumns} + dataSource={anomalies} + loading={loading} + pagination={false} + rowKey="id" + scroll={{ x: 1100, y: 480 }} + tableLayout="fixed" + /> +
+ ), + }, + ]} + /> +
+ + + setBriefModalOpen(false)} + footer={null} + width={920} + className="bgp-page__brief-modal" + style={{ top: 24 }} + styles={{ body: { paddingTop: 12 } }} + > + {briefLoading ? ( +
+ +
+ ) : brief ? ( +
+ + {brief.provider || '-'} + {brief.model || '-'} + {formatDateTimeZhCN(brief.generated_at)} + + {brief.content_markdown} +
+ ) : ( + 当前没有可查看的 BGP 简报。 + )} +
+ + + ) +} + +export default BGPAlertsPanel diff --git a/frontend/src/pages/Alerts/SituationalAlerts.tsx b/frontend/src/pages/Alerts/SituationalAlerts.tsx new file mode 100644 index 00000000..73299ce2 --- /dev/null +++ b/frontend/src/pages/Alerts/SituationalAlerts.tsx @@ -0,0 +1,202 @@ +import { useEffect, useMemo, useState } from 'react' + +import { DeploymentUnitOutlined, ReloadOutlined, RobotOutlined, WarningOutlined } from '@ant-design/icons' +import { + Alert, + Button, + Card, + Col, + Descriptions, + Drawer, + Row, + Space, + Spin, + Statistic, + Typography, + message, +} from 'antd' +import axios from 'axios' + +import AppLayout from '../../components/AppLayout/AppLayout' +import type { BGPSummarySnapshot } from '../../services/situational-awareness' +import { + getSituationalAwarenessGateway, + type SituationalAlertBriefResponse, +} from '../../services/situational-awareness' + +const { Text } = Typography + +const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1' +const gateway = getSituationalAwarenessGateway() + +interface AlertStatsResponse { + critical: number + warning: number + info: number +} + +export function SituationalAlertsPanel() { + const [messageApi, contextHolder] = message.useMessage() + const [systemStats, setSystemStats] = useState(null) + const [bgpSummary, setBgpSummary] = useState(null) + const [loading, setLoading] = useState(false) + const [briefOpen, setBriefOpen] = useState(false) + const [briefLoading, setBriefLoading] = useState(false) + const [briefError, setBriefError] = useState(null) + const [briefResult, setBriefResult] = useState(null) + + const loadOverview = async () => { + setLoading(true) + try { + const [alertStatsResponse, bgpSummaryResponse] = await Promise.all([ + axios.get(`${API_BASE_URL}/alerts/stats`), + gateway.getBGPSummary(), + ]) + setSystemStats(alertStatsResponse.data) + setBgpSummary(bgpSummaryResponse) + } catch (error) { + console.error('Failed to load situational alerts overview:', error) + messageApi.error('态势告警概览加载失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void loadOverview() + }, []) + + const summary = useMemo( + () => ({ + activeSystemAlerts: (systemStats?.critical || 0) + (systemStats?.warning || 0) + (systemStats?.info || 0), + criticalSystemAlerts: systemStats?.critical || 0, + activeBGPIncidents: bgpSummary?.incidentSummary?.by_status?.active || 0, + criticalBGPIncidents: bgpSummary?.incidentSummary?.by_severity?.critical || 0, + }), + [bgpSummary, systemStats], + ) + + const handleGenerateBrief = async () => { + setBriefOpen(true) + setBriefLoading(true) + setBriefError(null) + try { + const response = await axios.post(`${API_BASE_URL}/ai/situational-alerts/brief`, {}) + setBriefResult(response.data) + } catch (error) { + console.error('Failed to generate situational alert brief:', error) + const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null + setBriefError(detail || '生成态势告警 AI 简报失败') + messageApi.error('生成态势告警 AI 简报失败') + } finally { + setBriefLoading(false) + } + } + + return ( + +
+ {contextHolder} + +
+
+ 态势告警 +
把系统告警与 BGP 风险放在同一视角下综合研判,适合做值班总览和跨模块优先级排序。
+
+ + + + +
+ + + + +
+ } /> + + + + + + } /> + + + + + + + + + + + {String(systemStats?.critical ?? '-')} + {String(systemStats?.warning ?? '-')} + {String(systemStats?.info ?? '-')} + + + + + + + {String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')} + {String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')} + {String(bgpSummary?.collectorSummary?.active_collectors ?? '-')} + {String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')} + + + + + + + setBriefOpen(false)} open={briefOpen}> +
+ {briefLoading ? ( +
+ +
+ ) : null} + {!briefLoading && briefError ? ( + + ) : null} + {!briefLoading && briefResult ? ( + + + + {briefResult.objective} + {`${briefResult.provider} / ${briefResult.model}`} + {String(briefResult.context.active_system_alerts ?? '-')} + {String(briefResult.context.active_bgp_incidents ?? '-')} + + + + + {briefResult.facts.map((fact, index) => ( +
+ {index + 1}. + {fact} +
+ ))} +
+
+ + {briefResult.content} + +
+ ) : null} +
+
+ + + ) +} + +export default SituationalAlertsPanel diff --git a/frontend/src/pages/Alerts/SystemAlerts.tsx b/frontend/src/pages/Alerts/SystemAlerts.tsx new file mode 100644 index 00000000..3be8b54d --- /dev/null +++ b/frontend/src/pages/Alerts/SystemAlerts.tsx @@ -0,0 +1,303 @@ +import { useEffect, useMemo, useState } from 'react' + +import { AlertOutlined, InfoCircleOutlined, ReloadOutlined, RobotOutlined } from '@ant-design/icons' +import { + Alert, + Button, + Card, + Col, + Descriptions, + Drawer, + Modal, + Row, + Space, + Spin, + Statistic, + Table, + Tag, + Typography, + message, + type TableColumnsType, +} from 'antd' +import axios from 'axios' + +import AppLayout from '../../components/AppLayout/AppLayout' +import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness' +import { formatDateTimeZhCN } from '../../utils/datetime' + +const { Text } = Typography + +const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1' + +function renderAlertSeverityTag(value: AlertRecord['severity']) { + const colorMap = { critical: 'error', warning: 'warning', info: 'blue' } + const labelMap = { critical: '严重', warning: '警告', info: '信息' } + const iconMap = { + critical: , + warning: , + info: , + } + + return ( + + {labelMap[value]} + + ) +} + +function renderAlertStatusTag(value: AlertRecord['status']) { + const colorMap = { active: 'red', acknowledged: 'orange', resolved: 'green' } + const labelMap = { active: '待处理', acknowledged: '已确认', resolved: '已解决' } + return {labelMap[value]} +} + +export function SystemAlertsPanel() { + const [messageApi, contextHolder] = message.useMessage() + const [alerts, setAlerts] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedAlert, setSelectedAlert] = useState(null) + const [detailVisible, setDetailVisible] = useState(false) + const [briefOpen, setBriefOpen] = useState(false) + const [briefLoading, setBriefLoading] = useState(false) + const [briefError, setBriefError] = useState(null) + const [briefResult, setBriefResult] = useState(null) + + const fetchAlerts = async () => { + setLoading(true) + try { + const response = await axios.get<{ data: AlertRecord[] }>(`${API_BASE_URL}/alerts`) + setAlerts(response.data.data || []) + } catch (error) { + console.error('Failed to fetch system alerts:', error) + messageApi.error('系统告警加载失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void fetchAlerts() + }, []) + + const stats = useMemo( + () => + alerts.reduce( + (accumulator, item) => { + if (item.status === 'active') { + accumulator[item.severity] += 1 + } + return accumulator + }, + { critical: 0, warning: 0, info: 0 } as Record, + ), + [alerts], + ) + + const handleAcknowledge = async (alertId: number) => { + try { + await axios.post(`${API_BASE_URL}/alerts/${alertId}/acknowledge`) + messageApi.success('告警已确认') + await fetchAlerts() + } catch (error) { + console.error('Failed to acknowledge alert:', error) + messageApi.error('确认告警失败') + } + } + + const handleResolve = async (alertId: number) => { + try { + await axios.post(`${API_BASE_URL}/alerts/${alertId}/resolve`, { resolution: '已处理' }) + messageApi.success('告警已解决') + await fetchAlerts() + } catch (error) { + console.error('Failed to resolve alert:', error) + messageApi.error('解决告警失败') + } + } + + const handleGenerateBrief = async () => { + setBriefOpen(true) + setBriefLoading(true) + setBriefError(null) + try { + const response = await axios.post(`${API_BASE_URL}/ai/alerts/brief`, {}) + setBriefResult(response.data) + } catch (error) { + console.error('Failed to generate system alert brief:', error) + const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null + setBriefError(detail || '生成系统告警 AI 简报失败') + messageApi.error('生成系统告警 AI 简报失败') + } finally { + setBriefLoading(false) + } + } + + const columns: TableColumnsType = [ + { title: 'ID', dataIndex: 'id', width: 72 }, + { + title: '级别', + dataIndex: 'severity', + width: 108, + render: (value: AlertRecord['severity']) => renderAlertSeverityTag(value), + }, + { + title: '状态', + dataIndex: 'status', + width: 108, + render: (value: AlertRecord['status']) => renderAlertStatusTag(value), + }, + { + title: '数据源', + dataIndex: 'datasource_name', + width: 180, + render: (value: string | null) => value || '-', + }, + { + title: '消息', + dataIndex: 'message', + ellipsis: true, + }, + { + title: '时间', + dataIndex: 'created_at', + width: 180, + render: (value: string) => formatDateTimeZhCN(value), + }, + { + title: '操作', + key: 'action', + width: 180, + render: (_, record) => ( + + {record.status === 'active' ? ( + + ) : null} + {record.status !== 'resolved' ? ( + + ) : null} + + + ), + }, + ] + + return ( + +
+ {contextHolder} + +
+
+ 系统告警 +
聚焦平台运行、采集链路和系统内部异常。
+
+ + + + +
+ + + + +
+ } /> + + + } /> + + + } /> + + + + +
+ + columns={columns} + dataSource={alerts} + loading={loading} + pagination={{ pageSize: 10 }} + rowKey="id" + scroll={{ x: 1100, y: 480 }} + tableLayout="fixed" + /> +
+
+ + + setDetailVisible(false)} footer={null} width={640}> + {selectedAlert ? ( + + {selectedAlert.id} + {renderAlertSeverityTag(selectedAlert.severity)} + {renderAlertStatusTag(selectedAlert.status)} + {selectedAlert.datasource_name || '-'} + {selectedAlert.message} + {formatDateTimeZhCN(selectedAlert.created_at)} + {formatDateTimeZhCN(selectedAlert.acknowledged_at || null)} + {formatDateTimeZhCN(selectedAlert.resolved_at || null)} + {selectedAlert.resolution_notes || '-'} + + ) : null} + + + setBriefOpen(false)} open={briefOpen}> +
+ {briefLoading ? ( +
+ +
+ ) : null} + {!briefLoading && briefError ? ( + + ) : null} + {!briefLoading && briefResult ? ( + + + + {briefResult.objective} + {`${briefResult.provider} / ${briefResult.model}`} + {String(briefResult.context.active_alerts ?? '-')} + + + + + {briefResult.facts.map((fact, index) => ( +
+ {index + 1}. + {fact} +
+ ))} +
+
+ + {briefResult.content} + +
+ ) : null} +
+
+ + + ) +} + +export default SystemAlertsPanel diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index 2eb83801..e16aa54c 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -66,6 +66,19 @@ function sortBriefRecords(records: T[]) { return [...records].sort((left, right) => right.generated_at.localeCompare(left.generated_at)) } +function renderBriefContextValue(value: unknown) { + if (value === null || value === undefined) return '-' + if (Array.isArray(value)) { + return value.length > 0 ? JSON.stringify(value) : '-' + } + if (typeof value === 'object') { + const entries = Object.entries(value as Record) + if (entries.length === 0) return '-' + return entries.map(([key, count]) => `${key}: ${String(count)}`).join(',') + } + return String(value) +} + function renderCollectorLocation(_: unknown, record: BGPCollectorCoverage) { return [record.city, record.country].filter(Boolean).join(', ') || '-' } @@ -574,7 +587,23 @@ function BGP() { {formatDateTimeZhCN(brief.generated_at)} + {brief.facts.length} + {renderBriefContextValue(brief.context.incident_total)} + {renderBriefContextValue(brief.context.active_collectors)} + {brief.facts.length > 0 ? ( +
+ 事实快照 +
+ {brief.facts.slice(0, 3).map((fact, index) => ( +
+ {index + 1}. + {fact} +
+ ))} +
+
+ ) : null} ) : (
@@ -727,6 +756,34 @@ function BGP() { > {brief ? (
+ {(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? ( +
+ {brief.facts.length > 0 ? ( + + + {brief.facts.map((fact, index) => ( +
+ {index + 1}. + {fact} +
+ ))} +
+
+ ) : null} + + {Object.keys(brief.context || {}).length > 0 ? ( + + + {Object.entries(brief.context).map(([key, value]) => ( + + {renderBriefContextValue(value)} + + ))} + + + ) : null} +
+ ) : null}
) : ( diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx index 17660e2a..1f20417f 100644 --- a/frontend/src/pages/Playground/Playground.tsx +++ b/frontend/src/pages/Playground/Playground.tsx @@ -1,26 +1,36 @@ -import { useEffect, useState } from 'react' -import axios from 'axios' -import { CopyOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { ApiOutlined, ArrowDownOutlined, ArrowUpOutlined, BorderOutlined, CopyOutlined, EditOutlined, InfoCircleOutlined, RedoOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons' import { Alert, + Avatar, Button, Card, Collapse, Descriptions, - Form, + Drawer, Input, + Modal, Space, Spin, - Tabs, Tag, + Tooltip, Typography, message, } from 'antd' -import AppLayout from '../../components/AppLayout/AppLayout' +import axios from 'axios' +import { RobotOutlined, UserOutlined } from '@ant-design/icons' -const { Title, Text } = Typography +import AppLayout from '../../components/AppLayout/AppLayout' +import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer' +import { useAuthStore } from '../../stores/auth' + +const { Title, Text, Paragraph } = Typography const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1' const PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY = 'playground-provider-status' +const PLAYGROUND_REMOTE_SESSION_KEY = 'default' +const THREAD_POLL_INTERVAL_MS = 1200 +const SCROLL_BOTTOM_THRESHOLD_PX = 120 interface AIProviderStatus { provider: string @@ -47,17 +57,17 @@ interface SituationalAnalysisResponse { raw_response: Record } -interface PlaygroundFormValues { +interface PlaygroundPresetValues { title: string objective: string - observations?: string constraints?: string + message: string } interface PlaygroundPreset { key: string label: string - values: PlaygroundFormValues + values: PlaygroundPresetValues } interface AnalysisRunMeta { @@ -66,6 +76,76 @@ interface AnalysisRunMeta { completedAt: string | null } +interface PlaygroundSessionState { + selectedPresetKey: string + title: string + objective: string + constraints: string + inputValue: string + analysis: SituationalAnalysisResponse | null + latestAnalysisMessageId: string | null + analysisMeta: AnalysisRunMeta + helpExpanded: boolean +} + +interface PlaygroundSessionRecord { + id: string + session_key: string + title: string + state: PlaygroundSessionState + created_at: string + updated_at: string +} + +interface PlaygroundThreadRecord { + session: PlaygroundSessionRecord + messages: PlaygroundApiMessage[] +} + +interface PlaygroundApiMessage { + id: string + role: 'system' | 'user' | 'assistant' + kind?: 'message' | 'thinking' + status?: 'pending' | 'thinking' | 'answering' | 'done' | 'stopped' | 'error' + title?: string + content: string + thinking_content?: string + meta?: string[] + markdown?: boolean + provider?: string | null + model?: string | null + request_id?: string | null + raw_response?: Record + content_blocks?: AIContentBlock[] + text_blocks?: string[] + thinking_blocks?: string[] + parent_message_id?: string | null + created_at: string + updated_at: string +} + +interface PlaygroundMessage { + id: string + role: 'system' | 'user' | 'assistant' + kind?: 'message' | 'thinking' + status?: 'pending' | 'thinking' | 'answering' | 'done' | 'stopped' | 'error' + title?: string + content: string + thinkingContent?: string + meta?: string[] + markdown?: boolean + provider?: string | null + model?: string | null + requestId?: string | null + rawResponse?: Record + contentBlocks?: AIContentBlock[] + textBlocks?: string[] + thinkingBlocks?: string[] + parentMessageId?: string | null + createdAt?: string + updatedAt?: string +} + const PLAYGROUND_PRESETS: PlaygroundPreset[] = [ { key: 'bgp-brief', @@ -73,8 +153,8 @@ const PLAYGROUND_PRESETS: PlaygroundPreset[] = [ values: { title: 'BGP 告警态势简报', objective: '总结当前告警的主要风险、优先级与建议动作。', - observations: '出现新的高危告警\n部分观测站最近 24h 事件增多', constraints: '结论要简洁\n优先给出操作建议', + message: '出现新的高危告警\n部分观测站最近 24h 事件增多\n请先给出风险摘要,再列出建议动作。', }, }, { @@ -83,8 +163,8 @@ const PLAYGROUND_PRESETS: PlaygroundPreset[] = [ values: { title: '采集器健康检查说明', objective: '判断当前采集器失败是否属于上游接口失效、限流、结构变更或临时波动。', - observations: '最近 3 次任务失败\n部分数据源响应时间抬升\n个别接口返回结构不稳定', constraints: '区分事实与推断\n先给排障优先级', + message: '最近 3 次任务失败\n部分数据源响应时间抬升\n个别接口返回结构不稳定\n请帮我先做排障优先级排序。', }, }, { @@ -93,34 +173,202 @@ const PLAYGROUND_PRESETS: PlaygroundPreset[] = [ values: { title: 'AI 链路探测', objective: '验证 backend -> aiprovider -> model provider 调用链路是否正常。', - observations: '当前从 Playground 发起测试\n希望确认 provider 配置和返回结构正常', constraints: '输出简洁\n包含一段明确结论', + message: '当前从 Playground 发起测试,希望确认 provider 配置和返回结构正常,请直接给我链路结论。', }, }, ] -function splitLines(value?: string) { - return (value || '') - .split('\n') - .map((item) => item.trim()) - .filter(Boolean) +function toPlaygroundMessage(item: PlaygroundApiMessage): PlaygroundMessage { + return { + id: item.id, + role: item.role, + kind: item.kind || 'message', + status: item.status, + title: item.title, + content: item.content || '', + thinkingContent: item.thinking_content || '', + meta: item.meta || [], + markdown: item.markdown ?? item.role !== 'system', + provider: item.provider, + model: item.model, + requestId: item.request_id, + rawResponse: item.raw_response || {}, + contentBlocks: item.content_blocks || [], + textBlocks: item.text_blocks || [], + thinkingBlocks: item.thinking_blocks || [], + parentMessageId: item.parent_message_id || null, + createdAt: item.created_at, + updatedAt: item.updated_at, + } } function Playground() { - const [form] = Form.useForm() const [messageApi, contextHolder] = message.useMessage() + const { token } = useAuthStore() const [statusLoading, setStatusLoading] = useState(false) - const [analyzing, setAnalyzing] = useState(false) const [providerStatus, setProviderStatus] = useState(null) + const [providerStatusUpdatedAt, setProviderStatusUpdatedAt] = useState(null) + const [helpExpanded, setHelpExpanded] = useState(true) + const [settingsOpen, setSettingsOpen] = useState(false) + const [servicePanelOpen, setServicePanelOpen] = useState(false) + const [selectedPresetKey, setSelectedPresetKey] = useState(PLAYGROUND_PRESETS[0].key) + const [title, setTitle] = useState(PLAYGROUND_PRESETS[0].values.title) + const [objective, setObjective] = useState(PLAYGROUND_PRESETS[0].values.objective) + const [constraints, setConstraints] = useState(PLAYGROUND_PRESETS[0].values.constraints || '') + const [inputValue, setInputValue] = useState(PLAYGROUND_PRESETS[0].values.message) + const [messages, setMessages] = useState([ + { + id: 'playground-system-intro', + role: 'system', + title: 'Playground 已就绪', + content: '这里是 AI 测试台。选择一个快速预设,补充输入消息,然后发送一次真实后端链路请求。', + }, + ]) const [analysis, setAnalysis] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const [latestAnalysisMessageId, setLatestAnalysisMessageId] = useState(null) const [analysisMeta, setAnalysisMeta] = useState({ requestId: null, durationMs: null, completedAt: null, }) - const [providerStatusUpdatedAt, setProviderStatusUpdatedAt] = useState(null) - const [activeTab, setActiveTab] = useState<'request' | 'result'>('request') - const [helpExpanded, setHelpExpanded] = useState(true) + const [requestPending, setRequestPending] = useState(false) + const [editingMessageId, setEditingMessageId] = useState(null) + const [editingContent, setEditingContent] = useState('') + const [editSaving, setEditSaving] = useState(false) + const [showScrollToBottom, setShowScrollToBottom] = useState(false) + const pollTimerRef = useRef(null) + const messagesContainerRef = useRef(null) + const forceScrollToBottomRef = useRef(true) + + const selectedPreset = useMemo( + () => PLAYGROUND_PRESETS.find((item) => item.key === selectedPresetKey) || PLAYGROUND_PRESETS[0], + [selectedPresetKey], + ) + const activeAssistantMessage = useMemo( + () => messages.find((item) => item.role === 'assistant' && ['pending', 'thinking', 'answering'].includes(item.status || '')), + [messages], + ) + const streaming = Boolean(activeAssistantMessage) + const sendButtonTooltip = requestPending ? '发送中' : streaming ? '停止生成' : '发送消息' + + const applyThreadSnapshot = (thread: PlaygroundThreadRecord | null) => { + if (!thread) { + return + } + forceScrollToBottomRef.current = true + const sessionState = thread.session.state + setSelectedPresetKey(sessionState.selectedPresetKey || PLAYGROUND_PRESETS[0].key) + setTitle(sessionState.title || PLAYGROUND_PRESETS[0].values.title) + setObjective(sessionState.objective || PLAYGROUND_PRESETS[0].values.objective) + setConstraints(sessionState.constraints || '') + setInputValue(sessionState.inputValue || '') + setHelpExpanded(sessionState.helpExpanded ?? true) + const remoteMessages = thread.messages.map(toPlaygroundMessage) + setMessages([ + { + id: 'playground-system-intro', + role: 'system', + title: 'Playground 已就绪', + content: '这里是 AI 测试台。聊天记录与执行状态现在以后台数据库为准,刷新后会恢复到真实进度。', + }, + ...remoteMessages, + ]) + setLatestAnalysisMessageId(sessionState.latestAnalysisMessageId || null) + setAnalysis((sessionState.analysis as SituationalAnalysisResponse | null) || null) + setAnalysisMeta(sessionState.analysisMeta || { + requestId: null, + durationMs: null, + completedAt: null, + }) + } + + const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { + const container = messagesContainerRef.current + if (!container) { + return + } + container.scrollTo({ + top: container.scrollHeight, + behavior, + }) + setShowScrollToBottom(false) + } + + const handleMessagesScroll = () => { + const container = messagesContainerRef.current + if (!container) { + return + } + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight + setShowScrollToBottom(distanceFromBottom > SCROLL_BOTTOM_THRESHOLD_PX) + } + + useEffect(() => { + const container = messagesContainerRef.current + if (!container) { + return + } + if (forceScrollToBottomRef.current) { + forceScrollToBottomRef.current = false + scrollToBottom('auto') + return + } + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight + if (distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD_PX || requestPending) { + scrollToBottom('auto') + } + }, [messages, requestPending, streaming]) + + const refreshThread = async (options?: { silent?: boolean }) => { + if (!token) { + return + } + + try { + const res = await fetch(`${API_BASE_URL}/ai/playground/thread?session_key=${encodeURIComponent(PLAYGROUND_REMOTE_SESSION_KEY)}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + if (res.ok) { + const thread = (await res.json()) as PlaygroundThreadRecord | null + applyThreadSnapshot(thread) + } + } catch { + if (!options?.silent) { + messageApi.error('Playground 会话加载失败') + } + } finally { + } + } + + useEffect(() => { + void refreshThread() + }, [token]) + + useEffect(() => { + if (pollTimerRef.current !== null) { + window.clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + + if (!token || !activeAssistantMessage) { + return + } + + pollTimerRef.current = window.setInterval(() => { + void refreshThread({ silent: true }) + }, THREAD_POLL_INTERVAL_MS) + + return () => { + if (pollTimerRef.current !== null) { + window.clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + } + }, [token, activeAssistantMessage?.id]) const loadProviderStatus = async (force = false) => { if (!force) { @@ -149,43 +397,94 @@ function Playground() { useEffect(() => { void loadProviderStatus() + return () => { + if (pollTimerRef.current !== null) { + window.clearInterval(pollTimerRef.current) + } + } }, []) - const handleAnalyze = async (values: PlaygroundFormValues) => { - const startedAt = performance.now() - setAnalyzing(true) - setActiveTab('result') + const handleApplyPreset = (preset: PlaygroundPreset) => { + setSelectedPresetKey(preset.key) + setTitle(preset.values.title) + setObjective(preset.values.objective) + setConstraints(preset.values.constraints || '') + setInputValue(preset.values.message) + } + + const handleStop = async () => { + if (!token || !activeAssistantMessage) { + return + } try { - const res = await axios.post( - `${API_BASE_URL}/ai/situational-awareness/analyze`, - { - title: values.title.trim(), - objective: values.objective.trim(), - observations: splitLines(values.observations), - constraints: splitLines(values.constraints), - context: { - source: 'playground', - }, + const res = await fetch(`${API_BASE_URL}/ai/playground/messages/stop`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, }, - ) - setAnalysis(res.data) - setAnalysisMeta({ - requestId: typeof res.headers['x-request-id'] === 'string' ? res.headers['x-request-id'] : null, - durationMs: Math.round(performance.now() - startedAt), - completedAt: new Date().toLocaleString(), + body: JSON.stringify({ + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + message_id: activeAssistantMessage.id, + }), }) - messageApi.success('分析已完成') + if (!res.ok) { + throw new Error('STOP_FAILED') + } + const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } + applyThreadSnapshot({ + session: data.session, + messages: data.messages, + }) + messageApi.warning('已停止生成,已输出内容会保留') } catch { - messageApi.error('分析失败,请检查 AI Provider 配置或稍后再试') - setActiveTab('request') - } finally { - setAnalyzing(false) + messageApi.error('停止失败,请稍后再试') } } - const handleApplyPreset = (preset: PlaygroundPreset) => { - form.setFieldsValue(preset.values) - setActiveTab('request') + const handleSend = async (overrides?: { input?: string; title?: string; objective?: string }) => { + const trimmedInput = (overrides?.input ?? inputValue).trim() + const trimmedTitle = (overrides?.title ?? title).trim() + const trimmedObjective = (overrides?.objective ?? objective).trim() + + if (!trimmedInput || !trimmedTitle || !trimmedObjective) { + messageApi.error('请先补齐标题、目标和输入内容') + return + } + + setRequestPending(true) + + try { + const res = await fetch(`${API_BASE_URL}/ai/playground/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + title: trimmedTitle, + objective: trimmedObjective, + constraints, + input: trimmedInput, + selected_preset_key: selectedPreset.key, + help_expanded: helpExpanded, + }), + }) + if (!res.ok) { + throw new Error('SEND_FAILED') + } + const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } + applyThreadSnapshot({ + session: data.session, + messages: data.messages, + }) + setRequestPending(false) + void refreshThread({ silent: true }) + } catch { + setRequestPending(false) + messageApi.error('分析失败,请检查 AI Provider 配置或稍后再试') + } } const handleCopyResult = async () => { @@ -208,6 +507,205 @@ function Playground() { } } + const handleCopyMessage = async (content: string) => { + try { + await navigator.clipboard.writeText(content) + messageApi.success('消息已复制') + } catch { + messageApi.error('复制失败,请稍后再试') + } + } + + const handleResendMessage = async (entry: PlaygroundMessage) => { + if (!token) { + return + } + try { + const res = await fetch(`${API_BASE_URL}/ai/playground/messages/resend`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, + }), + }) + if (!res.ok) { + throw new Error('RESEND_FAILED') + } + const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } + applyThreadSnapshot({ + session: data.session, + messages: data.messages, + }) + messageApi.success('已重试,并清理该消息之后的旧分支') + } catch { + messageApi.error('重试失败,请稍后再试') + } + } + + const handleStartEditMessage = (entry: PlaygroundMessage) => { + setEditingMessageId(entry.id) + setEditingContent(entry.content) + } + + const handleCancelEditMessage = () => { + setEditingMessageId(null) + setEditingContent('') + setEditSaving(false) + } + + const handleSaveEditMessage = async (entry: PlaygroundMessage) => { + if (!token) { + return + } + const trimmed = editingContent.trim() + if (!trimmed) { + messageApi.error('Prompt 不能为空') + return + } + + try { + setEditSaving(true) + const editRes = await fetch(`${API_BASE_URL}/ai/playground/messages/edit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, + content: trimmed, + }), + }) + if (!editRes.ok) { + throw new Error('EDIT_FAILED') + } + const resendRes = await fetch(`${API_BASE_URL}/ai/playground/messages/resend`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, + }), + }) + if (!resendRes.ok) { + throw new Error('RESEND_AFTER_EDIT_FAILED') + } + const data = (await resendRes.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } + applyThreadSnapshot({ + session: data.session, + messages: data.messages, + }) + setEditingMessageId(null) + setEditingContent('') + setEditSaving(false) + messageApi.success('Prompt 已更新,已基于新内容重新生成') + } catch { + setEditSaving(false) + messageApi.error('修改或重试失败,请稍后再试') + } + } + + const providerStatusPanel = ( + } + onClick={() => void loadProviderStatus(true)} + aria-label="刷新 Provider 状态" + className="playground-card__icon-button" + /> + )} + > +
+ + {providerStatus ? ( +
+
+ Provider + {providerStatus.provider || '-'} +
+
+ 模型 + {providerStatus.model || '-'} +
+
+ API + {providerStatus.api || '-'} +
+
+ 状态 +
+ + {providerStatus.enabled ? 'enabled' : 'disabled'} + + + {providerStatus.configured ? 'configured' : 'not configured'} + +
+
+
+ Base URL + + {providerStatus.base_url || '-'} + +
+
+ 最后同步 + {providerStatusUpdatedAt || '-'} +
+
+ ) : ( + + )} +
+
+
+ ) + + const helpPanel = ( + setHelpExpanded(Array.isArray(keys) ? keys.includes('help') : keys === 'help')} + items={[ + { + key: 'help', + label: '测试说明', + children: ( +
+ + +
+ ), + }, + ]} + /> + ) + return ( {contextHolder} @@ -216,7 +714,7 @@ function Playground() {
AI Playground - 在前端测试 AI 能力,但实际请求仍统一经由主后端转发到 AI Provider。 + 这里现在是一个真实链路的 AI Chatbox。页面负责调试输入与展示,模型请求仍统一经由主后端转发到 AI Provider。
@@ -224,277 +722,376 @@ function Playground() {
- } - onClick={() => void loadProviderStatus(true)} - aria-label="刷新 Provider 状态" - className="playground-card__icon-button" - /> - )} - > -
-
- - {providerStatus ? ( -
-
- Provider - {providerStatus.provider || '-'} -
-
- 模型 - {providerStatus.model || '-'} -
-
- API - {providerStatus.api || '-'} -
-
- 状态 -
- - {providerStatus.enabled ? 'enabled' : 'disabled'} - - - {providerStatus.configured ? 'configured' : 'not configured'} - -
-
-
- Base URL - - {providerStatus.base_url || '-'} - -
-
- 最后同步 - {providerStatusUpdatedAt || '-'} -
-
- ) : ( - - )} -
-
-
-
- - setHelpExpanded(Array.isArray(keys) ? keys.includes('help') : keys === 'help')} - items={[ - { - key: 'help', - label: '测试说明', - children: ( -
- - 当前链路: - frontend /playground - {' -> '} - backend /api/v1/ai/* - {' -> '} - aiprovider /v1/* - - )} - /> - -
- ), - }, - ]} - /> + {providerStatusPanel} + {helpPanel}
- - setActiveTab(key as 'request' | 'result')} - className="playground-tabs" - items={[ - { - key: 'request', - label: '请求', - children: ( -
-
- 快速预设 -
- {PLAYGROUND_PRESETS.map((preset) => ( - - ))} -
-
-
- - - - - - - - -
- - - - - - - -
- -
- - -
- -
- ), - }, - { - key: 'result', - label: '结果', - children: ( -
- {analyzing ? ( -
- - AI 正在生成结果... -
- ) : analysis ? ( - -
-
- {analysis.provider} - {analysis.model} -
- - - - -
- -
-
{analysis.content}
-
- {analysis.text_blocks.length ? ( -
- 文本块 -
- {analysis.text_blocks.map((block, index) => ( - -
{block}
-
- ))} -
-
- ) : null} - {analysis.thinking_blocks.length ? ( -
- Thinking Blocks -
- {analysis.thinking_blocks.map((block, index) => ( - -
{block}
-
- ))} -
-
- ) : null} -
-
- Raw Response - -
-
- -
{JSON.stringify(analysis.raw_response, null, 2)}
-
-
-
-
- ) : ( - + + + +
+
+
+ ) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? ( +
+ +
+ ) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') ? ( +
{entry.content || ' '}
+ ) : null} + {(entry.role !== 'assistant' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && !(entry.role === 'user' && editingMessageId === entry.id) && entry.meta?.length ? ( +
+ {entry.meta.map((item) => ( + {item} + ))} +
+ ) : null} + + {!(entry.role === 'user' && editingMessageId === entry.id) ? ( +
+ {(entry.role === 'assistant' || entry.role === 'user') ? ( + +
+ ) : null} + - ), - }, - ]} - /> + ))} + + {showScrollToBottom ? ( + + + + + + + + +
+ + {analysis.text_blocks.length ? ( +
+ 文本块 +
+ {analysis.text_blocks.map((block, index) => ( + +
{block}
+
+ ))} +
+
+ ) : null} + {analysis.thinking_blocks.length ? ( +
+ Thinking Blocks +
+ {analysis.thinking_blocks.map((block, index) => ( + +
{block}
+
+ ))} +
+
+ ) : null} +
+ Raw Response +
+ +
{JSON.stringify(analysis.raw_response, null, 2)}
+
+
+
+
+
+ + ) : null} + ) } diff --git a/frontend/src/services/situational-awareness/index.ts b/frontend/src/services/situational-awareness/index.ts index 3e62a6e0..843493c7 100644 --- a/frontend/src/services/situational-awareness/index.ts +++ b/frontend/src/services/situational-awareness/index.ts @@ -1,6 +1,5 @@ import type { SituationalAwarenessGateway } from './port' import { HttpSituationalAwarenessGateway } from './http-gateway' -import { MockSituationalAwarenessGateway } from './mock-gateway' export * from './types' export type { SituationalAwarenessGateway } from './port' @@ -8,10 +7,6 @@ export type { SituationalAwarenessGateway } from './port' let singleton: SituationalAwarenessGateway | null = null export function createSituationalAwarenessGateway(): SituationalAwarenessGateway { - const provider = (import.meta as any).env?.VITE_SA_GATEWAY || 'http' - if (provider === 'mock') { - return new MockSituationalAwarenessGateway() - } return new HttpSituationalAwarenessGateway() } diff --git a/frontend/src/services/situational-awareness/mock-gateway.ts b/frontend/src/services/situational-awareness/mock-gateway.ts deleted file mode 100644 index e73fe066..00000000 --- a/frontend/src/services/situational-awareness/mock-gateway.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { SituationalAwarenessGateway } from './port' -import type { - BGPBriefRecord, - BGPBriefRecordSummary, - BGPOverviewOptions, - BGPOverviewSnapshot, - BGPSummarySnapshot, -} from './types' - -const EMPTY_SNAPSHOT: BGPOverviewSnapshot = { - incidents: [], - incidentSummary: { - total: 0, - by_type: {}, - by_severity: {}, - by_status: {}, - }, - anomalies: [], - events: [], - eventSummary: { - total: 0, - collector_count: 0, - prefix_count: 0, - by_type: {}, - }, - collectors: [], - collectorSummary: { - total: 0, - active_collectors: 0, - observed_prefixes: 0, - observed_origins: 0, - recent_24h_events: 0, - recent_7d_events: 0, - }, -} - -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 { - return EMPTY_SNAPSHOT - } - - async getBGPSummary(): Promise { - 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 { - return this.brief - } - - async listBGPBriefs(): Promise { - return [this.brief] - } - - async getBGPBrief(_briefId: string): Promise { - return this.brief - } - - async getLatestBGPBrief(): Promise { - return this.brief - } -} diff --git a/frontend/src/services/situational-awareness/types.ts b/frontend/src/services/situational-awareness/types.ts index 4279e3e1..fa20bbf3 100644 --- a/frontend/src/services/situational-awareness/types.ts +++ b/frontend/src/services/situational-awareness/types.ts @@ -144,4 +144,47 @@ export interface BGPBriefRecordSummary { export interface BGPBriefRecord extends BGPBriefRecordSummary { content_markdown: string + facts: string[] + context: Record +} + +export interface AlertRecord { + id: number + severity: 'critical' | 'warning' | 'info' + status: 'active' | 'acknowledged' | 'resolved' + datasource_name: string | null + message: string + created_at: string + acknowledged_at?: string | null + resolved_at?: string | null + alert_metadata?: string | null + resolution_notes?: string | null +} + +export interface AlertBriefResponse { + provider: string + model: string + content: string + content_blocks: AIContentBlock[] + text_blocks: string[] + thinking_blocks: string[] + raw_response: Record + title: string + objective: string + facts: string[] + context: Record +} + +export interface SituationalAlertBriefResponse { + provider: string + model: string + content: string + content_blocks: AIContentBlock[] + text_blocks: string[] + thinking_blocks: string[] + raw_response: Record + title: string + objective: string + facts: string[] + context: Record } From 62ad09e8161e1784eefdee7854d493643b23c9f9 Mon Sep 17 00:00:00 2001 From: linkong Date: Fri, 10 Apr 2026 16:04:50 +0800 Subject: [PATCH 24/34] release: bump version to 0.25.0 --- VERSION | 2 +- docs/CHANGELOG.md | 24 ++++++++++++++++++++++++ docs/version-history.md | 3 ++- frontend/package.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 1f1cd637..d21d277b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.8 +0.25.0 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 58318156..9e9e9421 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,30 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.25.0 + +Released: 2026-04-10 + +### Highlights + +- Turned `AI Playground` into a persistent backend-backed chat workspace, added dedicated alert workspaces as a foundation for future situational analysis, and aligned the operator UI around a more structured AI + alerts workflow instead of one-off playground calls. + +### Added + +- Added [backend/app/models/playground_session.py](/home/ray/dev/linkong/planet/backend/app/models/playground_session.py), [backend/app/models/playground_message.py](/home/ray/dev/linkong/planet/backend/app/models/playground_message.py), and [backend/app/services/playground_chat_service.py](/home/ray/dev/linkong/planet/backend/app/services/playground_chat_service.py) so Playground conversations, execution state, edits, retries, and stop/resume semantics are persisted in the backend database rather than living only in browser state. +- Added [backend/app/services/alert_ai_brief.py](/home/ray/dev/linkong/planet/backend/app/services/alert_ai_brief.py), [backend/app/services/situational_alert_ai_brief.py](/home/ray/dev/linkong/planet/backend/app/services/situational_alert_ai_brief.py), and the dedicated alert pages [frontend/src/pages/Alerts/SystemAlerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/SystemAlerts.tsx), [frontend/src/pages/Alerts/BGPAlerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/BGPAlerts.tsx), and [frontend/src/pages/Alerts/SituationalAlerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/SituationalAlerts.tsx) to establish the alert-analysis foundation for later situational awareness expansion. + +### Improved + +- Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by rebuilding Playground into a true chatbox workflow with persistent history, edit-and-resend behavior, grounded message actions, responsive composer behavior, bottom-stick scrolling, and tighter mobile layout handling. +- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page. +- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation. + +### Fixed + +- Fixed [backend/app/services/playground_chat_service.py](/home/ray/dev/linkong/planet/backend/app/services/playground_chat_service.py) so background Playground runs explicitly commit state transitions, allowing frontend polling to observe real pending/thinking/answering/done states instead of seeing stale empty threads. +- Fixed [frontend/src/services/situational-awareness/index.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/index.ts) by removing the temporary mock gateway path, so Playground and alert-related AI flows now reflect the real backend/provider chain instead of local fake responses. + ## 0.24.8 Released: 2026-04-10 diff --git a/docs/version-history.md b/docs/version-history.md index 08eb09c4..cb9dca0b 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.24.8` +- `dev` 当前开发分支历史推导到:`0.25.0` ## Timeline @@ -81,6 +81,7 @@ | `0.24.6` | bugfix | `dev` | `pending` | batch datasource and visualization hot-path queries, fix BGP collector JSON extraction, and rebuild the BGP AI brief tab layout and markdown rendering | | `0.24.7` | bugfix | `dev` | `pending` | formalize release workflow and frontend layout constraints with repo rules and a reusable release skill | | `0.24.8` | bugfix | `dev` | `pending` | move BGP brief markdown into a dedicated modal, keep tab content metadata-focused, and constrain modal scrolling to the viewport | +| `0.25.0` | feature | `dev` | `89a71e6f` | add persistent backend-backed AI Playground chat state, split alert workspaces into dedicated pages, and establish the situational-awareness foundation for later multi-signal analysis | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index bedfea5d..bd5e9e40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.24.8", + "version": "0.25.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/pyproject.toml b/pyproject.toml index 077071d2..3add7568 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.24.8" +version = "0.25.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 2b1bcbb1..b24b26e0 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.24.8" +version = "0.25.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From a2210f0f78123c1530a93e6e68cdc82f489538b3 Mon Sep 17 00:00:00 2001 From: linkong Date: Fri, 10 Apr 2026 16:19:57 +0800 Subject: [PATCH 25/34] release: bump version to 0.25.1 --- VERSION | 2 +- .../app/services/playground_chat_service.py | 246 ++++++++++-------- docs/CHANGELOG.md | 17 ++ docs/version-history.md | 3 +- frontend/package.json | 2 +- .../src/components/AppLayout/AppLayout.tsx | 16 +- frontend/src/pages/Playground/Playground.tsx | 205 ++++++--------- pyproject.toml | 2 +- rules.md | 15 ++ uv.lock | 2 +- 10 files changed, 258 insertions(+), 252 deletions(-) diff --git a/VERSION b/VERSION index d21d277b..af2dabf3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.25.0 +0.25.1 diff --git a/backend/app/services/playground_chat_service.py b/backend/app/services/playground_chat_service.py index 9ce32970..d4df9dc4 100644 --- a/backend/app/services/playground_chat_service.py +++ b/backend/app/services/playground_chat_service.py @@ -44,6 +44,56 @@ class _ActiveRun: _ACTIVE_RUNS: dict[str, _ActiveRun] = {} +async def _get_session_by_key( + db: AsyncSession, + *, + user_id: int, + session_key: str, +) -> PlaygroundSession | None: + result = await db.execute( + select(PlaygroundSession).where( + PlaygroundSession.user_id == user_id, + PlaygroundSession.session_key == session_key, + ) + ) + return result.scalar_one_or_none() + + +async def _require_session( + db: AsyncSession, + *, + user_id: int, + session_key: str, +) -> PlaygroundSession: + session = await _get_session_by_key(db, user_id=user_id, session_key=session_key) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") + return session + + +async def _require_visible_message( + db: AsyncSession, + *, + user_id: int, + public_id: str, + role: str | None = None, +) -> PlaygroundMessage: + conditions = [ + PlaygroundMessage.user_id == user_id, + PlaygroundMessage.public_id == public_id, + PlaygroundMessage.is_visible.is_(True), + ] + if role is not None: + conditions.append(PlaygroundMessage.role == role) + + result = await db.execute(select(PlaygroundMessage).where(*conditions)) + message = result.scalar_one_or_none() + if message is None: + detail = "User message not found" if role == "user" else "Playground message not found" + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) + return message + + def _message_to_record(message: PlaygroundMessage, parent_public_id: str | None = None) -> PlaygroundMessageRecord: return PlaygroundMessageRecord( id=message.public_id, @@ -142,18 +192,26 @@ async def get_thread( user_id: int, session_key: str, ) -> PlaygroundThreadResponse | None: - result = await db.execute( - select(PlaygroundSession).where( - PlaygroundSession.user_id == user_id, - PlaygroundSession.session_key == session_key, - ) - ) - session = result.scalar_one_or_none() + session = await _get_session_by_key(db, user_id=user_id, session_key=session_key) if session is None: return None return await _build_thread_response(db, session=session) +async def _build_action_response( + db: AsyncSession, + *, + session: PlaygroundSession, + active_message_id: str | None = None, +) -> PlaygroundMessageActionResponse: + thread = await _build_thread_response(db, session=session) + return PlaygroundMessageActionResponse( + session=thread.session, + messages=thread.messages, + active_message_id=active_message_id, + ) + + def _collect_constraints(raw_constraints: str) -> list[str]: return [item.strip() for item in raw_constraints.split("\n") if item.strip()] @@ -190,6 +248,31 @@ async def _set_session_state( return session +def _spawn_assistant_run( + *, + user_id: int, + session_id: int, + session_key: str, + user_message_id: int, + assistant_message_id: int, + assistant_public_id: str, + payload: PlaygroundMessageCreateRequest, + provider_client: AIProviderClient, +) -> None: + task = asyncio.create_task( + _run_assistant_message( + user_id=user_id, + session_id=session_id, + session_key=session_key, + user_message_id=user_message_id, + assistant_message_id=assistant_message_id, + payload=payload, + provider_client=provider_client, + ) + ) + _ACTIVE_RUNS[assistant_public_id] = _ActiveRun(task) + + async def create_turn( db: AsyncSession, *, @@ -252,23 +335,20 @@ async def create_turn( await db.refresh(user_message) await db.refresh(assistant_message) - task = asyncio.create_task( - _run_assistant_message( - user_id=user_id, - session_id=session.id, - session_key=payload.session_key, - user_message_id=user_message.id, - assistant_message_id=assistant_message.id, - payload=payload, - provider_client=provider_client, - ) + _spawn_assistant_run( + user_id=user_id, + session_id=session.id, + session_key=payload.session_key, + user_message_id=user_message.id, + assistant_message_id=assistant_message.id, + assistant_public_id=assistant_message.public_id, + payload=payload, + provider_client=provider_client, ) - _ACTIVE_RUNS[assistant_message.public_id] = _ActiveRun(task) - thread = await _build_thread_response(db, session=session) - return PlaygroundMessageActionResponse( - session=thread.session, - messages=thread.messages, + return await _build_action_response( + db, + session=session, active_message_id=assistant_message.public_id, ) @@ -303,23 +383,20 @@ async def _create_assistant_retry_turn( await db.refresh(session) await db.refresh(assistant_message) - task = asyncio.create_task( - _run_assistant_message( - user_id=user_id, - session_id=session.id, - session_key=payload.session_key, - user_message_id=user_message.id, - assistant_message_id=assistant_message.id, - payload=payload, - provider_client=provider_client, - ) + _spawn_assistant_run( + user_id=user_id, + session_id=session.id, + session_key=payload.session_key, + user_message_id=user_message.id, + assistant_message_id=assistant_message.id, + assistant_public_id=assistant_message.public_id, + payload=payload, + provider_client=provider_client, ) - _ACTIVE_RUNS[assistant_message.public_id] = _ActiveRun(task) - thread = await _build_thread_response(db, session=session) - return PlaygroundMessageActionResponse( - session=thread.session, - messages=thread.messages, + return await _build_action_response( + db, + session=session, active_message_id=assistant_message.public_id, ) @@ -330,34 +407,11 @@ async def stop_message( user_id: int, payload: PlaygroundMessageStopRequest, ) -> PlaygroundMessageActionResponse: - result = await db.execute( - select(PlaygroundSession).where( - PlaygroundSession.user_id == user_id, - PlaygroundSession.session_key == payload.session_key, - ) - ) - session = result.scalar_one_or_none() - if session is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") - - result = await db.execute( - select(PlaygroundMessage).where( - PlaygroundMessage.user_id == user_id, - PlaygroundMessage.public_id == payload.message_id, - PlaygroundMessage.is_visible.is_(True), - ) - ) - message = result.scalar_one_or_none() - if message is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground message not found") + session = await _require_session(db, user_id=user_id, session_key=payload.session_key) + message = await _require_visible_message(db, user_id=user_id, public_id=payload.message_id) if message.status not in {"pending", "thinking", "answering"}: - thread = await _build_thread_response(db, session=session) - return PlaygroundMessageActionResponse( - session=thread.session, - messages=thread.messages, - active_message_id=None, - ) + return await _build_action_response(db, session=session) active_run = _ACTIVE_RUNS.get(message.public_id) if active_run is not None: @@ -371,12 +425,7 @@ async def stop_message( await db.commit() await db.refresh(message) - thread = await _build_thread_response(db, session=session) - return PlaygroundMessageActionResponse( - session=thread.session, - messages=thread.messages, - active_message_id=None, - ) + return await _build_action_response(db, session=session) async def resend_turn( @@ -386,27 +435,13 @@ async def resend_turn( payload: PlaygroundMessageResendRequest, provider_client: AIProviderClient, ) -> PlaygroundMessageActionResponse: - result = await db.execute( - select(PlaygroundSession).where( - PlaygroundSession.user_id == user_id, - PlaygroundSession.session_key == payload.session_key, - ) + session = await _require_session(db, user_id=user_id, session_key=payload.session_key) + user_message = await _require_visible_message( + db, + user_id=user_id, + public_id=payload.user_message_id, + role="user", ) - session = result.scalar_one_or_none() - if session is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") - - result = await db.execute( - select(PlaygroundMessage).where( - PlaygroundMessage.user_id == user_id, - PlaygroundMessage.public_id == payload.user_message_id, - PlaygroundMessage.role == "user", - PlaygroundMessage.is_visible.is_(True), - ) - ) - user_message = result.scalar_one_or_none() - if user_message is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User message not found") later_messages = await db.execute( select(PlaygroundMessage).where( @@ -451,39 +486,20 @@ async def edit_user_message( user_id: int, payload: PlaygroundMessageEditRequest, ) -> PlaygroundMessageActionResponse: - result = await db.execute( - select(PlaygroundSession).where( - PlaygroundSession.user_id == user_id, - PlaygroundSession.session_key == payload.session_key, - ) + session = await _require_session(db, user_id=user_id, session_key=payload.session_key) + user_message = await _require_visible_message( + db, + user_id=user_id, + public_id=payload.user_message_id, + role="user", ) - session = result.scalar_one_or_none() - if session is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found") - - result = await db.execute( - select(PlaygroundMessage).where( - PlaygroundMessage.user_id == user_id, - PlaygroundMessage.public_id == payload.user_message_id, - PlaygroundMessage.role == "user", - PlaygroundMessage.is_visible.is_(True), - ) - ) - user_message = result.scalar_one_or_none() - if user_message is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User message not found") user_message.content = payload.content.strip() await db.flush() await db.commit() await db.refresh(user_message) - thread = await _build_thread_response(db, session=session) - return PlaygroundMessageActionResponse( - session=thread.session, - messages=thread.messages, - active_message_id=None, - ) + return await _build_action_response(db, session=session) async def _append_meta_if_missing(db: AsyncSession, message_id: int, meta_line: str) -> None: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9e9e9421..0fab8218 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,23 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.25.1 + +Released: 2026-04-10 + +### Highlights + +- Cleaned up the first persistent Playground rollout, fixed sidebar submenu persistence to match the intended operator behavior, and added reusable code-hygiene rules to prevent this class of drift from accumulating again. + +### Improved + +- Improved [backend/app/services/playground_chat_service.py](/home/ray/dev/linkong/planet/backend/app/services/playground_chat_service.py) and [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) by extracting repeated lookup, response, and request-action paths into shared helpers, reducing duplicated Playground flow code without changing behavior. +- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding a new `Code Hygiene - MANDATORY` section covering single-source-of-truth state, transitional cleanup, repeated-logic extraction, layout debugging order, and post-feature cleanup expectations. + +### Fixed + +- Fixed [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx) so first-level menu expansion now behaves correctly across in-app navigation: `采集与数据` remains the default expanded group after refresh, while manually expanded groups stay open when navigating to their own child routes and reset only on page reload. + ## 0.25.0 Released: 2026-04-10 diff --git a/docs/version-history.md b/docs/version-history.md index cb9dca0b..52ec0498 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.25.0` +- `dev` 当前开发分支历史推导到:`0.25.1` ## Timeline @@ -82,6 +82,7 @@ | `0.24.7` | bugfix | `dev` | `pending` | formalize release workflow and frontend layout constraints with repo rules and a reusable release skill | | `0.24.8` | bugfix | `dev` | `pending` | move BGP brief markdown into a dedicated modal, keep tab content metadata-focused, and constrain modal scrolling to the viewport | | `0.25.0` | feature | `dev` | `89a71e6f` | add persistent backend-backed AI Playground chat state, split alert workspaces into dedicated pages, and establish the situational-awareness foundation for later multi-signal analysis | +| `0.25.1` | bugfix | `dev` | `pending` | clean duplicated Playground flow code, add reusable code-hygiene rules, and fix first-level sidebar menu expansion behavior across route navigation and refresh | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index bd5e9e40..4f80d231 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.25.0", + "version": "0.25.1", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 5a11422f..79724870 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -23,6 +23,8 @@ import packageJson from '../../../package.json' const { Sider, Content } = Layout const { Text } = Typography +const DEFAULT_OPEN_MENU_KEY = 'collection' +let cachedOpenKeys: string[] = [DEFAULT_OPEN_MENU_KEY] interface AppLayoutProps { children: ReactNode @@ -33,7 +35,7 @@ function AppLayout({ children }: AppLayoutProps) { const navigate = useNavigate() const { user, logout } = useAuthStore() const [collapsed, setCollapsed] = useState(false) - const [openKeys, setOpenKeys] = useState(['collection']) + const [openKeys, setOpenKeys] = useState(cachedOpenKeys) const showBanner = true const appVersion = `v${packageJson.version}` @@ -91,6 +93,11 @@ function AppLayout({ children }: AppLayoutProps) { return location.pathname }, [location.pathname]) + const updateOpenKeys = (nextKeys: string[]) => { + cachedOpenKeys = nextKeys + setOpenKeys(nextKeys) + } + return ( { setCollapsed(nextCollapsed) if (nextCollapsed) { - setOpenKeys([]) + return + } + if (!openKeys.length) { + updateOpenKeys([DEFAULT_OPEN_MENU_KEY]) } }} className="dashboard-sider" @@ -129,7 +139,7 @@ function AppLayout({ children }: AppLayoutProps) { openKeys={collapsed ? [] : openKeys} items={menuItems} onOpenChange={(keys) => { - setOpenKeys(keys as string[]) + updateOpenKeys(keys as string[]) }} onClick={({ key }) => { if (key !== selectedKey) { diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx index 1f20417f..60198463 100644 --- a/frontend/src/pages/Playground/Playground.tsx +++ b/frontend/src/pages/Playground/Playground.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { ApiOutlined, ArrowDownOutlined, ArrowUpOutlined, BorderOutlined, CopyOutlined, EditOutlined, InfoCircleOutlined, RedoOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons' +import { ApiOutlined, ArrowDownOutlined, ArrowUpOutlined, BorderOutlined, CopyOutlined, EditOutlined, InfoCircleOutlined, RedoOutlined, RobotOutlined, SettingOutlined, SyncOutlined, UserOutlined } from '@ant-design/icons' import { Alert, Avatar, @@ -19,7 +19,6 @@ import { message, } from 'antd' import axios from 'axios' -import { RobotOutlined, UserOutlined } from '@ant-design/icons' import AppLayout from '../../components/AppLayout/AppLayout' import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer' @@ -31,6 +30,17 @@ const PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY = 'playground-provider-status' const PLAYGROUND_REMOTE_SESSION_KEY = 'default' const THREAD_POLL_INTERVAL_MS = 1200 const SCROLL_BOTTOM_THRESHOLD_PX = 120 +const PLAYGROUND_SYSTEM_MESSAGE: PlaygroundMessage = { + id: 'playground-system-intro', + role: 'system', + title: 'Playground 已就绪', + content: '这里是 AI 测试台。聊天记录与执行状态现在以后台数据库为准,刷新后会恢复到真实进度。', +} +const EMPTY_ANALYSIS_META: AnalysisRunMeta = { + requestId: null, + durationMs: null, + completedAt: null, +} interface AIProviderStatus { provider: string @@ -217,22 +227,11 @@ function Playground() { const [objective, setObjective] = useState(PLAYGROUND_PRESETS[0].values.objective) const [constraints, setConstraints] = useState(PLAYGROUND_PRESETS[0].values.constraints || '') const [inputValue, setInputValue] = useState(PLAYGROUND_PRESETS[0].values.message) - const [messages, setMessages] = useState([ - { - id: 'playground-system-intro', - role: 'system', - title: 'Playground 已就绪', - content: '这里是 AI 测试台。选择一个快速预设,补充输入消息,然后发送一次真实后端链路请求。', - }, - ]) + const [messages, setMessages] = useState([PLAYGROUND_SYSTEM_MESSAGE]) const [analysis, setAnalysis] = useState(null) const [detailOpen, setDetailOpen] = useState(false) const [latestAnalysisMessageId, setLatestAnalysisMessageId] = useState(null) - const [analysisMeta, setAnalysisMeta] = useState({ - requestId: null, - durationMs: null, - completedAt: null, - }) + const [analysisMeta, setAnalysisMeta] = useState(EMPTY_ANALYSIS_META) const [requestPending, setRequestPending] = useState(false) const [editingMessageId, setEditingMessageId] = useState(null) const [editingContent, setEditingContent] = useState('') @@ -266,24 +265,46 @@ function Playground() { setInputValue(sessionState.inputValue || '') setHelpExpanded(sessionState.helpExpanded ?? true) const remoteMessages = thread.messages.map(toPlaygroundMessage) - setMessages([ - { - id: 'playground-system-intro', - role: 'system', - title: 'Playground 已就绪', - content: '这里是 AI 测试台。聊天记录与执行状态现在以后台数据库为准,刷新后会恢复到真实进度。', - }, - ...remoteMessages, - ]) + setMessages([PLAYGROUND_SYSTEM_MESSAGE, ...remoteMessages]) setLatestAnalysisMessageId(sessionState.latestAnalysisMessageId || null) setAnalysis((sessionState.analysis as SituationalAnalysisResponse | null) || null) - setAnalysisMeta(sessionState.analysisMeta || { - requestId: null, - durationMs: null, - completedAt: null, + setAnalysisMeta(sessionState.analysisMeta || EMPTY_ANALYSIS_META) + } + + const authHeaders = (includeJson = false): HeadersInit => ({ + ...(includeJson ? { 'Content-Type': 'application/json' } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }) + + const applyActionResponse = (data: { messages: PlaygroundApiMessage[]; session: PlaygroundSessionRecord }) => { + applyThreadSnapshot({ + session: data.session, + messages: data.messages, }) } + const fetchPlaygroundThread = async () => { + const res = await fetch(`${API_BASE_URL}/ai/playground/thread?session_key=${encodeURIComponent(PLAYGROUND_REMOTE_SESSION_KEY)}`, { + headers: authHeaders(), + }) + if (!res.ok) { + throw new Error('THREAD_FETCH_FAILED') + } + return (await res.json()) as PlaygroundThreadRecord | null + } + + const postPlaygroundAction = async (path: string, body: TBody) => { + const res = await fetch(`${API_BASE_URL}${path}`, { + method: 'POST', + headers: authHeaders(true), + body: JSON.stringify(body), + }) + if (!res.ok) { + throw new Error(`PLAYGROUND_ACTION_FAILED:${path}`) + } + return (await res.json()) as { messages: PlaygroundApiMessage[]; session: PlaygroundSessionRecord } + } + const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { const container = messagesContainerRef.current if (!container) { @@ -327,20 +348,12 @@ function Playground() { } try { - const res = await fetch(`${API_BASE_URL}/ai/playground/thread?session_key=${encodeURIComponent(PLAYGROUND_REMOTE_SESSION_KEY)}`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }) - if (res.ok) { - const thread = (await res.json()) as PlaygroundThreadRecord | null - applyThreadSnapshot(thread) - } + const thread = await fetchPlaygroundThread() + applyThreadSnapshot(thread) } catch { if (!options?.silent) { messageApi.error('Playground 会话加载失败') } - } finally { } } @@ -417,25 +430,11 @@ function Playground() { return } try { - const res = await fetch(`${API_BASE_URL}/ai/playground/messages/stop`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - session_key: PLAYGROUND_REMOTE_SESSION_KEY, - message_id: activeAssistantMessage.id, - }), - }) - if (!res.ok) { - throw new Error('STOP_FAILED') - } - const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } - applyThreadSnapshot({ - session: data.session, - messages: data.messages, + const data = await postPlaygroundAction('/ai/playground/messages/stop', { + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + message_id: activeAssistantMessage.id, }) + applyActionResponse(data) messageApi.warning('已停止生成,已输出内容会保留') } catch { messageApi.error('停止失败,请稍后再试') @@ -455,30 +454,16 @@ function Playground() { setRequestPending(true) try { - const res = await fetch(`${API_BASE_URL}/ai/playground/messages`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - session_key: PLAYGROUND_REMOTE_SESSION_KEY, - title: trimmedTitle, - objective: trimmedObjective, - constraints, - input: trimmedInput, - selected_preset_key: selectedPreset.key, - help_expanded: helpExpanded, - }), - }) - if (!res.ok) { - throw new Error('SEND_FAILED') - } - const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } - applyThreadSnapshot({ - session: data.session, - messages: data.messages, + const data = await postPlaygroundAction('/ai/playground/messages', { + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + title: trimmedTitle, + objective: trimmedObjective, + constraints, + input: trimmedInput, + selected_preset_key: selectedPreset.key, + help_expanded: helpExpanded, }) + applyActionResponse(data) setRequestPending(false) void refreshThread({ silent: true }) } catch { @@ -521,25 +506,11 @@ function Playground() { return } try { - const res = await fetch(`${API_BASE_URL}/ai/playground/messages/resend`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - session_key: PLAYGROUND_REMOTE_SESSION_KEY, - user_message_id: entry.id, - }), - }) - if (!res.ok) { - throw new Error('RESEND_FAILED') - } - const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } - applyThreadSnapshot({ - session: data.session, - messages: data.messages, + const data = await postPlaygroundAction('/ai/playground/messages/resend', { + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, }) + applyActionResponse(data) messageApi.success('已重试,并清理该消息之后的旧分支') } catch { messageApi.error('重试失败,请稍后再试') @@ -569,40 +540,16 @@ function Playground() { try { setEditSaving(true) - const editRes = await fetch(`${API_BASE_URL}/ai/playground/messages/edit`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - session_key: PLAYGROUND_REMOTE_SESSION_KEY, - user_message_id: entry.id, - content: trimmed, - }), + await postPlaygroundAction('/ai/playground/messages/edit', { + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, + content: trimmed, }) - if (!editRes.ok) { - throw new Error('EDIT_FAILED') - } - const resendRes = await fetch(`${API_BASE_URL}/ai/playground/messages/resend`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - session_key: PLAYGROUND_REMOTE_SESSION_KEY, - user_message_id: entry.id, - }), - }) - if (!resendRes.ok) { - throw new Error('RESEND_AFTER_EDIT_FAILED') - } - const data = (await resendRes.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } - applyThreadSnapshot({ - session: data.session, - messages: data.messages, + const data = await postPlaygroundAction('/ai/playground/messages/resend', { + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, }) + applyActionResponse(data) setEditingMessageId(null) setEditingContent('') setEditSaving(false) diff --git a/pyproject.toml b/pyproject.toml index 3add7568..453f4625 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.25.0" +version = "0.25.1" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/rules.md b/rules.md index 68c78346..b4a2791f 100644 --- a/rules.md +++ b/rules.md @@ -225,6 +225,21 @@ class BaseCollector: - Feature flags for incomplete features - Use config files for environment-specific settings +## Code Hygiene - MANDATORY + +- Maintain a single source of truth for business state. Frontend temporary state, cached state, and persisted backend state must not evolve into parallel truths. +- Transitional paths are temporary. Once a new implementation is stable, remove old branches, old interfaces, old mocks, and compatibility layers instead of letting them linger. +- Repeated logic must be extracted. If request flow, response handling, auth/header assembly, validation, or state reconciliation appears more than once or twice, promote it into a helper or shared layer. +- Repeated backend resource lookup and response assembly must be centralized. Avoid scattering the same `load -> validate -> transform -> respond` pattern across multiple handlers or services. +- Default values, system prompts, placeholder structures, and other fixed constants must be centralized rather than re-declared in multiple states or code paths. +- When debugging layout, scrolling, or overflow issues, first inspect structural ownership of height, width, and overflow before applying isolated style patches. +- Distinct interaction modes must have explicit structure and state semantics. View, edit, loading, error, stopped, and retry states should not be forced through the exact same markup or logic path. +- Presentation state must not pretend to be business state. UI animation, phase labels, and optimistic display layers must defer to real persisted or backend task state when it exists. +- Responsive adaptations must preserve the primary action path. Reflow is fine; losing or displacing the main user action is not. +- After large feature commits, perform an explicit cleanup pass for dead code, temporary branches, duplicated helpers, stale interfaces, and naming drift before considering the work complete. +- If a file or module starts accumulating repeated patterns or mixed responsibilities, stop and refactor before continuing to add more features on top. +- Public interfaces, persisted fields, and state structures must have a current owner and caller. If something is no longer used, delete it instead of keeping it “just in case”. + --- ## Query Performance - MANDATORY diff --git a/uv.lock b/uv.lock index b24b26e0..08656abb 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.25.0" +version = "0.25.1" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From e85a9fc6141071d07b4fb0a68890c9ce620447f3 Mon Sep 17 00:00:00 2001 From: linkong Date: Fri, 10 Apr 2026 16:37:09 +0800 Subject: [PATCH 26/34] fix: refine earth settings modal and hud interactions --- frontend/public/earth/css/hud.css | 268 +++++++++++++++++++++++++++ frontend/public/earth/index.html | 84 ++++++++- frontend/public/earth/js/controls.js | 177 ++++++++++++++++++ frontend/public/earth/js/legend.js | 9 +- 4 files changed, 528 insertions(+), 10 deletions(-) diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 23f23071..42664966 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -62,6 +62,64 @@ font-size: var(--hud-title-size); } +.hud-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--hud-gap-sm); + margin-bottom: var(--hud-gap-sm); +} + +.hud-panel-header .hud-panel-title { + margin-bottom: 0; +} + +.hud-panel-drag-handle { + cursor: grab; + user-select: none; +} + +.hud-panel-drag-handle:active { + cursor: grabbing; +} + +.hud-panel-close { + width: 28px; + height: 28px; + min-width: 28px; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: #d9efff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.18s ease, transform 0.18s ease, opacity 0.18s ease; +} + +.hud-panel-close .material-symbols-rounded { + font-size: 18px; +} + +.hud-panel-close:hover { + background: rgba(255, 255, 255, 0.14); + transform: scale(1.04); +} + +.hud-panel.is-dragging { + transition: none !important; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.14), + inset 0 -1px 0 rgba(255, 255, 255, 0.04), + 0 24px 52px rgba(0, 0, 0, 0.28), + 0 0 36px rgba(120, 200, 255, 0.16); +} + +.hud-panel-hidden { + display: none !important; +} + .hud-panel-row { display: flex; justify-content: space-between; @@ -145,6 +203,216 @@ user-select: none; } +.earth-settings-modal { + position: fixed; + inset: 0; + z-index: 260; + display: none; +} + +.earth-settings-modal.is-open { + display: block; +} + +.earth-settings-backdrop { + position: fixed; + inset: 0; + background: rgba(2, 8, 20, 0.38); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.earth-settings-sheet { + position: fixed; + top: 15vh; + right: 20vw; + bottom: 15vh; + left: 20vw; + width: auto; + max-width: none; + transform: none; + border-radius: calc(28px * var(--hud-scale)); + padding: calc(20px * var(--hud-scale)); + display: flex; + flex-direction: column; + gap: var(--hud-gap-md); + overflow: hidden; + animation: none; +} + +.earth-settings-sheet.liquid-glass-surface:hover, +.earth-settings-sheet.liquid-glass-surface:active, +.earth-settings-sheet.liquid-glass-surface.is-pressed { + --btn-scale: 1; + --press-offset: 0px; + --glow-opacity: 0.24; + transform: none; +} + +.earth-settings-header, +.earth-settings-content { + position: relative; + z-index: 1; +} + +.earth-settings-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--hud-gap-md); + padding-bottom: var(--hud-gap-sm); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.earth-settings-kicker { + color: #9eb7d0; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.earth-settings-title { + margin-top: 4px; + color: #eef7ff; + font-size: calc(1.45rem * var(--hud-scale)); + font-weight: 600; +} + +.earth-settings-close { + width: 34px; + height: 34px; + min-width: 34px; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: #d9efff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.earth-settings-content { + overflow-y: auto; + padding-right: 4px; + scrollbar-width: thin; + scrollbar-color: rgba(160, 220, 255, 0.45) transparent; +} + +.earth-settings-content::-webkit-scrollbar { + width: 6px; +} + +.earth-settings-content::-webkit-scrollbar-track { + background: transparent; +} + +.earth-settings-content::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(210, 237, 255, 0.28), rgba(110, 176, 255, 0.34)); + border-radius: 999px; +} + +.earth-settings-section { + padding: 12px 0 20px; +} + +.earth-settings-section-title { + margin-bottom: 12px; + color: #9eb7d0; + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.earth-settings-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.earth-settings-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border-radius: 18px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(92, 151, 218, 0.03)); + border: 1px solid rgba(255, 255, 255, 0.08); + cursor: pointer; +} + +.earth-settings-copy { + display: flex; + flex-direction: column; + gap: 4px; +} + +.earth-settings-item-title { + color: #eef7ff; + font-size: 1rem; + font-weight: 500; +} + +.earth-settings-item-subtitle { + color: #90a6bb; + font-size: 0.82rem; + line-height: 1.4; +} + +.earth-settings-switch { + position: relative; + display: inline-flex; + align-items: center; +} + +.earth-settings-switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.earth-settings-switch-track { + width: 48px; + height: 30px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + border: 1px solid rgba(255, 255, 255, 0.12); + position: relative; + transition: background 0.18s ease, border-color 0.18s ease; +} + +.earth-settings-switch-track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #f5fbff; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.22); + transition: transform 0.18s ease; +} + +.earth-settings-switch input:checked + .earth-settings-switch-track { + background: linear-gradient(180deg, rgba(69, 171, 255, 0.92), rgba(34, 123, 255, 0.84)); + border-color: rgba(220, 243, 255, 0.36); +} + +.earth-settings-switch input:checked + .earth-settings-switch-track::after { + transform: translateX(18px); +} + +@media (max-width: 960px) { + .earth-settings-sheet { + top: 12vh; + right: 10vw; + bottom: 12vh; + left: 10vw; + } +} + .earth-app.layout-expanded .hud-panel-info { top: var(--hud-offset); left: var(--hud-offset); diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 39af22e9..bd9af3a3 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -128,6 +128,12 @@ + +
经度: 0.00° @@ -161,8 +172,13 @@
鼠标位置: 无
-
-

线缆图例

+
+
+

线缆图例

+ +
@@ -183,8 +199,13 @@
-
-

地球信息

+
+
+

地球信息

+ +
电缆系统: 0个 @@ -234,6 +255,57 @@
+
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 22b58736..c0f012f1 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -26,6 +26,11 @@ export let layoutExpanded = false; let earthObj = null; let listeners = []; let cleanupFns = []; +const HUD_PANEL_IDS = [ + "legend", + "coordinates-display", + "earth-stats", +]; function getFloatingGroups() { return [ @@ -44,6 +49,12 @@ function isFloatingMenuVisible() { }); } +function isSettingsModalOpen() { + return document + .getElementById("settings-modal") + ?.classList.contains("is-open"); +} + function closeFloatingMenus() { getFloatingGroups().forEach((group) => { group.classList.remove("open"); @@ -55,6 +66,164 @@ function closeFloatingMenus() { } } +function openSettingsModal() { + const modal = document.getElementById("settings-modal"); + if (!modal) return; + closeFloatingMenus(); + modal.classList.add("is-open"); + modal.setAttribute("aria-hidden", "false"); +} + +function closeSettingsModal() { + const modal = document.getElementById("settings-modal"); + if (!modal) return; + modal.classList.remove("is-open"); + modal.setAttribute("aria-hidden", "true"); +} + +function setHudPanelVisibility(panelId, visible) { + const panel = document.getElementById(panelId); + if (!panel) return; + panel.classList.toggle("hud-panel-hidden", !visible); + syncSettingsToggle(panelId, visible); +} + +function syncSettingsToggle(panelId, visible) { + const input = document.querySelector( + `[data-settings-panel="${panelId}"]`, + ); + if (input instanceof HTMLInputElement) { + input.checked = visible; + } +} + +function syncAllHudPanelToggles() { + HUD_PANEL_IDS.forEach((panelId) => { + const panel = document.getElementById(panelId); + syncSettingsToggle(panelId, !panel?.classList.contains("hud-panel-hidden")); + }); +} + +function setupSettingsControls() { + const settingsTrigger = document.getElementById("settings-trigger"); + const settingsClose = document.getElementById("settings-close"); + const settingsBackdrop = document.getElementById("settings-backdrop"); + const settingsModal = document.getElementById("settings-modal"); + + bindListener(settingsTrigger, "click", () => { + openSettingsModal(); + }); + + bindListener(settingsClose, "click", () => { + closeSettingsModal(); + }); + + bindListener(settingsBackdrop, "click", () => { + closeSettingsModal(); + }); + + bindListener(settingsModal, "click", (event) => { + const sheet = event.target.closest(".earth-settings-sheet"); + if (!sheet) { + closeSettingsModal(); + } + }); + + const toggleInputs = document.querySelectorAll("[data-settings-panel]"); + toggleInputs.forEach((input) => { + bindListener(input, "change", (event) => { + const target = event.currentTarget; + if (!(target instanceof HTMLInputElement)) return; + const panelId = target.dataset.settingsPanel; + if (!panelId) return; + setHudPanelVisibility(panelId, target.checked); + }); + }); + + syncAllHudPanelToggles(); +} + +function setupHudPanelControls() { + const closeButtons = document.querySelectorAll("[data-close-panel]"); + closeButtons.forEach((button) => { + bindListener(button, "click", (event) => { + event.stopPropagation(); + const target = event.currentTarget; + if (!(target instanceof HTMLElement)) return; + const panelId = target.dataset.closePanel; + if (!panelId) return; + setHudPanelVisibility(panelId, false); + }); + }); +} + +function setupDraggableHudPanels() { + const app = document.getElementById("container"); + const draggablePanels = document.querySelectorAll(".hud-panel-draggable"); + if (!app || draggablePanels.length === 0) return; + + draggablePanels.forEach((panel) => { + const handle = panel.querySelector(".hud-panel-drag-handle"); + if (!handle) return; + + let isDragging = false; + let startPointerX = 0; + let startPointerY = 0; + let startLeft = 0; + let startTop = 0; + + const stopDragging = () => { + isDragging = false; + panel.classList.remove("is-dragging"); + document.body.style.userSelect = ""; + }; + + const onMove = (event) => { + if (!isDragging) return; + const appRect = app.getBoundingClientRect(); + const panelRect = panel.getBoundingClientRect(); + const nextLeft = Math.min( + Math.max(startLeft + (event.clientX - startPointerX), 0), + appRect.width - panelRect.width, + ); + const nextTop = Math.min( + Math.max(startTop + (event.clientY - startPointerY), 0), + appRect.height - panelRect.height, + ); + + panel.style.left = `${nextLeft}px`; + panel.style.top = `${nextTop}px`; + panel.style.right = "auto"; + panel.style.bottom = "auto"; + panel.style.transform = "none"; + }; + + bindListener(handle, "pointerdown", (event) => { + if (event.target.closest(".hud-panel-close")) return; + isDragging = true; + startPointerX = event.clientX; + startPointerY = event.clientY; + const appRect = app.getBoundingClientRect(); + const panelRect = panel.getBoundingClientRect(); + startLeft = panelRect.left - appRect.left; + startTop = panelRect.top - appRect.top; + panel.style.left = `${startLeft}px`; + panel.style.top = `${startTop}px`; + panel.style.right = "auto"; + panel.style.bottom = "auto"; + panel.style.transform = "none"; + panel.classList.add("is-dragging"); + document.body.style.userSelect = "none"; + handle.setPointerCapture?.(event.pointerId); + }); + + bindListener(handle, "pointermove", onMove); + bindListener(handle, "pointerup", stopDragging); + bindListener(handle, "pointercancel", stopDragging); + bindListener(handle, "lostpointercapture", stopDragging); + }); +} + function clearForcedFloatingClose() { getFloatingGroups().forEach((group) => { group.classList.remove("force-closed"); @@ -379,6 +548,9 @@ function setupTerrainControls() { const reloadBtn = document.getElementById("reload-data"); const zoomGroup = document.getElementById("zoom-control-group"); const zoomTrigger = document.getElementById("zoom-trigger"); + setupSettingsControls(); + setupHudPanelControls(); + setupDraggableHudPanels(); if (trailsBtn) { trailsBtn.classList.add("active"); @@ -487,6 +659,11 @@ function setupKeyboardControls() { bindListener(document, "keydown", (event) => { if (event.key !== "Escape") return; + if (isSettingsModalOpen()) { + closeSettingsModal(); + return; + } + if (isFloatingMenuVisible()) { closeFloatingMenus(); return; diff --git a/frontend/public/earth/js/legend.js b/frontend/public/earth/js/legend.js index 45f9fc35..cb932e29 100644 --- a/frontend/public/earth/js/legend.js +++ b/frontend/public/earth/js/legend.js @@ -49,6 +49,9 @@ function renderLegend(mode) { const config = LEGEND_MODES[mode] || LEGEND_MODES.cables; const items = legendItemsByMode[mode] || []; + const titleEl = legend.querySelector(".legend-title"); + const listEl = legend.querySelector(".legend-list"); + if (!titleEl || !listEl) return; const itemsHtml = items .map( (item) => ` @@ -60,8 +63,6 @@ function renderLegend(mode) { ) .join(""); - legend.innerHTML = ` -

${config.title}

-
${itemsHtml}
- `; + titleEl.textContent = config.title; + listEl.innerHTML = itemsHtml; } From 60ed88b6094393cbbb763c6abd64fdff952abf04 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Fri, 10 Apr 2026 23:43:56 +0800 Subject: [PATCH 27/34] release: bump version to 0.25.2 --- VERSION | 2 +- docs/CHANGELOG.md | 18 +++++++ docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/hud.css | 59 ++++++++++---------- frontend/public/earth/index.html | 18 ++++++- frontend/public/earth/js/controls.js | 80 ++++++++++++++++++++++++++-- pyproject.toml | 2 +- uv.lock | 2 +- 9 files changed, 144 insertions(+), 42 deletions(-) diff --git a/VERSION b/VERSION index af2dabf3..166c9e29 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.25.1 +0.25.2 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0fab8218..c9dbee4a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,24 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.25.2 + +Released: 2026-04-10 + +### Highlights + +- Refined the Earth HUD operator polish so settings now behave like a true bounded menu, HUD panels render at the correct scale from the first frame, and dragged panels animate cleanly into and out of maximized layout targets without drifting to screen edges. + +### Improved + +- Improved [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) by precomputing the initial `--hud-scale` before Earth CSS loads, so HUD panels no longer flash at full size before shrinking to the target scale. +- Improved [frontend/public/earth/css/hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css) by cleaning up duplicated settings-modal close-button styles, aligning the settings title with the shared HUD title system, and constraining the settings sheet to a stable centered width instead of viewport-relative modal sizing. + +### Fixed + +- Fixed [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) so dragged HUD panels now fly from their dragged positions into maximized layout targets, closed panels stay out of the transition, and restoring layout clears drag overrides back to the initial positions. +- Fixed [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) and [frontend/public/earth/css/hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css) so layout transitions no longer visibly stick to screen edges before landing; the FLIP motion now composes with the existing corner transforms instead of fighting them. + ## 0.25.1 Released: 2026-04-10 diff --git a/docs/version-history.md b/docs/version-history.md index 52ec0498..7672e915 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.25.1` +- `dev` 当前开发分支历史推导到:`0.25.2` ## Timeline @@ -83,6 +83,7 @@ | `0.24.8` | bugfix | `dev` | `pending` | move BGP brief markdown into a dedicated modal, keep tab content metadata-focused, and constrain modal scrolling to the viewport | | `0.25.0` | feature | `dev` | `89a71e6f` | add persistent backend-backed AI Playground chat state, split alert workspaces into dedicated pages, and establish the situational-awareness foundation for later multi-signal analysis | | `0.25.1` | bugfix | `dev` | `pending` | clean duplicated Playground flow code, add reusable code-hygiene rules, and fix first-level sidebar menu expansion behavior across route navigation and refresh | +| `0.25.2` | bugfix | `dev` | `pending` | refine Earth settings modal sizing, eliminate first-frame HUD scale flicker, and make dragged HUD panels animate cleanly through maximized layout transitions | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 4f80d231..603c9b68 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.25.1", + "version": "0.25.2", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 42664966..725925aa 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -58,13 +58,14 @@ .hud-panel-title { color: #4db8ff; - margin-bottom: var(--hud-gap-sm); + margin: 0 0 var(--hud-gap-sm); font-size: var(--hud-title-size); + line-height: 1.2; } .hud-panel-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: var(--hud-gap-sm); margin-bottom: var(--hud-gap-sm); @@ -84,9 +85,11 @@ } .hud-panel-close { - width: 28px; - height: 28px; - min-width: 28px; + align-self: flex-start; + width: calc(var(--hud-title-size) * 1.24); + height: calc(var(--hud-title-size) * 1.24); + min-width: calc(var(--hud-title-size) * 1.24); + padding: 0; border: 0; border-radius: 999px; background: rgba(255, 255, 255, 0.08); @@ -99,7 +102,8 @@ } .hud-panel-close .material-symbols-rounded { - font-size: 18px; + font-size: calc(var(--hud-title-size) * 0.8); + line-height: 1; } .hud-panel-close:hover { @@ -116,6 +120,10 @@ 0 0 36px rgba(120, 200, 255, 0.16); } +.hud-panel.is-layout-animating { + transition: none !important; +} + .hud-panel-hidden { display: none !important; } @@ -224,12 +232,13 @@ .earth-settings-sheet { position: fixed; - top: 15vh; - right: 20vw; - bottom: 15vh; - left: 20vw; - width: auto; - max-width: none; + top: max(32px, 9vh); + right: 16px; + left: 16px; + width: min(560px, calc(100vw - 32px)); + max-width: 560px; + max-height: calc(100vh - max(64px, 18vh)); + margin-inline: auto; transform: none; border-radius: calc(28px * var(--hud-scale)); padding: calc(20px * var(--hud-scale)); @@ -272,24 +281,12 @@ } .earth-settings-title { - margin-top: 4px; + margin: 4px 0 0; color: #eef7ff; - font-size: calc(1.45rem * var(--hud-scale)); - font-weight: 600; } .earth-settings-close { - width: 34px; - height: 34px; - min-width: 34px; - border: 0; - border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - color: #d9efff; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; + margin-top: 4px; } .earth-settings-content { @@ -406,10 +403,12 @@ @media (max-width: 960px) { .earth-settings-sheet { - top: 12vh; - right: 10vw; - bottom: 12vh; - left: 10vw; + top: 24px; + right: 12px; + left: 12px; + width: auto; + max-width: none; + max-height: calc(100vh - 48px); } } diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index bd9af3a3..69198a25 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -13,6 +13,20 @@ } } + @@ -261,9 +275,9 @@
设置
-

显示与视图

+

显示与视图

-
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index c0f012f1..8a9114fc 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -31,6 +31,8 @@ const HUD_PANEL_IDS = [ "coordinates-display", "earth-stats", ]; +const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable"; +const PANEL_LAYOUT_ANIMATION_MS = 420; function getFloatingGroups() { return [ @@ -159,7 +161,7 @@ function setupHudPanelControls() { function setupDraggableHudPanels() { const app = document.getElementById("container"); - const draggablePanels = document.querySelectorAll(".hud-panel-draggable"); + const draggablePanels = document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR); if (!app || draggablePanels.length === 0) return; draggablePanels.forEach((panel) => { @@ -196,6 +198,7 @@ function setupDraggableHudPanels() { panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.transform = "none"; + panel.dataset.dragged = "true"; }; bindListener(handle, "pointerdown", (event) => { @@ -212,6 +215,7 @@ function setupDraggableHudPanels() { panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.transform = "none"; + panel.dataset.dragged = "true"; panel.classList.add("is-dragging"); document.body.style.userSelect = "none"; handle.setPointerCapture?.(event.pointerId); @@ -783,8 +787,74 @@ function updateLayoutUI(container) { } } -function toggleLayoutExpanded(container) { - layoutExpanded = !layoutExpanded; - updateLayoutUI(container); - return layoutExpanded; +function resetPanelInlineLayout(panel) { + panel.style.left = ""; + panel.style.top = ""; + panel.style.right = ""; + panel.style.bottom = ""; + panel.style.transform = ""; + delete panel.dataset.dragged; +} + +function isPanelVisible(panel) { + return !panel.classList.contains("hud-panel-hidden"); +} + +function animatePanelLayoutTransition(container, expand) { + const panels = Array.from( + container.querySelectorAll(DRAGGABLE_PANEL_SELECTOR), + ); + if (panels.length === 0) { + layoutExpanded = expand; + updateLayoutUI(container); + return expand; + } + + const visiblePanels = panels.filter(isPanelVisible); + const firstRects = new Map( + visiblePanels.map((panel) => [panel, panel.getBoundingClientRect()]), + ); + + panels.forEach((panel) => panel.classList.add("is-layout-animating")); + layoutExpanded = expand; + panels.forEach(resetPanelInlineLayout); + updateLayoutUI(container); + + visiblePanels.forEach((panel) => { + const firstRect = firstRects.get(panel); + if (!firstRect) return; + + const lastRect = panel.getBoundingClientRect(); + const deltaX = firstRect.left - lastRect.left; + const deltaY = firstRect.top - lastRect.top; + + if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) { + return; + } + + panel.animate( + [ + { + translate: `${deltaX}px ${deltaY}px`, + }, + { + translate: "0 0", + }, + ], + { + duration: PANEL_LAYOUT_ANIMATION_MS, + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + }, + ); + }); + + window.setTimeout(() => { + panels.forEach((panel) => panel.classList.remove("is-layout-animating")); + }, PANEL_LAYOUT_ANIMATION_MS); + + return expand; +} + +function toggleLayoutExpanded(container) { + return animatePanelLayoutTransition(container, !layoutExpanded); } diff --git a/pyproject.toml b/pyproject.toml index 453f4625..21f524bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.25.1" +version = "0.25.2" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 08656abb..5f5993d5 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.25.1" +version = "0.25.2" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From 10e2bae8c25a5fceb39cbc0979dd38fb5f2fe65f Mon Sep 17 00:00:00 2001 From: rayd1o Date: Sat, 11 Apr 2026 03:42:35 +0800 Subject: [PATCH 28/34] release: bump version to 0.25.3 --- VERSION | 2 +- docs/CHANGELOG.md | 22 ++ docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/assets/brand/README.md | 9 + .../public/earth/assets/brand/earth-logo.png | Bin 0 -> 187064 bytes .../public/earth/assets/brand/title-en.png | Bin 0 -> 170409 bytes .../public/earth/assets/brand/title-zh.png | Bin 0 -> 8702 bytes frontend/public/earth/css/base.css | 46 +++-- .../public/earth/css/coordinates-display.css | 24 ++- frontend/public/earth/css/earth-stats.css | 15 +- frontend/public/earth/css/hud.css | 191 +++++++++++------- frontend/public/earth/css/info-panel.css | 152 +++++++++----- frontend/public/earth/css/legend.css | 43 ++-- frontend/public/earth/css/toolbar.css | 3 + frontend/public/earth/index.html | 11 +- frontend/public/earth/js/brand.js | 55 +++++ frontend/public/earth/js/constants.js | 1 + frontend/public/earth/js/main.js | 6 + pyproject.toml | 2 +- uv.lock | 2 +- 21 files changed, 410 insertions(+), 179 deletions(-) create mode 100644 frontend/public/earth/assets/brand/README.md create mode 100644 frontend/public/earth/assets/brand/earth-logo.png create mode 100644 frontend/public/earth/assets/brand/title-en.png create mode 100644 frontend/public/earth/assets/brand/title-zh.png create mode 100644 frontend/public/earth/js/brand.js diff --git a/VERSION b/VERSION index 166c9e29..3d9dcb1b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.25.2 +0.25.3 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c9dbee4a..849dad95 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,28 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.25.3 + +Released: 2026-04-11 + +### Highlights + +- Refined the Earth HUD visual system into a calmer operator-facing style, turned the top-left Earth brand into a real reusable component with language-driven rendering, and cleaned up duplicated brand assets so the page now has a single source of truth for HUD branding. + +### Added + +- Added [frontend/public/earth/js/brand.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/brand.js) as a reusable Earth `brand` component that renders the top-left logo/title/subtitle block from a shared config instead of hardcoding the structure in HTML. +- Added [frontend/public/earth/assets/brand/earth-logo.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/earth-logo.svg), [frontend/public/earth/assets/brand/title-zh.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/title-zh.svg), and [frontend/public/earth/assets/brand/title-en.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/title-en.svg) as the canonical Earth brand asset set. + +### Improved + +- Improved [frontend/public/earth/css/base.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/base.css), [frontend/public/earth/css/hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css), [frontend/public/earth/css/coordinates-display.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/coordinates-display.css), [frontend/public/earth/css/legend.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/legend.css), [frontend/public/earth/css/earth-stats.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/earth-stats.css), and [frontend/public/earth/css/toolbar.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/toolbar.css) by rebalancing the Earth HUD into a more restrained deep-blue control-room look instead of the earlier over-layered glass-and-neon mix. +- Improved [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js), and [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) by moving the Earth brand mount to a dedicated root and letting `HUD_CONFIG.brandLanguage` choose between `zh` and `en` without embedding the language decision in the DOM. + +### Fixed + +- Fixed [frontend/public/earth/css/info-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/info-panel.css) so the Earth brand area now uses consistent `earth-brand` component selectors and English-specific typography hooks, avoiding the earlier one-off `brand-banner` naming drift and duplicated title styles. + ## 0.25.2 Released: 2026-04-10 diff --git a/docs/version-history.md b/docs/version-history.md index 7672e915..672df950 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.25.2` +- `dev` 当前开发分支历史推导到:`0.25.3` ## Timeline @@ -84,6 +84,7 @@ | `0.25.0` | feature | `dev` | `89a71e6f` | add persistent backend-backed AI Playground chat state, split alert workspaces into dedicated pages, and establish the situational-awareness foundation for later multi-signal analysis | | `0.25.1` | bugfix | `dev` | `pending` | clean duplicated Playground flow code, add reusable code-hygiene rules, and fix first-level sidebar menu expansion behavior across route navigation and refresh | | `0.25.2` | bugfix | `dev` | `pending` | refine Earth settings modal sizing, eliminate first-frame HUD scale flicker, and make dragged HUD panels animate cleanly through maximized layout transitions | +| `0.25.3` | bugfix | `dev` | `pending` | refactor the Earth HUD visual system, extract the top-left Earth brand into a reusable language-driven component, and consolidate duplicated brand assets into a single canonical set | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 603c9b68..2946501f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.25.2", + "version": "0.25.3", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/assets/brand/README.md b/frontend/public/earth/assets/brand/README.md new file mode 100644 index 00000000..586206c1 --- /dev/null +++ b/frontend/public/earth/assets/brand/README.md @@ -0,0 +1,9 @@ +Earth brand runtime assets live in this folder. + +- `earth-logo.png` +- `title-zh.png` +- `title-en.png` + +These runtime images were generated from the uploaded SVG artwork so the Earth +page can keep the intended look without tracking unusually large pixel-rect SVG +exports in Git history. diff --git a/frontend/public/earth/assets/brand/earth-logo.png b/frontend/public/earth/assets/brand/earth-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b3fe0363d1478f7fa3ed3c482fbd505b1af2084c GIT binary patch literal 187064 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%0DnEQ+zEbFk+~gWJ3CX~p6^O>J?^ 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 nnN(F(?CDZB=_jbO-qe2DgwmUxRwbS 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

$58CiVW9V|G_%{ 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 MDrw@%6wL7d2gyS2p8x;= literal 0 HcmV?d00001 diff --git a/frontend/public/earth/assets/brand/title-zh.png b/frontend/public/earth/assets/brand/title-zh.png new file mode 100644 index 0000000000000000000000000000000000000000..60ac41ebc6ac21a8296c8f1ed15df001c5db84bc GIT binary patch literal 8702 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 S0{_>6`7ZWfRPx!n-Twtw>k8fg literal 0 HcmV?d00001 diff --git a/frontend/public/earth/css/base.css b/frontend/public/earth/css/base.css index c6c0d5a9..c54831f9 100644 --- a/frontend/public/earth/css/base.css +++ b/frontend/public/earth/css/base.css @@ -9,23 +9,37 @@ :root { --hud-scale: 1; --hud-offset: calc(20px * var(--hud-scale)); - --hud-radius: calc(20px * var(--hud-scale)); - --hud-panel-padding: calc(16px * var(--hud-scale)); - --hud-panel-padding-sm: calc(12px * var(--hud-scale)); - --hud-gap-xs: calc(6px * var(--hud-scale)); - --hud-gap-sm: calc(10px * var(--hud-scale)); - --hud-gap-md: calc(14px * var(--hud-scale)); - --hud-gap-lg: calc(18px * var(--hud-scale)); + --hud-radius: calc(22px * var(--hud-scale)); + --hud-panel-padding: calc(18px * var(--hud-scale)); + --hud-panel-padding-sm: calc(13px * var(--hud-scale)); + --hud-gap-xs: calc(7px * var(--hud-scale)); + --hud-gap-sm: calc(11px * var(--hud-scale)); + --hud-gap-md: calc(16px * var(--hud-scale)); + --hud-gap-lg: calc(22px * var(--hud-scale)); --hud-font-size: calc(0.88rem * var(--hud-scale)); - --hud-font-size-sm: calc(0.76rem * var(--hud-scale)); - --hud-title-size: calc(1.05rem * var(--hud-scale)); - --hud-border: rgba(225, 242, 255, 0.2); - --hud-border-hover: rgba(236, 248, 255, 0.34); - --hud-border-active: rgba(240, 249, 255, 0.5); - --glass-fill-top: rgba(255, 255, 255, 0.14); - --glass-fill-bottom: rgba(118, 200, 255, 0.08); - --glass-shadow: 0 18px 34px rgba(0, 0, 0, 0.22); - --glass-glow: 0 0 26px rgba(145, 214, 255, 0.18); + --hud-font-size-sm: calc(0.75rem * var(--hud-scale)); + --hud-title-size: calc(1.02rem * var(--hud-scale)); + --hud-kicker-size: calc(0.68rem * var(--hud-scale)); + --hud-surface-top: rgba(17, 31, 53, 0.84); + --hud-surface-bottom: rgba(7, 17, 31, 0.76); + --hud-surface-overlay: rgba(157, 204, 255, 0.07); + --hud-border: rgba(201, 225, 247, 0.14); + --hud-border-hover: rgba(226, 238, 250, 0.24); + --hud-border-active: rgba(235, 244, 255, 0.32); + --hud-shadow: 0 18px 46px rgba(1, 7, 16, 0.34); + --hud-shadow-soft: 0 10px 28px rgba(3, 10, 22, 0.22); + --hud-highlight: rgba(248, 252, 255, 0.14); + --hud-line: rgba(197, 220, 242, 0.1); + --hud-title: #dbe8f5; + --hud-text: #eef4fb; + --hud-text-muted: #8ea3ba; + --hud-text-soft: #6f849b; + --hud-accent: #91baff; + --hud-accent-strong: #d7e8ff; + --glass-fill-top: rgba(255, 255, 255, 0.08); + --glass-fill-bottom: rgba(109, 157, 214, 0.04); + --glass-shadow: 0 16px 36px rgba(0, 0, 0, 0.2); + --glass-glow: 0 0 18px rgba(123, 176, 236, 0.08); } * { diff --git a/frontend/public/earth/css/coordinates-display.css b/frontend/public/earth/css/coordinates-display.css index bccfe8bd..2c528a25 100644 --- a/frontend/public/earth/css/coordinates-display.css +++ b/frontend/public/earth/css/coordinates-display.css @@ -4,28 +4,30 @@ top: var(--hud-offset); right: var(--hud-offset); border-radius: var(--hud-radius); - padding: var(--hud-panel-padding-sm) var(--hud-panel-padding); + padding: calc(14px * var(--hud-scale)) var(--hud-panel-padding); z-index: 10; font-size: var(--hud-font-size); - min-width: calc(180px * var(--hud-scale)); + min-width: calc(196px * var(--hud-scale)); } .hud-panel-coordinates .coord-item { - margin-bottom: var(--hud-gap-xs); + margin-bottom: calc(8px * var(--hud-scale)); } .hud-panel-coordinates .earth-zoom-level { - margin-top: var(--hud-gap-xs); - color: #ffff44; - font-weight: 500; + margin-top: calc(10px * var(--hud-scale)); + padding-top: calc(10px * var(--hud-scale)); + border-top: 1px solid var(--hud-line); + color: var(--hud-accent); + font-weight: 600; text-align: center; - font-size: calc(1rem * var(--hud-scale)); + font-size: calc(0.94rem * var(--hud-scale)); + letter-spacing: 0.02em; } .hud-panel-coordinates .earth-mouse-coords { font-size: var(--hud-font-size-sm); - color: #aaa; - margin-top: var(--hud-gap-xs); - padding-top: var(--hud-gap-xs); - border-top: 1px solid rgba(255, 255, 255, 0.1); + color: var(--hud-text-muted); + margin-top: calc(8px * var(--hud-scale)); + line-height: 1.45; } diff --git a/frontend/public/earth/css/earth-stats.css b/frontend/public/earth/css/earth-stats.css index f071676e..60f4e2c8 100644 --- a/frontend/public/earth/css/earth-stats.css +++ b/frontend/public/earth/css/earth-stats.css @@ -5,11 +5,22 @@ right: var(--hud-offset); border-radius: var(--hud-radius); padding: var(--hud-panel-padding); - width: calc(250px * var(--hud-scale)); + width: calc(264px * var(--hud-scale)); z-index: 10; font-size: var(--hud-font-size); } .hud-panel-stats .stats-item { - margin-bottom: var(--hud-gap-sm); + margin-bottom: 0; + padding: calc(9px * var(--hud-scale)) 0; + border-bottom: 1px solid rgba(214, 229, 245, 0.06); +} + +.hud-panel-stats .stats-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.hud-panel-stats .stats-item:first-of-type { + padding-top: 0; } diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 725925aa..d10c81e5 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -5,28 +5,28 @@ overflow: hidden; isolation: isolate; background: - radial-gradient(circle at 24% 12%, rgba(255, 255, 255, 0.12), transparent 28%), - radial-gradient(circle at 78% 115%, rgba(255, 255, 255, 0.06), transparent 32%), - linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(110, 176, 255, 0.06)), - rgba(7, 18, 36, 0.28); - border: 1px solid rgba(225, 242, 255, 0.2); + radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.08), transparent 30%), + radial-gradient(circle at 86% 115%, rgba(145, 186, 255, 0.08), transparent 36%), + linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 26%), + linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom)); + border: 1px solid var(--hud-border); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.14), - inset 0 -1px 0 rgba(255, 255, 255, 0.04), - 0 18px 40px rgba(0, 0, 0, 0.24), - 0 0 32px rgba(120, 200, 255, 0.12); - backdrop-filter: blur(20px) saturate(145%); - -webkit-backdrop-filter: blur(20px) saturate(145%); + inset 0 1px 0 var(--hud-highlight), + inset 0 -1px 0 rgba(255, 255, 255, 0.03), + var(--hud-shadow), + 0 0 0 1px rgba(255, 255, 255, 0.02); + backdrop-filter: blur(18px) saturate(125%); + -webkit-backdrop-filter: blur(18px) saturate(125%); } .hud-panel::before { content: ''; position: absolute; - inset: 1px 1px 24% 1px; + inset: 1px 1px 52% 1px; border-radius: inherit; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 26%, transparent 70%); - opacity: 0.46; + linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.02) 58%, transparent 100%); + opacity: 0.52; pointer-events: none; } @@ -34,13 +34,13 @@ content: ''; position: absolute; inset: -1px; - padding: 1.4px; + padding: 1px; border-radius: inherit; background: - linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(170, 223, 255, 0.2) 34%, rgba(88, 169, 255, 0.14) 68%, rgba(255, 255, 255, 0.24)); - opacity: 0.78; + linear-gradient(135deg, rgba(244, 249, 255, 0.22), rgba(164, 194, 226, 0.08) 36%, rgba(90, 123, 161, 0.04) 70%, rgba(255, 255, 255, 0.16)); + opacity: 0.72; pointer-events: none; - filter: url(#liquid-glass-distortion) blur(0.35px); + filter: blur(0.2px); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); @@ -57,9 +57,11 @@ } .hud-panel-title { - color: #4db8ff; + color: var(--hud-title); margin: 0 0 var(--hud-gap-sm); font-size: var(--hud-title-size); + font-weight: 600; + letter-spacing: 0.01em; line-height: 1.2; } @@ -69,6 +71,8 @@ justify-content: space-between; gap: var(--hud-gap-sm); margin-bottom: var(--hud-gap-sm); + padding-bottom: var(--hud-gap-sm); + border-bottom: 1px solid var(--hud-line); } .hud-panel-header .hud-panel-title { @@ -90,15 +94,20 @@ height: calc(var(--hud-title-size) * 1.24); min-width: calc(var(--hud-title-size) * 1.24); padding: 0; - border: 0; + border: 1px solid rgba(220, 234, 248, 0.08); border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - color: #d9efff; + background: rgba(255, 255, 255, 0.04); + color: var(--hud-text-muted); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; - transition: background 0.18s ease, transform 0.18s ease, opacity 0.18s ease; + transition: + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + transform 0.18s ease, + opacity 0.18s ease; } .hud-panel-close .material-symbols-rounded { @@ -107,8 +116,10 @@ } .hud-panel-close:hover { - background: rgba(255, 255, 255, 0.14); - transform: scale(1.04); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(225, 239, 255, 0.14); + color: var(--hud-accent-strong); + transform: translateY(-1px); } .hud-panel.is-dragging { @@ -132,15 +143,19 @@ display: flex; justify-content: space-between; gap: var(--hud-gap-sm); + align-items: baseline; } .hud-panel-label { - color: #aaa; + color: var(--hud-text-soft); + font-size: var(--hud-font-size-sm); + letter-spacing: 0.08em; + text-transform: uppercase; } .hud-panel-value { - color: #4db8ff; - font-weight: 500; + color: var(--hud-text); + font-weight: 600; } .hud-error-message { @@ -159,15 +174,16 @@ top: 20px; left: 50%; transform: translate(-50%, -18px); - background-color: rgba(10, 10, 30, 0.85); - border-radius: 10px; - padding: 10px 15px; + background: + linear-gradient(180deg, rgba(18, 31, 52, 0.92), rgba(8, 18, 32, 0.9)); + border-radius: 14px; + padding: 11px 15px; z-index: 210; - box-shadow: 0 0 20px rgba(0, 150, 255, 0.3); - border: 1px solid rgba(0, 150, 255, 0.2); + box-shadow: var(--hud-shadow-soft); + border: 1px solid var(--hud-border); font-size: 0.9rem; display: none; - backdrop-filter: blur(5px); + backdrop-filter: blur(10px); text-align: center; min-width: 180px; opacity: 0; @@ -182,31 +198,32 @@ } .earth-status-message.success { - color: #44ff44; - border-left: 3px solid #44ff44; + color: #d8f7df; + border-left: 3px solid #66d18f; } .earth-status-message.warning { - color: #ffff44; - border-left: 3px solid #ffff44; + color: #fff2c3; + border-left: 3px solid #e4c464; } .earth-status-message.error { - color: #ff4444; - border-left: 3px solid #ff4444; + color: #ffd4d7; + border-left: 3px solid #ff7b86; } .earth-tooltip { position: absolute; - background-color: rgba(10, 10, 30, 0.95); - border: 1px solid #4db8ff; - border-radius: 5px; - padding: 5px 10px; - font-size: 0.8rem; - color: #fff; + background: + linear-gradient(180deg, rgba(21, 37, 61, 0.96), rgba(8, 18, 31, 0.95)); + border: 1px solid rgba(214, 230, 247, 0.14); + border-radius: 10px; + padding: 8px 12px; + font-size: 0.78rem; + color: var(--hud-text); pointer-events: none; z-index: 100; - box-shadow: 0 0 10px rgba(77, 184, 255, 0.3); + box-shadow: var(--hud-shadow-soft); display: none; user-select: none; } @@ -225,9 +242,9 @@ .earth-settings-backdrop { position: fixed; inset: 0; - background: rgba(2, 8, 20, 0.38); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); + background: rgba(2, 8, 20, 0.46); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); } .earth-settings-sheet { @@ -240,13 +257,25 @@ max-height: calc(100vh - max(64px, 18vh)); margin-inline: auto; transform: none; - border-radius: calc(28px * var(--hud-scale)); + border-radius: calc(24px * var(--hud-scale)); padding: calc(20px * var(--hud-scale)); display: flex; flex-direction: column; gap: var(--hud-gap-md); overflow: hidden; +} + +.earth-settings-sheet.liquid-glass-surface { animation: none; + background: + radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.09), transparent 30%), + linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 28%), + linear-gradient(180deg, rgba(19, 34, 56, 0.92), rgba(8, 18, 31, 0.9)); + border-color: rgba(207, 224, 243, 0.12); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 24px 56px rgba(2, 7, 15, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.025); } .earth-settings-sheet.liquid-glass-surface:hover, @@ -270,19 +299,19 @@ justify-content: space-between; gap: var(--hud-gap-md); padding-bottom: var(--hud-gap-sm); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: 1px solid var(--hud-line); } .earth-settings-kicker { - color: #9eb7d0; - font-size: 0.78rem; - letter-spacing: 0.08em; + color: var(--hud-text-soft); + font-size: var(--hud-kicker-size); + letter-spacing: 0.16em; text-transform: uppercase; } .earth-settings-title { margin: 4px 0 0; - color: #eef7ff; + color: var(--hud-title); } .earth-settings-close { @@ -293,7 +322,7 @@ overflow-y: auto; padding-right: 4px; scrollbar-width: thin; - scrollbar-color: rgba(160, 220, 255, 0.45) transparent; + scrollbar-color: rgba(160, 186, 216, 0.34) transparent; } .earth-settings-content::-webkit-scrollbar { @@ -305,7 +334,7 @@ } .earth-settings-content::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, rgba(210, 237, 255, 0.28), rgba(110, 176, 255, 0.34)); + background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28)); border-radius: 999px; } @@ -315,9 +344,9 @@ .earth-settings-section-title { margin-bottom: 12px; - color: #9eb7d0; - font-size: 0.8rem; - letter-spacing: 0.08em; + color: var(--hud-text-soft); + font-size: var(--hud-kicker-size); + letter-spacing: 0.16em; text-transform: uppercase; } @@ -332,11 +361,23 @@ align-items: center; justify-content: space-between; gap: 16px; - padding: 14px 16px; - border-radius: 18px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(92, 151, 218, 0.03)); - border: 1px solid rgba(255, 255, 255, 0.08); + padding: 15px 16px; + border-radius: 16px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), + rgba(255, 255, 255, 0.025); + border: 1px solid rgba(212, 227, 244, 0.08); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease; +} + +.earth-settings-item:hover { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent), + rgba(255, 255, 255, 0.04); + border-color: rgba(224, 236, 249, 0.14); + transform: translateY(-1px); } .earth-settings-copy { @@ -346,13 +387,13 @@ } .earth-settings-item-title { - color: #eef7ff; - font-size: 1rem; - font-weight: 500; + color: var(--hud-text); + font-size: calc(0.98rem * var(--hud-scale)); + font-weight: 600; } .earth-settings-item-subtitle { - color: #90a6bb; + color: var(--hud-text-muted); font-size: 0.82rem; line-height: 1.4; } @@ -373,8 +414,8 @@ width: 48px; height: 30px; border-radius: 999px; - background: rgba(255, 255, 255, 0.14); - border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(215, 229, 242, 0.12); position: relative; transition: background 0.18s ease, border-color 0.18s ease; } @@ -387,14 +428,14 @@ width: 22px; height: 22px; border-radius: 50%; - background: #f5fbff; - box-shadow: 0 3px 10px rgba(0, 0, 0, 0.22); + background: #edf4fc; + box-shadow: 0 6px 14px rgba(1, 8, 18, 0.26); transition: transform 0.18s ease; } .earth-settings-switch input:checked + .earth-settings-switch-track { - background: linear-gradient(180deg, rgba(69, 171, 255, 0.92), rgba(34, 123, 255, 0.84)); - border-color: rgba(220, 243, 255, 0.36); + background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78)); + border-color: rgba(223, 236, 252, 0.28); } .earth-settings-switch input:checked + .earth-settings-switch-track::after { diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css index 7a99bbad..3f5231dc 100644 --- a/frontend/public/earth/css/info-panel.css +++ b/frontend/public/earth/css/info-panel.css @@ -4,45 +4,97 @@ top: var(--hud-offset); left: var(--hud-offset); border-radius: var(--hud-radius); - padding: calc(20px * var(--hud-scale)); - width: calc(320px * var(--hud-scale)); + padding: calc(22px * var(--hud-scale)); + width: calc(336px * var(--hud-scale)); z-index: 10; font-size: var(--hud-font-size); } -.hud-panel-info .earth-app-title { - font-size: calc(1.8rem * var(--hud-scale)); - margin-bottom: var(--hud-gap-xs); - color: #4db8ff; - text-shadow: 0 0 10px rgba(77, 184, 255, 0.5); - text-align: center; +.hud-panel-info .earth-brand { + margin-bottom: calc(12px * var(--hud-scale)); + display: flex; + align-items: flex-start; + gap: calc(10px * var(--hud-scale)); + min-width: 0; } -.hud-panel-info .subtitle { - margin-bottom: calc(20px * var(--hud-scale)); - border-bottom: 1px solid rgba(255,255,255,0.1); - padding-bottom: calc(12px * var(--hud-scale)); - text-align: center; +.hud-panel-info .earth-brand__logo { + display: block; + flex: 0 0 auto; + width: min(calc(148px * var(--hud-scale)), 41%); + height: auto; + object-fit: contain; + image-rendering: auto; +} + +.hud-panel-info .earth-brand__copy { + display: flex; + flex: 1 1 auto; + min-width: 0; + flex-direction: column; + justify-content: center; + gap: calc(8px * var(--hud-scale)); +} + +.hud-panel-info .earth-brand__title { + display: block; + width: min(100%, calc(196px * var(--hud-scale))); + max-width: 100%; + height: auto; + object-fit: contain; + image-rendering: auto; +} + +.hud-panel-info .earth-brand__meta { + text-align: left; display: flex; flex-direction: column; - align-items: center; + align-items: flex-start; gap: calc(4px * var(--hud-scale)); } -.hud-panel-info .subtitle-main { - color: #d7e7f5; - font-size: calc(0.95rem * var(--hud-scale)); - line-height: 1.35; +.hud-panel-info .earth-brand__subtitle { + color: var(--hud-text-muted); + font-size: calc(0.84rem * var(--hud-scale)); + line-height: 1.38; font-weight: 500; - letter-spacing: 0.02em; + letter-spacing: 0.015em; } -.hud-panel-info .subtitle-meta { - color: #8ea5bc; - font-size: calc(0.74rem * var(--hud-scale)); - line-height: 1.3; +.hud-panel-info .earth-brand__description { + color: var(--hud-text-soft); + font-size: calc(0.66rem * var(--hud-scale)); + line-height: 1.35; letter-spacing: 0.08em; - text-transform: uppercase; +} + +.hud-panel-info .earth-brand--en .earth-brand__logo { + width: min(calc(140px * var(--hud-scale)), 39%); +} + +.hud-panel-info .earth-brand--en .earth-brand__title { + width: min(100%, calc(208px * var(--hud-scale))); +} + +.hud-panel-info .earth-brand--en .earth-brand__meta { + gap: calc(3px * var(--hud-scale)); +} + +.hud-panel-info .earth-brand--en .earth-brand__subtitle, +.hud-panel-info .earth-brand--en .earth-brand__description { + font-family: "Roboto Condensed", "Arial Narrow", "Trebuchet MS", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; +} + +.hud-panel-info .earth-brand--en .earth-brand__subtitle { + font-size: calc(0.84rem * var(--hud-scale)); + line-height: 1.26; + letter-spacing: 0.01em; +} + +.hud-panel-info .earth-brand--en .earth-brand__description { + font-size: calc(0.66rem * var(--hud-scale)); + line-height: 1.24; + letter-spacing: 0.045em; } /* Info Card - Unified details panel (inside info-panel) */ @@ -50,13 +102,13 @@ display: none; margin-top: var(--hud-gap-lg); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(110, 176, 255, 0.04)), - rgba(7, 18, 36, 0.2); - border-radius: calc(14px * var(--hud-scale)); - border: 1px solid rgba(225, 242, 255, 0.12); + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 48%), + rgba(255, 255, 255, 0.025); + border-radius: calc(18px * var(--hud-scale)); + border: 1px solid rgba(214, 229, 245, 0.08); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - 0 10px 24px rgba(0, 0, 0, 0.16); + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 12px 28px rgba(0, 0, 0, 0.14); padding: 0; overflow: hidden; pointer-events: auto; @@ -70,8 +122,9 @@ .info-card-header { display: flex; align-items: center; - padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale)); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(77, 184, 255, 0.06)); + padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale)); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015)); + border-bottom: 1px solid var(--hud-line); gap: var(--hud-gap-sm); } @@ -82,16 +135,17 @@ .info-card-header h3 { flex: 1; margin: 0; - font-size: calc(1rem * var(--hud-scale)); - color: #4db8ff; + font-size: calc(0.96rem * var(--hud-scale)); + color: var(--hud-title); + font-weight: 600; } .info-card-content { - padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale)); + padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale)); max-height: 40vh; overflow-y: auto; scrollbar-width: thin; - scrollbar-color: rgba(160, 220, 255, 0.45) transparent; + scrollbar-color: rgba(160, 186, 216, 0.34) transparent; pointer-events: auto; } @@ -104,19 +158,20 @@ } .info-card-content::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, rgba(210, 237, 255, 0.32), rgba(110, 176, 255, 0.34)); + background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28)); border-radius: 999px; } .info-card-content::-webkit-scrollbar-thumb:hover { - background: linear-gradient(180deg, rgba(232, 246, 255, 0.42), rgba(128, 198, 255, 0.46)); + background: linear-gradient(180deg, rgba(226, 236, 246, 0.28), rgba(136, 164, 196, 0.34)); } .info-card-property { display: flex; justify-content: space-between; - padding: calc(6px * var(--hud-scale)); - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + align-items: flex-start; + padding: calc(8px * var(--hud-scale)) 0; + border-bottom: 1px solid rgba(214, 229, 245, 0.06); pointer-events: auto; gap: var(--hud-gap-sm); } @@ -126,22 +181,25 @@ } .info-card-label { - color: #aaa; - font-size: calc(0.85rem * var(--hud-scale)); + color: var(--hud-text-soft); + font-size: calc(0.72rem * var(--hud-scale)); + letter-spacing: 0.1em; + text-transform: uppercase; cursor: pointer; transition: color 0.18s ease; } .info-card-label:hover { - color: #d9f1ff; + color: var(--hud-text-muted); } .info-card-value { - color: #4db8ff; - font-weight: 500; - font-size: calc(0.9rem * var(--hud-scale)); + color: var(--hud-text); + font-weight: 600; + font-size: calc(0.88rem * var(--hud-scale)); + line-height: 1.45; text-align: right; - max-width: calc(180px * var(--hud-scale)); + max-width: calc(190px * var(--hud-scale)); word-break: break-word; } diff --git a/frontend/public/earth/css/legend.css b/frontend/public/earth/css/legend.css index 75f51eb8..1c975035 100644 --- a/frontend/public/earth/css/legend.css +++ b/frontend/public/earth/css/legend.css @@ -5,24 +5,24 @@ left: var(--hud-offset); border-radius: var(--hud-radius); padding: var(--hud-panel-padding); - width: calc(220px * var(--hud-scale)); + width: calc(236px * var(--hud-scale)); z-index: 10; font-size: var(--hud-font-size); } .hud-panel-legend .legend-title { - margin-bottom: var(--hud-gap-md); + margin-bottom: 0; } .hud-panel-legend .legend-list { display: flex; flex-direction: column; - gap: var(--hud-gap-sm); - max-height: calc(202px * var(--hud-scale)); + gap: calc(9px * var(--hud-scale)); + max-height: calc(214px * var(--hud-scale)); overflow-y: auto; padding-right: calc(4px * var(--hud-scale)); scrollbar-width: thin; - scrollbar-color: rgba(160, 220, 255, 0.4) transparent; + scrollbar-color: rgba(160, 186, 216, 0.32) transparent; } .hud-panel-legend .legend-list::-webkit-scrollbar { @@ -34,27 +34,38 @@ } .hud-panel-legend .legend-list::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, rgba(210, 237, 255, 0.28), rgba(110, 176, 255, 0.34)); + background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28)); border-radius: 999px; } .hud-panel-legend .legend-item { display: flex; align-items: center; - padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale)); - border-radius: calc(10px * var(--hud-scale)); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(120, 180, 255, 0.02)); - border: 1px solid rgba(225, 242, 255, 0.06); + gap: calc(10px * var(--hud-scale)); + padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale)); + border-radius: calc(14px * var(--hud-scale)); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent), + rgba(255, 255, 255, 0.018); + border: 1px solid rgba(214, 229, 245, 0.07); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); } .hud-panel-legend .legend-color { - width: calc(20px * var(--hud-scale)); - height: calc(20px * var(--hud-scale)); - border-radius: calc(6px * var(--hud-scale)); - margin-right: var(--hud-gap-md); + width: calc(16px * var(--hud-scale)); + height: calc(16px * var(--hud-scale)); + border-radius: 999px; + margin-right: 0; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.2), - 0 0 12px rgba(77, 184, 255, 0.18); + inset 0 1px 0 rgba(255, 255, 255, 0.28), + 0 0 0 3px rgba(255, 255, 255, 0.04); +} + +.hud-panel-legend .legend-item span { + color: var(--hud-text); + font-size: calc(0.84rem * var(--hud-scale)); + font-weight: 500; + line-height: 1.35; } .legend-color-americas { diff --git a/frontend/public/earth/css/toolbar.css b/frontend/public/earth/css/toolbar.css index 2de21ee9..104cfdc1 100644 --- a/frontend/public/earth/css/toolbar.css +++ b/frontend/public/earth/css/toolbar.css @@ -415,6 +415,9 @@ .earth-zoom-toolbar .earth-zoom-btn { height: 42px; + font-size: 20px; + font-weight: 500; + line-height: 1; } .earth-zoom-toolbar .earth-zoom-value { diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 69198a25..b176841c 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -34,6 +34,7 @@ + @@ -48,11 +49,7 @@

-

智能星球计划

-
- 现实层宇宙全息感知系统 - 卫星 · 海底光缆 · 算力基础设施 -
+
@@ -137,9 +134,9 @@ 缩放控制
- + 100%重置缩放到100% - +
+
+
+
+
+

新闻直播

+ 等待加载直播源 +
+ +
+
+ +
+ + +
+
+
+
暂无可用频道
+
当前未配置可播放新闻直播源
+
频道目录待同步
+
支持后台配置默认源与采集器补充源。
+
+
+
暂无可播放直播源,请先在系统配置中添加频道。
+ + +
+ +
+
正在初始化全球态势数据...
@@ -312,6 +364,16 @@ +
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 8a9114fc..a883e6a2 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -17,6 +17,7 @@ import { } from "./satellites.js"; import { getShowCables } from "./cables.js"; import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js"; +import { ensureTVPanelReady } from "./tv.js"; export let autoRotate = true; export let zoomLevel = 1.0; @@ -30,6 +31,7 @@ const HUD_PANEL_IDS = [ "legend", "coordinates-display", "earth-stats", + "tv-panel", ]; const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable"; const PANEL_LAYOUT_ANIMATION_MS = 420; @@ -88,6 +90,14 @@ function setHudPanelVisibility(panelId, visible) { if (!panel) return; panel.classList.toggle("hud-panel-hidden", !visible); syncSettingsToggle(panelId, visible); + if (panelId === "tv-panel") { + updateTVToggleUI(visible); + if (visible) { + ensureTVPanelReady().catch((error) => { + console.error("初始化电视直播面板失败:", error); + }); + } + } } function syncSettingsToggle(panelId, visible) { @@ -273,6 +283,13 @@ function bindFloatingMenu(trigger, group) { }); } +function updateTVToggleUI(visible) { + const btn = document.getElementById("toggle-tv"); + if (!btn) return; + btn.classList.toggle("active", visible); + setButtonTooltip(btn, visible ? "关闭新闻直播" : "打开新闻直播"); +} + function bindListener(element, eventName, handler, options) { if (!element) return; element.addEventListener(eventName, handler, options); @@ -656,6 +673,9 @@ function setupTerrainControls() { showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info"); }); + updateTVToggleUI( + !document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden"), + ); updateLayoutUI(container); } diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 419e5d44..b4309bb0 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -124,6 +124,7 @@ import { setLegendItems, } from "./legend.js"; import { mountBrand } from "./brand.js"; +import { initTVPanel } from "./tv.js"; export let scene; export let camera; @@ -187,6 +188,8 @@ const HUD_INTERACTIVE_SELECTORS = [ "#legend *", "#earth-stats", "#earth-stats *", + "#tv-panel", + "#tv-panel *", ]; function bindListener(target, eventName, handler, options) { @@ -856,6 +859,7 @@ export function init() { updateHudScale(); const brandRoot = document.getElementById("brand-root"); mountBrand(brandRoot, HUD_CONFIG.brandLanguage); + initTVPanel(); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera( diff --git a/frontend/public/earth/js/tv.js b/frontend/public/earth/js/tv.js new file mode 100644 index 00000000..d0f1fcfb --- /dev/null +++ b/frontend/public/earth/js/tv.js @@ -0,0 +1,596 @@ +import Hls from "hls.js"; +import { showStatusMessage } from "./ui.js"; + +const TV_STREAMS_API = "/api/v1/tv/streams"; +const TV_PROXY_API = "/api/v1/tv/proxy"; +const TV_STATUS_MESSAGE = { + idle: "等待加载直播源", + syncing: "正在同步直播源...", + empty: "暂无可播放直播源", + iframeReady: "直播页已加载", + videoReady: "视频流已加载", + videoError: "当前视频流不可播放,请尝试其他频道", + externalOnly: "当前频道仅支持外部打开", + loadFailed: "电视直播源加载失败", +}; + +let tvPayload = null; +let currentSourceId = ""; +let initialized = false; +let refreshPromise = null; +let hlsPlayer = null; +let hlsRecoveryAttempts = 0; + +const HLS_MAX_RECOVERY_ATTEMPTS = 3; +const HLS_RETRY_CONFIG = { + maxNumRetry: 4, + retryDelayMs: 1500, + maxRetryDelayMs: 8000, + backoff: "exponential", +}; + +function getElements() { + return { + panel: document.getElementById("tv-panel"), + toggleBtn: document.getElementById("toggle-tv"), + resizeHandle: document.getElementById("tv-resize-handle"), + select: document.getElementById("tv-source-select"), + title: document.getElementById("tv-source-title"), + meta: document.getElementById("tv-source-meta"), + catalog: document.getElementById("tv-source-catalog"), + status: document.getElementById("tv-source-status"), + notes: document.getElementById("tv-source-notes"), + iframe: document.getElementById("tv-iframe"), + video: document.getElementById("tv-video"), + empty: document.getElementById("tv-empty-state"), + refreshBtn: document.getElementById("tv-refresh"), + openBtn: document.getElementById("tv-open-external"), + }; +} + +function clearPanelPositioningForResize(panel) { + panel.style.left = `${panel.offsetLeft}px`; + panel.style.top = `${panel.offsetTop}px`; + panel.style.right = "auto"; + panel.style.bottom = "auto"; + panel.style.transform = "none"; + panel.dataset.dragged = "true"; +} + +function getHudScale() { + const scale = Number.parseFloat( + getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"), + ); + return Number.isFinite(scale) && scale > 0 ? scale : 1; +} + +function setupResizeHandle() { + const { panel, resizeHandle } = getElements(); + const container = document.getElementById("container"); + if (!(panel instanceof HTMLElement) || !(resizeHandle instanceof HTMLElement) || !(container instanceof HTMLElement)) { + return; + } + + let resizing = false; + let startX = 0; + let startY = 0; + let startWidth = 0; + let startHeight = 0; + + const stopResize = () => { + resizing = false; + panel.classList.remove("is-resizing"); + document.body.style.userSelect = ""; + }; + + resizeHandle.addEventListener("pointerdown", (event) => { + if (document.getElementById("container")?.classList.contains("layout-expanded")) { + return; + } + event.preventDefault(); + event.stopPropagation(); + resizing = true; + startX = event.clientX; + startY = event.clientY; + + clearPanelPositioningForResize(panel); + + const rect = panel.getBoundingClientRect(); + startWidth = rect.width; + startHeight = rect.height; + panel.classList.add("is-resizing"); + document.body.style.userSelect = "none"; + resizeHandle.setPointerCapture?.(event.pointerId); + }); + + resizeHandle.addEventListener("pointermove", (event) => { + if (!resizing) return; + const containerRect = container.getBoundingClientRect(); + const panelRect = panel.getBoundingClientRect(); + const currentLeft = panelRect.left - containerRect.left; + const currentTop = panelRect.top - containerRect.top; + const hudScale = getHudScale(); + const minWidth = Math.max(320, Math.round(360 * hudScale)); + const minHeight = Math.max(260, Math.round(340 * hudScale)); + const maxWidth = Math.max(minWidth, containerRect.width - currentLeft - 12); + const maxHeight = Math.max(minHeight, containerRect.height - currentTop - 12); + const nextWidth = Math.min( + maxWidth, + Math.max(minWidth, startWidth + (event.clientX - startX)), + ); + const nextHeight = Math.min( + maxHeight, + Math.max(minHeight, startHeight + (event.clientY - startY)), + ); + + panel.style.width = `${nextWidth}px`; + panel.style.minHeight = `${nextHeight}px`; + }); + + resizeHandle.addEventListener("pointerup", stopResize); + resizeHandle.addEventListener("pointercancel", stopResize); + resizeHandle.addEventListener("lostpointercapture", stopResize); +} + +function updateToggleButton(visible) { + const { toggleBtn } = getElements(); + if (!toggleBtn) return; + toggleBtn.classList.toggle("active", visible); + const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip"); + if (tooltip) { + tooltip.textContent = visible ? "关闭新闻直播" : "打开新闻直播"; + } +} + +function syncSettingsToggle(visible) { + const input = document.querySelector('[data-settings-panel="tv-panel"]'); + if (input instanceof HTMLInputElement) { + input.checked = visible; + } +} + +function setPanelVisible(visible) { + const { panel } = getElements(); + if (!panel) return; + panel.classList.toggle("hud-panel-hidden", !visible); + updateToggleButton(visible); + syncSettingsToggle(visible); +} + +function getEmbeddedUrl(source) { + if (!source) return ""; + if (source.source_type === "youtube" && source.youtube_video_id) { + const videoId = encodeURIComponent(source.youtube_video_id); + return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&playsinline=1&rel=0`; + } + if (source.source_type === "external") return ""; + if (source.source_type === "video" || source.source_type === "hls") { + return ""; + } + return source.embed_url || source.homepage_url || ""; +} + +function buildProxyUrl(url) { + if (!url) return ""; + return `${TV_PROXY_API}?url=${encodeURIComponent(url)}`; +} + +function getVideoUrl(source) { + if (!source) return ""; + if (source.source_type !== "video" && source.source_type !== "hls") { + return ""; + } + return buildProxyUrl(source.stream_url || source.embed_url || ""); +} + +function destroyHlsPlayer() { + if (hlsPlayer) { + hlsPlayer.destroy(); + hlsPlayer = null; + } + hlsRecoveryAttempts = 0; +} + +function showEmbeddedFallback(source, reasonMessage = TV_STATUS_MESSAGE.videoError) { + const { iframe, video, empty } = getElements(); + const embeddedUrl = getEmbeddedUrl(source); + if (!embeddedUrl) { + setPanelMessage(reasonMessage); + return false; + } + + destroyHlsPlayer(); + + if (video) { + video.removeAttribute("src"); + video.hidden = true; + video.load(); + } + + if (iframe) { + iframe.hidden = false; + if (iframe.src !== embeddedUrl) { + iframe.src = embeddedUrl; + } + } + + if (empty) { + empty.hidden = true; + } + + setPanelMessage("直播放流不可用,已回退到官网直播页"); + return true; +} + +function canPlayNativeHls(video, sourceUrl) { + if (!(video instanceof HTMLVideoElement) || !sourceUrl) return false; + const isLikelyHls = sourceUrl.includes(".m3u8") || sourceUrl.includes("mpegurl"); + if (!isLikelyHls) return false; + return video.canPlayType("application/vnd.apple.mpegurl") !== ""; +} + +function tryStartPlayback(video) { + if (!(video instanceof HTMLVideoElement)) return; + video.autoplay = true; + video.muted = true; + const playPromise = video.play(); + if (playPromise && typeof playPromise.catch === "function") { + playPromise.catch((error) => { + console.warn("TV 自动播放未成功:", error); + setPanelMessage("已加载视频流,点击播放继续"); + }); + } +} + +function attachVideoSource(video, source) { + const sourceUrl = getVideoUrl(source); + if (!(video instanceof HTMLVideoElement) || !sourceUrl) return; + + destroyHlsPlayer(); + video.autoplay = true; + video.muted = true; + + if (source.source_type === "hls") { + if (canPlayNativeHls(video, sourceUrl)) { + video.src = sourceUrl; + video.load(); + tryStartPlayback(video); + return; + } + + if (Hls.isSupported()) { + hlsPlayer = new Hls({ + enableWorker: true, + lowLatencyMode: false, + manifestLoadingTimeOut: 20000, + levelLoadingTimeOut: 20000, + fragLoadingTimeOut: 25000, + fragLoadingMaxRetry: 3, + fragLoadingRetryDelay: 1500, + levelLoadingMaxRetry: 3, + levelLoadingRetryDelay: 1500, + manifestLoadingMaxRetry: 2, + manifestLoadingRetryDelay: 1500, + liveSyncDurationCount: 4, + liveMaxLatencyDurationCount: 10, + manifestLoadPolicy: { + default: { + maxTimeToFirstByteMs: 12000, + maxLoadTimeMs: 20000, + timeoutRetry: { + ...HLS_RETRY_CONFIG, + maxNumRetry: 2, + }, + errorRetry: { + ...HLS_RETRY_CONFIG, + maxNumRetry: 2, + }, + }, + }, + playlistLoadPolicy: { + default: { + maxTimeToFirstByteMs: 12000, + maxLoadTimeMs: 20000, + timeoutRetry: HLS_RETRY_CONFIG, + errorRetry: HLS_RETRY_CONFIG, + }, + }, + fragLoadPolicy: { + default: { + maxTimeToFirstByteMs: 12000, + maxLoadTimeMs: 30000, + timeoutRetry: HLS_RETRY_CONFIG, + errorRetry: HLS_RETRY_CONFIG, + }, + }, + }); + hlsPlayer.loadSource(sourceUrl); + hlsPlayer.attachMedia(video); + hlsPlayer.on(Hls.Events.MANIFEST_PARSED, () => { + hlsRecoveryAttempts = 0; + setPanelMessage(TV_STATUS_MESSAGE.videoReady); + tryStartPlayback(video); + }); + hlsPlayer.on(Hls.Events.ERROR, (_event, data) => { + console.error("HLS 播放失败:", data); + if (!data?.fatal) { + if (data?.type === Hls.ErrorTypes.NETWORK_ERROR) { + setPanelMessage("直播流网络波动,正在重试..."); + return; + } + if (data?.type === Hls.ErrorTypes.MEDIA_ERROR) { + setPanelMessage("直播流正在恢复..."); + return; + } + } + + if (data?.fatal && hlsRecoveryAttempts < HLS_MAX_RECOVERY_ATTEMPTS) { + hlsRecoveryAttempts += 1; + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + setPanelMessage(`直播流连接异常,正在重试 (${hlsRecoveryAttempts}/${HLS_MAX_RECOVERY_ATTEMPTS})...`); + hlsPlayer?.startLoad(); + return; + } + if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + setPanelMessage(`直播流解码异常,正在恢复 (${hlsRecoveryAttempts}/${HLS_MAX_RECOVERY_ATTEMPTS})...`); + hlsPlayer?.recoverMediaError(); + return; + } + } + + if (!showEmbeddedFallback(source)) { + setPanelMessage(TV_STATUS_MESSAGE.videoError); + } + }); + return; + } + } + + video.src = sourceUrl; + video.load(); + tryStartPlayback(video); +} + +function getExternalUrl(source) { + return source?.homepage_url || source?.youtube_channel || source?.embed_url || source?.stream_url || ""; +} + +function updateOpenButton(source) { + const { openBtn } = getElements(); + if (!openBtn) return; + const targetUrl = getExternalUrl(source); + openBtn.disabled = !targetUrl; + openBtn.onclick = targetUrl + ? () => { + window.open(targetUrl, "_blank", "noopener,noreferrer"); + } + : null; +} + +function findSourceById(sourceId) { + return tvPayload?.sources?.find((source) => source.id === sourceId) || null; +} + +function setPanelMessage(message) { + const { status } = getElements(); + if (status) { + status.textContent = message || TV_STATUS_MESSAGE.idle; + } +} + +function renderSourceOptions() { + const { select } = getElements(); + if (!select) return; + const sources = tvPayload?.sources || []; + const fragment = document.createDocumentFragment(); + + sources.forEach((source) => { + const marker = source.id === tvPayload?.default_source_id ? " · 默认" : ""; + const option = document.createElement("option"); + option.value = source.id; + option.textContent = `${source.name}${marker}`; + fragment.appendChild(option); + }); + + select.replaceChildren(fragment); + if (currentSourceId) { + select.value = currentSourceId; + } +} + +function renderSource(source) { + const { title, meta, catalog, notes, iframe, video, empty } = getElements(); + const embeddedUrl = getEmbeddedUrl(source); + const videoUrl = getVideoUrl(source); + const externalUrl = getExternalUrl(source); + const isVideo = Boolean(videoUrl); + const isExternalOnly = Boolean(source) && !embeddedUrl && !videoUrl && Boolean(externalUrl); + + if (title) { + title.textContent = source?.name || "暂无可用频道"; + } + if (meta) { + meta.textContent = source + ? `${source.provider} · ${source.region} · ${source.language} · ${source.source_type}` + : "当前未配置可播放新闻直播源"; + } + if (catalog) { + const sourceCount = tvPayload?.source_count || tvPayload?.sources?.length || 0; + const latestUpdatedAt = tvPayload?.latest_updated_at || tvPayload?.generated_at || ""; + const latestLabel = latestUpdatedAt + ? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}` + : "尚未同步"; + const collectorLabel = source?.collector_source ? ` · 采集器 ${source.collector_source}` : ""; + catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${collectorLabel}`; + } + if (notes) { + notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。"; + } + + if (!source || (!embeddedUrl && !videoUrl)) { + destroyHlsPlayer(); + if (iframe) { + iframe.removeAttribute("src"); + iframe.hidden = true; + } + if (video) { + video.removeAttribute("src"); + video.hidden = true; + video.load(); + } + if (empty) { + empty.hidden = false; + empty.textContent = isExternalOnly + ? "当前频道仅支持跳转官网或外部播放器打开。" + : "暂无可播放直播源,请先在系统配置中添加频道。"; + } + setPanelMessage(isExternalOnly ? TV_STATUS_MESSAGE.externalOnly : TV_STATUS_MESSAGE.empty); + updateOpenButton(source); + return; + } + + if (isVideo && videoUrl) { + if (iframe) { + iframe.removeAttribute("src"); + iframe.hidden = true; + } + if (video) { + video.hidden = false; + attachVideoSource(video, source); + } + } else { + destroyHlsPlayer(); + if (video) { + video.removeAttribute("src"); + video.hidden = true; + video.load(); + } + if (iframe) { + iframe.hidden = false; + if (iframe.src !== embeddedUrl) { + iframe.src = embeddedUrl; + } + } + } + + if (empty) { + empty.hidden = true; + } + + setPanelMessage( + source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道", + ); + updateOpenButton(source); +} + +function resolveInitialSourceId() { + if (findSourceById(currentSourceId)) { + return currentSourceId; + } + return tvPayload?.selected_source?.id || tvPayload?.default_source_id || tvPayload?.sources?.[0]?.id || ""; +} + +function renderPanel() { + renderSourceOptions(); + currentSourceId = resolveInitialSourceId(); + renderSource(findSourceById(currentSourceId)); +} + +export async function loadTVStreams() { + const response = await fetch(TV_STREAMS_API, { + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error(`Failed to load TV streams: ${response.status}`); + } + tvPayload = await response.json(); + return tvPayload; +} + +export async function refreshTVPanel() { + if (refreshPromise) { + return refreshPromise; + } + + setPanelMessage(TV_STATUS_MESSAGE.syncing); + refreshPromise = (async () => { + try { + await loadTVStreams(); + renderPanel(); + } catch (error) { + console.error("加载电视直播源失败:", error); + setPanelMessage(TV_STATUS_MESSAGE.loadFailed); + renderSource(findSourceById(currentSourceId)); + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; +} + +export async function ensureTVPanelReady() { + if (!initialized) { + initTVPanel(); + } + if (!tvPayload) { + await refreshTVPanel(); + return; + } + renderPanel(); +} + +export function initTVPanel() { + if (initialized) return; + initialized = true; + + const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements(); + + updateToggleButton(!panel?.classList.contains("hud-panel-hidden")); + syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden")); + + toggleBtn?.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true; + setPanelVisible(nextVisible); + if (nextVisible) { + await ensureTVPanelReady(); + showStatusMessage("新闻直播窗口已打开", "info"); + } else { + showStatusMessage("新闻直播窗口已关闭", "info"); + } + }); + + select?.addEventListener("change", (event) => { + const target = event.currentTarget; + if (!(target instanceof HTMLSelectElement)) return; + currentSourceId = target.value; + renderSource(findSourceById(currentSourceId)); + }); + + refreshBtn?.addEventListener("click", () => { + refreshTVPanel(); + }); + + iframe?.addEventListener("load", () => { + if (iframe.hidden) return; + setPanelMessage(TV_STATUS_MESSAGE.iframeReady); + }); + + video?.addEventListener("loadedmetadata", () => { + if (video.hidden) return; + setPanelMessage(TV_STATUS_MESSAGE.videoReady); + }); + + video?.addEventListener("error", () => { + const currentSource = findSourceById(currentSourceId); + if (!showEmbeddedFallback(currentSource)) { + setPanelMessage(TV_STATUS_MESSAGE.videoError); + } + }); + + setupResizeHandle(); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 6d8b0f8a..0e6a8f44 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2315,6 +2315,32 @@ body { max-height: none !important; } +.settings-tv-toolbar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.settings-tv-toolbar__controls { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: flex-end; +} + +.settings-tv-toolbar__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.settings-tv-field { + display: grid; + gap: 8px; +} + diff --git a/frontend/src/pages/Settings/Settings.tsx b/frontend/src/pages/Settings/Settings.tsx index 670f261d..d684dff0 100644 --- a/frontend/src/pages/Settings/Settings.tsx +++ b/frontend/src/pages/Settings/Settings.tsx @@ -55,6 +55,32 @@ interface CollectorSettings { next_run_at: string | null } +interface TVStreamSource { + id: string + name: string + provider: string + region: string + language: string + source_type: 'iframe' | 'hls' | 'video' | 'external' | 'youtube' + embed_url: string + stream_url: string + homepage_url: string + poster_url: string + youtube_video_id: string + youtube_channel: string + is_enabled: boolean + is_fallback: boolean + sort_order: number + collector_source: string | null + notes: string +} + +interface TVSettings { + default_source_id: string + auto_fallback: boolean + sources: TVStreamSource[] +} + function SettingsPanel({ loading, children, @@ -78,6 +104,8 @@ function Settings() { const [systemSettings, setSystemSettings] = useState(null) const [notificationSettings, setNotificationSettings] = useState(null) const [securitySettings, setSecuritySettings] = useState(null) + const [tvSettings, setTvSettings] = useState(null) + const [savingTvSettings, setSavingTvSettings] = useState(false) const collectorTableRegionRef = useRef(null) const [collectorTableHeight, setCollectorTableHeight] = useState(360) const [systemForm] = Form.useForm() @@ -91,6 +119,7 @@ function Settings() { setSystemSettings(response.data.system) setNotificationSettings(response.data.notifications) setSecuritySettings(response.data.security) + setTvSettings(response.data.tv || null) setCollectors(response.data.collectors || []) } catch (error) { message.error('获取系统配置失败') @@ -175,6 +204,100 @@ function Settings() { } } + const updateTvSetting = (field: K, value: TVSettings[K]) => { + setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev)) + } + + const updateTvSourceField = ( + sourceId: string, + field: K, + value: TVStreamSource[K] + ) => { + setTvSettings((prev) => { + if (!prev) return prev + const nextSources = prev.sources.map((source) => { + if (field === 'is_fallback' && value === true) { + return { ...source, is_fallback: source.id === sourceId } + } + if (source.id === sourceId) { + return { ...source, [field]: value } + } + return source + }) + + const nextDefaultSourceId = + field === 'is_enabled' && value === false && prev.default_source_id === sourceId + ? nextSources.find((source) => source.id !== sourceId && source.is_enabled)?.id || '' + : prev.default_source_id + + return { + ...prev, + default_source_id: nextDefaultSourceId, + sources: nextSources, + } + }) + } + + const addTvSource = () => { + setTvSettings((prev) => { + if (!prev) return prev + const nextIndex = prev.sources.length + 1 + const newSource: TVStreamSource = { + id: `manual-tv-${Date.now()}`, + name: `新闻直播源 ${nextIndex}`, + provider: 'Manual', + region: 'Global', + language: 'und', + source_type: 'iframe', + embed_url: '', + stream_url: '', + homepage_url: '', + poster_url: '', + youtube_video_id: '', + youtube_channel: '', + is_enabled: true, + is_fallback: false, + sort_order: nextIndex * 10, + collector_source: null, + notes: '', + } + + return { + ...prev, + sources: [...prev.sources, newSource], + } + }) + } + + const removeTvSource = (sourceId: string) => { + setTvSettings((prev) => { + if (!prev) return prev + const nextSources = prev.sources.filter((source) => source.id !== sourceId) + const nextDefaultSourceId = + prev.default_source_id === sourceId ? nextSources[0]?.id || '' : prev.default_source_id + return { + ...prev, + default_source_id: nextDefaultSourceId, + sources: nextSources, + } + }) + } + + const saveTvSettings = async () => { + if (!tvSettings) return + try { + setSavingTvSettings(true) + await axios.put('/api/v1/settings/tv', tvSettings) + message.success('电视直播配置已保存') + await fetchSettings() + } catch (error) { + message.error('电视直播配置保存失败') + console.error(error) + } finally { + setSavingTvSettings(false) + } + } + const collectorColumns = [ { title: '数据源', @@ -274,6 +397,133 @@ function Settings() { }, ] + const tvSourceColumns = [ + { + title: '频道', + dataIndex: 'name', + key: 'name', + width: 220, + render: (_: string, record: TVStreamSource) => ( +
+ updateTvSourceField(record.id, 'name', event.target.value)} /> + updateTvSourceField(record.id, 'provider', event.target.value)} + /> +
+ ), + }, + { + title: '区域 / 语言', + key: 'locale', + width: 160, + render: (_: unknown, record: TVStreamSource) => ( +
+ updateTvSourceField(record.id, 'region', event.target.value)} + /> + updateTvSourceField(record.id, 'language', event.target.value)} + /> +
+ ), + }, + { + title: '类型', + dataIndex: 'source_type', + key: 'source_type', + width: 120, + render: (value: TVStreamSource['source_type'], record: TVStreamSource) => ( + updateTvSourceField(record.id, 'embed_url', event.target.value)} + /> + updateTvSourceField(record.id, 'stream_url', event.target.value)} + /> + updateTvSourceField(record.id, 'youtube_video_id', event.target.value)} + /> + updateTvSourceField(record.id, 'youtube_channel', event.target.value)} + /> +
+ ), + }, + { + title: '官网', + dataIndex: 'homepage_url', + key: 'homepage_url', + width: 220, + render: (value: string, record: TVStreamSource) => ( + updateTvSourceField(record.id, 'homepage_url', event.target.value)} /> + ), + }, + { + title: '状态', + key: 'status', + width: 110, + render: (_: unknown, record: TVStreamSource) => ( +
+ updateTvSourceField(record.id, 'is_enabled', checked)} /> + updateTvSourceField(record.id, 'is_fallback', checked)} /> +
+ ), + }, + { + title: '备注', + dataIndex: 'notes', + key: 'notes', + width: 220, + render: (value: string, record: TVStreamSource) => ( + updateTvSourceField(record.id, 'notes', event.target.value)} /> + ), + }, + { + title: '操作', + key: 'action', + width: 90, + fixed: 'right' as const, + render: (_: unknown, record: TVStreamSource) => ( + + ), + }, + ] + const tabItems = [ { key: 'system', @@ -356,6 +606,58 @@ function Settings() { ), }, + { + key: 'tv', + label: '电视直播', + children: ( +
+ +
+
+
+
+ 默认直播源 +
+ + + + + ), + }, { key: 'collectors', label: '采集调度', diff --git a/pyproject.toml b/pyproject.toml index 1af60377..257b2443 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.25.3" +version = "0.26.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index fabc823b..e2c3cd02 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.25.3" +version = "0.26.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From b1d06240616d5d664bc7ded336d7dec3adfdf0c0 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Sun, 12 Apr 2026 05:20:23 +0800 Subject: [PATCH 32/34] release: bump version to 0.26.1 --- VERSION | 2 +- docs/CHANGELOG.md | 38 ++- docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/js/tv.js | 97 +++--- .../src/components/AppLayout/AppLayout.tsx | 39 +-- .../src/components/Scrollbar/Scrollbar.tsx | 301 ++++++++++++++++++ frontend/src/index.css | 138 +++++++- pyproject.toml | 2 +- uv.lock | 2 +- 10 files changed, 547 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/Scrollbar/Scrollbar.tsx diff --git a/VERSION b/VERSION index 4e8f395f..30f6cf8d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.26.0 +0.26.1 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0b6b22ce..e66fdf5e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,27 +7,21 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` -## 0.25.3 +## 0.26.1 -Released: 2026-04-11 +Released: 2026-04-12 ### Highlights -- Refined the Earth HUD visual system into a calmer operator-facing style, turned the top-left Earth brand into a real reusable component with language-driven rendering, and cleaned up duplicated brand assets so the page now has a single source of truth for HUD branding. - -### Added - -- Added [frontend/public/earth/js/brand.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/brand.js) as a reusable Earth `brand` component that renders the top-left logo/title/subtitle block from a shared config instead of hardcoding the structure in HTML. -- Added [frontend/public/earth/assets/brand/earth-logo.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/earth-logo.svg), [frontend/public/earth/assets/brand/title-zh.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/title-zh.svg), and [frontend/public/earth/assets/brand/title-en.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/title-en.svg) as the canonical Earth brand asset set. +- Cleaned up the first TV follow-up and replaced the dashboard sidebar's brittle one-off scroll behavior with a reusable `Scrollbar` component, so the new live module code is easier to maintain and the console navigation can scroll without layout jitter. ### Improved -- Improved [frontend/public/earth/css/base.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/base.css), [frontend/public/earth/css/hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css), [frontend/public/earth/css/coordinates-display.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/coordinates-display.css), [frontend/public/earth/css/legend.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/legend.css), [frontend/public/earth/css/earth-stats.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/earth-stats.css), and [frontend/public/earth/css/toolbar.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/toolbar.css) by rebalancing the Earth HUD into a more restrained deep-blue control-room look instead of the earlier over-layered glass-and-neon mix. -- Improved [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js), and [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) by moving the Earth brand mount to a dedicated root and letting `HUD_CONFIG.brandLanguage` choose between `zh` and `en` without embedding the language decision in the DOM. +- Improved [frontend/src/components/Scrollbar/Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/Scrollbar.tsx), [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by extracting the sidebar scrollbar into a dedicated component with explicit `x / y / both` axis support, hidden native scrollbars, compact account/version rows, and a sidebar-only vertical setup instead of the earlier patchwork CSS glued directly onto the layout. ### Fixed -- Fixed [frontend/public/earth/css/info-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/info-panel.css) so the Earth brand area now uses consistent `earth-brand` component selectors and English-specific typography hooks, avoiding the earlier one-off `brand-banner` naming drift and duplicated title styles. +- Fixed [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js) by refactoring repeated iframe/video reset paths into shared helpers, so TV source switching, empty-state fallback, and playback retry handling no longer duplicate cleanup logic across multiple branches. ## 0.26.0 @@ -53,6 +47,28 @@ Released: 2026-04-12 - Fixed [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js) and [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) so HLS/video playback now actively attempts autoplay in the TV panel instead of only loading metadata and leaving the player visually idle. - Fixed [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css) so the TV source selector and action controls better match the Earth HUD dark theme instead of falling back to a bright native dropdown presentation. +## 0.25.3 + +Released: 2026-04-11 + +### Highlights + +- Refined the Earth HUD visual system into a calmer operator-facing style, turned the top-left Earth brand into a real reusable component with language-driven rendering, and cleaned up duplicated brand assets so the page now has a single source of truth for HUD branding. + +### Added + +- Added [frontend/public/earth/js/brand.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/brand.js) as a reusable Earth `brand` component that renders the top-left logo/title/subtitle block from a shared config instead of hardcoding the structure in HTML. +- Added [frontend/public/earth/assets/brand/earth-logo.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/earth-logo.svg), [frontend/public/earth/assets/brand/title-zh.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/title-zh.svg), and [frontend/public/earth/assets/brand/title-en.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/title-en.svg) as the canonical Earth brand asset set. + +### Improved + +- Improved [frontend/public/earth/css/base.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/base.css), [frontend/public/earth/css/hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css), [frontend/public/earth/css/coordinates-display.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/coordinates-display.css), [frontend/public/earth/css/legend.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/legend.css), [frontend/public/earth/css/earth-stats.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/earth-stats.css), and [frontend/public/earth/css/toolbar.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/toolbar.css) by rebalancing the Earth HUD into a more restrained deep-blue control-room look instead of the earlier over-layered glass-and-neon mix. +- Improved [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js), and [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) by moving the Earth brand mount to a dedicated root and letting `HUD_CONFIG.brandLanguage` choose between `zh` and `en` without embedding the language decision in the DOM. + +### Fixed + +- Fixed [frontend/public/earth/css/info-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/info-panel.css) so the Earth brand area now uses consistent `earth-brand` component selectors and English-specific typography hooks, avoiding the earlier one-off `brand-banner` naming drift and duplicated title styles. + ## 0.25.2 Released: 2026-04-10 diff --git a/docs/version-history.md b/docs/version-history.md index 5bb3e5bb..633b4a58 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.26.0` +- `dev` 当前开发分支历史推导到:`0.26.1` ## Timeline @@ -86,6 +86,7 @@ | `0.25.2` | bugfix | `dev` | `pending` | refine Earth settings modal sizing, eliminate first-frame HUD scale flicker, and make dragged HUD panels animate cleanly through maximized layout transitions | | `0.25.3` | bugfix | `dev` | `pending` | refactor the Earth HUD visual system, extract the top-left Earth brand into a reusable language-driven component, and consolidate duplicated brand assets into a single canonical set | | `0.26.0` | feature | `dev` | `pending` | add the Earth TV live module with backend-configurable sources, a draggable/resizable TV HUD window, a first curated news channel catalog, and TV source management hooks in system settings | +| `0.26.1` | bugfix | `dev` | `pending` | extract the dashboard sidebar scrollbar into a reusable component and clean duplicated Earth TV player reset logic after the first live-module rollout | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index bc8aab64..4c79e57d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.26.0", + "version": "0.26.1", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/js/tv.js b/frontend/public/earth/js/tv.js index d0f1fcfb..1b52edcd 100644 --- a/frontend/public/earth/js/tv.js +++ b/frontend/public/earth/js/tv.js @@ -371,6 +371,10 @@ function findSourceById(sourceId) { return tvPayload?.sources?.find((source) => source.id === sourceId) || null; } +function getCurrentSource() { + return findSourceById(currentSourceId); +} + function setPanelMessage(message) { const { status } = getElements(); if (status) { @@ -378,6 +382,50 @@ function setPanelMessage(message) { } } +function resetIframe(iframe) { + if (!(iframe instanceof HTMLIFrameElement)) return; + iframe.removeAttribute("src"); + iframe.hidden = true; +} + +function resetVideo(video) { + if (!(video instanceof HTMLVideoElement)) return; + video.removeAttribute("src"); + video.hidden = true; + video.load(); +} + +function showEmptyState(empty, message) { + if (!(empty instanceof HTMLElement)) return; + empty.hidden = false; + empty.textContent = message; +} + +function hideEmptyState(empty) { + if (empty instanceof HTMLElement) { + empty.hidden = true; + } +} + +function renderVideoSource(video, iframe, source) { + resetIframe(iframe); + if (video instanceof HTMLVideoElement) { + video.hidden = false; + attachVideoSource(video, source); + } +} + +function renderEmbeddedSource(iframe, video, embeddedUrl) { + destroyHlsPlayer(); + resetVideo(video); + if (iframe instanceof HTMLIFrameElement) { + iframe.hidden = false; + if (iframe.src !== embeddedUrl) { + iframe.src = embeddedUrl; + } + } +} + function renderSourceOptions() { const { select } = getElements(); if (!select) return; @@ -429,53 +477,26 @@ function renderSource(source) { if (!source || (!embeddedUrl && !videoUrl)) { destroyHlsPlayer(); - if (iframe) { - iframe.removeAttribute("src"); - iframe.hidden = true; - } - if (video) { - video.removeAttribute("src"); - video.hidden = true; - video.load(); - } - if (empty) { - empty.hidden = false; - empty.textContent = isExternalOnly + resetIframe(iframe); + resetVideo(video); + showEmptyState( + empty, + isExternalOnly ? "当前频道仅支持跳转官网或外部播放器打开。" - : "暂无可播放直播源,请先在系统配置中添加频道。"; - } + : "暂无可播放直播源,请先在系统配置中添加频道。", + ); setPanelMessage(isExternalOnly ? TV_STATUS_MESSAGE.externalOnly : TV_STATUS_MESSAGE.empty); updateOpenButton(source); return; } if (isVideo && videoUrl) { - if (iframe) { - iframe.removeAttribute("src"); - iframe.hidden = true; - } - if (video) { - video.hidden = false; - attachVideoSource(video, source); - } + renderVideoSource(video, iframe, source); } else { - destroyHlsPlayer(); - if (video) { - video.removeAttribute("src"); - video.hidden = true; - video.load(); - } - if (iframe) { - iframe.hidden = false; - if (iframe.src !== embeddedUrl) { - iframe.src = embeddedUrl; - } - } + renderEmbeddedSource(iframe, video, embeddedUrl); } - if (empty) { - empty.hidden = true; - } + hideEmptyState(empty); setPanelMessage( source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道", @@ -586,7 +607,7 @@ export function initTVPanel() { }); video?.addEventListener("error", () => { - const currentSource = findSourceById(currentSourceId); + const currentSource = getCurrentSource(); if (!showEmbeddedFallback(currentSource)) { setPanelMessage(TV_STATUS_MESSAGE.videoError); } diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 79724870..515372dd 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -20,6 +20,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import type { ItemType, MenuItemType } from 'antd/es/menu/interface' import { useAuthStore } from '../../stores/auth' import packageJson from '../../../package.json' +import Scrollbar from '../Scrollbar/Scrollbar' const { Sider, Content } = Layout const { Text } = Typography @@ -117,7 +118,7 @@ function AppLayout({ children }: AppLayoutProps) { className="dashboard-sider" >
-
+
setCollapsed(false) : undefined}>
- { - updateOpenKeys(keys as string[]) - }} - onClick={({ key }) => { - if (key !== selectedKey) { - navigate(key) - } - }} - /> + + { + updateOpenKeys(keys as string[]) + }} + onClick={({ key }) => { + if (key !== selectedKey) { + navigate(key) + } + }} + /> +
{showBanner && !collapsed ? (
-
+
当前账号 {user?.username}
-
+
版本号 {appVersion}
diff --git a/frontend/src/components/Scrollbar/Scrollbar.tsx b/frontend/src/components/Scrollbar/Scrollbar.tsx new file mode 100644 index 00000000..a1f293fe --- /dev/null +++ b/frontend/src/components/Scrollbar/Scrollbar.tsx @@ -0,0 +1,301 @@ +import { + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +type Axis = 'x' | 'y' +type ScrollbarAxis = 'x' | 'y' | 'both' + +interface AxisScrollbarState { + visible: boolean + dragging: boolean + thumbSize: number + thumbOffset: number +} + +interface ScrollbarState { + x: AxisScrollbarState + y: AxisScrollbarState +} + +interface ScrollbarProps { + children: ReactNode + className?: string + minThumbSize?: number + axis?: ScrollbarAxis + suppressScrollX?: boolean + suppressScrollY?: boolean +} + +interface DragState { + axis: Axis + pointerId: number + pointerOffset: number +} + +const INITIAL_AXIS_STATE: AxisScrollbarState = { + visible: false, + dragging: false, + thumbSize: 0, + thumbOffset: 0, +} + +const INITIAL_STATE: ScrollbarState = { + x: INITIAL_AXIS_STATE, + y: INITIAL_AXIS_STATE, +} + +function Scrollbar({ + children, + className = '', + minThumbSize = 28, + axis = 'both', + suppressScrollX = false, + suppressScrollY = false, +}: ScrollbarProps) { + const viewportRef = useRef(null) + const trackXRef = useRef(null) + const trackYRef = useRef(null) + const dragStateRef = useRef(null) + const [scrollbar, setScrollbar] = useState(INITIAL_STATE) + + const updateScrollbar = useCallback(() => { + const viewport = viewportRef.current + const trackX = trackXRef.current + const trackY = trackYRef.current + if (!viewport || !trackX || !trackY) return + + const { + clientWidth, + clientHeight, + scrollWidth, + scrollHeight, + scrollLeft, + scrollTop, + } = viewport + + const enableX = axis === 'x' || axis === 'both' + const enableY = axis === 'y' || axis === 'both' + const hasOverflowX = enableX && !suppressScrollX && scrollWidth - clientWidth > 1 + const hasOverflowY = enableY && !suppressScrollY && scrollHeight - clientHeight > 1 + + const nextX: AxisScrollbarState = hasOverflowX && clientWidth > 0 + ? (() => { + const trackSize = trackX.clientWidth + const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackSize) + const maxThumbOffset = Math.max(0, trackSize - thumbSize) + const scrollRange = Math.max(1, scrollWidth - clientWidth) + return { + visible: true, + dragging: scrollbar.x.dragging && dragStateRef.current?.axis === 'x', + thumbSize, + thumbOffset: (scrollLeft / scrollRange) * maxThumbOffset, + } + })() + : { ...INITIAL_AXIS_STATE } + + const nextY: AxisScrollbarState = hasOverflowY && clientHeight > 0 + ? (() => { + const trackSize = trackY.clientHeight + const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackSize) + const maxThumbOffset = Math.max(0, trackSize - thumbSize) + const scrollRange = Math.max(1, scrollHeight - clientHeight) + return { + visible: true, + dragging: scrollbar.y.dragging && dragStateRef.current?.axis === 'y', + thumbSize, + thumbOffset: (scrollTop / scrollRange) * maxThumbOffset, + } + })() + : { ...INITIAL_AXIS_STATE } + + setScrollbar({ x: nextX, y: nextY }) + }, [minThumbSize, scrollbar.x.dragging, scrollbar.y.dragging, suppressScrollX, suppressScrollY]) + + useEffect(() => { + const viewport = viewportRef.current + const trackX = trackXRef.current + const trackY = trackYRef.current + if (!viewport || !trackX || !trackY) return + + updateScrollbar() + + const resizeObserver = new ResizeObserver(() => { + updateScrollbar() + }) + + resizeObserver.observe(viewport) + resizeObserver.observe(trackX) + resizeObserver.observe(trackY) + Array.from(viewport.children).forEach((child) => resizeObserver.observe(child)) + viewport.addEventListener('scroll', updateScrollbar, { passive: true }) + window.addEventListener('resize', updateScrollbar) + + return () => { + resizeObserver.disconnect() + viewport.removeEventListener('scroll', updateScrollbar) + window.removeEventListener('resize', updateScrollbar) + } + }, [children, updateScrollbar]) + + useEffect(() => { + const stopDragging = () => { + dragStateRef.current = null + document.body.style.userSelect = '' + setScrollbar((previous) => ({ + x: { ...previous.x, dragging: false }, + y: { ...previous.y, dragging: false }, + })) + } + + window.addEventListener('pointerup', stopDragging) + window.addEventListener('pointercancel', stopDragging) + + return () => { + window.removeEventListener('pointerup', stopDragging) + window.removeEventListener('pointercancel', stopDragging) + } + }, []) + + const syncScrollFromThumbOffset = (axis: Axis, nextThumbOffset: number) => { + const viewport = viewportRef.current + const track = axis === 'x' ? trackXRef.current : trackYRef.current + const axisState = scrollbar[axis] + if (!viewport || !track) return + + const trackSize = axis === 'x' ? track.clientWidth : track.clientHeight + const maxThumbOffset = Math.max(0, trackSize - axisState.thumbSize) + if (maxThumbOffset <= 0) { + if (axis === 'x') viewport.scrollLeft = 0 + else viewport.scrollTop = 0 + return + } + + const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset + if (axis === 'x') { + const scrollRange = Math.max(0, viewport.scrollWidth - viewport.clientWidth) + viewport.scrollLeft = progress * scrollRange + return + } + const scrollRange = Math.max(0, viewport.scrollHeight - viewport.clientHeight) + viewport.scrollTop = progress * scrollRange + } + + const handleThumbPointerDown = (axis: Axis) => (event: ReactPointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect() + const axisState = scrollbar[axis] + if (!axisState.visible) return + + dragStateRef.current = { + axis, + pointerId: event.pointerId, + pointerOffset: axis === 'x' ? event.clientX - rect.left : event.clientY - rect.top, + } + + setScrollbar((previous) => ({ + x: { ...previous.x, dragging: axis === 'x' }, + y: { ...previous.y, dragging: axis === 'y' }, + })) + document.body.style.userSelect = 'none' + event.currentTarget.setPointerCapture(event.pointerId) + event.stopPropagation() + event.preventDefault() + } + + const handleThumbPointerMove = (axis: Axis) => (event: ReactPointerEvent) => { + const track = axis === 'x' ? trackXRef.current : trackYRef.current + const dragState = dragStateRef.current + if (!track || !dragState || dragState.pointerId !== event.pointerId || dragState.axis !== axis) return + + const rect = track.getBoundingClientRect() + const nextThumbOffset = axis === 'x' + ? event.clientX - rect.left - dragState.pointerOffset + : event.clientY - rect.top - dragState.pointerOffset + syncScrollFromThumbOffset(axis, nextThumbOffset) + } + + const handleThumbPointerUp = (axis: Axis) => (event: ReactPointerEvent) => { + if (dragStateRef.current?.pointerId !== event.pointerId || dragStateRef.current.axis !== axis) return + dragStateRef.current = null + document.body.style.userSelect = '' + setScrollbar((previous) => ({ + x: { ...previous.x, dragging: false }, + y: { ...previous.y, dragging: false }, + })) + event.currentTarget.releasePointerCapture?.(event.pointerId) + } + + const handleTrackPointerDown = (axis: Axis) => (event: ReactPointerEvent) => { + const axisState = scrollbar[axis] + if (!axisState.visible || event.target !== event.currentTarget) return + + const rect = event.currentTarget.getBoundingClientRect() + const nextThumbOffset = axis === 'x' + ? event.clientX - rect.left - axisState.thumbSize / 2 + : event.clientY - rect.top - axisState.thumbSize / 2 + syncScrollFromThumbOffset(axis, nextThumbOffset) + } + + const showCorner = scrollbar.x.visible && scrollbar.y.visible + const scrollbarClassName = [ + 'scrollbar', + `scrollbar--${axis}`, + className, + ].filter(Boolean).join(' ') + + return ( +
+
+ {children} +
+ + {(axis === 'y' || axis === 'both') ? ( +
+ {scrollbar.y.visible ? ( +
+ ) : null} +
+ ) : null} + + {(axis === 'x' || axis === 'both') ? ( +
+ {scrollbar.x.visible ? ( +
+ ) : null} +
+ ) : null} +
+ ) +} + +export default Scrollbar diff --git a/frontend/src/index.css b/frontend/src/index.css index 0e6a8f44..e72973c6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -55,11 +55,132 @@ body { background: #001529 !important; } +.dashboard-sider.ant-layout-sider-has-trigger { + padding-bottom: 0 !important; +} + .dashboard-sider-inner { height: 100%; display: flex; flex-direction: column; - justify-content: space-between; + min-height: 0; +} + +.dashboard-sider-main { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dashboard-sider-menu-scroll { + flex: 1 1 auto; + min-height: 0; +} + +.dashboard-sider-menu-scroll .scrollbar__viewport > .ant-menu { + width: 100%; + min-width: 0; +} + +.scrollbar { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.scrollbar--y .scrollbar__track--x, +.scrollbar--x .scrollbar__track--y { + display: none; +} + +.scrollbar__viewport { + height: 100%; + overflow: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.scrollbar__viewport::-webkit-scrollbar { + width: 0; + height: 0; +} + +.scrollbar__track { + position: absolute; + z-index: 2; + pointer-events: none; + overflow: visible; +} + +.scrollbar__track--y { + top: 0; + right: 8px; + width: 0; + height: 100%; +} + +.scrollbar__track--x { + left: 0; + bottom: 8px; + width: 100%; + height: 0; +} + +.scrollbar__thumb { + position: absolute; + min-height: 28px; + min-width: 28px; + border-radius: 999px; + background: rgba(196, 208, 224, 0.46); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + opacity: 0; + cursor: pointer; + pointer-events: auto; + transition: opacity 0.18s ease, background 0.18s ease, width 0.18s ease; +} + +.scrollbar__thumb::after { + content: ""; + position: absolute; +} + +.scrollbar__thumb--y { + top: 8px; + right: 0; + width: 6px; + min-width: 6px; +} + +.scrollbar__thumb--y::after { + inset: 0 -6px; +} + +.scrollbar__thumb--x { + left: 8px; + bottom: 0; + height: 6px; + min-height: 6px; +} + +.scrollbar__thumb--x::after { + inset: -6px 0; +} + +.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb, +.scrollbar__track--visible .scrollbar__thumb, +.scrollbar__track--dragging .scrollbar__thumb { + opacity: 1; +} + +.scrollbar__thumb:hover { + background: rgba(216, 226, 240, 0.68); +} + +.scrollbar__track--dragging .scrollbar__thumb { + background: rgba(226, 235, 246, 0.82); } .dashboard-brand { @@ -106,24 +227,31 @@ body { } .dashboard-sider-banner { - margin: 12px; + flex: 0 0 auto; + margin: 12px 12px 0; padding: 14px 12px; border-radius: 14px; background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08); } +.dashboard-sider-banner-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + .dashboard-sider-banner-label { - display: block; - margin-bottom: 4px; color: rgba(255, 255, 255, 0.62); font-size: 12px; + white-space: nowrap; } .dashboard-sider-banner-value { - display: block; color: white !important; font-size: 14px; + text-align: right; } .dashboard-sider-logout { diff --git a/pyproject.toml b/pyproject.toml index 257b2443..8341bf27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.26.0" +version = "0.26.1" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index e2c3cd02..83aca449 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.26.0" +version = "0.26.1" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From 2ee4773f4faa7c2951f67be3b134ef8831c41b6d Mon Sep 17 00:00:00 2001 From: rayd1o Date: Sun, 12 Apr 2026 09:27:01 +0800 Subject: [PATCH 33/34] release: bump version to 0.26.2 --- VERSION | 2 +- docs/CHANGELOG.md | 20 ++ docs/version-history.md | 3 +- frontend/package.json | 2 +- .../src/components/AppLayout/AppLayout.tsx | 2 +- .../src/components/Scrollbar/Scrollbar.tsx | 184 +++++----- .../components/Scrollbar/ScrollbarOverlay.tsx | 327 ++++++++++++++++++ frontend/src/index.css | 86 ++--- .../src/pages/DataSources/DataSources.tsx | 3 + pyproject.toml | 2 +- uv.lock | 2 +- 11 files changed, 494 insertions(+), 139 deletions(-) create mode 100644 frontend/src/components/Scrollbar/ScrollbarOverlay.tsx diff --git a/VERSION b/VERSION index 30f6cf8d..894542aa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.26.1 +0.26.2 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e66fdf5e..122f5ec9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -23,6 +23,26 @@ Released: 2026-04-12 - Fixed [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js) by refactoring repeated iframe/video reset paths into shared helpers, so TV source switching, empty-state fallback, and playback retry handling no longer duplicate cleanup logic across multiple branches. +## 0.26.2 + +Released: 2026-04-12 + +### Highlights + +- Stabilized the new reusable scrollbar work by restoring reliable sidebar visibility and extending the same floating scrollbar language to the Data Sources tables without letting scrollbars squeeze layout width or regress the console navigation. + +### Added + +- Added [frontend/src/components/Scrollbar/ScrollbarOverlay.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx) as an overlay variant that binds to existing scroll containers such as Ant Table bodies, so heavy data grids can adopt the new scrollbar visuals without replacing their built-in scrolling mechanics. + +### Improved + +- Improved [frontend/src/components/Scrollbar/Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/Scrollbar.tsx), [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by reverting the sidebar to a reliable vertical-first scrollbar path, then reintroducing automatic dual-axis support with independent floating tracks that no longer hide the thumb when only the sidebar needs vertical scrolling. + +### Fixed + +- Fixed [frontend/src/pages/DataSources/DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) so the built-in and custom datasource tables now use the new overlay scrollbar instead of native table scrollbars, keeping horizontal and vertical scrolling available without changing Ant Table’s internal layout behavior. + ## 0.26.0 Released: 2026-04-12 diff --git a/docs/version-history.md b/docs/version-history.md index 633b4a58..566fafe2 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.26.1` +- `dev` 当前开发分支历史推导到:`0.26.2` ## Timeline @@ -87,6 +87,7 @@ | `0.25.3` | bugfix | `dev` | `pending` | refactor the Earth HUD visual system, extract the top-left Earth brand into a reusable language-driven component, and consolidate duplicated brand assets into a single canonical set | | `0.26.0` | feature | `dev` | `pending` | add the Earth TV live module with backend-configurable sources, a draggable/resizable TV HUD window, a first curated news channel catalog, and TV source management hooks in system settings | | `0.26.1` | bugfix | `dev` | `pending` | extract the dashboard sidebar scrollbar into a reusable component and clean duplicated Earth TV player reset logic after the first live-module rollout | +| `0.26.2` | bugfix | `dev` | `pending` | stabilize the reusable sidebar scrollbar, restore automatic dual-axis floating tracks safely, and apply the same overlay scrollbar system to datasource tables | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 4c79e57d..3d6e099c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.26.1", + "version": "0.26.2", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 515372dd..c0daa9f3 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -133,7 +133,7 @@ function AppLayout({ children }: AppLayoutProps) { 智能星球 ) : null}
- + (null) const trackXRef = useRef(null) @@ -78,35 +71,31 @@ function Scrollbar({ scrollTop, } = viewport - const enableX = axis === 'x' || axis === 'both' - const enableY = axis === 'y' || axis === 'both' - const hasOverflowX = enableX && !suppressScrollX && scrollWidth - clientWidth > 1 - const hasOverflowY = enableY && !suppressScrollY && scrollHeight - clientHeight > 1 + const hasOverflowX = scrollWidth - clientWidth > 1 + const hasOverflowY = scrollHeight - clientHeight > 1 - const nextX: AxisScrollbarState = hasOverflowX && clientWidth > 0 + const nextX: AxisState = hasOverflowX && clientWidth > 0 && trackX.clientWidth > 0 ? (() => { - const trackSize = trackX.clientWidth - const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackSize) - const maxThumbOffset = Math.max(0, trackSize - thumbSize) + const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackX.clientWidth) + const maxThumbOffset = Math.max(0, trackX.clientWidth - thumbSize) const scrollRange = Math.max(1, scrollWidth - clientWidth) return { visible: true, - dragging: scrollbar.x.dragging && dragStateRef.current?.axis === 'x', + dragging: dragStateRef.current?.axis === 'x', thumbSize, thumbOffset: (scrollLeft / scrollRange) * maxThumbOffset, } })() : { ...INITIAL_AXIS_STATE } - const nextY: AxisScrollbarState = hasOverflowY && clientHeight > 0 + const nextY: AxisState = hasOverflowY && clientHeight > 0 && trackY.clientHeight > 0 ? (() => { - const trackSize = trackY.clientHeight - const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackSize) - const maxThumbOffset = Math.max(0, trackSize - thumbSize) + const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackY.clientHeight) + const maxThumbOffset = Math.max(0, trackY.clientHeight - thumbSize) const scrollRange = Math.max(1, scrollHeight - clientHeight) return { visible: true, - dragging: scrollbar.y.dragging && dragStateRef.current?.axis === 'y', + dragging: dragStateRef.current?.axis === 'y', thumbSize, thumbOffset: (scrollTop / scrollRange) * maxThumbOffset, } @@ -114,7 +103,7 @@ function Scrollbar({ : { ...INITIAL_AXIS_STATE } setScrollbar({ x: nextX, y: nextY }) - }, [minThumbSize, scrollbar.x.dragging, scrollbar.y.dragging, suppressScrollX, suppressScrollY]) + }, [minThumbSize]) useEffect(() => { const viewport = viewportRef.current @@ -122,23 +111,45 @@ function Scrollbar({ const trackY = trackYRef.current if (!viewport || !trackX || !trackY) return + let rafId = 0 + const scheduleUpdate = () => { + cancelAnimationFrame(rafId) + rafId = window.requestAnimationFrame(() => { + updateScrollbar() + }) + } + updateScrollbar() + scheduleUpdate() const resizeObserver = new ResizeObserver(() => { - updateScrollbar() + scheduleUpdate() + }) + const mutationObserver = new MutationObserver(() => { + scheduleUpdate() }) resizeObserver.observe(viewport) resizeObserver.observe(trackX) resizeObserver.observe(trackY) Array.from(viewport.children).forEach((child) => resizeObserver.observe(child)) - viewport.addEventListener('scroll', updateScrollbar, { passive: true }) - window.addEventListener('resize', updateScrollbar) + + mutationObserver.observe(viewport, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }) + + viewport.addEventListener('scroll', scheduleUpdate, { passive: true }) + window.addEventListener('resize', scheduleUpdate) return () => { + cancelAnimationFrame(rafId) resizeObserver.disconnect() - viewport.removeEventListener('scroll', updateScrollbar) - window.removeEventListener('resize', updateScrollbar) + mutationObserver.disconnect() + viewport.removeEventListener('scroll', scheduleUpdate) + window.removeEventListener('resize', scheduleUpdate) } }, [children, updateScrollbar]) @@ -177,19 +188,16 @@ function Scrollbar({ const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset if (axis === 'x') { - const scrollRange = Math.max(0, viewport.scrollWidth - viewport.clientWidth) - viewport.scrollLeft = progress * scrollRange + viewport.scrollLeft = progress * Math.max(0, viewport.scrollWidth - viewport.clientWidth) return } - const scrollRange = Math.max(0, viewport.scrollHeight - viewport.clientHeight) - viewport.scrollTop = progress * scrollRange + viewport.scrollTop = progress * Math.max(0, viewport.scrollHeight - viewport.clientHeight) } const handleThumbPointerDown = (axis: Axis) => (event: ReactPointerEvent) => { - const rect = event.currentTarget.getBoundingClientRect() - const axisState = scrollbar[axis] - if (!axisState.visible) return + if (!scrollbar[axis].visible) return + const rect = event.currentTarget.getBoundingClientRect() dragStateRef.current = { axis, pointerId: event.pointerId, @@ -207,9 +215,9 @@ function Scrollbar({ } const handleThumbPointerMove = (axis: Axis) => (event: ReactPointerEvent) => { - const track = axis === 'x' ? trackXRef.current : trackYRef.current const dragState = dragStateRef.current - if (!track || !dragState || dragState.pointerId !== event.pointerId || dragState.axis !== axis) return + const track = axis === 'x' ? trackXRef.current : trackYRef.current + if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId || !track) return const rect = track.getBoundingClientRect() const nextThumbOffset = axis === 'x' @@ -219,7 +227,9 @@ function Scrollbar({ } const handleThumbPointerUp = (axis: Axis) => (event: ReactPointerEvent) => { - if (dragStateRef.current?.pointerId !== event.pointerId || dragStateRef.current.axis !== axis) return + const dragState = dragStateRef.current + if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId) return + dragStateRef.current = null document.body.style.userSelect = '' setScrollbar((previous) => ({ @@ -230,70 +240,60 @@ function Scrollbar({ } const handleTrackPointerDown = (axis: Axis) => (event: ReactPointerEvent) => { - const axisState = scrollbar[axis] - if (!axisState.visible || event.target !== event.currentTarget) return + if (!scrollbar[axis].visible || event.target !== event.currentTarget) return const rect = event.currentTarget.getBoundingClientRect() const nextThumbOffset = axis === 'x' - ? event.clientX - rect.left - axisState.thumbSize / 2 - : event.clientY - rect.top - axisState.thumbSize / 2 + ? event.clientX - rect.left - scrollbar.x.thumbSize / 2 + : event.clientY - rect.top - scrollbar.y.thumbSize / 2 syncScrollFromThumbOffset(axis, nextThumbOffset) } - const showCorner = scrollbar.x.visible && scrollbar.y.visible - const scrollbarClassName = [ - 'scrollbar', - `scrollbar--${axis}`, - className, - ].filter(Boolean).join(' ') + const hasCorner = scrollbar.x.visible && scrollbar.y.visible return ( -
+
{children}
- {(axis === 'y' || axis === 'both') ? ( -
- {scrollbar.y.visible ? ( -
- ) : null} -
- ) : null} +
+ {scrollbar.y.visible ? ( +
+ ) : null} +
- {(axis === 'x' || axis === 'both') ? ( -
- {scrollbar.x.visible ? ( -
- ) : null} -
- ) : null} +
+ {scrollbar.x.visible ? ( +
+ ) : null} +
) } diff --git a/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx b/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx new file mode 100644 index 00000000..a7b0f23e --- /dev/null +++ b/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx @@ -0,0 +1,327 @@ +import { + type PointerEvent as ReactPointerEvent, + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +type Axis = 'x' | 'y' + +interface AxisState { + visible: boolean + dragging: boolean + thumbSize: number + thumbOffset: number +} + +interface ScrollbarState { + x: AxisState + y: AxisState +} + +interface ScrollbarOverlayProps { + containerRef: RefObject + targetSelector: string + minThumbSize?: number +} + +interface DragState { + axis: Axis + pointerId: number + pointerOffset: number +} + +const INITIAL_AXIS_STATE: AxisState = { + visible: false, + dragging: false, + thumbSize: 0, + thumbOffset: 0, +} + +const INITIAL_STATE: ScrollbarState = { + x: INITIAL_AXIS_STATE, + y: INITIAL_AXIS_STATE, +} + +function ScrollbarOverlay({ + containerRef, + targetSelector, + minThumbSize = 28, +}: ScrollbarOverlayProps) { + const trackXRef = useRef(null) + const trackYRef = useRef(null) + const dragStateRef = useRef(null) + const targetRef = useRef(null) + const [scrollbar, setScrollbar] = useState(INITIAL_STATE) + + const bindTarget = useCallback(() => { + const container = containerRef.current + if (!container) { + targetRef.current = null + return null + } + + const target = container.querySelector(targetSelector) + targetRef.current = target + return target + }, [containerRef, targetSelector]) + + const updateScrollbar = useCallback(() => { + const target = targetRef.current ?? bindTarget() + const trackX = trackXRef.current + const trackY = trackYRef.current + if (!target || !trackX || !trackY) return + + const { + clientWidth, + clientHeight, + scrollWidth, + scrollHeight, + scrollLeft, + scrollTop, + } = target + + const hasOverflowX = scrollWidth - clientWidth > 1 + const hasOverflowY = scrollHeight - clientHeight > 1 + + const nextX: AxisState = hasOverflowX && clientWidth > 0 && trackX.clientWidth > 0 + ? (() => { + const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackX.clientWidth) + const maxThumbOffset = Math.max(0, trackX.clientWidth - thumbSize) + const scrollRange = Math.max(1, scrollWidth - clientWidth) + return { + visible: true, + dragging: dragStateRef.current?.axis === 'x', + thumbSize, + thumbOffset: (scrollLeft / scrollRange) * maxThumbOffset, + } + })() + : { ...INITIAL_AXIS_STATE } + + const nextY: AxisState = hasOverflowY && clientHeight > 0 && trackY.clientHeight > 0 + ? (() => { + const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackY.clientHeight) + const maxThumbOffset = Math.max(0, trackY.clientHeight - thumbSize) + const scrollRange = Math.max(1, scrollHeight - clientHeight) + return { + visible: true, + dragging: dragStateRef.current?.axis === 'y', + thumbSize, + thumbOffset: (scrollTop / scrollRange) * maxThumbOffset, + } + })() + : { ...INITIAL_AXIS_STATE } + + setScrollbar({ x: nextX, y: nextY }) + }, [bindTarget, minThumbSize]) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + let cleanupTargetScroll: (() => void) | null = null + let resizeObserver: ResizeObserver | null = null + let mutationObserver: MutationObserver | null = null + let rafId = 0 + + const scheduleUpdate = () => { + cancelAnimationFrame(rafId) + rafId = window.requestAnimationFrame(() => { + updateScrollbar() + }) + } + + const attachTarget = () => { + cleanupTargetScroll?.() + resizeObserver?.disconnect() + + const target = bindTarget() + if (!target) { + targetRef.current = null + return + } + + const handleScroll = () => { + scheduleUpdate() + } + + target.addEventListener('scroll', handleScroll, { passive: true }) + cleanupTargetScroll = () => { + target.removeEventListener('scroll', handleScroll) + } + + resizeObserver = new ResizeObserver(() => { + scheduleUpdate() + }) + resizeObserver.observe(target) + Array.from(target.children).forEach((child) => resizeObserver?.observe(child)) + scheduleUpdate() + } + + attachTarget() + + mutationObserver = new MutationObserver(() => { + attachTarget() + scheduleUpdate() + }) + mutationObserver.observe(container, { + childList: true, + subtree: true, + attributes: true, + }) + + window.addEventListener('resize', scheduleUpdate) + + return () => { + cancelAnimationFrame(rafId) + cleanupTargetScroll?.() + resizeObserver?.disconnect() + mutationObserver?.disconnect() + window.removeEventListener('resize', scheduleUpdate) + } + }, [bindTarget, containerRef, updateScrollbar]) + + useEffect(() => { + const stopDragging = () => { + dragStateRef.current = null + document.body.style.userSelect = '' + setScrollbar((previous) => ({ + x: { ...previous.x, dragging: false }, + y: { ...previous.y, dragging: false }, + })) + } + + window.addEventListener('pointerup', stopDragging) + window.addEventListener('pointercancel', stopDragging) + + return () => { + window.removeEventListener('pointerup', stopDragging) + window.removeEventListener('pointercancel', stopDragging) + } + }, []) + + const syncScrollFromThumbOffset = (axis: Axis, nextThumbOffset: number) => { + const target = targetRef.current + const track = axis === 'x' ? trackXRef.current : trackYRef.current + const axisState = scrollbar[axis] + if (!target || !track) return + + const trackSize = axis === 'x' ? track.clientWidth : track.clientHeight + const maxThumbOffset = Math.max(0, trackSize - axisState.thumbSize) + if (maxThumbOffset <= 0) { + if (axis === 'x') target.scrollLeft = 0 + else target.scrollTop = 0 + return + } + + const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset + if (axis === 'x') { + target.scrollLeft = progress * Math.max(0, target.scrollWidth - target.clientWidth) + return + } + target.scrollTop = progress * Math.max(0, target.scrollHeight - target.clientHeight) + } + + const handleThumbPointerDown = (axis: Axis) => (event: ReactPointerEvent) => { + if (!scrollbar[axis].visible) return + + const rect = event.currentTarget.getBoundingClientRect() + dragStateRef.current = { + axis, + pointerId: event.pointerId, + pointerOffset: axis === 'x' ? event.clientX - rect.left : event.clientY - rect.top, + } + + setScrollbar((previous) => ({ + x: { ...previous.x, dragging: axis === 'x' }, + y: { ...previous.y, dragging: axis === 'y' }, + })) + document.body.style.userSelect = 'none' + event.currentTarget.setPointerCapture(event.pointerId) + event.stopPropagation() + event.preventDefault() + } + + const handleThumbPointerMove = (axis: Axis) => (event: ReactPointerEvent) => { + const dragState = dragStateRef.current + const track = axis === 'x' ? trackXRef.current : trackYRef.current + if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId || !track) return + + const rect = track.getBoundingClientRect() + const nextThumbOffset = axis === 'x' + ? event.clientX - rect.left - dragState.pointerOffset + : event.clientY - rect.top - dragState.pointerOffset + syncScrollFromThumbOffset(axis, nextThumbOffset) + } + + const handleThumbPointerUp = (axis: Axis) => (event: ReactPointerEvent) => { + const dragState = dragStateRef.current + if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId) return + + dragStateRef.current = null + document.body.style.userSelect = '' + setScrollbar((previous) => ({ + x: { ...previous.x, dragging: false }, + y: { ...previous.y, dragging: false }, + })) + event.currentTarget.releasePointerCapture?.(event.pointerId) + } + + const handleTrackPointerDown = (axis: Axis) => (event: ReactPointerEvent) => { + if (!scrollbar[axis].visible || event.target !== event.currentTarget) return + + const rect = event.currentTarget.getBoundingClientRect() + const nextThumbOffset = axis === 'x' + ? event.clientX - rect.left - scrollbar.x.thumbSize / 2 + : event.clientY - rect.top - scrollbar.y.thumbSize / 2 + syncScrollFromThumbOffset(axis, nextThumbOffset) + } + + const hasCorner = scrollbar.x.visible && scrollbar.y.visible + + return ( +
+
+ {scrollbar.y.visible ? ( +
+ ) : null} +
+ +
+ {scrollbar.x.visible ? ( +
+ ) : null} +
+
+ ) +} + +export default ScrollbarOverlay diff --git a/frontend/src/index.css b/frontend/src/index.css index e72973c6..c706dd9a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -91,11 +91,6 @@ body { overflow: hidden; } -.scrollbar--y .scrollbar__track--x, -.scrollbar--x .scrollbar__track--y { - display: none; -} - .scrollbar__viewport { height: 100%; overflow: auto; @@ -117,29 +112,35 @@ body { .scrollbar__track--y { top: 0; - right: 8px; - width: 0; + right: 0; + width: 12px; height: 100%; } .scrollbar__track--x { left: 0; - bottom: 8px; + bottom: 0; width: 100%; - height: 0; + height: 12px; +} + +.scrollbar__track--y.scrollbar__track--has-corner { + height: calc(100% - 12px); +} + +.scrollbar__track--x.scrollbar__track--has-corner { + width: calc(100% - 12px); } .scrollbar__thumb { position: absolute; - min-height: 28px; - min-width: 28px; border-radius: 999px; background: rgba(196, 208, 224, 0.46); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); opacity: 0; cursor: pointer; pointer-events: auto; - transition: opacity 0.18s ease, background 0.18s ease, width 0.18s ease; + transition: opacity 0.18s ease, background 0.18s ease; } .scrollbar__thumb::after { @@ -149,24 +150,26 @@ body { .scrollbar__thumb--y { top: 8px; - right: 0; - width: 6px; - min-width: 6px; + right: 3px; + width: 4px; + min-width: 4px; + min-height: 28px; } .scrollbar__thumb--y::after { - inset: 0 -6px; + inset: 0 -4px; } .scrollbar__thumb--x { left: 8px; - bottom: 0; - height: 6px; - min-height: 6px; + bottom: 3px; + height: 4px; + min-height: 4px; + min-width: 28px; } .scrollbar__thumb--x::after { - inset: -6px 0; + inset: -4px 0; } .scrollbar:hover .scrollbar__track--visible .scrollbar__thumb, @@ -175,6 +178,10 @@ body { opacity: 1; } +.scrollbar__track--visible { + pointer-events: auto; +} + .scrollbar__thumb:hover { background: rgba(216, 226, 240, 0.68); } @@ -1555,6 +1562,7 @@ body { } .table-scroll-region { + position: relative; width: 100%; max-width: 100%; min-width: 0; @@ -1575,35 +1583,31 @@ body { padding: 10px 12px !important; } -.table-scroll-region .ant-table-body, -.table-scroll-region .ant-table-content { - scrollbar-width: thin; - scrollbar-color: rgba(148, 163, 184, 0.88) transparent; +.data-source-table-region .ant-table-body, +.data-source-table-region .ant-table-content { + scrollbar-width: none; + -ms-overflow-style: none; } -.table-scroll-region .ant-table-body::-webkit-scrollbar, -.table-scroll-region .ant-table-content::-webkit-scrollbar { - width: 8px; - height: 8px; +.data-source-table-region .ant-table-body::-webkit-scrollbar, +.data-source-table-region .ant-table-content::-webkit-scrollbar { + width: 0; + height: 0; } -.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb, -.table-scroll-region .ant-table-content::-webkit-scrollbar-thumb { - background: rgba(148, 163, 184, 0.82); - border-radius: 999px; - border: 2px solid transparent; - background-clip: padding-box; +.scrollbar--overlay { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; } -.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb:hover, -.table-scroll-region .ant-table-content::-webkit-scrollbar-thumb:hover { - background: rgba(100, 116, 139, 0.9); - background-clip: padding-box; +.scrollbar--overlay .scrollbar__track--y { + right: 2px; } -.table-scroll-region .ant-table-body::-webkit-scrollbar-track, -.table-scroll-region .ant-table-content::-webkit-scrollbar-track { - background: transparent; +.scrollbar--overlay .scrollbar__track--x { + bottom: 2px; } .bgp-page { diff --git a/frontend/src/pages/DataSources/DataSources.tsx b/frontend/src/pages/DataSources/DataSources.tsx index 9a667978..6c07933b 100644 --- a/frontend/src/pages/DataSources/DataSources.tsx +++ b/frontend/src/pages/DataSources/DataSources.tsx @@ -11,6 +11,7 @@ import { } from '@ant-design/icons' import axios, { type AxiosResponse } from 'axios' import AppLayout from '../../components/AppLayout/AppLayout' +import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay' import { formatDateTimeZhCN } from '../../utils/datetime' import { useWebSocket } from '../../hooks/useWebSocket' @@ -1050,6 +1051,7 @@ function DataSources() { tableLayout="fixed" size="small" /> +
), @@ -1084,6 +1086,7 @@ function DataSources() { tableLayout="fixed" size="small" /> +
)}
diff --git a/pyproject.toml b/pyproject.toml index 8341bf27..55681f9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.26.1" +version = "0.26.2" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 83aca449..3ffd1d32 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.26.1" +version = "0.26.2" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, From 7cd29cf9c023ab58ed0c855514218c7fd85470b8 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Tue, 14 Apr 2026 07:37:45 +0800 Subject: [PATCH 34/34] release: bump version to 0.27.0 Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- backend/app/services/tv_streams.py | 2 +- docs/CHANGELOG.md | 25 ++ docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/earth-stats.css | 135 ++++++++- frontend/public/earth/css/hud.css | 35 +-- frontend/public/earth/css/info-panel.css | 221 ++++++++------- frontend/public/earth/css/layer-panel.css | 260 +++++++++++++++++ frontend/public/earth/css/legend.css | 214 ++++++++++---- frontend/public/earth/css/toolbar.css | 103 ++++++- frontend/public/earth/css/tv-panel.css | 61 ++-- frontend/public/earth/index.html | 330 ++++++++++++---------- frontend/public/earth/js/brand.js | 4 + frontend/public/earth/js/cables.js | 4 +- frontend/public/earth/js/constants.js | 35 +++ frontend/public/earth/js/controls.js | 132 ++++++++- frontend/public/earth/js/earth.js | 197 ++++++++----- frontend/public/earth/js/info-card.js | 65 ++++- frontend/public/earth/js/legend.js | 65 +++-- frontend/public/earth/js/main.js | 294 +++++++++++-------- frontend/public/earth/js/tv.js | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 24 files changed, 1565 insertions(+), 630 deletions(-) create mode 100644 frontend/public/earth/css/layer-panel.css diff --git a/VERSION b/VERSION index 894542aa..1b58cc10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.26.2 +0.27.0 diff --git a/backend/app/services/tv_streams.py b/backend/app/services/tv_streams.py index d5fcd7c5..7e28b3e1 100644 --- a/backend/app/services/tv_streams.py +++ b/backend/app/services/tv_streams.py @@ -21,7 +21,7 @@ DEFAULT_TV_SETTINGS = { "auto_fallback": True, "sources": [ { - "id": DEFAULT_TV_SOURCE_ID, + "id": "cctv4", "name": "CCTV-4 中文国际", "provider": "CCTV", "region": "China", diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 122f5ec9..180543e6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,31 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.27.0 + +Released: 2026-04-14 + +### Highlights + +- 全面重构 Earth HUD 布局:品牌面板、图层控制面板、信息详情卡片各自独立,支持拖拽与折叠,信息卡片改为跟随点击位置悬浮显示。 +- 新增图层控制面板(Layer Panel),海缆、卫星、地形、BGP 等图层集中管理,支持关键字搜索过滤。 +- Earth 大气层渲染升级,引入 Fresnel 着色器双层辉光效果和深度遮挡球体。 + +### ✨ Features + +- 新增独立 Layer Panel(`js/controls.js`, `css/layer-panel.css`),图层开关、搜索过滤、折叠收起,取代原工具栏弹出菜单 +- 信息详情面板(info-panel)改为点击时定位到鼠标附近(`js/info-card.js`),悬停改为轻量 tooltip,降低视觉干扰 +- Earth 材质重构(`js/earth.js`, `js/constants.js`):新增 `EARTH_MATERIAL_CONFIG`,Fresnel 内外大气层辉光、深度遮挡球体,纹理加载独立为 `loadEarthTexture()` + +### 🔧 Improvements + +- Earth Stats 面板改为 2 列 KPI 网格布局,支持拖拽和关闭(`css/earth-stats.css`) +- 数据加载改为分步串行(登陆点 → 海缆 → 卫星 → BGP → 纹理),每步之间 yield 帧,改善视觉渐现体验(`js/main.js`) +- 图层按钮状态更新逻辑统一至 `updateLayerButtonState()`,消除重复实现 +- 海缆状态识别新增 `active` 枚举值(兼容旧 `In Service`) + +--- + ## 0.26.1 Released: 2026-04-12 diff --git a/docs/version-history.md b/docs/version-history.md index 566fafe2..e1cf4469 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.26.2` +- `dev` 当前开发分支历史推导到:`0.27.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.27.0` | feature | `dev` | — | Earth HUD 重构:图层面板、信息卡片悬浮定位、Fresnel 大气层渲染 | | `0.0.1-beta` | bootstrap | `main` | `e7033775` | first commit | | `0.1.0` | feature | `main` | `6cb4398f` | Modularize 3D Earth page with ES Modules | | `0.2.0` | feature | `main` | `aaae6a53` | Add cable graph service and data collectors | diff --git a/frontend/package.json b/frontend/package.json index 3d6e099c..9b4570d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.26.2", + "version": "0.27.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/earth-stats.css b/frontend/public/earth/css/earth-stats.css index 60f4e2c8..6affa4c0 100644 --- a/frontend/public/earth/css/earth-stats.css +++ b/frontend/public/earth/css/earth-stats.css @@ -1,26 +1,133 @@ -/* earth-stats */ +/* earth-stats.css — compact KPI grid panel */ .hud-panel-stats { - bottom: var(--hud-offset); + top: var(--hud-offset); right: var(--hud-offset); - border-radius: var(--hud-radius); - padding: var(--hud-panel-padding); - width: calc(264px * var(--hud-scale)); + border-radius: 0; /* square / angular — matches layer panel */ + padding: 0; + width: min(calc(240px * var(--hud-scale)), calc(100vw - 32px)); z-index: 10; - font-size: var(--hud-font-size); + overflow: hidden; } -.hud-panel-stats .stats-item { - margin-bottom: 0; - padding: calc(9px * var(--hud-scale)) 0; - border-bottom: 1px solid rgba(214, 229, 245, 0.06); +/* ── Thin drag bar ────────────────────────────────────────────── */ + +.stats-drag-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: calc(8px * var(--hud-scale)) calc(12px * var(--hud-scale)); + border-bottom: 1px solid var(--hud-line); + cursor: grab; + user-select: none; } -.hud-panel-stats .stats-item:last-child { +.stats-drag-bar:active { + cursor: grabbing; +} + +.stats-kicker { + color: var(--hud-text-soft); + font-size: calc(0.64rem * var(--hud-scale)); + letter-spacing: 0.16em; + text-transform: uppercase; +} + +/* Reuse hud-panel-close — just override size to match kicker line */ +.stats-drag-bar .hud-panel-close { + width: calc(20px * var(--hud-scale)); + height: calc(20px * var(--hud-scale)); + min-width: calc(20px * var(--hud-scale)); +} + +.stats-drag-bar .hud-panel-close .material-symbols-rounded { + font-size: calc(12px * var(--hud-scale)); +} + +/* ── 2-column KPI grid ────────────────────────────────────────── */ + +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; +} + +.stat-cell { + display: flex; + flex-direction: column; + gap: calc(2px * var(--hud-scale)); + padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale)); + border-right: 1px solid var(--hud-line); + border-bottom: 1px solid var(--hud-line); +} + +/* Right column cells: remove right border */ +.stat-cell:nth-child(even) { + border-right: none; +} + +/* Bottom row cells: remove bottom border */ +.stat-cell:nth-last-child(-n+2) { border-bottom: none; - padding-bottom: 0; } -.hud-panel-stats .stats-item:first-of-type { - padding-top: 0; +.stat-num { + color: var(--hud-title); + font-size: calc(1.3rem * var(--hud-scale)); + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +/* Smaller number for multi-word values like "256/314" */ +.stat-num--sm { + font-size: calc(0.94rem * var(--hud-scale)); + font-weight: 600; + letter-spacing: 0; +} + +.stat-label { + color: var(--hud-text-soft); + font-size: calc(0.62rem * var(--hud-scale)); + letter-spacing: 0.1em; + text-transform: uppercase; + line-height: 1.2; +} + +/* ── BGP status footer ────────────────────────────────────────── */ + +.stats-footer { + display: flex; + align-items: center; + gap: calc(6px * var(--hud-scale)); + padding: calc(7px * var(--hud-scale)) calc(12px * var(--hud-scale)); + border-top: 1px solid var(--hud-line); +} + +.stats-footer-dot { + flex-shrink: 0; + width: calc(5px * var(--hud-scale)); + height: calc(5px * var(--hud-scale)); + border-radius: 50%; + background: var(--hud-accent); + box-shadow: 0 0 5px var(--hud-accent); +} + +.stats-footer-text { + color: var(--hud-text-muted); + font-size: calc(0.7rem * var(--hud-scale)); + line-height: 1.3; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Layout-expanded: slide off-screen ───────────────────────── */ + +.earth-app.layout-expanded .hud-panel-stats:not([data-dragged="true"]) { + top: var(--hud-offset); + right: var(--hud-offset); + transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset))); } diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index d10c81e5..38d8ad74 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -94,9 +94,9 @@ height: calc(var(--hud-title-size) * 1.24); min-width: calc(var(--hud-title-size) * 1.24); padding: 0; - border: 1px solid rgba(220, 234, 248, 0.08); - border-radius: 999px; - background: rgba(255, 255, 255, 0.04); + border: 1px solid transparent; + border-radius: calc(4px * var(--hud-scale)); + background: transparent; color: var(--hud-text-muted); display: inline-flex; align-items: center; @@ -119,7 +119,6 @@ background: rgba(255, 255, 255, 0.08); border-color: rgba(225, 239, 255, 0.14); color: var(--hud-accent-strong); - transform: translateY(-1px); } .hud-panel.is-dragging { @@ -453,26 +452,8 @@ } } -.earth-app.layout-expanded .hud-panel-info { - top: var(--hud-offset); - left: var(--hud-offset); - transform: translate(calc(-100% + var(--hud-offset)), calc(-100% + var(--hud-offset))); -} - -.earth-app.layout-expanded .hud-panel-coordinates { - top: var(--hud-offset); - right: var(--hud-offset); - transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset))); -} - -.earth-app.layout-expanded .hud-panel-legend { - left: var(--hud-offset); - bottom: var(--hud-offset); - transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset))); -} - -.earth-app.layout-expanded .hud-panel-stats { - right: var(--hud-offset); - bottom: var(--hud-offset); - transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset))); -} +/* .earth-left-column layout-expanded rule lives in info-panel.css */ +/* .hud-panel-legend layout-expanded rule lives in legend.css */ +/* .hud-panel-stats layout-expanded rule lives in earth-stats.css */ +/* .hud-panel-layers layout-expanded rule lives in layer-panel.css */ +/* .hud-panel-tv layout-expanded rule lives in tv-panel.css */ diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css index e7ba5cb6..05f82979 100644 --- a/frontend/public/earth/css/info-panel.css +++ b/frontend/public/earth/css/info-panel.css @@ -1,146 +1,175 @@ -/* info-panel */ +/* info-panel.css — brand panel + detail card */ -.hud-panel-info { +/* ── Left column wrapper ──────────────────────────────────────── */ + +.earth-left-column { + position: absolute; top: var(--hud-offset); left: var(--hud-offset); - border-radius: var(--hud-radius); - padding: calc(22px * var(--hud-scale)); - width: calc(336px * var(--hud-scale)); + display: flex; + flex-direction: column; z-index: 10; - font-size: var(--hud-font-size); + pointer-events: none; + /* Width is set by the widest child (brand or info card) */ + max-width: min(calc(340px * var(--hud-scale)), calc(100vw - 32px)); } -.hud-panel-info .earth-brand { - margin-bottom: calc(12px * var(--hud-scale)); +.earth-left-column > * { + position: relative; /* override .hud-panel position:absolute */ + pointer-events: auto; + width: 100%; + box-sizing: border-box; +} + +/* ── Brand panel ──────────────────────────────────────────────── */ + +.hud-panel-brand { + border-radius: 0; + padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale)); display: flex; - align-items: flex-start; + align-items: center; + justify-content: center; + /* Reserve full panel height before brand images load */ + min-height: calc(66px * var(--hud-scale)); +} + +.hud-panel-brand .earth-brand { + display: flex; + align-items: center; gap: calc(10px * var(--hud-scale)); min-width: 0; } -.hud-panel-info .earth-brand__logo { +.hud-panel-brand .earth-brand__logo { display: block; flex: 0 0 auto; - width: min(calc(148px * var(--hud-scale)), 41%); - height: auto; + width: calc(128px * var(--hud-scale)); + height: calc(128px * var(--hud-scale)); object-fit: contain; - image-rendering: auto; } -.hud-panel-info .earth-brand__copy { +.hud-panel-brand .earth-brand__copy { display: flex; flex: 1 1 auto; min-width: 0; flex-direction: column; justify-content: center; - gap: calc(8px * var(--hud-scale)); + gap: calc(5px * var(--hud-scale)); } -.hud-panel-info .earth-brand__title { +.hud-panel-brand .earth-brand__title { display: block; - width: min(100%, calc(196px * var(--hud-scale))); + width: min(100%, calc(160px * var(--hud-scale))); max-width: 100%; height: auto; + min-height: calc(20px * var(--hud-scale)); object-fit: contain; - image-rendering: auto; } -.hud-panel-info .earth-brand__meta { - text-align: left; +.hud-panel-brand .earth-brand__meta { display: flex; flex-direction: column; - align-items: flex-start; - gap: calc(4px * var(--hud-scale)); + gap: calc(2px * var(--hud-scale)); + width: fit-content; } -.hud-panel-info .earth-brand__subtitle { +.hud-panel-brand .earth-brand__subtitle { color: var(--hud-text-muted); - font-size: calc(0.84rem * var(--hud-scale)); - line-height: 1.38; + font-size: calc(0.74rem * var(--hud-scale)); + line-height: 1.3; font-weight: 500; - letter-spacing: 0.015em; + letter-spacing: 0.01em; + /* Prevent text from pushing brand wider than logo column */ + width: fit-content; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } -.hud-panel-info .earth-brand__description { +.hud-panel-brand .earth-brand__description { color: var(--hud-text-soft); - font-size: calc(0.66rem * var(--hud-scale)); - line-height: 1.35; + font-size: calc(0.6rem * var(--hud-scale)); + line-height: 1.3; letter-spacing: 0.08em; + width: fit-content; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } -.hud-panel-info .earth-brand--en .earth-brand__logo { - width: min(calc(140px * var(--hud-scale)), 39%); +.hud-panel-brand .earth-brand--en .earth-brand__title { + width: min(100%, calc(172px * var(--hud-scale))); } -.hud-panel-info .earth-brand--en .earth-brand__title { - width: min(100%, calc(208px * var(--hud-scale))); -} - -.hud-panel-info .earth-brand--en .earth-brand__meta { - gap: calc(3px * var(--hud-scale)); -} - -.hud-panel-info .earth-brand--en .earth-brand__subtitle, -.hud-panel-info .earth-brand--en .earth-brand__description { +.hud-panel-brand .earth-brand--en .earth-brand__subtitle, +.hud-panel-brand .earth-brand--en .earth-brand__description { font-family: "Roboto Condensed", "Arial Narrow", "Trebuchet MS", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; } -.hud-panel-info .earth-brand--en .earth-brand__subtitle { - line-height: 1.26; - letter-spacing: 0.01em; -} +/* ── Info detail panel (floating, positioned near click by JS) ── */ -.hud-panel-info .earth-brand--en .earth-brand__description { - line-height: 1.24; - letter-spacing: 0.045em; -} - -/* Info Card - Unified details panel (inside info-panel) */ -.info-card { - display: none; - margin-top: var(--hud-gap-lg); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 48%), - rgba(255, 255, 255, 0.025); - border-radius: calc(18px * var(--hud-scale)); - border: 1px solid rgba(214, 229, 245, 0.08); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.05), - 0 12px 28px rgba(0, 0, 0, 0.14); +.hud-panel-info { + position: absolute; + z-index: 50; + width: min(calc(300px * var(--hud-scale)), calc(100vw - 32px)); + border-radius: 0; padding: 0; overflow: hidden; + /* Start hidden */ + opacity: 0; + transform: scale(0.94) translateY(4px); + pointer-events: none; + transition: + opacity 0.22s ease, + transform 0.22s ease; +} + +.hud-panel-info.is-visible { + opacity: 1; + transform: scale(1) translateY(0); pointer-events: auto; } -.info-card.no-border { - background: transparent; - border: none; +/* ── Info Card ────────────────────────────────────────────────── */ + +.info-card { + display: block; } .info-card-header { display: flex; align-items: center; - padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale)); + padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale)); background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015)); border-bottom: 1px solid var(--hud-line); - gap: var(--hud-gap-sm); + gap: var(--hud-gap-xs); } .info-card-icon { - font-size: calc(18px * var(--hud-scale)); + font-size: calc(16px * var(--hud-scale)); + flex-shrink: 0; } .info-card-header h3 { flex: 1; margin: 0; - font-size: calc(0.96rem * var(--hud-scale)); + font-size: calc(0.92rem * var(--hud-scale)); color: var(--hud-title); font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.info-card-close { + flex-shrink: 0; } .info-card-content { - padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale)); - max-height: 40vh; + padding: calc(8px * var(--hud-scale)) calc(12px * var(--hud-scale)); + max-height: 58vh; overflow-y: auto; scrollbar-width: thin; scrollbar-color: rgba(160, 186, 216, 0.34) transparent; @@ -148,7 +177,7 @@ } .info-card-content::-webkit-scrollbar { - width: 6px; + width: 4px; } .info-card-content::-webkit-scrollbar-track { @@ -160,15 +189,11 @@ border-radius: 999px; } -.info-card-content::-webkit-scrollbar-thumb:hover { - background: linear-gradient(180deg, rgba(226, 236, 246, 0.28), rgba(136, 164, 196, 0.34)); -} - .info-card-property { display: flex; justify-content: space-between; align-items: flex-start; - padding: calc(8px * var(--hud-scale)) 0; + padding: calc(6px * var(--hud-scale)) 0; border-bottom: 1px solid rgba(214, 229, 245, 0.06); pointer-events: auto; gap: var(--hud-gap-sm); @@ -180,10 +205,11 @@ .info-card-label { color: var(--hud-text-soft); - font-size: calc(0.72rem * var(--hud-scale)); + font-size: calc(0.68rem * var(--hud-scale)); letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; + flex-shrink: 0; transition: color 0.18s ease; } @@ -194,35 +220,34 @@ .info-card-value { color: var(--hud-text); font-weight: 600; - font-size: calc(0.88rem * var(--hud-scale)); + font-size: calc(0.82rem * var(--hud-scale)); line-height: 1.45; text-align: right; - max-width: calc(190px * var(--hud-scale)); + max-width: calc(180px * var(--hud-scale)); word-break: break-word; } -/* Cable type */ -.info-card.cable { - border-color: rgba(255, 200, 0, 0.4); -} - +/* Type-specific header accent colors */ .info-card.cable .info-card-header { - background: rgba(255, 200, 0, 0.15); -} - -.info-card.cable .info-card-header h3 { - color: #ffc800; -} - -/* Satellite type */ -.info-card.satellite { - border-color: rgba(0, 229, 255, 0.4); + background: rgba(255, 200, 0, 0.12); + border-bottom-color: rgba(255, 200, 0, 0.15); } +.info-card.cable .info-card-header h3 { color: #ffc800; } .info-card.satellite .info-card-header { - background: rgba(0, 229, 255, 0.15); + background: rgba(0, 229, 255, 0.12); + border-bottom-color: rgba(0, 229, 255, 0.15); } +.info-card.satellite .info-card-header h3 { color: #00e5ff; } -.info-card.satellite .info-card-header h3 { - color: #00e5ff; +.info-card.bgp .info-card-header { + background: rgba(120, 180, 255, 0.12); + border-bottom-color: rgba(120, 180, 255, 0.15); +} +.info-card.bgp .info-card-header h3 { color: var(--hud-accent-strong); } + +/* ── Layout-expanded: slide left column off-screen ────────────── */ + +.earth-app.layout-expanded .earth-left-column { + transform: translate(calc(-100% + var(--hud-offset)), 0); } diff --git a/frontend/public/earth/css/layer-panel.css b/frontend/public/earth/css/layer-panel.css new file mode 100644 index 00000000..1a88dc31 --- /dev/null +++ b/frontend/public/earth/css/layer-panel.css @@ -0,0 +1,260 @@ +/* layer-panel.css — layer toggle panel (below brand, in left column) */ + +.hud-panel-layers { + /* Lives inside .earth-left-column — position is relative via column rule */ + border-radius: 0; + padding: 0; + width: 100%; + z-index: 10; + overflow: hidden; + margin-top: calc(6px * var(--hud-scale)); +} + +/* ── Header / drag handle ─────────────────────────────────────── */ + +.layer-panel-header { + display: flex; + align-items: center; + gap: calc(6px * var(--hud-scale)); + padding: calc(8px * var(--hud-scale)) calc(10px * var(--hud-scale)); + border-bottom: 1px solid var(--hud-line); + cursor: grab; + user-select: none; +} + +.layer-panel-header:active { + cursor: grabbing; +} + +.layer-panel-icon { + flex-shrink: 0; + font-size: calc(15px * var(--hud-scale)); + color: var(--hud-text-soft); + line-height: 1; + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20; + pointer-events: none; +} + +.layer-panel-title { + flex: 1 1 auto; + margin: 0; + color: var(--hud-title); + font-size: calc(0.82rem * var(--hud-scale)); + font-weight: 600; + letter-spacing: 0.04em; + line-height: 1.2; +} + +/* ── Collapse / generic icon button ──────────────────────────── */ + +.layer-panel-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: calc(22px * var(--hud-scale)); + height: calc(22px * var(--hud-scale)); + min-width: calc(22px * var(--hud-scale)); + padding: 0; + border: none; + border-radius: calc(4px * var(--hud-scale)); + background: transparent; + color: var(--hud-text-muted); + cursor: pointer; + flex-shrink: 0; + transition: background 0.14s ease, color 0.14s ease; +} + +.layer-panel-btn:hover { + background: rgba(255, 255, 255, 0.07); + color: var(--hud-text); +} + +.layer-panel-btn .material-symbols-rounded { + font-size: calc(14px * var(--hud-scale)); + line-height: 1; + pointer-events: none; + transition: transform 0.22s ease; +} + +/* Chevron rotates when collapsed */ +.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded { + transform: rotate(180deg); +} + +/* ── Search bar ───────────────────────────────────────────────── */ + +.layer-panel-search { + display: flex; + align-items: center; + gap: calc(5px * var(--hud-scale)); + padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale)); + border-bottom: 1px solid var(--hud-line); +} + +.layer-panel-search-icon { + flex-shrink: 0; + font-size: calc(14px * var(--hud-scale)); + color: var(--hud-text-soft); + line-height: 1; + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20; + pointer-events: none; +} + +.layer-panel-search-input { + flex: 1 1 auto; + min-width: 0; + border: none; + background: transparent; + color: var(--hud-text); + font-size: calc(0.78rem * var(--hud-scale)); + line-height: 1.4; + outline: none; + padding: 0; +} + +.layer-panel-search-input::placeholder { + color: var(--hud-text-soft); +} + +/* ── Empty search state ───────────────────────────────────────── */ + +.layer-panel-empty { + padding: calc(12px * var(--hud-scale)) calc(10px * var(--hud-scale)); + color: var(--hud-text-soft); + font-size: calc(0.78rem * var(--hud-scale)); + text-align: center; +} + +/* ── Collapsible body ─────────────────────────────────────────── */ + +.layer-panel-body { + overflow: hidden; + max-height: 600px; + transition: max-height 0.24s ease, opacity 0.18s ease; + opacity: 1; +} + +.layer-panel--collapsed .layer-panel-body { + max-height: 0; + opacity: 0; +} + +/* ── Layer rows ───────────────────────────────────────────────── */ + +.layer-panel-list { + display: flex; + flex-direction: column; +} + +.layer-row { + display: flex; + align-items: center; + gap: calc(8px * var(--hud-scale)); + padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale)); + border-bottom: 1px solid var(--hud-line); + transition: background 0.14s ease; +} + +.layer-row:last-child { + border-bottom: none; +} + +.layer-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.layer-row-icon { + flex-shrink: 0; + font-size: calc(15px * var(--hud-scale)); + color: var(--hud-text-soft); + line-height: 1; + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20; + transition: color 0.18s ease; +} + +/* Icon brightens when layer is active */ +.layer-row:has(.layer-row-toggle.active) .layer-row-icon { + color: var(--hud-accent); +} + +.layer-row-copy { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: calc(1px * var(--hud-scale)); +} + +.layer-row-label { + color: var(--hud-text); + font-size: calc(0.82rem * var(--hud-scale)); + font-weight: 500; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: color 0.18s ease; +} + +.layer-row:has(.layer-row-toggle.active) .layer-row-label { + color: var(--hud-title); +} + +.layer-row-meta { + color: var(--hud-text-soft); + font-size: calc(0.58rem * var(--hud-scale)); + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1.2; +} + +/* ── Toggle switch ────────────────────────────────────────────── */ + +.layer-row-toggle { + flex-shrink: 0; + position: relative; + width: calc(34px * var(--hud-scale)); + height: calc(20px * var(--hud-scale)); + padding: 0; + border: none; + background: transparent; + cursor: pointer; +} + +.layer-row-toggle-track { + display: block; + position: absolute; + inset: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(215, 229, 242, 0.12); + transition: background 0.18s ease, border-color 0.18s ease; +} + +/* Thumb */ +.layer-row-toggle-track::after { + content: ""; + position: absolute; + top: calc(2px * var(--hud-scale)); + left: calc(2px * var(--hud-scale)); + width: calc(14px * var(--hud-scale)); + height: calc(14px * var(--hud-scale)); + border-radius: 50%; + background: #c8d8ea; + box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3); + transition: transform 0.18s ease, background 0.18s ease; +} + +/* Active (ON) state */ +.layer-row-toggle.active .layer-row-toggle-track { + background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78)); + border-color: rgba(223, 236, 252, 0.28); +} + +.layer-row-toggle.active .layer-row-toggle-track::after { + background: #f0f6ff; + transform: translateX(calc(14px * var(--hud-scale))); +} + +/* Layout-expanded: layer panel slides off with .earth-left-column — no + individual rule needed since the whole column translates together. */ diff --git a/frontend/public/earth/css/legend.css b/frontend/public/earth/css/legend.css index 1c975035..3993eb43 100644 --- a/frontend/public/earth/css/legend.css +++ b/frontend/public/earth/css/legend.css @@ -1,85 +1,177 @@ -/* legend */ +/* legend.css — compact tab-strip legend */ .hud-panel-legend { bottom: var(--hud-offset); left: var(--hud-offset); - border-radius: var(--hud-radius); - padding: var(--hud-panel-padding); - width: calc(236px * var(--hud-scale)); + border-radius: 0; + padding: 0; + width: min(calc(200px * var(--hud-scale)), calc(100vw - 32px)); z-index: 10; - font-size: var(--hud-font-size); + overflow: hidden; } -.hud-panel-legend .legend-title { - margin-bottom: 0; -} +/* ── Drag bar ─────────────────────────────────────────────────── */ -.hud-panel-legend .legend-list { - display: flex; - flex-direction: column; - gap: calc(9px * var(--hud-scale)); - max-height: calc(214px * var(--hud-scale)); - overflow-y: auto; - padding-right: calc(4px * var(--hud-scale)); - scrollbar-width: thin; - scrollbar-color: rgba(160, 186, 216, 0.32) transparent; -} - -.hud-panel-legend .legend-list::-webkit-scrollbar { - width: 6px; -} - -.hud-panel-legend .legend-list::-webkit-scrollbar-track { - background: transparent; -} - -.hud-panel-legend .legend-list::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28)); - border-radius: 999px; -} - -.hud-panel-legend .legend-item { +.legend-bar { display: flex; align-items: center; - gap: calc(10px * var(--hud-scale)); - padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale)); - border-radius: calc(14px * var(--hud-scale)); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent), - rgba(255, 255, 255, 0.018); - border: 1px solid rgba(214, 229, 245, 0.07); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); + justify-content: space-between; + gap: calc(6px * var(--hud-scale)); + padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale)); + border-bottom: 1px solid var(--hud-line); + cursor: grab; + user-select: none; } -.hud-panel-legend .legend-color { - width: calc(16px * var(--hud-scale)); - height: calc(16px * var(--hud-scale)); - border-radius: 999px; - margin-right: 0; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.28), - 0 0 0 3px rgba(255, 255, 255, 0.04); +.legend-bar:active { + cursor: grabbing; } -.hud-panel-legend .legend-item span { +/* ── Mode tabs ────────────────────────────────────────────────── */ + +.legend-tabs { + display: flex; + gap: calc(2px * var(--hud-scale)); + flex: 1 1 auto; + min-width: 0; +} + +.legend-tab { + padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale)); + border-radius: calc(4px * var(--hud-scale)); + border: 1px solid transparent; + background: transparent; + color: var(--hud-text-muted); + font-size: calc(0.68rem * var(--hud-scale)); + font-family: inherit; + letter-spacing: 0.08em; + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease; + white-space: nowrap; +} + +.legend-tab:hover { + background: rgba(255, 255, 255, 0.06); color: var(--hud-text); - font-size: calc(0.84rem * var(--hud-scale)); - font-weight: 500; - line-height: 1.35; } -.legend-color-americas { - background-color: #ff4444; +.legend-tab--active { + background: rgba(120, 180, 255, 0.12); + border-color: rgba(120, 180, 255, 0.2); + color: var(--hud-accent-strong); } -.legend-color-au-a { - background-color: #44ff44; +/* ── Bar action buttons ───────────────────────────────────────── */ + +.legend-bar-actions { + display: flex; + align-items: center; + gap: calc(2px * var(--hud-scale)); + flex-shrink: 0; } -.legend-color-au-b { - background-color: #4444ff; +.legend-bar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: calc(20px * var(--hud-scale)); + height: calc(20px * var(--hud-scale)); + min-width: calc(20px * var(--hud-scale)); + padding: 0; + border: none; + border-radius: calc(4px * var(--hud-scale)); + background: transparent; + color: var(--hud-text-muted); + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease; } -.legend-color-default { - background-color: #ffff44; +.legend-bar-btn:hover { + background: rgba(255, 255, 255, 0.07); + color: var(--hud-text); +} + +.legend-bar-btn .material-symbols-rounded { + font-size: calc(13px * var(--hud-scale)); + line-height: 1; + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20; + pointer-events: none; +} + +/* Collapse chevron */ +#legend-collapse .material-symbols-rounded { + transition: transform 0.22s ease; +} + +.legend--collapsed #legend-collapse .material-symbols-rounded { + transform: rotate(180deg); +} + +/* ── Collapsible list body ────────────────────────────────────── */ + +.legend-body { + max-height: calc(220px * var(--hud-scale)); + overflow: hidden; + transition: + max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.2s ease; + opacity: 1; +} + +.legend--collapsed .legend-body { + max-height: 0; + opacity: 0; + pointer-events: none; +} + +/* ── Item list ────────────────────────────────────────────────── */ + +.legend-list { + display: flex; + flex-direction: column; + padding: calc(4px * var(--hud-scale)) 0; + overflow-y: auto; + max-height: calc(220px * var(--hud-scale)); + scrollbar-width: thin; + scrollbar-color: rgba(160, 186, 216, 0.28) transparent; +} + +.legend-list::-webkit-scrollbar { width: 3px; } +.legend-list::-webkit-scrollbar-track { background: transparent; } +.legend-list::-webkit-scrollbar-thumb { + background: rgba(160, 186, 216, 0.26); + border-radius: 999px; +} + +.legend-item { + display: flex; + align-items: center; + gap: calc(8px * var(--hud-scale)); + padding: calc(5px * var(--hud-scale)) calc(10px * var(--hud-scale)); +} + +.legend-dot { + flex-shrink: 0; + width: calc(7px * var(--hud-scale)); + height: calc(7px * var(--hud-scale)); + border-radius: 50%; + box-shadow: 0 0 4px currentColor; +} + +.legend-label { + color: var(--hud-text); + font-size: calc(0.78rem * var(--hud-scale)); + font-weight: 400; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Layout-expanded ──────────────────────────────────────────── */ + +.earth-app.layout-expanded .hud-panel-legend:not([data-dragged="true"]) { + left: var(--hud-offset); + bottom: var(--hud-offset); + transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset))); } diff --git a/frontend/public/earth/css/toolbar.css b/frontend/public/earth/css/toolbar.css index 104cfdc1..f6a83b82 100644 --- a/frontend/public/earth/css/toolbar.css +++ b/frontend/public/earth/css/toolbar.css @@ -388,20 +388,106 @@ gap: 8px; } -.earth-info-toolbar .earth-toolbar-btn:nth-child(1) { - animation-delay: 0.34s; +.earth-info-toolbar { + width: min(280px, calc(100vw - 36px)); + padding: 12px; + border-radius: 22px; + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.12), transparent 34%), + linear-gradient(180deg, rgba(16, 29, 48, 0.96), rgba(8, 18, 33, 0.94)); + border: 1px solid rgba(211, 228, 246, 0.14); + box-shadow: + 0 20px 40px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + backdrop-filter: blur(18px) saturate(135%); + -webkit-backdrop-filter: blur(18px) saturate(135%); } -.earth-info-toolbar .earth-toolbar-btn:nth-child(2) { - animation-delay: 0.18s; +.earth-layer-toolbar-header { + width: 100%; + padding: 2px 4px 8px; + border-bottom: 1px solid rgba(201, 225, 247, 0.08); + margin-bottom: 2px; } -.earth-info-toolbar .earth-toolbar-btn:nth-child(3) { - animation-delay: 0.1s; +.earth-layer-toolbar-title { + display: block; + color: var(--hud-accent-strong); + font-size: 0.86rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } -.earth-info-toolbar .earth-toolbar-btn:nth-child(4) { - animation-delay: 0s; +.earth-layer-toolbar-subtitle { + display: block; + margin-top: 4px; + color: var(--hud-text-soft); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.earth-layer-btn { + width: 100%; + min-width: 0; + min-height: 52px; + height: auto; + border-radius: 16px; + padding: 12px 14px; + justify-content: space-between; + align-items: center; + gap: 12px; + overflow: hidden; + animation: none; +} + +.earth-layer-btn__copy { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; +} + +.earth-layer-btn__label { + color: var(--hud-text); + font-size: 0.92rem; + font-weight: 600; + line-height: 1.15; +} + +.earth-layer-btn__meta { + color: var(--hud-text-soft); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1.2; +} + +.earth-layer-btn__state { + flex: 0 0 auto; + min-width: 42px; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid rgba(201, 225, 247, 0.12); + color: var(--hud-text-soft); + font-size: 0.67rem; + font-weight: 700; + letter-spacing: 0.12em; + text-align: center; + text-transform: uppercase; + background: rgba(255, 255, 255, 0.04); +} + +.earth-layer-btn.active .earth-layer-btn__state { + color: #dff4ff; + border-color: rgba(220, 240, 255, 0.24); + background: rgba(131, 197, 255, 0.14); +} + +.earth-layer-btn .earth-toolbar-tooltip { + display: none; } .earth-zoom-toolbar .earth-zoom-btn, @@ -458,6 +544,7 @@ border-top-color: rgba(77, 184, 255, 0.4); } + .earth-app.layout-expanded .earth-toolbar-group { bottom: 18px; transform: translateX(-50%); diff --git a/frontend/public/earth/css/tv-panel.css b/frontend/public/earth/css/tv-panel.css index b85b7b0d..73e6b1c7 100644 --- a/frontend/public/earth/css/tv-panel.css +++ b/frontend/public/earth/css/tv-panel.css @@ -1,10 +1,11 @@ /* tv-panel */ .hud-panel-tv { - top: calc(96px * var(--hud-scale)); - right: calc(92px * var(--hud-scale)); - width: min(calc(460px * var(--hud-scale)), calc(100vw - 32px)); - min-width: calc(360px * var(--hud-scale)); + bottom: var(--hud-offset); + right: var(--hud-offset); + width: calc(420px * var(--hud-scale)); + max-width: calc(100vw - 32px); + min-width: calc(300px * var(--hud-scale)); min-height: calc(340px * var(--hud-scale)); padding: calc(18px * var(--hud-scale)); display: flex; @@ -73,6 +74,18 @@ transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; } +.tv-panel-action--icon { + padding: calc(9px * var(--hud-scale)); + border-radius: calc(10px * var(--hud-scale)); +} + +.tv-panel-action--icon .material-symbols-rounded { + font-size: calc(18px * var(--hud-scale)); + line-height: 1; + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20; + pointer-events: none; +} + .tv-panel-action:hover:not(:disabled) { background: rgba(255, 255, 255, 0.08); border-color: rgba(225, 239, 255, 0.2); @@ -184,39 +197,11 @@ user-select: none; } -.earth-app.layout-expanded .hud-panel-tv { - top: calc(50% + 12px); - left: 50%; - right: auto; - width: min(calc(760px * var(--hud-scale)), calc(100vw - 48px)); - min-height: calc(410px * var(--hud-scale)); - transform: translateX(-50%); +.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) { + bottom: var(--hud-offset); + right: var(--hud-offset); + transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset))); } -@media (max-width: 900px) { - .hud-panel-tv { - left: 16px; - right: 16px; - top: auto; - bottom: 16px; - width: auto; - min-width: 0; - min-height: min(48vh, 360px); - } - - .tv-panel-controls { - flex-wrap: wrap; - } - - .tv-panel-actions { - width: 100%; - } - - .tv-panel-action { - flex: 1 1 0; - } - - .tv-panel-resize-handle { - display: none; - } -} +/* TV panel keeps its fixed width on all screen sizes. + Responsive stretching removed — width only changes if user manually resizes. */ diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 8cd83cb8..267833d0 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -32,10 +32,10 @@ - + @@ -50,17 +50,111 @@
-
-
- +
+
+
+
+ +
+ +
+ layers + 图层 + +
+ + +
+ + + + +
+
+ landscape +
+ 地形 + Terrain +
+ +
+
+ satellite_alt +
+ 卫星 + Satellites +
+ +
+
+ timeline +
+ 轨迹 + Trails +
+ +
+
+ cable +
+ 海缆 + Subsea Cables +
+ +
+
+ hub +
+ BGP观测 + Routing Signals +
+ +
+
+ + + +
+
+
+ + +
-
+
🛰️

详情

+
-
@@ -82,52 +176,12 @@ 自动旋转 -
- -
- - - - - - -
-
+
-
-
-

坐标信息

- -
-
- 经度: - 0.00° -
-
- 纬度: - 0.00° -
-
缩放: 1.0x
-
鼠标位置: 无
-
-
-

线缆图例

- + +
+
+ + + +
+
+ + +
-
-
-
- Americas II -
-
-
- AU Aleutian A -
-
-
- AU Aleutian B -
-
-
- 其他电缆 -
+ +
+
-
-

地球信息

+ +
+ 全球态势
-
- 电缆系统: - 0个 + + +
+
+ + 海缆系统 +
+
+ + 登陆点 +
+
+ + 在轨卫星 +
+
+ + BGP 事件 +
+
+ + BGP 观测站 +
+
+ + 运行中 +
-
- 状态: - - -
-
- 登陆点: - 0个 -
-
- 地形: - 开启 -
-
- 卫星: - 0 颗 -
-
- BGP事件: - 0 条 -
-
- 观测站: - 0 个 -
-
- BGP态势: - 暂无观测数据 -
-
- 视角距离: - 300 km -
-
- 纹理质量: - 8K 卫星图 + + + + + + + +
-
+

新闻直播

@@ -280,8 +312,12 @@
- - + +
@@ -334,6 +370,16 @@
视图
+