Compare commits

...

47 Commits

Author SHA1 Message Date
linkong
c97dd83f3e Merge pull request 'release: bump version to 0.27.6' (#7) from dev into feature/ue5-led-client
Reviewed-on: #7
2026-04-15 01:20:07 +00:00
rayd1o
f8b43a995b release: bump version to 0.27.6 2026-04-15 07:37:41 +08:00
linkong
a4e6ce7489 docs: update mocap delivery timeline and migration workflow 2026-04-14 19:20:40 +08:00
linkong
7ffc8537e4 docs: update mocap TODO — confirmed as UE5 plugin, not Live Link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 19:00:50 +08:00
linkong
4dd396ea65 docs: update Marketplace → Fab in UE setup guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:56:37 +08:00
linkong
1e6f4b338b feat: add UE5 LED display client — backend API, C++ source, stereo framework
Backend:
- Add /api/v1/ue/* endpoints (compute-points, cables, landing-points, satellites, status)
  returning flat JSON optimised for UE5 C++ parsing

UE5 client (ue_client/):
- PlanetDataManager: HTTP fetch + local mock JSON loader, spawns ComputePointActors
- ComputePointActor / InteractiveObjectBase: hover/select state, material switching
- GlobeInteractionComponent: drag-to-rotate via CesiumGeoreference origin shift, inertia, zoom
- StereoRenderingManager: runtime SbS/TbB stereo toggle, IPD control (format TBD)
- MotionCaptureInterface: protocol-agnostic gesture/rotate/zoom delegate interface (impl TBD)
- PlanetPlayerController: unified mouse + motion-capture input routing
- PlanetGameMode, Build.cs, Config, mock data

Docs:
- ue5_mvp_fused_plan.md updated to v3.0 for LED display context
- ue_client_setup_guide.md: step-by-step editor setup guide
- ue_todo.md: pending items blocked on vendor answers (stereo format + mocap protocol)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:39:05 +08:00
linkong
d9adaf4134 release: bump version to 0.27.5 2026-04-14 17:34:04 +08:00
linkong
40e51d5b20 release: bump version to 0.27.4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:11:39 +08:00
linkong
93c1c1e550 release: bump version to 0.27.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:52:05 +08:00
linkong
48eb13b993 release: bump version to 0.27.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:55:04 +08:00
linkong
11179e7e67 release: bump version to 0.27.1 2026-04-14 10:22:05 +08:00
rayd1o
07e26d6d5a chore: add cleanup and release skills for claude and codex
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 07:46:19 +08:00
rayd1o
7cd29cf9c0 release: bump version to 0.27.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 07:37:45 +08:00
rayd1o
2ee4773f4f release: bump version to 0.26.2 2026-04-12 09:27:01 +08:00
rayd1o
b1d0624061 release: bump version to 0.26.1 2026-04-12 05:20:23 +08:00
rayd1o
812c825dc6 release: bump version to 0.26.0 2026-04-12 04:36:38 +08:00
rayd1o
a359d94127 refactor: clean up earth brand component wiring 2026-04-11 03:58:28 +08:00
rayd1o
c92be9c054 chore: remove unused earth icon assets 2026-04-11 03:46:59 +08:00
rayd1o
10e2bae8c2 release: bump version to 0.25.3 2026-04-11 03:42:35 +08:00
rayd1o
60ed88b609 release: bump version to 0.25.2 2026-04-10 23:43:56 +08:00
linkong
e85a9fc614 fix: refine earth settings modal and hud interactions 2026-04-10 22:53:09 +08:00
linkong
a2210f0f78 release: bump version to 0.25.1 2026-04-10 16:19:57 +08:00
linkong
62ad09e816 release: bump version to 0.25.0 2026-04-10 16:04:50 +08:00
linkong
89a71e6f29 feat: ship persistent ai playground and alerts foundation 2026-04-10 15:57:34 +08:00
rayd1o
60f5ff9bab fix: move bgp brief markdown into a modal workspace 2026-04-10 03:57:13 +08:00
rayd1o
fbb6adfbf5 docs: formalize release workflow and frontend layout guardrails 2026-04-10 03:08:39 +08:00
rayd1o
749e6e76b6 fix: optimize visualization hot paths and refine bgp brief workspace 2026-04-10 02:12:29 +08:00
rayd1o
83839b8b11 fix: ship persistent bgp ai briefs and optimize bgp queries 2026-04-10 00:33:01 +08:00
rayd1o
ed898aef9c 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
2026-04-09 23:08:31 +08:00
linkong
abe0b5c11b fix: polish ai provider rebuild feedback 2026-04-09 16:52:21 +08:00
linkong
306ba7f850 fix: expand playground diagnostics and compose fallback 2026-04-09 16:38:55 +08:00
linkong
39f90bd575 fix: polish playground routing and bundle splits 2026-04-09 15:23:10 +08:00
rayd1o
c4ea918fac 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
2026-04-09 02:12:22 +08:00
rayd1o
34d94a6b6b Redirect login success to admin 2026-04-09 00:37:02 +08:00
rayd1o
ef65acd49c Add AI playground and frontend layout guidelines 2026-04-09 00:35:38 +08:00
linkong
5639546990 fix: stabilize bgp overview table viewport 2026-04-08 17:48:32 +08:00
linkong
c8fe8cad59 fix: refine planet startup script UX 2026-04-08 16:54:45 +08:00
linkong
d395769df6 docs: add agent runtime planning docs 2026-04-08 12:49:49 +08:00
linkong
8bd9d34376 feat: configure collector endpoints and health plan 2026-04-08 10:15:33 +08:00
linkong
2d43263b9e fix: tighten datasource trigger guards 2026-04-08 09:33:48 +08:00
rayd1o
2da6ed166b Bump version to 0.23.2 2026-04-08 03:24:08 +08:00
rayd1o
da587398d9 Refine datasource trigger flow and CLI output 2026-04-08 03:22:08 +08:00
rayd1o
f5308340af Add force rerun recovery and polish CLI startup 2026-04-08 02:49:29 +08:00
rayd1o
981617ee80 Bump version to 0.23.1 2026-04-07 23:51:44 +08:00
rayd1o
7abf391c74 Harden startup retry and recovery flow 2026-04-07 23:25:13 +08:00
linkong
f12719914d feat: align aiprovider with adapter-based compatibility 2026-04-07 17:54:44 +08:00
linkong
bc90e00e25 Merge branch 'codex/aiprovider-foundation' into dev 2026-04-07 17:33:37 +08:00
168 changed files with 24401 additions and 3203 deletions

120
.claude/commands/cleanup.md Normal file
View File

@@ -0,0 +1,120 @@
---
description: 审查当前工作区未提交代码中的垃圾代码,并在不影响逻辑的前提下自动清理
argument-hint: 可选:指定要检查的文件或目录(默认检查所有未提交修改)
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
---
# /cleanup — 垃圾代码审查与清理
分析当前工作区git diff中的未提交代码找出并修复常见垃圾代码**不得改变任何运行逻辑**。
## 检查范围
`$ARGUMENTS` 非空,则只检查指定文件/目录;否则检查所有未提交修改(`git diff HEAD`)。
## 审查清单
按优先级检查以下问题(只报告在本次 diff 中**新增或修改**的代码里存在的问题):
### 1. 重复逻辑 (Duplicate Logic)
- 完全相同或高度相似的代码块在多处出现
- 同一函数/方法被多个地方各自实现,已有公共版本未被复用
- 相同的 DOM 查询、正则、模板字符串在同一文件重复
### 2. Magic Numbers / Magic Strings
- 裸数字直接参与计算(如偏移量、时间、尺寸、阈值),没有命名常量
- 硬编码字符串(如 id 名、状态值、URL 片段)散落在逻辑中
- 例外:`0`, `1`, `-1`, `100`, `""` 等语义明确的惯用值不算
### 3. 命名问题
- 含义不明的缩写变量(如 `or_`, `tmp2`, `x2`
- 命名与实际用途不符
- 同一概念在不同地方用不同名字表达
### 4. 死代码 / 无效代码
- 注释掉的旧代码块3行以上
- 声明后从未使用的变量/参数/导入
- 永远不会执行的条件分支
### 5. 代码风格问题
- 尾部空白字符trailing whitespace
- 同一文件内风格不一致(如混用单双引号、缩进不统一)
- 空行使用不一致(连续多个空行等)
### 6. 其他常见问题
- 私有辅助函数应被 export 但没有,导致调用方重复实现
- 类型/接口重复定义
- 过于冗长的条件表达式可以简化(不改逻辑)
## 执行步骤
### Step 1 — 获取待检查文件列表
```bash
# 无参数时:获取所有未提交修改
git diff HEAD --name-only
# 有参数时:用 $ARGUMENTS 过滤
```
### Step 2 — 逐文件阅读并分析
- 用 Read 工具读取完整文件(不只读 diff
- 对照审查清单,记录每个问题:文件名、行号、问题类型、建议修复方式
### Step 3 — 报告问题清单
在修改前,先以列表形式输出所有发现的问题:
```
发现 N 个问题:
[文件] js/foo.js
· L34, L78: 重复逻辑 — 两处都实现了相同的 DOM 查询,可提取到 getPanel()
· L91: Magic number — 硬编码 14 作为偏移量,应命名为 TOOLTIP_OFFSET
[文件] js/bar.js
· L12: 命名问题 — 变量 `or_` 语义不明,应命名为 outerR/outerG/outerB
...
```
如果没有发现问题,直接输出"未发现垃圾代码,当前代码质量良好。"并停止。
### Step 4 — 执行修复
对每个问题,使用 Edit 工具进行**最小化修改**
- **重复逻辑**:提取为共享常量/函数,更新所有调用点
- **Magic number**:在文件顶部或逻辑附近声明 `const NAME = value`,替换所有引用
- **命名问题**:重命名变量,更新所有使用处
- **死代码**:直接删除
- **尾部空白/风格**:修正
- **未 export 的函数**:添加 `export`,在调用方改为导入(不重复实现)
**修复原则:**
- 只改在审查清单中发现的问题,不做额外优化
- 每次 Edit 只修改确实有问题的行,保持 diff 最小
- 改完后用 `grep` 验证旧的坏代码已消失
### Step 5 — 输出总结
```
清理完成:
修复了 N 个问题:
✓ earth.js — 提取重复 vertexShader 为 ATMOS_VERTEX_SHADER 常量
✓ main.js — 提取 TOOLTIP_CURSOR_OFFSET = 144处引用
✓ controls.js — export updateLayerButtonState移除 main.js 中的重复实现
...
未修改的问题(需人工确认):
! foo.js L45 — 注释代码块较长,建议手动确认是否可删除
```
## 约束
- **禁止**改变函数签名、接口定义、导出 API除非问题正是私有函数应被 export
- **禁止**添加新功能、新抽象、新参数
- **禁止**修改注释内容(只删除注释掉的死代码)
- **禁止**修改测试文件逻辑
- 如果一个 Magic number 的语义不完全确定,**跳过**,在总结中标记为"需人工确认"

146
.claude/commands/release.md Normal file
View File

@@ -0,0 +1,146 @@
---
description: 发版工作流:根据变更类型决定版本号,更新所有版本文件和 changelog运行验证commit 并 push
argument-hint: 可选feature | bugfix | 或直接描述本次发布内容
allowed-tools: ["Read", "Edit", "Bash", "Glob", "Grep"]
---
# /release — Planet 发版工作流
## 版本号规则
| 变更类型 | 版本跳动 | 适用场景 |
|---------|---------|---------|
| `feature` | `+0.1.0` | 纯新功能,无 bugfix |
| `improvement` | `+0.0.1` | UI 调整、小功能增强、bugfix 混合,或以 UI/体验改进为主的迭代 |
| `bugfix` | `+0.0.1` | 纯 bug 修复,无新功能 |
| `docs` / `maintenance` / `refactor` | 默认不发版,除非用户明确要求 |
意图混合时以用户明确描述为准bugfix + 小 feature 混合默认判定为 `improvement``+0.0.1`)。
## 必须同步更新的文件
使用 `git rev-parse --show-toplevel` 获取仓库根目录,以下路径均相对于根目录:
- `VERSION`
- `frontend/package.json``"version"` 字段)
- `pyproject.toml``version =` 字段)
- `uv.lock`**不要手动编辑**,通过 `uv lock` 重新生成)
- `docs/CHANGELOG.md`
- `docs/version-history.md`
## 执行步骤
### Step 1 — 环境检查
```bash
git branch --show-current # 确认在 dev 分支
git status --short # 检查是否有无关的未暂存修改
cat VERSION # 读取当前版本
```
若当前**不在 `dev` 分支**,停下来告知用户,不要继续。
若存在无关的未暂存修改,列出并询问用户是否一并提交,或先 stash。
### Step 2 — 确定发版类型与新版本号
-`$ARGUMENTS` 提供了明确类型(`feature` / `bugfix`),直接使用
- 否则根据当前 `git diff HEAD``git log` 推断
- 计算新版本号(例:`0.26.2` → bugfix → `0.26.3`
- **先输出发版计划供用户确认**
```
发版计划:
类型bugfix
版本0.26.2 → 0.26.3
分支dev
将更新VERSION, frontend/package.json, pyproject.toml, uv.lock, CHANGELOG.md, version-history.md
```
### Step 3 — 更新版本号文件
按顺序更新(每步用 Edit 工具,精确替换,不要重写整个文件):
1. `VERSION` — 直接替换全部内容为新版本号
2. `frontend/package.json` — 替换 `"version": "x.x.x"`
3. `pyproject.toml` — 替换 `version = "x.x.x"`
4. 运行 `uv lock` 重新生成 `uv.lock`(在仓库根目录下执行)
### Step 4 — 更新 CHANGELOG.md
在文件顶部插入新条目,格式:
```markdown
## [x.x.x] — YYYY-MM-DD
### ✨ Features / 🐛 Fixes / 🔧 Improvements
- ...(只列高信号条目,最多 5 条)
- ...
---
```
日期使用 `date +%Y-%m-%d` 获取今天的日期。
### Step 5 — 更新 docs/version-history.md
- 更新文件头部的"当前开发版本"字段
- 在时间线表格顶部插入新行:`| vx.x.x | YYYY-MM-DD | 一句话摘要 |`
### Step 6 — 验证
针对本次变更范围做最小验证:
- Python 文件有修改:`python3 -m py_compile <changed_files>`
- Frontend 文件有修改:运行项目标准检查(若无则跳过并说明)
- 版本号一致性检查:用 grep 确认 VERSION、package.json、pyproject.toml 中的版本号完全一致
```bash
grep -h "version" VERSION frontend/package.json pyproject.toml
```
### Step 7 — 提交前预览
展示将要提交的文件列表:
```bash
git diff --stat HEAD
```
再次确认所有必须文件都在变更列表中,**不包含**非预期文件(如调试文件、.env 等)。
### Step 8 — Commit & Push用户确认后
```bash
git add VERSION frontend/package.json pyproject.toml uv.lock docs/CHANGELOG.md docs/version-history.md
# 若有代码变更也一并 stage
git add <code_files>
git commit -m "release: bump version to x.x.x"
git tag vx.x.x
git push origin dev
git push origin vx.x.x
```
commit message 固定格式:`release: bump version to x.x.x`
### Step 9 — 完成确认
输出摘要:
```
✓ 版本号已更新0.26.2 → 0.26.3
✓ CHANGELOG 已更新
✓ version-history 已更新
✓ uv.lock 已重新生成
✓ 验证通过
✓ commit: release: bump version to 0.26.3
✓ tag: v0.26.3
✓ 已 push 到 origin/dev
```
## 注意事项
- `uv.lock` 只能通过 `uv lock` 生成,绝不手动编辑
- 发版 commit 只包含版本文件 + 本次功能代码,不混入无关改动
- 若环境中 `uv` 不可用,说明原因并跳过 lockfile 更新,提醒用户手动运行

View File

@@ -0,0 +1,124 @@
---
name: cleanup
description: Use when the user asks to clean up, lint, or review uncommitted code for common code smells — duplicate logic, magic numbers, unclear naming, dead code, style inconsistencies. Fixes issues without changing any runtime behavior.
---
# Cleanup
Review and fix code quality issues in the current working tree without altering any logic or behavior.
## When To Use
- The user asks to clean up, tidy, or lint uncommitted changes
- The user wants a code smell review before releasing or committing
- The user mentions magic numbers, duplicate logic, dead code, or naming issues
Do not refactor architecture, add features, or change behavior.
## Scope
If the user specifies a file or directory, check only that. Otherwise check all uncommitted changes (`git diff HEAD`).
Only report issues present in **newly added or modified** lines of this diff — do not audit unchanged code.
## Checklist
### 1. Duplicate Logic
- Identical or near-identical code blocks appearing in multiple places
- A function/helper that already exists but is re-implemented elsewhere instead of being reused
- Repeated DOM queries, regex literals, or template strings within the same file
### 2. Magic Numbers / Magic Strings
- Bare numeric literals used in calculations (offsets, timeouts, sizes, thresholds) without a named constant
- Hardcoded strings (IDs, status values, URL fragments) scattered through logic
- Exceptions: `0`, `1`, `-1`, `100`, `""` and other idiomatically clear values are fine
### 3. Naming Issues
- Cryptic abbreviations (`or_`, `tmp2`, `x2`)
- Names that do not match actual behavior
- The same concept referred to by different names in different places
### 4. Dead Code
- Commented-out code blocks (3+ lines)
- Variables, parameters, or imports declared but never used
- Branches that can never execute
### 5. Style Inconsistencies
- Trailing whitespace
- Mixed quote styles or indentation within the same file
- Inconsistent blank-line usage (multiple consecutive blank lines, etc.)
### 6. Other
- Private helper functions that should be exported but are not, causing callers to duplicate the implementation
- Overly verbose conditions that can be simplified without changing logic
## Steps
### Step 1 — Get the file list
```bash
git diff HEAD --name-only
```
Filter to the user-specified path if one was provided.
### Step 2 — Read and analyze each file
Read the full file (not just the diff) with the Read tool. For each file, record every issue found: filename, line number, category, and suggested fix.
### Step 3 — Report findings before touching anything
Print a structured list:
```
Found N issues:
[file] js/foo.js
· L34, L78: Duplicate logic — same DOM query implemented twice; extract to getPanel()
· L91: Magic number — bare 14 used as pixel offset; name it TOOLTIP_OFFSET
[file] js/bar.js
· L12: Naming — variable `or_` is unclear; rename to outerR, outerG, outerB
...
```
If no issues are found, output "No code smells detected. Code quality looks good." and stop.
### Step 4 — Fix each issue
Use the Edit tool for **minimal, targeted changes**:
- **Duplicate logic**: extract to a shared constant or function; update all call sites
- **Magic number/string**: declare `const NAME = value` near the top of the relevant scope; replace all usages
- **Naming**: rename the variable/function; update all references
- **Dead code**: delete it
- **Trailing whitespace / style**: fix in place
- **Unexported helper**: add `export`; update callers to import instead of re-implementing
Principles:
- Only fix issues identified in the checklist — no extra improvements
- Keep each Edit as small as possible
- After fixing, verify the old bad pattern is gone with grep
### Step 5 — Summary
```
Cleanup complete:
Fixed N issues:
✓ earth.js — extracted duplicate vertexShader into ATMOS_VERTEX_SHADER constant
✓ main.js — extracted TOOLTIP_CURSOR_OFFSET = 14 (4 references updated)
✓ controls.js — exported updateLayerButtonState; removed duplicate implementation in main.js
...
Skipped (needs manual review):
! foo.js L45 — large commented-out block; confirm it is safe to delete
```
## Constraints
- **Do not** change function signatures, exported interfaces, or public APIs (unless the issue is a missing export)
- **Do not** add new features, abstractions, or parameters
- **Do not** rewrite comments (only delete commented-out dead code)
- **Do not** touch test file logic
- If a magic number's intent is uncertain, skip it and flag it in the summary

View File

@@ -0,0 +1,157 @@
---
name: release
description: Use when the user asks to release, bump version, update changelog/version files, or commit/push a repository release for the Planet repo. Determines version bump type from changes, updates all required version-bearing files, updates changelog and version-history, runs minimal validation, then commits, tags, and pushes.
---
# 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.
## Required Files
Use `git rev-parse --show-toplevel` to get the repo root. All paths are relative to it:
- `VERSION`
- `frontend/package.json` (`"version"` field)
- `pyproject.toml` (`version =` field)
- `uv.lock` (**never edit manually** — regenerate by running `uv lock`)
- `docs/CHANGELOG.md`
- `docs/version-history.md`
## Workflow
### Step 1 — Environment check
```bash
git branch --show-current # must be on dev
git status --short # check for unrelated uncommitted changes
cat VERSION # read current version
```
If not on `dev`, stop and tell the user. Do not proceed.
If unrelated uncommitted changes exist, list them and ask the user whether to include them or stash first.
### Step 2 — Determine release type and next version
- If the user provided an explicit type (`feature` / `bugfix`), use it
- Otherwise infer from `git diff HEAD` and recent `git log`
- Compute the next version (e.g. `0.26.2` → bugfix → `0.26.3`)
- **Show the release plan before making any changes:**
```
Release plan:
Type: bugfix
Version: 0.26.2 → 0.26.3
Branch: dev
Will update: VERSION, frontend/package.json, pyproject.toml, uv.lock, CHANGELOG.md, version-history.md
```
### Step 3 — Update version files
Update in order (use Edit for precise replacement, never rewrite whole files):
1. `VERSION` — replace entire content with new version string
2. `frontend/package.json` — replace `"version": "x.x.x"` line
3. `pyproject.toml` — replace `version = "x.x.x"` line
4. Run `uv lock` at repo root to regenerate `uv.lock`
### Step 4 — Update CHANGELOG.md
Insert a new entry at the top of the file:
```markdown
## x.x.x
Released: YYYY-MM-DD
### Highlights
- ...
### Added / Fixed / Improved
- ... (high-signal items only, max 5)
---
```
Get today's date with `date +%Y-%m-%d`.
### Step 5 — Update docs/version-history.md
- Update the "current dev version" field in the file header
- Insert a new row at the top of the timeline table: `| vx.x.x | YYYY-MM-DD | one-line summary |`
### Step 6 — Validate
Run the smallest relevant validation for the changes in scope:
- Python files changed: `python3 -m py_compile <changed_files>`
- Frontend files changed: run the project-standard check if available; otherwise skip and say so
- Version consistency: confirm VERSION, package.json, pyproject.toml, and uv.lock all show the same version
```bash
grep -h "version" VERSION frontend/package.json pyproject.toml
```
### Step 7 — Pre-commit preview
Show what will be committed:
```bash
git diff --stat HEAD
```
Confirm all required files are present and no unexpected files (debug files, `.env`, etc.) are included.
### Step 8 — Commit, tag, and push
```bash
git add VERSION frontend/package.json pyproject.toml uv.lock docs/CHANGELOG.md docs/version-history.md
# also stage any code changes included in this release
git add <code_files>
git commit -m "release: bump version to x.x.x"
git tag vx.x.x
git push origin dev
git push origin vx.x.x
```
Commit message format is fixed: `release: bump version to x.x.x`
### Step 9 — Completion summary
```
✓ Version bumped: 0.26.2 → 0.26.3
✓ CHANGELOG updated
✓ version-history updated
✓ uv.lock regenerated
✓ Validation passed
✓ commit: release: bump version to 0.26.3
✓ tag: v0.26.3
✓ Pushed to origin/dev
```
## Notes
- `uv.lock` must only be updated by running `uv lock`, never manually
- The release commit should include only version files + the code for this release — no unrelated changes
- If `uv` is unavailable in the environment, say so explicitly and remind the user to run it manually

5
.gitignore vendored
View File

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

View File

@@ -102,6 +102,13 @@
| Axios | HTTP 客户端 |
| Socket.io-client | WebSocket 客户端 |
| ECharts | 统计图表 |
| Bun | 前端包管理与脚本运行 |
前端工程统一使用 Bun
- 安装依赖使用 `bun install`
- 运行脚本使用 `bun run <script>`
- 不使用 `npm``pnpm``yarn`
### 虚幻引擎客户端
@@ -205,10 +212,47 @@
./planet.sh health
```
前端命令约定:
```bash
cd frontend
bun install
bun run dev
bun run build
```
不要使用 `npm run ...`,避免在 WSL/Windows 混合环境里触发 `cmd.exe` 路径兼容问题。
## API 文档
启动服务后访问: `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 接口预留
项目现在采用“两层”设计:
@@ -286,6 +330,23 @@ 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)
- [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-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

View File

@@ -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降低后续维护复杂度

View File

@@ -1 +1 @@
0.23.0
0.27.6

View File

@@ -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

View File

@@ -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`
启动模板:

View File

@@ -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 = ""

View File

@@ -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,30 @@ 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 +334,39 @@ 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 []

View File

@@ -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

View File

@@ -14,6 +14,8 @@ from app.api.v1 import (
visualization,
bgp,
system_control,
tv,
ue_data,
)
api_router = APIRouter()
@@ -33,3 +35,5 @@ api_router.include_router(settings.router, prefix="/settings", tags=["settings"]
api_router.include_router(system_control.router, prefix="/system", tags=["system"])
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
api_router.include_router(ue_data.router, prefix="/ue", tags=["ue-client"])

View File

@@ -1,15 +1,52 @@
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,
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 (
get_bgp_brief_record,
get_latest_bgp_brief_record,
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()
@@ -37,3 +74,208 @@ 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("/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),
):
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, facts, context = 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,
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,
)

View File

@@ -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()}
@@ -101,25 +102,44 @@ async def get_alert_stats(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
critical_query = select(func.count(Alert.id)).where(
Alert.severity == AlertSeverity.CRITICAL,
Alert.status == AlertStatus.ACTIVE,
result = await db.execute(
select(
func.sum(
case(
(
(Alert.severity == AlertSeverity.CRITICAL)
& (Alert.status == AlertStatus.ACTIVE),
1,
),
else_=0,
)
).label("critical"),
func.sum(
case(
(
(Alert.severity == AlertSeverity.WARNING)
& (Alert.status == AlertStatus.ACTIVE),
1,
),
else_=0,
)
).label("warning"),
func.sum(
case(
(
(Alert.severity == AlertSeverity.INFO)
& (Alert.status == AlertStatus.ACTIVE),
1,
),
else_=0,
)
).label("info"),
)
)
warning_query = select(func.count(Alert.id)).where(
Alert.severity == AlertSeverity.WARNING,
Alert.status == AlertStatus.ACTIVE,
)
info_query = select(func.count(Alert.id)).where(
Alert.severity == AlertSeverity.INFO,
Alert.status == AlertStatus.ACTIVE,
)
critical_result = await db.execute(critical_query)
warning_result = await db.execute(warning_query)
info_result = await db.execute(info_query)
row = result.one()
return {
"critical": critical_result.scalar() or 0,
"warning": warning_result.scalar() or 0,
"info": info_result.scalar() or 0,
"critical": row.critical or 0,
"warning": row.warning or 0,
"info": row.info or 0,
}

View File

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

View File

@@ -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
@@ -118,58 +118,77 @@ async def get_stats(
built_in_count = len(COLLECTOR_INFO)
built_in_active = built_in_count # Built-in are always "active" for counting purposes
# Count custom configs from database
result = await db.execute(select(func.count(DataSourceConfig.id)))
custom_count = result.scalar() or 0
result = await db.execute(
select(func.count(DataSourceConfig.id)).where(DataSourceConfig.is_active == True)
select(
func.count(DataSourceConfig.id).label("custom_count"),
func.sum(
case((DataSourceConfig.is_active == True, 1), else_=0)
).label("custom_active"),
)
)
custom_active = result.scalar() or 0
datasource_stats = result.one()
custom_count = datasource_stats.custom_count or 0
custom_active = datasource_stats.custom_active or 0
# Total datasources
total_datasources = built_in_count + custom_count
active_datasources = built_in_active + custom_active
# Tasks today (from database)
result = await db.execute(
select(func.count(CollectionTask.id)).where(CollectionTask.started_at >= today_start)
)
tasks_today = result.scalar() or 0
result = await db.execute(
select(func.count(CollectionTask.id)).where(
CollectionTask.status == "success",
CollectionTask.started_at >= today_start,
select(
func.count(CollectionTask.id).label("tasks_today"),
func.sum(
case(
(CollectionTask.status == "success", 1),
else_=0,
)
).label("success_tasks"),
)
.where(CollectionTask.started_at >= today_start)
)
success_tasks = result.scalar() or 0
task_stats = result.one()
tasks_today = task_stats.tasks_today or 0
success_tasks = task_stats.success_tasks or 0
success_rate = (success_tasks / tasks_today * 100) if tasks_today > 0 else 0
# Alerts
result = await db.execute(
select(func.count(Alert.id)).where(
Alert.severity == AlertSeverity.CRITICAL,
Alert.status == "active",
select(
func.sum(
case(
(
(Alert.severity == AlertSeverity.CRITICAL)
& (Alert.status == "active"),
1,
),
else_=0,
)
).label("critical_alerts"),
func.sum(
case(
(
(Alert.severity == AlertSeverity.WARNING)
& (Alert.status == "active"),
1,
),
else_=0,
)
).label("warning_alerts"),
func.sum(
case(
(
(Alert.severity == AlertSeverity.INFO)
& (Alert.status == "active"),
1,
),
else_=0,
)
).label("info_alerts"),
)
)
critical_alerts = result.scalar() or 0
result = await db.execute(
select(func.count(Alert.id)).where(
Alert.severity == AlertSeverity.WARNING,
Alert.status == "active",
)
)
warning_alerts = result.scalar() or 0
result = await db.execute(
select(func.count(Alert.id)).where(
Alert.severity == AlertSeverity.INFO,
Alert.status == "active",
)
)
info_alerts = result.scalar() or 0
alert_stats = result.one()
critical_alerts = alert_stats.critical_alerts or 0
warning_alerts = alert_stats.warning_alerts or 0
info_alerts = alert_stats.info_alerts or 0
response = {
"total_datasources": total_datasources,

View File

@@ -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,17 @@ 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.datasource_config import DataSourceConfig
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
@@ -34,6 +41,156 @@ def is_due_for_collection(datasource: DataSource, now: datetime) -> bool:
return datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes) <= now
def _task_rank_column(order_column):
return func.row_number().over(
partition_by=CollectionTask.datasource_id,
order_by=(order_column.desc().nullslast(), CollectionTask.id.desc()),
).label("row_num")
async def _load_latest_running_tasks(
db: AsyncSession,
datasource_ids: list[int],
) -> dict[int, CollectionTask]:
if not datasource_ids:
return {}
ranked_tasks = (
select(
CollectionTask.id.label("task_id"),
_task_rank_column(CollectionTask.started_at),
)
.where(CollectionTask.datasource_id.in_(datasource_ids))
.where(CollectionTask.status == "running")
.subquery()
)
result = await db.execute(
select(CollectionTask)
.join(ranked_tasks, CollectionTask.id == ranked_tasks.c.task_id)
.where(ranked_tasks.c.row_num == 1)
)
return {task.datasource_id: task for task in result.scalars().all()}
async def _load_latest_completed_tasks(
db: AsyncSession,
datasource_ids: list[int],
) -> dict[int, CollectionTask]:
if not datasource_ids:
return {}
ranked_tasks = (
select(
CollectionTask.id.label("task_id"),
_task_rank_column(CollectionTask.completed_at),
)
.where(CollectionTask.datasource_id.in_(datasource_ids))
.where(CollectionTask.completed_at.isnot(None))
.where(CollectionTask.status.in_(("success", "failed", "cancelled")))
.subquery()
)
result = await db.execute(
select(CollectionTask)
.join(ranked_tasks, CollectionTask.id == ranked_tasks.c.task_id)
.where(ranked_tasks.c.row_num == 1)
)
return {task.datasource_id: task for task in result.scalars().all()}
async def _load_latest_task_ids(
db: AsyncSession,
datasource_ids: list[int],
) -> dict[int, int]:
if not datasource_ids:
return {}
ranked_tasks = (
select(
CollectionTask.id.label("task_id"),
CollectionTask.datasource_id.label("datasource_id"),
func.row_number().over(
partition_by=CollectionTask.datasource_id,
order_by=CollectionTask.id.desc(),
).label("row_num"),
)
.where(CollectionTask.datasource_id.in_(datasource_ids))
.subquery()
)
result = await db.execute(
select(ranked_tasks.c.datasource_id, ranked_tasks.c.task_id)
.where(ranked_tasks.c.row_num == 1)
)
return {datasource_id: task_id for datasource_id, task_id in result.all()}
async def _load_datasource_data_counts(
db: AsyncSession,
sources: list[str],
) -> dict[str, int]:
if not sources:
return {}
result = await db.execute(
select(CollectedData.source, func.count(CollectedData.id))
.where(CollectedData.source.in_(sources))
.group_by(CollectedData.source)
)
return {source: count for source, count in result.all()}
async def _load_datasource_endpoint_overrides(
db: AsyncSession,
sources: list[str],
) -> dict[str, str]:
if not sources:
return {}
result = await db.execute(
select(DataSourceConfig.name, DataSourceConfig.endpoint)
.where(DataSourceConfig.name.in_(sources))
.where(DataSourceConfig.is_active.is_(True))
.where(DataSourceConfig.endpoint.isnot(None))
)
return {
name: endpoint
for name, endpoint in result.all()
if endpoint
}
async def _load_datasource_list_context(
db: AsyncSession,
datasources: list[DataSource],
) -> tuple[dict[int, CollectionTask], dict[int, CollectionTask], dict[str, int], dict[str, str]]:
datasource_ids = [datasource.id for datasource in datasources]
sources = [datasource.source for datasource in datasources]
running_tasks = await _load_latest_running_tasks(db, datasource_ids)
datasource_by_id = {datasource.id: datasource for datasource in datasources}
now = datetime.now(timezone.utc)
stale_datasource_ids: list[int] = []
for datasource_id, task in running_tasks.items():
started_at = task.started_at
if started_at is None:
continue
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
if now - started_at > timedelta(minutes=STALE_RUNNING_TASK_TIMEOUT_MINUTES):
datasource = datasource_by_id.get(datasource_id)
if datasource is not None:
await fail_and_rollback_stale_running_task(db, datasource, task)
stale_datasource_ids.append(datasource_id)
if stale_datasource_ids:
running_tasks = await _load_latest_running_tasks(db, datasource_ids)
completed_tasks = await _load_latest_completed_tasks(db, datasource_ids)
data_counts = await _load_datasource_data_counts(db, sources)
endpoint_overrides = await _load_datasource_endpoint_overrides(db, sources)
return running_tasks, completed_tasks, data_counts, endpoint_overrides
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
datasource = None
try:
@@ -52,18 +209,6 @@ async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[Da
return result.scalar_one_or_none()
async def get_last_completed_task(db: AsyncSession, datasource_id: int) -> Optional[CollectionTask]:
result = await db.execute(
select(CollectionTask)
.where(CollectionTask.datasource_id == datasource_id)
.where(CollectionTask.completed_at.isnot(None))
.where(CollectionTask.status.in_(("success", "failed", "cancelled")))
.order_by(CollectionTask.completed_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[CollectionTask]:
result = await db.execute(
select(CollectionTask)
@@ -87,17 +232,152 @@ 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()
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
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()
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)"
f"({STALE_RUNNING_TASK_TIMEOUT_MINUTES}m); incomplete writes rolled back"
)
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
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()
return None
@router.get("")
@@ -121,14 +401,17 @@ async def list_datasources(
collector_list = []
config = get_data_sources_config()
running_tasks, completed_tasks, data_counts, endpoint_overrides = await _load_datasource_list_context(
db,
datasources,
)
for datasource in datasources:
running_task = await get_running_task(db, datasource.id)
last_task = await get_last_completed_task(db, datasource.id)
endpoint = await config.get_url(datasource.source, db)
data_count_result = await db.execute(
select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
running_task = running_tasks.get(datasource.id)
last_task = completed_tasks.get(datasource.id)
endpoint = endpoint_overrides.get(datasource.source) or config.get_yaml_url(
datasource.source,
)
data_count = data_count_result.scalar() or 0
data_count = data_counts.get(datasource.source, 0)
last_run_at = datasource.last_run_at or (last_task.completed_at if last_task else None)
last_run = to_iso8601_utc(last_run_at)
@@ -189,9 +472,13 @@ async def trigger_all_datasources(
skipped_sources: list[dict] = []
failed_sources: list[dict] = []
now = datetime.now(timezone.utc)
running_tasks = await _load_latest_running_tasks(
db,
[datasource.id for datasource in datasources],
)
for datasource in datasources:
running_task = await get_running_task(db, datasource.id)
running_task = running_tasks.get(datasource.id)
if running_task is not None:
skipped_sources.append(
{
@@ -219,7 +506,7 @@ async def trigger_all_datasources(
)
continue
previous_task_ids[datasource.id] = await get_latest_task_id_for_datasource(datasource.id)
previous_task_ids[datasource.id] = None
success = run_collector_now(datasource.source)
if not success:
failed_sources.append(
@@ -241,13 +528,24 @@ async def trigger_all_datasources(
}
)
latest_task_ids = await _load_latest_task_ids(
db,
[datasource.id for datasource in datasources],
)
for datasource_id in previous_task_ids:
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
for _ in range(20):
await asyncio.sleep(0.1)
pending = [item for item in triggered_sources if item["task_id"] is None]
if not pending:
break
latest_task_ids = await _load_latest_task_ids(
db,
[item["id"] for item in pending],
)
for item in pending:
task_id = await get_latest_task_id_for_datasource(item["id"])
task_id = latest_task_ids.get(item["id"])
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
item["task_id"] = task_id
@@ -346,6 +644,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 +655,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 +694,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",
}
@@ -412,6 +732,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)

View File

@@ -1,3 +1,4 @@
from copy import deepcopy
from datetime import UTC, datetime
from typing import Optional
@@ -13,6 +14,7 @@ from app.models.datasource import DataSource
from app.models.system_setting import SystemSetting
from app.models.user import User
from app.services.scheduler import sync_datasource_job
from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings
router = APIRouter()
@@ -36,6 +38,7 @@ DEFAULT_SETTINGS = {
"max_login_attempts": 5,
"password_policy": "medium",
},
"tv": DEFAULT_TV_SETTINGS,
}
@@ -67,8 +70,34 @@ class CollectorSettingsUpdate(BaseModel):
frequency_minutes: int = Field(default=60, ge=1, le=10080)
class TVStreamSourceUpdate(BaseModel):
id: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=200)
provider: str = Field(default="Unknown", max_length=100)
region: str = Field(default="Global", max_length=100)
language: str = Field(default="und", max_length=32)
source_type: str = Field(default="iframe", pattern="^(iframe|hls|video|external|youtube)$")
embed_url: str = ""
stream_url: str = ""
homepage_url: str = ""
poster_url: str = ""
youtube_video_id: str = ""
youtube_channel: str = ""
is_enabled: bool = True
is_fallback: bool = False
sort_order: int = Field(default=10, ge=0, le=9999)
collector_source: Optional[str] = None
notes: str = ""
class TVSettingsUpdate(BaseModel):
default_source_id: str = Field(default=DEFAULT_TV_SETTINGS["default_source_id"], min_length=1)
auto_fallback: bool = True
sources: list[TVStreamSourceUpdate] = Field(default_factory=list)
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
merged = DEFAULT_SETTINGS[category].copy()
merged = deepcopy(DEFAULT_SETTINGS[category])
if payload:
merged.update(payload)
return merged
@@ -79,6 +108,26 @@ async def get_setting_record(db: AsyncSession, category: str) -> Optional[System
return result.scalar_one_or_none()
async def get_setting_payloads(db: AsyncSession, categories: list[str]) -> dict[str, dict]:
if not categories:
return {}
result = await db.execute(
select(SystemSetting).where(SystemSetting.category.in_(categories))
)
records_by_category = {
record.category: record
for record in result.scalars().all()
}
return {
category: merge_with_defaults(
category,
records_by_category.get(category).payload if records_by_category.get(category) else None,
)
for category in categories
}
async def get_setting_payload(db: AsyncSession, category: str) -> dict:
record = await get_setting_record(db, category)
return merge_with_defaults(category, record.payload if record else None)
@@ -175,6 +224,25 @@ async def update_security_settings(
return {"status": "updated", "security": payload}
@router.get("/tv")
async def get_tv_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"tv": await get_tv_settings_payload(db)}
@router.put("/tv")
async def update_tv_settings(
settings: TVSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
payload = normalize_tv_settings(settings.model_dump())
saved = await save_setting_payload(db, "tv", payload)
return {"status": "updated", "tv": normalize_tv_settings(saved)}
@router.get("/collectors")
async def get_collector_settings(
current_user: User = Depends(get_current_user),
@@ -212,10 +280,15 @@ async def get_all_settings(
):
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
datasources = result.scalars().all()
setting_payloads = await get_setting_payloads(
db,
["system", "notifications", "security"],
)
return {
"system": await get_setting_payload(db, "system"),
"notifications": await get_setting_payload(db, "notifications"),
"security": await get_setting_payload(db, "security"),
"system": setting_payloads["system"],
"notifications": setting_payloads["notifications"],
"security": setting_payloads["security"],
"tv": await get_tv_settings_payload(db),
"collectors": [serialize_collector(datasource) for datasource in datasources],
"generated_at": to_iso8601_utc(datetime.now(UTC)),
}

70
backend/app/api/v1/tv.py Normal file
View File

@@ -0,0 +1,70 @@
from urllib.parse import quote, urljoin
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.services.tv_streams import get_public_tv_payload, is_allowed_tv_proxy_url
router = APIRouter()
@router.get("/streams")
async def list_public_tv_streams(
db: AsyncSession = Depends(get_db),
):
return await get_public_tv_payload(db)
@router.get("/proxy")
async def proxy_tv_stream(
url: str = Query(..., description="Upstream TV stream or manifest URL"),
db: AsyncSession = Depends(get_db),
):
payload = await get_public_tv_payload(db)
if not is_allowed_tv_proxy_url(url, payload.get("sources", [])):
raise HTTPException(status_code=403, detail="TV proxy target is not allowed")
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
upstream = await client.get(
url,
headers={
"User-Agent": "Mozilla/5.0",
"Referer": "https://tv.cctv.com/live/cctv4/",
},
)
upstream.raise_for_status()
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Failed to fetch TV stream: {exc}") from exc
content_type = upstream.headers.get("content-type", "application/octet-stream")
raw_content = upstream.content
response_url = str(upstream.url)
is_manifest = (
response_url.endswith(".m3u8")
or "mpegurl" in content_type.lower()
or raw_content.lstrip().startswith(b"#EXTM3U")
)
headers = {"Cache-Control": "no-store"}
if is_manifest:
manifest_text = upstream.text
rewritten_lines: list[str] = []
for line in manifest_text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
rewritten_lines.append(line)
continue
absolute_url = urljoin(response_url, stripped)
rewritten_lines.append(f"/api/v1/tv/proxy?url={quote(absolute_url, safe='')}")
return Response(
content="\n".join(rewritten_lines),
media_type="application/vnd.apple.mpegurl",
headers=headers,
)
return Response(content=raw_content, media_type=content_type, headers=headers)

View File

@@ -0,0 +1,351 @@
"""UE Client Data API
Flat JSON endpoints designed for easy parsing in Unreal Engine C++/Blueprint.
Avoids GeoJSON nesting — every field is at the top level of each item.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.collected_data_fields import get_record_field
from app.core.satellite_tle import build_tle_lines_from_elements
from app.core.time import to_iso8601_utc
from app.db.session import get_db
from app.models.collected_data import CollectedData
router = APIRouter()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _current_stmt(source: str, limit: Optional[int] = None):
stmt = (
select(CollectedData)
.where(CollectedData.source == source)
.where(CollectedData.is_current.is_(True))
.order_by(CollectedData.id.desc())
)
if limit:
stmt = stmt.limit(limit)
return stmt
async def _fetch(db: AsyncSession, source: str, limit: Optional[int] = None) -> List[CollectedData]:
result = await db.execute(_current_stmt(source, limit))
return list(result.scalars().all())
def _safe_float(value: Any) -> Optional[float]:
try:
v = float(value)
return v if v == v else None # reject NaN
except (TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# Status endpoint
# ---------------------------------------------------------------------------
@router.get("/status")
async def ue_status(db: AsyncSession = Depends(get_db)):
"""Quick health-check + data counts for the UE client."""
from datetime import UTC, datetime
async def count_source(source: str) -> int:
result = await db.execute(
select(func.count())
.select_from(CollectedData)
.where(CollectedData.source == source)
.where(CollectedData.is_current.is_(True))
)
return result.scalar() or 0
return {
"ok": True,
"server_time": to_iso8601_utc(datetime.now(UTC)),
"compute_points_count": await count_source("top500"),
"cables_count": await count_source("telegeography_cables"),
"landing_points_count": await count_source("arcgis_landing"),
"satellites_count": await count_source("celestrak"),
}
# ---------------------------------------------------------------------------
# Compute points (TOP500 supercomputers)
# ---------------------------------------------------------------------------
@router.get("/compute-points")
async def ue_compute_points(
limit: int = Query(default=500, ge=1, le=2000),
db: AsyncSession = Depends(get_db),
):
"""
Returns TOP500 supercomputer data as a flat JSON array.
Response shape:
{
"count": 500,
"items": [
{
"id": "top500_1",
"name": "Frontier",
"latitude": 36.01,
"longitude": -84.26,
"country": "United States",
"city": "Oak Ridge",
"rank": 1,
"rmax_tflops": 1194000.0,
"rpeak_tflops": 1679616.0,
"cores": 8730112,
"power_kw": 22703.0
}
]
}
"""
records = await _fetch(db, "top500", limit)
items = []
for record in records:
meta = record.extra_data or {}
lat = _safe_float(get_record_field(record, "latitude"))
lon = _safe_float(get_record_field(record, "longitude"))
if lat is None or lon is None:
continue
items.append({
"id": f"top500_{record.id}",
"name": record.name or "Unknown",
"latitude": lat,
"longitude": lon,
"country": get_record_field(record, "country") or "",
"city": get_record_field(record, "city") or "",
"rank": meta.get("rank"),
"rmax_tflops": _safe_float(get_record_field(record, "rmax")),
"rpeak_tflops": _safe_float(get_record_field(record, "rpeak")),
"cores": meta.get("cores"),
"power_kw": _safe_float(get_record_field(record, "power")),
})
return {"count": len(items), "items": items}
# ---------------------------------------------------------------------------
# Cable landing points
# ---------------------------------------------------------------------------
@router.get("/landing-points")
async def ue_landing_points(db: AsyncSession = Depends(get_db)):
"""
Returns cable landing points as a flat JSON array.
Response shape:
{
"count": 1200,
"items": [
{
"id": "lp_42",
"name": "Shoreham",
"latitude": 50.83,
"longitude": -0.28,
"country": "United Kingdom",
"city": "Shoreham-by-Sea",
"cable_names": ["FLAG", "TAT-14"]
}
]
}
"""
# Load landing points
lp_records = await _fetch(db, "arcgis_landing")
# Load relation + cable data for cable_names mapping
rel_result = await db.execute(
select(CollectedData)
.where(CollectedData.source == "arcgis_relation")
.where(CollectedData.is_current.is_(True))
)
rel_records = list(rel_result.scalars().all())
cable_result = await db.execute(
select(CollectedData)
.where(CollectedData.source == "telegeography_cables")
.where(CollectedData.is_current.is_(True))
)
cable_records = list(cable_result.scalars().all())
# Build mapping: city_id → list of cable names
city_to_cable_ids: Dict[int, List[int]] = {}
for r in rel_records:
meta = r.extra_data or {}
city_id = meta.get("city_id")
cable_id = meta.get("cable_id")
if city_id is not None and cable_id is not None:
city_to_cable_ids.setdefault(city_id, [])
if cable_id not in city_to_cable_ids[city_id]:
city_to_cable_ids[city_id].append(cable_id)
cable_id_to_name: Dict[int, str] = {}
for r in cable_records:
meta = r.extra_data or {}
cable_id = meta.get("cable_id")
if cable_id and r.name:
cable_id_to_name[cable_id] = r.name
items = []
for record in lp_records:
lat = _safe_float(get_record_field(record, "latitude"))
lon = _safe_float(get_record_field(record, "longitude"))
if lat is None or lon is None:
continue
meta = record.extra_data or {}
city_id = meta.get("city_id")
cable_names = []
if city_id in city_to_cable_ids:
cable_names = [
cable_id_to_name[cid]
for cid in city_to_cable_ids[city_id]
if cid in cable_id_to_name
]
items.append({
"id": f"lp_{record.id}",
"name": record.name or "Unknown",
"latitude": lat,
"longitude": lon,
"country": get_record_field(record, "country") or "",
"city": get_record_field(record, "city") or "",
"cable_names": cable_names,
})
return {"count": len(items), "items": items}
# ---------------------------------------------------------------------------
# Cables (route geometry)
# ---------------------------------------------------------------------------
@router.get("/cables")
async def ue_cables(db: AsyncSession = Depends(get_db)):
"""
Returns cable route geometry.
Each segment is a flat array of [lon, lat] pairs.
Response shape:
{
"count": 100,
"items": [
{
"id": "cable_42",
"cable_id": "flag",
"name": "FLAG",
"status": "active",
"length_km": 28000,
"segments": [
[[lon, lat], [lon, lat], ...]
]
}
]
}
"""
records = await _fetch(db, "telegeography_cables")
items = []
for record in records:
meta = record.extra_data or {}
route_coords = meta.get("route_coordinates", [])
segments: List[List[List[float]]] = []
if route_coords:
# Support both flat [lon,lat] array and array-of-arrays
if route_coords and isinstance(route_coords[0][0], list):
raw_lines = route_coords
else:
raw_lines = [route_coords]
for raw_line in raw_lines:
line = []
for pt in raw_line:
try:
line.append([float(pt[0]), float(pt[1])])
except (TypeError, ValueError, IndexError):
continue
if len(line) >= 2:
segments.append(line)
if not segments:
continue
items.append({
"id": f"cable_{record.id}",
"cable_id": record.source_id or record.name or "",
"name": record.name or "Unknown",
"status": meta.get("status", "active"),
"length_km": _safe_float(get_record_field(record, "value")),
"owners": meta.get("owners") or [],
"rfs": meta.get("rfs"),
"color": meta.get("color"),
"segments": segments,
})
return {"count": len(items), "items": items}
# ---------------------------------------------------------------------------
# Satellites (TLE data)
# ---------------------------------------------------------------------------
@router.get("/satellites")
async def ue_satellites(
limit: int = Query(default=200, ge=1, le=5000),
db: AsyncSession = Depends(get_db),
):
"""
Returns satellite TLE data for orbit propagation in UE.
Response shape:
{
"count": 200,
"items": [
{
"id": "sat_42",
"norad_id": "25544",
"name": "ISS (ZARYA)",
"tle_line1": "1 25544U ...",
"tle_line2": "2 25544 ...",
"epoch": "2026-04-14T00:00:00Z",
"inclination": 51.6,
"mean_motion": 15.5
}
]
}
"""
records = await _fetch(db, "celestrak", limit)
items = []
for record in records:
meta = record.extra_data or {}
norad_id = meta.get("norad_cat_id")
if not norad_id:
continue
tle1 = meta.get("tle_line1")
tle2 = meta.get("tle_line2")
if not tle1 or not tle2:
tle1, tle2 = build_tle_lines_from_elements(
norad_cat_id=norad_id,
epoch=meta.get("epoch"),
inclination=meta.get("inclination"),
raan=meta.get("raan"),
eccentricity=meta.get("eccentricity"),
arg_of_perigee=meta.get("arg_of_perigee"),
mean_anomaly=meta.get("mean_anomaly"),
mean_motion=meta.get("mean_motion"),
)
items.append({
"id": f"sat_{record.id}",
"norad_id": str(norad_id),
"name": record.name or "Unknown",
"tle_line1": tle1 or "",
"tle_line2": tle2 or "",
"epoch": meta.get("epoch") or "",
"inclination": _safe_float(meta.get("inclination")),
"raan": _safe_float(meta.get("raan")),
"eccentricity": _safe_float(meta.get("eccentricity")),
"mean_motion": _safe_float(meta.get("mean_motion")),
})
return {"count": len(items), "items": items}

View File

@@ -205,42 +205,84 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
return {"type": "FeatureCollection", "features": features}
def dedupe_satellite_records(records: List[CollectedData]) -> List[CollectedData]:
"""Keep only the newest record for each satellite identity."""
latest_by_key: Dict[str, CollectedData] = {}
for record in records:
metadata = record.extra_data or {}
norad_id = metadata.get("norad_cat_id")
dedupe_key = (
str(norad_id)
if norad_id not in (None, "")
else str(record.source_id or record.entity_key or record.name or record.id)
)
existing = latest_by_key.get(dedupe_key)
if existing is None or (record.id or 0) > (existing.id or 0):
latest_by_key[dedupe_key] = record
return sorted(latest_by_key.values(), key=lambda item: item.id or 0, reverse=True)
def _current_collected_data_stmt(source: str):
return (
select(CollectedData)
.where(CollectedData.source == source)
.where(CollectedData.is_current.is_(True))
.order_by(CollectedData.id.desc())
)
def dedupe_collected_records(records: List[CollectedData]) -> List[CollectedData]:
"""Keep only the newest record for each collected entity."""
latest_by_key: Dict[str, CollectedData] = {}
async def _load_current_collected_data(
db: AsyncSession,
source: str,
*,
exclude_unknown_name: bool = False,
limit: Optional[int] = None,
) -> List[CollectedData]:
stmt = _current_collected_data_stmt(source)
if exclude_unknown_name:
stmt = stmt.where(CollectedData.name != "Unknown")
if limit is not None:
stmt = stmt.limit(limit)
for record in records:
dedupe_key = str(
record.source_id
or record.entity_key
or record.name
or record.id
)
existing = latest_by_key.get(dedupe_key)
if existing is None or (record.id or 0) > (existing.id or 0):
latest_by_key[dedupe_key] = record
result = await db.execute(stmt)
return list(result.scalars().all())
return sorted(latest_by_key.values(), key=lambda item: item.id or 0, reverse=True)
async def _load_current_collected_data_by_sources(
db: AsyncSession,
sources: List[str],
) -> Dict[str, List[CollectedData]]:
if not sources:
return {}
stmt = (
select(CollectedData)
.where(CollectedData.source.in_(sources))
.where(CollectedData.is_current.is_(True))
.order_by(CollectedData.source.asc(), CollectedData.id.desc())
)
result = await db.execute(stmt)
grouped_records: Dict[str, List[CollectedData]] = {source: [] for source in sources}
for record in result.scalars().all():
grouped_records.setdefault(record.source, []).append(record)
return grouped_records
def _build_landing_point_cable_maps(
relation_records: List[CollectedData],
cable_records: List[CollectedData],
) -> tuple[Dict[int, List[int]], Dict[int, str]]:
city_to_cable_ids_map: Dict[int, List[int]] = {}
for relation_record in relation_records:
if not relation_record.extra_data:
continue
city_id = relation_record.extra_data.get("city_id")
cable_id = relation_record.extra_data.get("cable_id")
if city_id is None or cable_id is None:
continue
city_to_cable_ids_map.setdefault(city_id, [])
if cable_id not in city_to_cable_ids_map[city_id]:
city_to_cable_ids_map[city_id].append(cable_id)
cable_id_to_name_map: Dict[int, str] = {}
for cable_record in cable_records:
if not cable_record.extra_data:
continue
cable_id = cable_record.extra_data.get("cable_id")
cable_name = cable_record.name
if cable_id and cable_name:
cable_id_to_name_map[cable_id] = cable_name
return city_to_cable_ids_map, cable_id_to_name_map
def _filter_known_records(records: List[CollectedData]) -> List[CollectedData]:
return [record for record in records if record.name != "Unknown"]
def convert_supercomputer_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
@@ -722,9 +764,7 @@ def convert_bgp_incidents_to_geojson(
async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
"""获取海底电缆 GeoJSON 数据 (LineString)"""
try:
stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
result = await db.execute(stmt)
records = dedupe_collected_records(list(result.scalars().all()))
records = await _load_current_collected_data(db, "arcgis_cables")
if not records:
raise HTTPException(
@@ -742,36 +782,14 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
@router.get("/geo/landing-points")
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
try:
landing_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
landing_result = await db.execute(landing_stmt)
records = dedupe_collected_records(list(landing_result.scalars().all()))
relation_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation")
relation_result = await db.execute(relation_stmt)
relation_records = dedupe_collected_records(list(relation_result.scalars().all()))
cable_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
cable_result = await db.execute(cable_stmt)
cable_records = dedupe_collected_records(list(cable_result.scalars().all()))
city_to_cable_ids_map = {}
for rel in relation_records:
if rel.extra_data:
city_id = rel.extra_data.get("city_id")
cable_id = rel.extra_data.get("cable_id")
if city_id is not None and cable_id is not None:
if city_id not in city_to_cable_ids_map:
city_to_cable_ids_map[city_id] = []
if cable_id not in city_to_cable_ids_map[city_id]:
city_to_cable_ids_map[city_id].append(cable_id)
cable_id_to_name_map = {}
for cable in cable_records:
if cable.extra_data:
cable_id = cable.extra_data.get("cable_id")
cable_name = cable.name
if cable_id and cable_name:
cable_id_to_name_map[cable_id] = cable_name
records = await _load_current_collected_data(db, "arcgis_landing_points")
relation_records = await _load_current_collected_data(db, "arcgis_cable_landing_relation")
cable_records = await _load_current_collected_data(db, "arcgis_cables")
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
relation_records,
cable_records,
)
if not records:
raise HTTPException(
@@ -788,36 +806,21 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
@router.get("/geo/all")
async def get_all_geojson(db: AsyncSession = Depends(get_db)):
cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
cables_result = await db.execute(cables_stmt)
cables_records = dedupe_collected_records(list(cables_result.scalars().all()))
points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
points_result = await db.execute(points_stmt)
points_records = dedupe_collected_records(list(points_result.scalars().all()))
relation_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation")
relation_result = await db.execute(relation_stmt)
relation_records = dedupe_collected_records(list(relation_result.scalars().all()))
city_to_cable_ids_map = {}
for rel in relation_records:
if rel.extra_data:
city_id = rel.extra_data.get("city_id")
cable_id = rel.extra_data.get("cable_id")
if city_id is not None and cable_id is not None:
if city_id not in city_to_cable_ids_map:
city_to_cable_ids_map[city_id] = []
if cable_id not in city_to_cable_ids_map[city_id]:
city_to_cable_ids_map[city_id].append(cable_id)
cable_id_to_name_map = {}
for cable in cables_records:
if cable.extra_data:
cable_id = cable.extra_data.get("cable_id")
cable_name = cable.name
if cable_id and cable_name:
cable_id_to_name_map[cable_id] = cable_name
records_by_source = await _load_current_collected_data_by_sources(
db,
[
"arcgis_cables",
"arcgis_landing_points",
"arcgis_cable_landing_relation",
],
)
cables_records = records_by_source.get("arcgis_cables", [])
points_records = records_by_source.get("arcgis_landing_points", [])
relation_records = records_by_source.get("arcgis_cable_landing_relation", [])
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
relation_records,
cables_records,
)
cables = (
convert_cable_to_geojson(cables_records)
@@ -850,17 +853,12 @@ async def get_satellites_geojson(
db: AsyncSession = Depends(get_db),
):
"""获取卫星 TLE GeoJSON 数据"""
stmt = (
select(CollectedData)
.where(CollectedData.source == "celestrak_tle")
.where(CollectedData.name != "Unknown")
.order_by(CollectedData.id.desc())
records = await _load_current_collected_data(
db,
"celestrak_tle",
exclude_unknown_name=True,
limit=limit,
)
result = await db.execute(stmt)
records = dedupe_satellite_records(list(result.scalars().all()))
if limit is not None:
records = records[:limit]
if not records:
return {"type": "FeatureCollection", "features": [], "count": 0}
@@ -878,15 +876,12 @@ async def get_supercomputers_geojson(
db: AsyncSession = Depends(get_db),
):
"""获取 TOP500 超算中心 GeoJSON 数据"""
stmt = (
select(CollectedData)
.where(CollectedData.source == "top500")
.where(CollectedData.name != "Unknown")
.order_by(CollectedData.id.desc())
records = await _load_current_collected_data(
db,
"top500",
exclude_unknown_name=True,
limit=limit,
)
result = await db.execute(stmt)
records = dedupe_collected_records(list(result.scalars().all()))
records = records[:limit]
if not records:
return {"type": "FeatureCollection", "features": [], "count": 0}
@@ -904,15 +899,12 @@ async def get_gpu_clusters_geojson(
db: AsyncSession = Depends(get_db),
):
"""获取 GPU 集群 GeoJSON 数据"""
stmt = (
select(CollectedData)
.where(CollectedData.source == "epoch_ai_gpu")
.where(CollectedData.name != "Unknown")
.order_by(CollectedData.id.desc())
records = await _load_current_collected_data(
db,
"epoch_ai_gpu",
exclude_unknown_name=True,
limit=limit,
)
result = await db.execute(stmt)
records = dedupe_collected_records(list(result.scalars().all()))
records = records[:limit]
if not records:
return {"type": "FeatureCollection", "features": [], "count": 0}
@@ -990,37 +982,27 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
- supercomputers: TOP500 超算
- gpu_clusters: GPU 集群
"""
cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
cables_result = await db.execute(cables_stmt)
cables_records = dedupe_collected_records(list(cables_result.scalars().all()))
points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
points_result = await db.execute(points_stmt)
points_records = dedupe_collected_records(list(points_result.scalars().all()))
satellites_stmt = (
select(CollectedData)
.where(CollectedData.source == "celestrak_tle")
.where(CollectedData.name != "Unknown")
records_by_source = await _load_current_collected_data_by_sources(
db,
[
"arcgis_cables",
"arcgis_landing_points",
"celestrak_tle",
"top500",
"epoch_ai_gpu",
],
)
satellites_result = await db.execute(satellites_stmt)
satellites_records = dedupe_satellite_records(list(satellites_result.scalars().all()))
supercomputers_stmt = (
select(CollectedData)
.where(CollectedData.source == "top500")
.where(CollectedData.name != "Unknown")
cables_records = records_by_source.get("arcgis_cables", [])
points_records = records_by_source.get("arcgis_landing_points", [])
satellites_records = _filter_known_records(
records_by_source.get("celestrak_tle", []),
)
supercomputers_result = await db.execute(supercomputers_stmt)
supercomputers_records = dedupe_collected_records(list(supercomputers_result.scalars().all()))
gpu_stmt = (
select(CollectedData)
.where(CollectedData.source == "epoch_ai_gpu")
.where(CollectedData.name != "Unknown")
supercomputers_records = _filter_known_records(
records_by_source.get("top500", []),
)
gpu_records = _filter_known_records(
records_by_source.get("epoch_ai_gpu", []),
)
gpu_result = await db.execute(gpu_stmt)
gpu_records = dedupe_collected_records(list(gpu_result.scalars().all()))
cables = (
convert_cable_to_geojson(cables_records)
@@ -1084,13 +1066,8 @@ async def get_cable_graph(db: AsyncSession) -> CableGraph:
global _cable_graph
if _cable_graph is None:
cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
cables_result = await db.execute(cables_stmt)
cables_records = list(cables_result.scalars().all())
points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
points_result = await db.execute(points_stmt)
points_records = list(points_result.scalars().all())
cables_records = await _load_current_collected_data(db, "arcgis_cables")
points_records = await _load_current_collected_data(db, "arcgis_landing_points")
cables_data = convert_cable_to_geojson(cables_records)
points_data = convert_landing_point_to_geojson(points_records)

View File

@@ -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:

View File

@@ -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"

View File

@@ -155,6 +155,13 @@ DEFAULT_DATASOURCES = {
"priority": "P1",
"frequency_minutes": 1440,
},
"news_live_streams": {
"id": 26,
"name": "News Live Streams",
"module": "L4",
"priority": "P2",
"frequency_minutes": 720,
},
}
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}

View File

@@ -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)

View File

@@ -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",

View File

@@ -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"<PlaygroundMessage public_id={self.public_id} role={self.role} status={self.status}>"

View File

@@ -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"<PlaygroundSession user_id={self.user_id} session_key={self.session_key}>"

View File

@@ -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,18 +18,158 @@ 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 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 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
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 BGPBriefRecordSummary(BaseModel):
id: str
title: str
provider: str
model: str
request_id: str | None = None
generated_at: str
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):
provider: str
api: str | None = None
enabled: bool
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)

View File

@@ -0,0 +1,5 @@
from pydantic import BaseModel, Field
class AlertResolutionRequest(BaseModel):
resolution: str = Field(..., min_length=1, max_length=1000)

View File

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

View File

@@ -0,0 +1,259 @@
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.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
from app.services.bgp_enrichment import lookup_prefix_geography
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)
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,
) -> 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())
.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)
incident_region_counts = _collect_incident_regions(incidents)
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)]
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} 条原始观测事件。",
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 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(
[
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
]
)
)
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、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。",
observations=observations_lines,
constraints=[
"明确区分事实、推断与建议。",
"优先指出需要立即关注的高严重度 incident 或异常模式。",
"需要单独指出哪些区域结论来自 prefix geography / affected regions哪些可能受 collector coverage 偏差影响。",
"结论应服务值班排障,不要写成泛泛的模型演示文案。",
"如果证据不足,要明确指出缺失数据。",
],
context=context,
), observations_lines, context

View File

@@ -0,0 +1,160 @@
from __future__ import annotations
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
from app.schemas.ai import BGPBriefRecordResponse, BGPBriefRecordSummary, SituationalAnalysisResponse
_BRIEF_STORAGE_DIR = ROOT_DIR / "data" / "ai" / "bgp-briefs"
_METADATA_PREFIX = "<!-- planet-bgp-brief-meta "
_METADATA_SUFFIX = " -->"
_BRIEF_TITLE = "BGP AI 简报"
@dataclass(slots=True)
class _StoredBrief:
id: str
title: str
provider: str
model: str
request_id: str | None
generated_at: str
content_markdown: str
facts: list[str]
context: dict[str, Any]
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, Any]) -> 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"),
facts=list(metadata.get("facts") or []),
context=dict(metadata.get("context") or {}),
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,
facts=parsed.facts,
context=parsed.context,
)
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,
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)
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(),
"facts": facts or [],
"context": context or {},
}
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,
facts=facts or [],
context=context or {},
)

View File

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

View File

@@ -7,7 +7,7 @@ from collections import defaultdict
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, text
from sqlalchemy import Integer, cast, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.countries import get_country_centroid, normalize_country
@@ -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,
*,
@@ -261,29 +268,40 @@ async def enrich_bgp_events_for_batch(
historical_prefix_baseline: dict[str, dict[str, Any]] = {}
if prefix_values:
previous_result = await db.execute(
select(BGPObservation).where(
select(
BGPObservation.prefix,
BGPObservation.origin_asn,
BGPObservation.collector,
BGPObservation.collector_geo,
).where(
BGPObservation.source == source,
BGPObservation.prefix.in_(prefix_values),
)
)
by_prefix: defaultdict[str, list[BGPObservation]] = defaultdict(list)
for observation in previous_result.scalars().all():
if observation.prefix:
by_prefix[observation.prefix].append(observation)
by_prefix: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
for prefix, origin_asn, collector, collector_geo in previous_result.all():
if prefix:
by_prefix[str(prefix)].append(
{
"origin_asn": origin_asn,
"collector": collector,
"collector_geo": collector_geo or {},
}
)
for prefix, observations in by_prefix.items():
unique_origins = sorted(
{
observation.origin_asn
observation["origin_asn"]
for observation in observations
if observation.origin_asn is not None
if observation["origin_asn"] is not None
}
)
unique_collectors = sorted(
{
observation.collector
observation["collector"]
for observation in observations
if observation.collector
if observation["collector"]
}
)
historical_prefix_baseline[prefix] = {
@@ -292,9 +310,9 @@ async def enrich_bgp_events_for_batch(
"historical_observation_count": len(observations),
"historical_regions": _compact_locations(
[
observation.collector_geo or {}
observation["collector_geo"] or {}
for observation in observations
if observation.collector_geo
if observation["collector_geo"]
]
),
}
@@ -303,7 +321,13 @@ async def enrich_bgp_events_for_batch(
prefix_geographies = await _lookup_prefix_geography(db, prefix_values) if prefix_values else {}
if origin_asns:
peeringdb_result = await db.execute(
select(CollectedData).where(CollectedData.source == "peeringdb_network")
select(CollectedData)
.where(CollectedData.source == "peeringdb_network")
.where(CollectedData.is_current.is_(True))
.where(
cast(CollectedData.extra_data["asn"].as_string(), Integer).in_(origin_asns),
)
.order_by(CollectedData.id.desc())
)
for record in peeringdb_result.scalars().all():
metadata = record.extra_data or {}

View File

@@ -48,14 +48,36 @@ def _collector_regions_from_anomaly(anomaly: BGPAnomaly) -> list[dict]:
return collected
def _dedupe_collected_records(records: list[CollectedData]) -> list[CollectedData]:
latest_by_key: dict[str, CollectedData] = {}
for record in records:
dedupe_key = str(record.source_id or record.entity_key or record.name or record.id)
existing = latest_by_key.get(dedupe_key)
if existing is None or (record.id or 0) > (existing.id or 0):
latest_by_key[dedupe_key] = record
return list(latest_by_key.values())
async def _load_current_infrastructure_records(
db: AsyncSession,
) -> tuple[list[CollectedData], list[CollectedData], list[CollectedData]]:
result = await db.execute(
select(CollectedData)
.where(
CollectedData.source.in_(
(
"arcgis_landing_points",
"arcgis_cable_landing_relation",
"arcgis_cables",
)
)
)
.where(CollectedData.is_current.is_(True))
.order_by(CollectedData.source.asc(), CollectedData.id.desc())
)
grouped_records = {
"arcgis_landing_points": [],
"arcgis_cable_landing_relation": [],
"arcgis_cables": [],
}
for record in result.scalars().all():
grouped_records.setdefault(record.source, []).append(record)
return (
grouped_records["arcgis_landing_points"],
grouped_records["arcgis_cable_landing_relation"],
grouped_records["arcgis_cables"],
)
async def infer_related_infrastructure(
@@ -75,19 +97,9 @@ async def infer_related_infrastructure(
if not valid_regions:
return {"related_cables": [], "related_ixps": []}
landing_result = await db.execute(
select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
landing_records, relation_records, cable_records = await _load_current_infrastructure_records(
db,
)
relation_result = await db.execute(
select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation")
)
cable_result = await db.execute(
select(CollectedData).where(CollectedData.source == "arcgis_cables")
)
landing_records = _dedupe_collected_records(list(landing_result.scalars().all()))
relation_records = _dedupe_collected_records(list(relation_result.scalars().all()))
cable_records = _dedupe_collected_records(list(cable_result.scalars().all()))
city_to_cable_ids: dict[int, list[int]] = {}
for relation in relation_records:

View File

@@ -35,6 +35,7 @@ from app.services.collectors.bgpstream import BGPStreamBackfillCollector
from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
collector_registry.register(TOP500Collector())
collector_registry.register(EpochAIGPUCollector())
@@ -61,3 +62,4 @@ collector_registry.register(BGPStreamBackfillCollector())
collector_registry.register(IPtoASNPrefixGeoCollector())
collector_registry.register(OpenGeoFeedPrefixGeoCollector())
collector_registry.register(NRODelegatedPrefixGeoCollector())
collector_registry.register(NewsLiveStreamsCollector())

View File

@@ -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,7 +281,24 @@ class BaseCollector(ABC):
"records_processed": records_count,
"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"
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:
await db.rollback()
task.status = "failed"
task.phase = "failed"
task.error_message = str(e)
@@ -276,20 +347,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 +403,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 +449,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:

View File

@@ -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:

View File

@@ -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 = []

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = []

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import httpx
from app.services.collectors.base import BaseCollector
class NewsLiveStreamsCollector(BaseCollector):
"""Collect normalized news live-stream sources from a JSON endpoint."""
name = "news_live_streams"
priority = "P2"
module = "L4"
frequency_hours = 12
data_type = "news_live_stream"
fail_on_empty = False
async def fetch(self) -> list[dict[str, Any]]:
request_url = (self._resolved_url or "").strip()
if not request_url:
return []
async with httpx.AsyncClient(timeout=45.0, follow_redirects=True) as client:
response = await client.get(
request_url,
headers={
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
"Accept": "application/json",
},
)
response.raise_for_status()
return self.parse_response(response.json())
def parse_response(self, response: Any) -> list[dict[str, Any]]:
if isinstance(response, dict):
candidates = response.get("sources") or response.get("streams") or response.get("data") or []
elif isinstance(response, list):
candidates = response
else:
candidates = []
normalized: list[dict[str, Any]] = []
for index, item in enumerate(candidates):
if not isinstance(item, dict):
continue
stream_id = item.get("id") or item.get("source_id") or item.get("slug") or f"news-live-{index + 1}"
name = str(item.get("name") or item.get("title") or f"News Live {index + 1}").strip()
if not name:
continue
metadata = {
"provider": item.get("provider") or item.get("publisher") or "Collector",
"region": item.get("region") or item.get("country") or "Global",
"language": item.get("language") or "und",
"source_type": item.get("source_type") or "iframe",
"embed_url": item.get("embed_url") or item.get("url") or "",
"stream_url": item.get("stream_url") or "",
"homepage_url": item.get("homepage_url") or item.get("source_url") or "",
"poster_url": item.get("poster_url") or "",
"sort_order": item.get("sort_order", 200 + index),
"notes": item.get("notes") or item.get("description") or "",
"is_enabled": item.get("is_enabled", True),
}
normalized.append(
{
"source_id": str(stream_id),
"name": name,
"description": metadata["notes"],
"metadata": metadata,
"reference_date": item.get("reference_date", datetime.now(UTC).isoformat()),
}
)
return normalized

View File

@@ -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)

View File

@@ -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",

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -0,0 +1,694 @@
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] = {}
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,
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:
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()]
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
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,
*,
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)
_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,
)
return await _build_action_response(
db,
session=session,
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)
_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,
)
return await _build_action_response(
db,
session=session,
active_message_id=assistant_message.public_id,
)
async def stop_message(
db: AsyncSession,
*,
user_id: int,
payload: PlaygroundMessageStopRequest,
) -> PlaygroundMessageActionResponse:
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"}:
return await _build_action_response(db, session=session)
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)
return await _build_action_response(db, session=session)
async def resend_turn(
db: AsyncSession,
*,
user_id: int,
payload: PlaygroundMessageResendRequest,
provider_client: AIProviderClient,
) -> PlaygroundMessageActionResponse:
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",
)
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:
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",
)
user_message.content = payload.content.strip()
await db.flush()
await db.commit()
await db.refresh(user_message)
return await _build_action_response(db, session=session)
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)

View File

@@ -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)

View File

@@ -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"
@@ -244,10 +274,37 @@ 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:
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()

View File

@@ -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

View File

@@ -0,0 +1,466 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlparse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import to_iso8601_utc
from app.models.collected_data import CollectedData
from app.models.system_setting import SystemSetting
DEFAULT_TV_SOURCE_ID = "cgtn-en"
TV_SETTINGS_CATEGORY = "tv"
TV_LIVE_SOURCE_COLLECTOR = "news_live_streams"
TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream"
DEFAULT_TV_SETTINGS = {
"default_source_id": DEFAULT_TV_SOURCE_ID,
"auto_fallback": True,
"sources": [
{
"id": "cctv4",
"name": "CCTV-4 中文国际",
"provider": "CCTV",
"region": "China",
"language": "zh-CN",
"source_type": "hls",
"embed_url": "https://tv.cctv.com/live/cctv4/",
"stream_url": "https://ldocctvwbcdtxy.liveplay.myqcloud.com/ldocctvwbcd/cdrmldcctv4_1_td.m3u8",
"homepage_url": "https://tv.cctv.com/live/cctv4/",
"poster_url": "",
"is_enabled": True,
"is_fallback": True,
"sort_order": 10,
"collector_source": None,
"notes": "默认兜底新闻直播源。优先尝试 CCTV-4 官方 HLS 播放流,若直播放失败则回退到央视官网直播页。",
},
{
"id": "reuters-tv",
"name": "Reuters TV",
"provider": "Reuters",
"region": "Global",
"language": "en",
"source_type": "hls",
"embed_url": "",
"stream_url": "https://reuters-reutersnow-1-eu.rakuten.wurl.tv/playlist.m3u8",
"homepage_url": "https://www.reuters.com/video/live/",
"poster_url": "",
"is_enabled": True,
"is_fallback": False,
"sort_order": 20,
"collector_source": None,
"notes": "参考 worldmonitor 的默认新闻频道清单,优先作为全球英文新闻直播放源。",
},
{
"id": "cgtn-en",
"name": "CGTN English",
"provider": "CGTN",
"region": "Global",
"language": "en",
"source_type": "youtube",
"embed_url": "https://www.youtube.com/watch?v=BOy2xDU1LC8",
"stream_url": "https://news.cgtn.com/resource/live/english/cgtn-news.m3u8",
"youtube_video_id": "BOy2xDU1LC8",
"homepage_url": "https://news.cgtn.com/",
"poster_url": "",
"is_enabled": True,
"is_fallback": False,
"sort_order": 30,
"collector_source": None,
"notes": "优先使用官方 YouTube 直播源,保留 HLS 直播放流作为候选信息。",
},
{
"id": "cgtn-es",
"name": "CGTN Espanol",
"provider": "CGTN",
"region": "Latin America",
"language": "es",
"source_type": "hls",
"embed_url": "",
"stream_url": "https://news.cgtn.com/resource/live/espanol/cgtn-e.m3u8",
"homepage_url": "https://news.cgtn.com/",
"poster_url": "",
"is_enabled": True,
"is_fallback": False,
"sort_order": 40,
"collector_source": None,
"notes": "西语国际新闻频道,覆盖拉美方向态势。",
},
{
"id": "dw-espanol",
"name": "DW Espanol",
"provider": "Deutsche Welle",
"region": "Europe",
"language": "es",
"source_type": "hls",
"embed_url": "",
"stream_url": "https://dwamdstream104.akamaized.net/hls/live/2015530/dwstream104/stream04/streamPlaylist.m3u8",
"homepage_url": "https://www.dw.com/es/",
"poster_url": "",
"is_enabled": True,
"is_fallback": False,
"sort_order": 50,
"collector_source": None,
"notes": "来自 worldmonitor 可选频道清单的直播放源。",
},
{
"id": "dw-arabic",
"name": "DW Arabic",
"provider": "Deutsche Welle",
"region": "Middle East",
"language": "ar",
"source_type": "hls",
"embed_url": "",
"stream_url": "https://dwamdstream103.akamaized.net/hls/live/2015526/dwstream103/index.m3u8",
"homepage_url": "https://www.dw.com/ar/",
"poster_url": "",
"is_enabled": True,
"is_fallback": False,
"sort_order": 60,
"collector_source": None,
"notes": "阿拉伯语新闻流,适合作为中东方向新闻补充源。",
},
{
"id": "aljazeera-mubasher",
"name": "Al Jazeera Mubasher",
"provider": "Al Jazeera",
"region": "Middle East",
"language": "ar",
"source_type": "hls",
"embed_url": "",
"stream_url": "https://live-hls-web-ajm.getaj.net/AJM/index.m3u8",
"homepage_url": "https://www.aljazeera.net/live",
"poster_url": "",
"is_enabled": True,
"is_fallback": False,
"sort_order": 70,
"collector_source": None,
"notes": "中东实时新闻流,来自 worldmonitor HLS 频道目录。",
},
{
"id": "arirang-news",
"name": "Arirang News",
"provider": "Arirang",
"region": "Korea",
"language": "en",
"source_type": "hls",
"embed_url": "",
"stream_url": "https://amdlive-ch01-ctnd-com.akamaized.net/arirang_1ch/smil:arirang_1ch.smil/playlist.m3u8",
"homepage_url": "https://www.arirang.com/",
"poster_url": "",
"is_enabled": True,
"is_fallback": False,
"sort_order": 80,
"collector_source": None,
"notes": "东北亚英语新闻源,适合补充韩半岛与东亚视角。",
},
{
"id": "abp-news",
"name": "ABP News",
"provider": "ABP",
"region": "India",
"language": "hi",
"source_type": "hls",
"embed_url": "",
"stream_url": "https://abplivetv.pc.cdn.bitgravity.com/httppush/abp_livetv/abp_abpnews/master.m3u8",
"homepage_url": "https://news.abplive.com/live-tv",
"poster_url": "",
"is_enabled": True,
"is_fallback": False,
"sort_order": 90,
"collector_source": None,
"notes": "印度新闻直播放源,补充南亚区域视角。",
},
{
"id": "sabc-news",
"name": "SABC News",
"provider": "SABC",
"region": "Africa",
"language": "en",
"source_type": "hls",
"embed_url": "",
"stream_url": "https://sabconetanw.cdn.mangomolo.com/news/smil:news.stream.smil/playlist.m3u8",
"homepage_url": "https://www.sabcnews.com/sabcnews/",
"poster_url": "",
"is_enabled": True,
"is_fallback": False,
"sort_order": 100,
"collector_source": None,
"notes": "非洲英语新闻源,补充非洲区域新闻覆盖。",
},
],
}
def _clean_text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _clean_url(value: Any) -> str:
text = _clean_text(value)
if not text:
return ""
parsed = urlparse(text)
if parsed.scheme and parsed.scheme not in {"http", "https"}:
return ""
if parsed.scheme and not parsed.netloc:
return ""
return text
def _clean_bool(value: Any, *, default: bool) -> bool:
if isinstance(value, bool):
return value
if value in (None, ""):
return default
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"1", "true", "yes", "on"}:
return True
if lowered in {"0", "false", "no", "off"}:
return False
return bool(value)
def _clean_int(value: Any, *, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def normalize_tv_source(source: dict[str, Any] | None, *, index: int = 0) -> dict[str, Any]:
payload = dict(source or {})
source_id = _clean_text(payload.get("id")) or f"tv-source-{index + 1}"
source_type = _clean_text(payload.get("source_type")).lower()
youtube_video_id = _clean_text(payload.get("youtube_video_id"))
youtube_channel = _clean_text(payload.get("youtube_channel"))
if source_type not in {"iframe", "hls", "video", "external", "youtube"}:
if youtube_video_id or youtube_channel:
source_type = "youtube"
else:
source_type = "iframe" if _clean_text(payload.get("embed_url")) else "external"
if source_type == "youtube" and not youtube_video_id and not youtube_channel:
source_type = "iframe" if _clean_text(payload.get("embed_url")) else "external"
return {
"id": source_id,
"name": _clean_text(payload.get("name")) or f"新闻直播源 {index + 1}",
"provider": _clean_text(payload.get("provider")) or "Unknown",
"region": _clean_text(payload.get("region")) or "Global",
"language": _clean_text(payload.get("language")) or "und",
"source_type": source_type,
"embed_url": _clean_url(payload.get("embed_url")),
"stream_url": _clean_url(payload.get("stream_url")),
"homepage_url": _clean_url(payload.get("homepage_url")),
"poster_url": _clean_url(payload.get("poster_url")),
"youtube_video_id": youtube_video_id,
"youtube_channel": youtube_channel,
"is_enabled": _clean_bool(payload.get("is_enabled"), default=True),
"is_fallback": _clean_bool(payload.get("is_fallback"), default=False),
"sort_order": _clean_int(payload.get("sort_order"), default=(index + 1) * 10),
"collector_source": payload.get("collector_source"),
"notes": _clean_text(payload.get("notes")),
"updated_at": _clean_text(payload.get("updated_at")),
}
def normalize_tv_settings(payload: dict[str, Any] | None) -> dict[str, Any]:
merged = {
"default_source_id": DEFAULT_TV_SETTINGS["default_source_id"],
"auto_fallback": DEFAULT_TV_SETTINGS["auto_fallback"],
"sources": [],
}
raw_sources = []
if isinstance(payload, dict):
merged["default_source_id"] = (
_clean_text(payload.get("default_source_id")) or merged["default_source_id"]
)
merged["auto_fallback"] = _clean_bool(
payload.get("auto_fallback"),
default=DEFAULT_TV_SETTINGS["auto_fallback"],
)
if isinstance(payload.get("sources"), list):
raw_sources = payload["sources"]
if not raw_sources:
raw_sources = DEFAULT_TV_SETTINGS["sources"]
normalized_sources = [
normalize_tv_source(source, index=index)
for index, source in enumerate(raw_sources)
]
if not any(source["id"] == DEFAULT_TV_SOURCE_ID for source in normalized_sources):
normalized_sources.append(
normalize_tv_source(DEFAULT_TV_SETTINGS["sources"][0], index=len(normalized_sources))
)
default_source_exists = any(
source["id"] == merged["default_source_id"] and source["is_enabled"]
for source in normalized_sources
)
if not default_source_exists:
fallback_source = next(
(source for source in normalized_sources if source["is_fallback"] and source["is_enabled"]),
None,
)
first_enabled_source = next(
(source for source in normalized_sources if source["is_enabled"]),
None,
)
merged["default_source_id"] = (
fallback_source["id"]
if fallback_source
else first_enabled_source["id"]
if first_enabled_source
else DEFAULT_TV_SOURCE_ID
)
merged["sources"] = sorted(
normalized_sources,
key=lambda item: (item["sort_order"], item["name"], item["id"]),
)
return merged
async def get_tv_settings_payload(db: AsyncSession) -> dict[str, Any]:
result = await db.execute(
select(SystemSetting).where(SystemSetting.category == TV_SETTINGS_CATEGORY)
)
record = result.scalar_one_or_none()
payload = record.payload if record else None
return normalize_tv_settings(payload)
def _build_collected_tv_source(record: CollectedData, index: int) -> dict[str, Any]:
metadata = dict(record.extra_data or {})
return normalize_tv_source(
{
"id": metadata.get("id") or record.source_id or record.entity_key,
"name": record.name or record.title or metadata.get("name") or f"采集直播源 {index + 1}",
"provider": metadata.get("provider") or metadata.get("publisher") or "Collector",
"region": metadata.get("region") or metadata.get("country") or "Global",
"language": metadata.get("language") or "und",
"source_type": metadata.get("source_type") or "iframe",
"embed_url": metadata.get("embed_url") or metadata.get("url") or "",
"stream_url": metadata.get("stream_url") or "",
"homepage_url": metadata.get("homepage_url") or metadata.get("source_url") or "",
"poster_url": metadata.get("poster_url") or "",
"youtube_video_id": metadata.get("youtube_video_id") or metadata.get("video_id") or "",
"youtube_channel": metadata.get("youtube_channel") or metadata.get("channel_handle") or "",
"is_enabled": metadata.get("is_enabled", True),
"is_fallback": False,
"sort_order": metadata.get("sort_order", 200 + index),
"collector_source": record.source,
"notes": record.description or metadata.get("notes") or "",
"updated_at": to_iso8601_utc(record.updated_at or record.reference_date or datetime.now(UTC)),
},
index=index,
)
async def get_collected_tv_sources(db: AsyncSession) -> list[dict[str, Any]]:
result = await db.execute(
select(CollectedData)
.where(CollectedData.source == TV_LIVE_SOURCE_COLLECTOR)
.where(CollectedData.data_type == TV_LIVE_SOURCE_DATA_TYPE)
.where(CollectedData.is_current.is_(True))
.where(CollectedData.is_valid == 1)
.order_by(CollectedData.reference_date.desc().nullslast(), CollectedData.id.desc())
)
rows = result.scalars().all()
return [_build_collected_tv_source(record, index) for index, record in enumerate(rows)]
def build_public_tv_payload(
settings_payload: dict[str, Any],
collected_sources: list[dict[str, Any]],
) -> dict[str, Any]:
configured_sources = [
source for source in settings_payload["sources"] if source["is_enabled"]
]
merged_by_id = {source["id"]: source for source in configured_sources}
for source in collected_sources:
if source["id"] in merged_by_id or not source["is_enabled"]:
continue
merged_by_id[source["id"]] = source
available_sources = sorted(
merged_by_id.values(),
key=lambda item: (item["sort_order"], item["name"], item["id"]),
)
default_source = next(
(
source
for source in available_sources
if source["id"] == settings_payload["default_source_id"]
),
None,
)
fallback_source = next(
(source for source in available_sources if source["is_fallback"]),
None,
)
resolved_source = default_source or fallback_source or (available_sources[0] if available_sources else None)
latest_updated_at = max(
(source.get("updated_at") or "" for source in available_sources),
default="",
)
return {
"default_source_id": settings_payload["default_source_id"],
"auto_fallback": settings_payload["auto_fallback"],
"selected_source": resolved_source,
"fallback_source": fallback_source,
"sources": available_sources,
"source_count": len(available_sources),
"latest_updated_at": latest_updated_at or to_iso8601_utc(datetime.now(UTC)),
"generated_at": to_iso8601_utc(datetime.now(UTC)),
}
async def get_public_tv_payload(db: AsyncSession) -> dict[str, Any]:
settings_payload = await get_tv_settings_payload(db)
collected_sources = await get_collected_tv_sources(db)
return build_public_tv_payload(settings_payload, collected_sources)
def _extract_allowed_tv_hosts(sources: list[dict[str, Any]]) -> set[str]:
hosts: set[str] = set()
for source in sources:
for field in ("stream_url", "embed_url", "homepage_url", "youtube_channel"):
value = _clean_url(source.get(field))
if not value:
continue
parsed = urlparse(value)
if parsed.hostname:
hosts.add(parsed.hostname.lower())
return hosts
def is_allowed_tv_proxy_url(url: str, sources: list[dict[str, Any]]) -> bool:
cleaned = _clean_url(url)
if not cleaned:
return False
parsed = urlparse(cleaned)
hostname = (parsed.hostname or "").lower()
if not hostname:
return False
allowed_hosts = _extract_allowed_tv_hosts(sources)
if hostname in allowed_hosts:
return True
return any(hostname.endswith(f".{allowed_host}") for allowed_host in allowed_hosts)

View File

@@ -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
@@ -90,6 +95,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"""
@@ -165,7 +179,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 +208,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 +223,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 +260,297 @@ 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
@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()

View File

@@ -5,6 +5,8 @@ services:
build:
context: .
dockerfile: aiprovider/Dockerfile
env_file:
- ./aiprovider/.env
container_name: planet_aiprovider
ports:
- "8010:8010"

View File

@@ -5,8 +5,485 @@ All notable changes to `planet` are documented here.
This project follows the repository versioning rule:
- `feature` -> `+0.1.0`
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.27.6] — 2026-04-15
### 🔧 Improvements
- BGP 告警页表格纵向 overflow 修复:补全 flex 布局链tabs content-holder 正确撑满剩余高度
- 用户管理表格横向滚动修复:采用 flex-fill 方案替换 `height: auto !important`,自定义滚动条 X 轨道位置对齐表格底部
- Playground 宽布局隐藏"服务状态"按钮:侧边栏可见时不显示冗余入口
- AI Chatbox 输入框失焦收起为单行,聚焦或有内容时展开完整 composer
---
## [0.27.4] — 2026-04-14
### 🔧 Improvements
- info-card 改为懒加载动态挂载:页面初始 DOM 不再含隐藏的 `#info-panel` 节点,仅首次点击交互元素时创建
---
## [0.27.5] — 2026-04-14
### 🔧 Improvements
- 统一控制台多页面滚动体验BGP、alerts、采集数据、用户管理、任务、设置、Playground 等区域接入自定义滚动条与表格滚动容器
- 优化 BGP 与 alerts 页响应式布局:顶部概览卡在窄宽度下优先重排,必要时才启用横向滚动,避免卡片裁切和全局滚动条接管
- 调整 `situational alerts` 布局策略:统计卡按宽度在单行、两列和横滚之间切换,下方详情卡保持单行高度优先
- 实时采集进度优化:一键采集完成后在未刷新页面时保留 100% 完成态,不再错误归零
- 补充 UE5 MVP 融合方案文档,完善后续集成规划沉淀
### 🐛 Fixes
- 修复 BGP summary 与 alerts 顶部卡片在无真实溢出时误出现横向滚动的问题
- 修复 alerts 页面缩窄后外层全局竖向滚动被接管的问题,恢复“一屏内、内部滚动”的布局逻辑
- 修复 `situational alerts` 在两排布局下详情卡竖向溢出的问题,改为更稳定的分区响应式排版
- 修复自定义滚动条交互反馈,悬停、聚焦、拖拽时颜色加深但不再显示多余外圈
---
## [0.27.3] — 2026-04-14
### 🔧 Improvements
- TV panel meta 折叠展开方向稳定:底部锚定时向上生长,拖拽后(顶部锚定)通过 JS 补偿 top 保持播放器底部位置不变
- 修复 TV panel 展开/折叠时视频区域跳动问题:移除面板 min-height使播放器高度在两种状态下保持一致
- 修正 TV panel meta toggle 箭头方向:展开朝下,折叠朝上
- 修复图例面板折叠按钮失效legend-bar-btn 补充进拖拽排除列表)
- 调整图层搜索框图标尺寸为 20pxBR 缩放角标改为直角 L 形
---
## [0.27.2] — 2026-04-14
### 🔧 Improvements
- 修复 brand copy 宽度不随内容收缩的问题,现在与 title 图片宽度保持一致
- 提取 `--brand-copy-width` CSS 自定义属性,消除 160px / 172px 魔法数字重复
---
## [0.27.1] — 2026-04-14
### 🔧 Improvements
- 面板拖拽新增 L 形边界约束,其他面板无法覆盖 brand 面板区域,并从右侧/底部自然卡边
- brand 组件引入 `--brand-scale` 整体缩放变量padding 与内容尺寸独立控制
- 图层控制面板宽度收窄260px与 brand 面板错落排列,间距调大
### 🐛 Fixes
- 修复搜索框 `type="search"` 导致清除按钮重复显示的问题
- 修复 `[hidden]` 属性被组件 `display` 规则覆盖的问题
---
## 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
### Highlights
- 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/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/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 Tables internal layout behavior.
## 0.26.0
Released: 2026-04-12
### Highlights
- Added an operator-facing TV live module to Earth, including backend-configurable live sources, a draggable/resizable live-news HUD window, default global news channels, and a dedicated settings workflow so the Earth page can open real news playback instead of only static telemetry.
### Added
- Added [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion.
- Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling.
- Added [docs/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-tv-live-module-plan.md) and [docs/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
### Improved
- Improved [frontend/src/pages/Settings/Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx), [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py), and [backend/app/core/datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py) by adding TV source administration to system settings and registering the `news_live_streams` datasource as a first-class configurable collector.
- Improved [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py) by seeding a curated first-pass news channel catalog that now defaults to `CGTN English` YouTube playback while keeping `CCTV-4` as a built-in fallback and exposing additional Reuters, CGTN, DW, Al Jazeera, Arirang, ABP, and SABC entries for operator testing.
### Fixed
- 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
### 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
### 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
### 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
### 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 repos 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
### Highlights
- 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 [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 [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
Released: 2026-04-10
### Highlights
- Tightened several backend hot paths outside the original BGP page fixes, stabilized BGP collector coverage after the recent query refactors, and rebuilt the BGP AI brief tab so saved Markdown briefs render and scroll like a proper operator workspace instead of collapsing inside the shared table layout.
### Improved
- Improved [backend/app/api/v1/datasources.py](/home/ray/dev/linkong/planet/backend/app/api/v1/datasources.py) by replacing per-datasource task, count, and endpoint lookups with batched prefetch helpers, reducing the worst `1 + N` behavior on the datasource list and `trigger-all` flow.
- Improved [backend/app/api/v1/visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py) by switching the main Earth-facing `CollectedData` endpoints to `is_current` records, batching multi-source loads for aggregate endpoints, and removing stale Python-side dedupe paths from the hot route.
- Improved [backend/app/services/bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) by avoiding historical full-table infrastructure scans, narrowing observation baseline payloads to required columns, and pushing more ASN filtering into the database.
- Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads.
- Improved [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), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully.
- Improved [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
### Fixed
- Fixed [backend/app/services/bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) so JSON field extraction no longer depends on the less portable `.astext` path that could break BGP collector endpoints in local environments.
- Fixed the BGP AI brief tab in [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 long saved briefs are no longer compressed into a tiny clipped viewport by the shared tab/table overflow rules.
## 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
### 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
### 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
### Highlights
- Refined the `/earth` HUD into a cleaner class-first structure with responsive scaling, clearer CSS layer boundaries, and lower coupling between HTML, CSS, and runtime UI updates.
- Hardened local startup conventions around Bun so frontend tooling, docs, and `planet.sh` behave more predictably in fresh Ubuntu and mixed WSL environments.
### Added
- Added [frontend/public/earth/css/hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css), extracting shared HUD panel surfaces, shared HUD typography rows, status messaging, tooltip overlays, and layout-expanded panel transitions out of the old monolithic base layer.
- Added [frontend/public/earth/css/toolbar.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/toolbar.css), isolating Earth toolbar, popover, zoom dock, liquid-glass button, and toolbar-tooltip behavior into a dedicated toolbar layer.
- Added explicit Bun package-manager metadata to [frontend/package.json](/home/ray/dev/linkong/planet/frontend/package.json) so the frontend package manager choice is declared instead of inferred from the lockfile alone.
### Improved
- Improved [frontend/public/earth/css/base.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/base.css) by reducing it to app-shell concerns only: global tokens, Earth app container, loading panel, and shared animation primitives.
- Improved [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) by wiring the new CSS layer order, standardizing HUD utility classes on `hud-panel-*`, and replacing generic toolbar/tooltip hooks with more explicit Earth-specific classes.
- Improved [frontend/public/earth/css/info-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/info-panel.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), and [frontend/public/earth/css/earth-stats.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/earth-stats.css) by leaving only panel-specific responsibilities in each file after the shared HUD and toolbar primitives moved out.
- Improved [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js), [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js), [frontend/public/earth/js/ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js), and [frontend/public/earth/js/legend.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/legend.js) by aligning runtime DOM queries and generated markup with the new class-first HUD and toolbar structure.
- Improved [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) and [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) by keeping HUD scaling configurable through extracted constants instead of scattering scaling assumptions through the main Earth entrypoint.
- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by prepending `~/.local/bin` and `~/.bun/bin` automatically, preferring direct `~/.bun/bin/bun` detection, and auto-installing missing `uv`/`bun` instead of requiring the user's interactive shell config to expose them first.
- Improved [README.md](/home/ray/dev/linkong/planet/README.md), [project_context.md](/home/ray/dev/linkong/planet/project_context.md), [rules.md](/home/ray/dev/linkong/planet/rules.md), and [scripts/bootstrap-dev.sh](/home/ray/dev/linkong/planet/scripts/bootstrap-dev.sh) by making the frontend Bun-only workflow explicit in both onboarding docs and command-line guidance.
### Fixed
- Fixed the Earth HUD cleanup path where splitting styles previously left the page with a missing `base.css` entry and mismatched utility class names during the refactor.
- Fixed several Earth UI update paths so toolbar tooltip text, legend re-rendering, status message classes, and mouse coordinate styling continue to work after removing legacy fallback selectors.
- Fixed the local developer bootstrap path where `planet.sh start` could fail in a clean Ubuntu or agent shell session simply because Bun or uv were installed outside the current shell's inherited `PATH`.
## 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
### 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
### 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
### 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
### 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
@@ -31,6 +508,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

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1,361 @@
# 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
- 让 BGP 页面逐步从“摘要汇总”升级为“证据驱动的区域态势分析”
建议实现:
- 新增专用后端接口,例如:
- `POST /api/v1/ai/bgp/brief`
- 后端自动读取:
- incidents summary
- anomalies
- recent events
- collector coverage summary
- 后端将结构化事实注入 `context / observations`
- 前端在 BGP 页面增加“生成 AI 简报”
当前阶段说明:
- 第一版 `BGP AI 简报` 允许先落地为“值班摘要生成器”
- 也就是先把 incidents / anomalies / events / collector coverage 自动注入
- 允许模型先做事实摘要、风险归纳、建议动作
但这不应被视为 Phase B 的最终形态。
Phase B 后续还需要补齐:
- prefix geography 证据注入
- `iptoasn`
- `opengeofeed`
- `nro_delegated`
- 基于 `affected_regions` 与 prefix geography 的区域聚合
- 区分“真实区域热度”与“collector coverage 偏差”
- 对高风险 prefix / ASN 给出更明确的国家、城市、运营商归属线索
- 让 AI 输出明确回答:
- 哪些区域正在异常升温
- 哪些结论只是观测站偏差
- 当前还缺哪些区域证据
完成标准:
- 用户不需要手工录入 BGP 观察项
- AI 输出能明确区分“事实”和“研判”
- AI 不只是复述总量和最近几条事件,还能利用 prefix geography 与 affected regions 做区域态势判断
- 输出中能明确指出:
- 高风险区域
- 区域证据来源
- collector coverage 偏差对判断的影响
### 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. 补齐 `BGP brief` 的区域态势证据层
5. 把 AI 输出逐步从自由文本升级为结构化 assessment
### BGP Brief 后续子项
为避免把“已有 AI 简报”误判成“区域分析已完成”,这里单独记录 `BGP brief` 的后续 backlog
1. 把高风险 prefix 命中的 `iptoasn / opengeofeed / nro_delegated` 结果注入 brief context
2. 按国家/城市聚合 active incidents、anomalies、affected prefixes生成区域热点事实层
3. 把 collector coverage 与区域热点并排注入,避免模型把观测偏差误判成区域风险
4. 对高风险 ASN / prefix 追加归属线索,如国家、城市、可能运营商或注册区域
5. 在输出结构中单独增加:
- 区域态势
- 证据来源
- 观测偏差说明
- 缺失区域证据

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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 <Path中对应的docker-buildx文件>
```
---
## 清理无用依赖
```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 .
```

View File

@@ -0,0 +1,117 @@
# Earth 电视直播模块计划
## 目标
`Earth` 页面增加一个可配置、可扩展、可拖拽的电视直播模块:
- 后台可配置新闻直播源
- 默认兜底源为央视 `CCTV-4`
- 未来可通过采集器接入世界各地新闻直播源
- Earth 工具栏 `显示控制` 子菜单新增电视按钮
- 点击后打开一个与其他 HUD 一致的可拖拽/可关闭窗口
- 窗口内部可播放或承载新闻直播页面
## 设计原则
- 第一阶段先交付“后台可配 + Earth 可用 + 默认可回退”的版本
- 公开读取接口与后台管理接口分离
- 手工配置源与采集器源共用统一的前端消费结构
- Earth 里的电视窗口必须复用现有 HUD 拖拽、关闭、布局最大化逻辑
- 小屏下优先保证窗口完整显示,超出部分在窗口内部滚动
## 分阶段实现
### Phase 1后端配置与公开读取
- 在系统设置中新增 `tv` 分类
- 定义直播源配置结构:
- `default_source_id`
- `auto_fallback`
- `sources[]`
- 每个直播源至少包含:
- `id`
- `name`
- `provider`
- `region`
- `language`
- `source_type`
- `embed_url`
- `stream_url`
- `homepage_url`
- `is_enabled`
- `is_fallback`
- `sort_order`
- `collector_source`
- `notes`
- 默认兜底源使用央视官网 `CCTV-4` 直播页
- 新增公开读取接口,供 Earth 页面无登录态读取直播源配置
### Phase 2采集器扩展位
- 新增 `news_live_streams` collector 占位
- 规范采集器入库数据结构,使其能与后台手工配置源合并
- TV 公开接口支持合并:
- 后台手工配置源
- 采集器入库源
- 保持手工配置源优先级更高,避免采集器覆盖人工兜底配置
### Phase 3后台配置界面
- 在系统配置页新增 `电视直播` tab
- 支持:
- 查看当前默认源
- 开关自动回退
- 新增直播源
- 编辑直播源
- 删除直播源
- 启用/禁用直播源
- 将某个直播源设为默认源
- 明确区分:
- 手工配置源
- 采集器来源
### Phase 4Earth HUD 集成
-`显示控制` 子菜单加入电视按钮
- 新增 TV HUD 面板:
- 可拖拽
- 可关闭
- 支持显示/隐藏状态同步
- 参与布局最大化与恢复布局
- 面板内容至少包含:
- 当前频道标题
- 源切换下拉菜单
- 刷新按钮
- 打开官网按钮
- 播放区域
### Phase 5播放策略
- 第一版优先支持 `iframe`/嵌入页类直播源
- 为未来扩展保留:
- `hls`
- `video`
- `external`
- 如果默认源不可用:
- 优先回退到标记为 `is_fallback=true` 的源
- 若无明确回退源,则回退到第一个可用源
- 面板内要有清晰的加载、错误、回退提示
### Phase 6打磨与清理
- 统一 HUD 风格
- 小屏下限制窗口尺寸并启用内部滚动
- 避免窗口超出屏幕
- 补最小验证
- 清理临时代码、重复样式和无用资源
## 首版交付定义
当以下条件满足时,认为首版可用:
- 后台可以配置新闻直播源
- Earth 可以读取并显示默认直播源
- 工具栏可打开电视窗口
- 电视窗口可拖拽、可关闭
- 央视 `CCTV-4` 作为默认兜底源可被使用
- 代码结构已为后续采集器接入预留统一接口

View File

@@ -0,0 +1,309 @@
# 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>
<div className="page-shell">
<div className="page-shell__header">...</div>
<div className="page-shell__body">...</div>
</div>
</AppLayout>
```
页面总高度应被限制在 `AppLayout` 内容区内,而不是继续让整个页面自然向下增长。
### 2. 滚动优先发生在模块内部
如果表格、日志、长列表、图表明细超出空间:
- 让卡片内部滚动
- 让表格内部滚动
- 让标签页内容区内部滚动
不要默认依赖整个页面滚动去“解决”空间问题。
### 3. 主工作区必须拿到主要空间
页面里最重要的模块必须是视觉和空间上的主角。通常应保证:
- 页头始终可见
- 摘要区高度被控制
- 主表格 / 主图表 / 主分析区占据 50% 以上可视高度
如果一个页面有多个大模块,优先顺序是:
1. 先压缩说明区和摘要区
2. 再把次级模块收进标签页或切换视图
3. 最后才考虑继续增加整页滚动
### 4. 小屏幕和高缩放必须进入紧凑模式
在窗口高度较低、宽度较窄、或系统缩放较高时,应主动切换紧凑布局,例如:
- 缩小卡片 padding
- 缩小表头和单元格间距
- 将摘要区改为更紧凑的单行/横向滚动布局
- 将次级模块移入标签页、抽屉、折叠区
紧凑模式的目标是保持可用,不是单纯把文字和控件一股脑缩小。
### 5. overflow 责任必须明确
页面中的大块内容必须明确:
- 谁负责占满剩余高度
- 谁负责裁剪
- 谁负责滚动
常见要求:
- 父容器链路需要 `min-height: 0`
- 工作区容器通常需要 `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 没跟着切换
这类约束同样适用于后台页面:
- 被隐藏、卸载、切换出视图的内容,不应继续保留活跃交互状态
## 推荐实现模式
### 页面骨架
优先复用项目里已有的通用结构:
- `.dashboard-content-inner`
- `.page-shell`
- `.page-shell__header`
- `.page-shell__body`
- `.table-scroll-region`
不要每个页面都重新发明一套完全不同的高度和滚动语义。
### 表格工作区
推荐模式:
```tsx
<Card>
<div className="table-scroll-region" ref={tableRegionRef}>
<Table
pagination={false}
scroll={{ x: 1200, y: tableHeight }}
/>
</div>
</Card>
```
要求:
- 表格尽量在卡片内部滚动
- `scroll.y` 应来自实际可用高度估算,而不是完全静态的魔法数字
- 父容器链路要保证 header、body、content 的 overflow 都在表格内部闭合
### 多模块页面
如果一个页面同时有:
- 摘要卡
- 表格
- 异常明细
- 最近事件
不建议简单纵向堆叠全部模块。优先使用:
- 顶部摘要 + 底部单一主工作区
- 标签页切换多个次级数据视图
- 左右分栏,并保证每栏内部独立滚动
## 不推荐的做法
以下模式默认视为不符合本项目页面规范:
- 依赖整页纵向滚动来显示主要工作区
- 一个页面纵向堆 3 到 4 个大卡片,每个都想完整展示
- 表格没有内部滚动,导致缩放后只能看到 1 到 2 行数据
- 父容器缺少 `min-height: 0`,导致内部滚动失效
- 只做视觉缩小,不处理真正的空间分配
## 页面验收检查清单
提交前至少检查:
- 页头、摘要区、主工作区能否同时出现
- 主工作区是否拿到了页面中最多的高度
- 表格或明细溢出时,滚动条是否出现在模块内部
- 卡片是否被压缩到文字显示不完整;如果会,是否已经切换为内部滚动
- 浏览器缩放到 `125%` / `150%` 时是否仍可用
- 低高度窗口下是否还保有合理的可见内容行数
- Tabs、Card、Table 在 overflow 时是否仍可操作
- 非表格 tabMarkdown、帮助说明、日志是否有独立且合理的滚动策略
## 落地顺序
后续新增或重构后台页时,优先按这个顺序设计:
1. 先定义主工作区
2. 再确定哪些模块必须常驻可见
3. 最后再做样式和视觉层次
简单说:
- 先保证空间分配正确
- 再处理滚动边界
- 最后再做美化

View File

@@ -0,0 +1,97 @@
# News Live Streams Collector Format
`news_live_streams` 采集器面向“频道目录 JSON”输入而不是直接抓网页。
这样做的目标是:
- 让后台能够稳定接入世界各地新闻直播源
-`Earth` 页面电视模块始终消费统一结构
- 便于后续接入类似 `worldmonitor` 那种 YouTube / HLS / iframe 混合频道目录
## 推荐 JSON 结构
```json
{
"sources": [
{
"id": "bbc-world-news",
"name": "BBC World News",
"provider": "BBC",
"region": "UK",
"language": "en",
"source_type": "youtube",
"youtube_video_id": "dQw4w9WgXcQ",
"youtube_channel": "https://www.youtube.com/@BBCNews",
"embed_url": "",
"stream_url": "",
"homepage_url": "https://www.youtube.com/@BBCNews/live",
"poster_url": "",
"sort_order": 220,
"is_enabled": true,
"notes": "Primary English global news channel"
},
{
"id": "france24-en",
"name": "France 24 English",
"provider": "France 24",
"region": "France",
"language": "en",
"source_type": "hls",
"stream_url": "https://example.com/live.m3u8",
"homepage_url": "https://www.france24.com/en/live",
"sort_order": 230,
"is_enabled": true
},
{
"id": "cctv4-page",
"name": "CCTV-4 中文国际",
"provider": "CCTV",
"region": "China",
"language": "zh-CN",
"source_type": "iframe",
"embed_url": "https://tv.cctv.com/live/cctv4/",
"homepage_url": "https://tv.cctv.com/live/cctv4/",
"sort_order": 10,
"is_enabled": true
}
]
}
```
## 字段约定
- `id`: 唯一标识,建议稳定不变
- `name`: 频道显示名
- `provider`: 提供方
- `region`: 国家或地区
- `language`: 语言代码
- `source_type`: `iframe` / `hls` / `video` / `external` / `youtube`
- `embed_url`: 适合 iframe 内嵌的页面
- `stream_url`: 直接视频流地址
- `homepage_url`: 官网或频道页
- `youtube_video_id`: YouTube 直播视频 ID
- `youtube_channel`: YouTube 频道 handle 或频道 URL
- `poster_url`: 封面图,可选
- `sort_order`: 排序值,越小越靠前
- `is_enabled`: 是否启用
- `notes`: 简短备注
## 面板行为约定
- `youtube`
- 优先使用 `youtube_video_id`
- 无法内嵌时至少保留 `youtube_channel``homepage_url` 供外部打开
- `hls` / `video`
- 优先走 `stream_url`
- `iframe`
- 优先走 `embed_url`
- `external`
- 不尝试内嵌,只保留外部打开
## 当前实现状态
- 后台设置页可以手工维护频道目录
- `Earth` 电视模块会合并:
- 手工配置源
- `news_live_streams` 采集器采集源
- 当前默认兜底源为 `CCTV-4 中文国际`

View File

@@ -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 2Evidence-first Assessment
目标:
- 从“文本摘要”升级成“结构化 assessment”
工作项:
- 为 brief/assessment 定义统一 schema
- 固化:
- summary
- key_risks
- evidence
- confidence
- recommendations
- missing_data
- 页面以结构化区块展示,而不只是大段文本
完成标准:
- AI 输出可持久化、可比较、可审计
## Phase 3多维证据接入
目标:
- 让“态势感知”真正拥有更多维度,而不是只靠 BGP 与系统告警
优先接入方向:
- datasource health findings
- 流量或业务指标
- 区域/资产/链路映射
- 外部事件与公告
- 业务垂直数据,例如电商分析相关指标
完成标准:
- AI 能基于多个维度做交叉说明
- 不再只围绕单一模块自说自话
## Phase 4Correlation Layer
目标:
- 不同来源的信号不再只是并列,而是形成统一的事件关联
工作项:
- 统一 signal/finding 模型
- 跨模块事件聚合
- 证据来源权重
- collector bias 与真实热度分离
完成标准:
- 系统能回答“这些异常是不是同一件事”
- 系统能回答“哪些结论只是观测偏差”
## Phase 5Agent-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` 是核心服务底座
现阶段不需要追求“已经具备完整态势感知能力”。
现阶段真正的成功标准是:
- 这套底座可用
- 可回看
- 可扩展
- 不自欺欺人

144
docs/ue5_led_context.md Normal file
View File

@@ -0,0 +1,144 @@
# 项目背景UE5 + 3D LED 大屏展示系统
> 供其他 AI 快速了解项目背景和当前状态。
> 最后更新2026-04-14
---
## 一、项目定位
这不是一个普通的桌面地球应用,而是一套**领导演示用的 3D 沉浸式展示系统**。
核心逻辑:
- 态势感知数据(超算、海缆、卫星)是内容
- 3D LED 大屏 + 实时渲染 + 动捕交互是"醋"——没有它,内容再好也只是普通屏幕
- 主要受众:领导/决策层,注重视觉冲击力
---
## 二、硬件配置
| 硬件 | 规格 | 说明 |
|------|------|------|
| 3D LED 大屏 | 5.12m × 2.88mP1.538mm,被动偏振式 | 观众戴无源 3D 眼镜≤18g60Hz 即可出稳定 3D |
| 渲染工作站 | 双路国产 X86 CPU + RTX 5090 32GB | 驱动 UE5 实时渲染 |
| 视频处理器 | 随屏配套(品牌待确认) | 接收 GPU 信号,驱动 LED 墙 |
| 动捕摄像头 | RGB 摄像头 ×24K直连电脑 | 无穿戴姿态识别 |
| 音响 | 解码功放 + 吸顶喇叭 ×5 + 低音炮 | 配套 |
---
## 三、软件架构
```
摄像头×2直连
↓ 动捕插件(供应商提供 UE5 插件)
UE5 主程序(我们开发)
├── Cesium 地球(实时渲染)
├── 超算数据点(可交互)
├── 其他可交互物件
└── 立体渲染输出
↓ 视频信号(格式待确认)
视频处理器(随屏配套)
↓ LED 驱动信号(行偏振)
5m 被动偏振 3D LED 大屏
```
---
## 四、分工
| 部分 | 谁做 | 状态 |
|------|------|------|
| LED 屏体 + 视频处理器 | 屏幕供应商 | 采购中 |
| 动捕插件UE5 插件形式) | 动捕供应商 | 待交付 |
| UE5 基础工程(关卡+角色+动捕绑定) | 动捕供应商 | 待交付 |
| 地球场景 + 数据可视化 + 交互逻辑 | 我们(本项目) | 开发中 |
| 后端数据接口 | 我们(本项目) | 已完成 |
| 其他 9 个定制 3D 资产和动画 | 3D 内容供应商 | 采购中 |
---
## 五、动捕交互方式
**交付形式(已确认)**
- 供应商给我们**完整 UE5 工程**,包含:
- 视频动捕插件
- 已配好绑定和重定向的 3D 角色
- 3D 模型和动画资产
- 接两台摄像头即可直接运行
- **我们的任务**:把他们工程的内容(插件 + 角色 + 资产)**迁移进我们的 `ue_client/` 工程**,然后把角色动作映射到地球操作
**待确认**:角色动作的触发点是什么形式?
- 蓝图 Custom Event`OnGestureRotate`
- AnimNotify
- 需要我们自己判断骨骼姿态?
**交互目标(一期)**
1. 手势旋转地球
2. 手势缩放地球
3. 手势指向/确认 → 选中数据点,弹出信息卡
4. 鼠标作为备用输入(始终可用)
---
## 六、立体渲染
**待确认**
1. 供应商基础工程里是否已配好立体渲染输出?
2. 如果没有视频处理器接受什么格式Side-by-Side / Top-Bottom / 其他)
3. 给供应商的文件形式UE5 工程 / .exe / 视频文件 / 直连实时输出?
**已准备**`StereoRenderingManager.h/.cpp` 支持运行时切换 SbS/TbB格式确认后直接启用。
---
## 七、已完成的代码
### 后端 (`backend/app/api/v1/ue_data.py`)
- `GET /api/v1/ue/status` — 健康检查
- `GET /api/v1/ue/compute-points` — TOP500 超算(平铺 JSON
- `GET /api/v1/ue/landing-points` — 海缆登陆点
- `GET /api/v1/ue/cables` — 海缆路由几何
- `GET /api/v1/ue/satellites` — 卫星 TLE 数据
### UE5 C++ (`ue_client/Source/PlanetClient/`)
| 文件 | 功能 |
|------|------|
| `PlanetDataTypes.h` | FComputePoint 等数据结构 |
| `PlanetDataManager` | HTTP 拉取 + mock 数据 + 生成 Actor |
| `ComputePointActor` | 超算点 Actor三态材质正常/悬停/选中)|
| `InteractiveObjectBase` | 所有可交互物件的基类 |
| `GlobeInteractionComponent` | 拖拽旋转地球(改 Cesium 经纬度原点)+ 缩放,带惯性 |
| `StereoRenderingManager` | 立体渲染开关SbS/TbBIPD 可调 |
| `MotionCaptureInterface.h` | 动捕接口抽象(待用插件 API 替换实现)|
| `PlanetPlayerController` | 统一处理鼠标 + 动捕输入 |
| `PlanetGameMode` | 场景入口 |
### 文档
- `docs/ue_client_setup_guide.md` — 编辑器操作 step-by-step 指南
- `docs/ue_todo.md` — 待供应商回复的 TODO
- `docs/ue5_mvp_fused_plan.md` — 完整实施方案v3.0
---
## 八、交付时间线
| 时间 | 内容 | 状态 |
|------|------|------|
| 本周末前 | 动捕供应商:含动捕插件 + 3D 角色(绑定/重定向已配好)的基础 UE5 工程 | 等待中 |
| 之后尽快 | 动捕供应商:动画资产(复制进 Content/ 即可直接调用) | 等待中 |
| TBD | LED 屏供应商:视频处理器接受的 3D 信号格式(或直接技术支持对接) | 等待中 |
**拿到基础工程后可立即做**:迁移插件和角色,接摄像头做动捕调试,然后对接动作→地球操作映射。
**双目 3D 显示**:供应商可提供技术支持,等视频处理器到位后直接对接。
---
## 九、当前阻塞项
1. **动捕基础工程**(本周末前到)→ 迁移内容,确认动作触发方式,完成交互对接
2. **动画资产**(尽快)→ 复制入 Content/,在场景中引用
3. **3D 显示格式**(有供应商技术支持)→ 配置 `StereoRenderingManager`

152
docs/ue5_mvp_fused_plan.md Normal file
View File

@@ -0,0 +1,152 @@
# 智能星球 UE5 客户端实施方案LED 大屏版)
> 版本v3.0
> 日期2026-04-14
> 背景更新:目标从"普通桌面地球"升级为"5m 被动偏振 3D LED 大屏 + 动捕交互演示系统"
---
## 一、真实场景描述
```
领导进入展示间
5.12m × 2.88m 被动偏振 3D LED 大屏开机
UE5 实时渲染的 3D 地球从屏幕"飞出"
(配合被动 3D 眼镜,数据点有真实景深)
演示者做手势(无穿戴动捕摄像头捕捉)
→ 地球旋转、缩放
→ 指向数据点 → 高亮
→ 确认手势 → 信息卡弹出(漂浮在屏幕前方)
鼠标/触控作为备用输入
```
---
## 二、系统架构
```
┌─────────────────────────────────────────────────┐
│ RTX 5090 渲染工作站 │
│ │
│ Planet 后端FastAPI
│ ↕ /api/v1/ue/* │
│ UE5 主程序(本项目) │
│ ├── Cesium 地球 │
│ ├── 超算数据点 │
│ ├── 其他可交互物件 │
│ └── 立体渲染输出 (Side-by-Side / Top-Bottom) │
│ │
└─────────────┬───────────────────────────────────┘
│ HDMI/DP 视频信号
视频处理器(随屏配套)
│ LED 驱动信号(行偏振)
5.12m × 2.88m 被动偏振 3D LED 大屏
观众戴无源 3D 眼镜≤18g无需充电
动捕摄像头×2
↓ 手势识别(无穿戴)
动捕中间件 ─→ UE5Live Link / OSC待确认
```
---
## 三、一期交付目标6 项)
1. ✅ 地球在 3D 大屏上正确显示,有景深效果
2. ✅ 超算数据点散布在地球上,可悬停高亮
3. ✅ 点击/手势确认 → 弹出数据点信息卡
4. ✅ 鼠标拖拽/手势 → 地球旋转(带惯性)
5. ✅ 滚轮/手势 → 缩放
6. ⏳ 立体渲染格式配置(等视频处理器格式答复)
7. ⏳ 动捕手势接入(等中间件协议答复)
---
## 四、已完成的代码
### 后端(`backend/app/api/v1/ue_data.py`
| 接口 | 说明 |
|------|------|
| `GET /api/v1/ue/status` | 健康检查 |
| `GET /api/v1/ue/compute-points` | 超算数据(平铺 JSON|
| `GET /api/v1/ue/landing-points` | 海缆登陆点 |
| `GET /api/v1/ue/cables` | 海缆几何 |
| `GET /api/v1/ue/satellites` | 卫星 TLE |
### UE5 C++ 源码(`ue_client/Source/PlanetClient/`
| 文件 | 功能 |
|------|------|
| `PlanetDataTypes.h` | 数据结构FComputePoint 等)|
| `PlanetDataManager.h/.cpp` | HTTP 拉取 + 本地 mock 数据 + 生成 Actor |
| `ComputePointActor.h/.cpp` | 单个超算点 Actor三态材质 |
| `InteractiveObjectBase.h/.cpp` | 所有可交互物件的基类 |
| `GlobeInteractionComponent.h/.cpp` | 地球旋转/缩放,带惯性 |
| `StereoRenderingManager.h/.cpp` | 立体渲染框架,运行时切换模式 |
| `MotionCaptureInterface.h` | 动捕接口定义,协议无关 |
| `PlanetPlayerController.h/.cpp` | 统一处理鼠标 + 动捕输入 |
| `PlanetGameMode.h/.cpp` | 场景入口,自动初始化管理器 |
| `PlanetClient.Build.cs` | 模块依赖(含 TODO 注释)|
### 数据和配置
| 文件 | 说明 |
|------|------|
| `Content/Data/mock_compute_points.json` | 10 个真实超算的 mock 数据 |
| `Config/DefaultGame.ini` | GameMode 配置 |
| `Config/DefaultEngine.ini` | 渲染设置 |
| `Config/DefaultInput.ini` | 键鼠输入绑定 |
---
## 五、你需要在编辑器里做的操作
> 完整步骤见 `docs/ue_client_setup_guide.md`
**最小操作清单Phase A 本地演示):**
1. 安装 UE5.3 + Cesium for Unreal 插件
2. 打开 `ue_client/PlanetClient.uproject`,等待编译
3. 创建空关卡 `EarthMap`,通过 Cesium 菜单添加地球
4. 拖入 CesiumDynamicPawn设置 Auto Possess Player 0
5. World Settings → GameMode → PlanetGameMode
6. 创建 `BP_ComputePointActor`(父类 `AComputePointActor`),配置球体网格 + 三色材质
7. 创建 `BP_PlanetDataManager`,拖入场景,绑定 `ComputePointClass`
8. Play → 看到 10 个橙色球体,可悬停 + 点击
---
## 六、TODO 项(等供应商答复)
> 详细对照表见 `docs/ue_todo.md`
### TODO-1立体渲染格式
- **等待**:视频处理器接受什么 3D 输入格式SbS / TbB / 行交错)
- **代码位置**`StereoRenderingManager.cpp`
- **工作量**0.5 天
### TODO-2动捕协议
- **等待**:中间件使用 Live Link / OSC / 私有 SDK
- **代码位置**`MotionCaptureInterface.h``PlanetPlayerController.cpp`
- **工作量**Live Link=2hOSC=1天私有SDK=1-3天
---
## 七、后续阶段规划
| 阶段 | 内容 | 状态 |
|------|------|------|
| Phase A | 本地 mock 数据,鼠标交互,单目 2D | ✅ 代码就绪 |
| Phase B | 接入真实后端 `/api/v1/ue/*` | ✅ 代码就绪 |
| Phase C | 立体 3D 输出 | ⏳ 等格式确认 |
| Phase D | 动捕手势交互 | ⏳ 等协议确认 |
| Phase E | 海缆 Spline 渲染 | 待开发 |
| Phase F | 其他可交互物件(基类已就绪) | 待定义 |

View File

@@ -0,0 +1,319 @@
# UE5 客户端手动操作指南
> 本指南对应 `ue_client/` 目录下已生成的所有代码和配置。
> 代码已写好,你只需要做编辑器里的点击操作。
> 遇到红色错误先看文末"常见问题"章节。
---
## 前置条件
| 软件 | 版本 | 下载地址 |
|------|------|---------|
| Unreal Engine | **5.3** | Epic Games Launcher → Library → 5.3 |
| Cesium for Unreal | 最新 | 直接在下一步从 Marketplace 安装 |
| Visual Studio | 2022 Community | visualstudio.microsoft.com安装 C++ 游戏开发工作负载) |
> **注意**Cesium for Unreal 必须先安装,否则代码会编译失败。
---
## 第一阶段Phase A本地演示版不需要后端
### 步骤 1安装 Cesium for Unreal
1. 打开 **Epic Games Launcher**
2. 顶部切换到 **Unreal Engine** 选项卡
3. 左侧点击 **Fab**(原 Marketplace已改名→ 搜索 `Cesium for Unreal`
4. 点击 **免费获取**Free然后点击 **安装到引擎** → 选择 5.3
5. 等待安装完成
---
### 步骤 2打开项目
1. 打开 **Epic Games Launcher****Unreal Engine****Library**
2. 找到 5.3,点击右侧 **Launch** 旁边的下拉箭头 → **Browse**
3. 导航到 `planet/ue_client/`,选择 `PlanetClient.uproject`,点击打开
4. UE 会提示"缺少模块,需要重新编译" → 点击 **Yes**
5. 等待编译完成(首次约 5-10 分钟)
> 如果编译报错:见文末"常见问题 → 编译错误"
---
### 步骤 3创建新关卡
1. 菜单栏 → **File****New Level**
2. 选择 **Empty Level**(空关卡)
3. 保存:**File** → **Save Current Level As**
路径:`Content/Maps/`,名称:`EarthMap`
4. 点击 **Save**
---
### 步骤 4添加 Cesium 地球
1. 顶部菜单栏 → **Cesium**如果没有此菜单说明插件未激活Edit → Plugins → 搜索 Cesium → 勾选 Enable → 重启)
2. 在 Cesium 面板里点击 **Add Blank 3D Tiles Tileset** — 这会自动在场景里添加:
- `CesiumGeoreference` Actor
- `Cesium3DTileset` Actor地球瓦片
3. 再点击 **Add Cesium ion Bing Maps Aerial** 添加卫星影像底图(需要免费 Cesium ion 账号)
> 如果没有 Cesium ion 账号Cesium 菜单 → Connect to Cesium ion → 注册免费账号
---
### 步骤 5添加相机 Pawn
1. 菜单栏 → **Cesium** → 找到 **Dynamic Pawn**(名称可能是 `CesiumFlyToComponent` 相关的 Blueprint
**或者**Content Browser → 顶部搜索框输入 `DynamicPawn` → 找到插件内容里的 `DynamicPawn` → 拖入场景
2.**Outliner** 面板里点击刚拖入的 `DynamicPawn`
3.**Details** 面板里,找到 **Auto Possess Player** → 改为 **Player 0**
> 这样游戏启动时摄像机会自动使用这个可飞行的地球相机。
---
### 步骤 6配置 GameMode
1. 菜单栏 → **Window****World Settings**(如果没有,也可以在 Details Panel 里找)
2.**World Settings** 面板里找到 **Game Mode Override**
3. 点击下拉框 → 搜索 `PlanetGameMode` → 选择它
4. 找到 **Default Pawn Class** → 改为上一步拖入的 `DynamicPawn`
---
### 步骤 7创建 ComputePoint Blueprint
这步把我写的 C++ 类包装成可以在编辑器里配置材质的 Blueprint。
1. **Content Browser** → 空白处右键 → **Blueprint Class**
2. 搜索父类:输入 `ComputePointActor` → 找到 `AComputePointActor` → 点击 **Select**
3. 命名为 `BP_ComputePointActor`,保存到 `Content/Blueprints/`
**配置 BP_ComputePointActor 的网格和材质:**
4. 双击打开 `BP_ComputePointActor`
5. 在左侧 **Components** 面板里点击 `SphereMesh`
6. 在右侧 **Details** 面板里找到 **Static Mesh** → 点击下拉 → 搜索 `Sphere` → 选择 **Engine/BasicShapes/Sphere**
7. 找到 **Material** → 点击下拉 → 搜索 `M_Basic_Wall` 或者创建新材质(见下方)
**创建三种状态的材质(颜色点即可):**
8. Content Browser → 右键 → **Material** → 命名 `M_PointNormal`
- 双击打开 → 右键空白区域 → 搜索 `Constant3Vector` → 连接到 `Base Color`
- 颜色设为橙色:`(1.0, 0.4, 0.0)`
- 保存
9. 同样方式创建 `M_PointHovered`(颜色白色 `1,1,1`)和 `M_PointSelected`(颜色青色 `0,1,1`
**回到 BP_ComputePointActor**
10.**Details** 面板里:
- **Normal Material** → 选 `M_PointNormal`
- **Hovered Material** → 选 `M_PointHovered`
- **Selected Material** → 选 `M_PointSelected`
- **Point Scale** → `80000`(根据实际效果调整)
11. 点击左上角 **Compile****Save**
---
### 步骤 8放置 DataManager 并配置
1. **Content Browser** → 右键 → **Blueprint Class** → 父类搜索 `PlanetDataManager` → 选择 `APlanetDataManager`
2. 命名为 `BP_PlanetDataManager`,保存到 `Content/Blueprints/`
3.`BP_PlanetDataManager` **拖入场景**Outliner 里会出现它)
4. 在 Outliner 里点击它 → 在 **Details** 面板里配置:
- **Use Local Mock Data** → ✅ **勾选**Phase A 不需要后端)
- **Mock Data Path** → 留空(代码会自动找 `Content/Data/mock_compute_points.json`
- **Compute Point Class** → 选择 `BP_ComputePointActor`
- **Point Altitude Meters** → `50000`(海拔 50km可调
---
### 步骤 9测试 Phase A
1. 点击顶部工具栏绿色 **Play** 按钮(或 Alt+P
2. 地球应该加载卫星影像
3. 应该看到 10 个橙色球体分布在地球上(对应 mock JSON 里的 TOP500 超算)
4. 鼠标移到球体上 → 变白色Hover
5. 点击球体 → 变青色Selected
**验证通过标准:**
- [x] 地球可见
- [x] 橙色球体出现在正确位置(美国、日本、荷兰、芬兰等)
- [x] 悬停变色
- [x] 点击变色
---
## 第二阶段Phase B连接真实后端
### 步骤 10确认后端新接口可用
后端代码已添加新路由,先验证它已经运行:
```bash
# 在 WSL 或终端里
curl http://localhost:8000/api/v1/ue/status
```
应该返回类似:
```json
{"ok": true, "server_time": "...", "compute_points_count": 500, ...}
```
如果 curl 失败:
- 检查 `planet.sh` 是否在运行(`./planet.sh start`
- 检查 WSL2 → Windows 的网络:在 UE 里使用 `172.x.x.x`WSL 网关地址)而不是 `localhost`
---
### 步骤 11获取 WSL2 → Windows 的正确 IP
在 WSL 终端里运行:
```bash
cat /etc/resolv.conf | grep nameserver | awk '{print $2}'
```
记下这个 IP例如 `172.22.32.1`)。
---
### 步骤 12切换 DataManager 到实时模式
1. 在 Outliner 里点击 `BP_PlanetDataManager`
2. Details 面板里:
- **Use Local Mock Data** → **取消勾选**
- **Backend Base URL** → 填入 `http://172.22.32.1:8000`(你的实际 WSL IP
3. 重新 Play → DataManager 会通过 HTTP 拉取真实数据
---
### 步骤 13绑定点击事件显示信息卡可选需要 UMG
这步是可选的,需要创建一个 Widget Blueprint 来显示选中点的信息。
1. Content Browser → 右键 → **User Interface****Widget Blueprint**
2. 命名为 `WBP_PointInfo`
**设计 Widget 布局:**
3. 双击打开 `WBP_PointInfo`
4. 从左侧 **Palette** 拖入以下控件到画布:
- `Canvas Panel`(容器,设置为全屏)
- `Border`(右下角定位,用作信息卡背景,宽 300高 200
- `Text Block` × 4名称、排名、算力、国家
**绑定事件Blueprint 里操作):**
5. 打开 `BP_PlanetDataManager` 的 Event Graph
6. 找到 **BeginPlay** 节点
7. 拖出线 → 搜索 **Bind Event to On Point Selected**(这是我在 PlayerController 里定义的委托)
> 具体蓝图连线:从 PlayerController 获取 OnPointSelected → Bind → 在回调里 Create Widget WBP_PointInfo → Add to Viewport → Set 各个文本
---
## 文件结构总览
```
ue_client/
PlanetClient.uproject ← UE 项目入口
Config/
DefaultGame.ini ← GameMode 配置
DefaultEngine.ini ← 渲染/引擎设置
DefaultInput.ini ← 键鼠输入绑定
Content/
Data/
mock_compute_points.json ← Phase A 本地测试数据10个超算
Maps/
EarthMap.umap ← 你在步骤3创建的关卡
Blueprints/
BP_ComputePointActor ← 步骤7创建
BP_PlanetDataManager ← 步骤8创建
Source/
PlanetClient/
PlanetClient.Build.cs ← 模块依赖HTTP、JSON、Cesium
PlanetClient.h/.cpp ← 模块入口
PlanetDataTypes.h ← 数据结构定义FComputePoint 等)
PlanetDataManager.h/.cpp ← HTTP 拉取 + 生成 Actor
ComputePointActor.h/.cpp ← 单个超算点的可视化 Actor
PlanetPlayerController.h/.cpp ← 鼠标点击、悬停、相机控制
PlanetGameMode.h/.cpp ← GameMode 入口
```
---
## 后端新接口一览
后端已新增以下接口(无需认证,直接访问):
| 接口 | 说明 |
|------|------|
| `GET /api/v1/ue/status` | 健康检查 + 各数据源数量 |
| `GET /api/v1/ue/compute-points` | TOP500 超算数据(平铺 JSON |
| `GET /api/v1/ue/landing-points` | 海缆登陆点(平铺 JSON |
| `GET /api/v1/ue/cables` | 海缆路由几何segments 数组) |
| `GET /api/v1/ue/satellites` | 卫星 TLE 数据 |
---
## 常见问题
### Q: 编译报错 "Cannot open include file: CesiumGeoreference.h"
**A:** Cesium for Unreal 没有正确安装,或者没有在 `.uproject` 里启用。检查:
1. Epic Launcher → 插件是否安装到 5.3
2. `PlanetClient.uproject``Plugins` 数组是否有 `CesiumForUnreal: true`
3. UE 编辑器 → Edit → Plugins → 搜索 Cesium → 确认已勾选 Enabled
### Q: Play 之后没有看到橙色球体
**A:** 按以下顺序排查:
1. Output LogWindow → Output Log里搜索 `PlanetDataManager` — 查看是否有报错
2. 检查 `BP_PlanetDataManager` 的 Details → **Compute Point Class** 是否已设置为 `BP_ComputePointActor`
3. 检查 **Mock Data Path** 是否正确(留空则自动用 `Content/Data/mock_compute_points.json`
4. 检查 **Use Local Mock Data** 是否已勾选
### Q: 地球是灰色的没有卫星影像
**A:** 需要 Cesium ion 账号:
1. Cesium 菜单 → Connect to Cesium ion
2. 注册免费账号并授权
3. 重新添加 **Cesium ion Bing Maps Aerial** tileset
### Q: WSL2 里的后端 UE 无法访问Phase B
**A:** WSL2 和 Windows 是不同网络命名空间。方法:
1. 在 WSL 里运行 `ip route show default | awk '{print $3}'` — 这是 Windows 主机的 IP
2. 后端绑定到 `0.0.0.0:8000`(检查 `uvicorn` 启动参数,应已如此配置)
3. 在 UE 的 DataManager 里填写这个 IP 而不是 `localhost`
### Q: TransformLongitudeLatitudeHeightPositionToUnreal 不存在
**A:** Cesium for Unreal API 在不同版本有变化。如果编译报此错,将 `PlanetDataManager.cpp` 里的调用改为:
```cpp
// Cesium for Unreal v1.x 的旧 API
FVector WorldPos = Georeference->TransformLongitudeLatitudeHeightToUnreal(
Pt.Longitude, Pt.Latitude, PointAltitudeMeters);
// 或者通过 GeoTransforms
#include "CesiumGlobeAnchorComponent.h"
// ... 见 Cesium 文档
```
### Q: 点击球体没有反应
**A:**
1. 确认 `SphereMesh`**Collision Presets** 不是 `NoCollision`
- 打开 `BP_ComputePointActor` → 点击 `SphereMesh` → Details → Collision → 改为 `BlockAllDynamic`
2. 确认 PlayerController 的 `bEnableClickEvents = true`(代码里已设置)
3. Output Log 里搜索是否有输入相关报错
---
## 下一步(一期完成后)
| 功能 | 说明 |
|------|------|
| 海缆路径渲染 | `/api/v1/ue/cables` 已就绪,需要在 UE 里用 Spline 绘制 |
| 信息卡 UMG | 创建 Widget Blueprint 并在 PlayerController OnPointSelected 里显示 |
| WebSocket 实时更新 | 后端已有 WebSocketUE 端需要使用 WebSockets 插件 |
| 卫星轨迹 | TLE 数据已就绪,需要在 UE 里做轨道传播计算 |

90
docs/ue_todo.md Normal file
View File

@@ -0,0 +1,90 @@
# UE5 客户端 — 待供应商回复的 TODO
> 以下两个问题答复后,对应代码可在一天内完成。
> 其余所有代码均已写好,不依赖这两个答案。
---
## TODO-1视频处理器输入格式
**等待信息**LED 屏配套视频处理器(如诺瓦星云)接受什么 3D 信号格式?
| 可能答案 | 对应操作 |
|---------|---------|
| Side-by-Side左右并排 | `StereoRenderingManager.cpp``EnableStereo``SideBySide` 分支已写好,直接启用 |
| Top-Bottom上下叠加 | 同上,切换到 `TopBottom` 分支 |
| 行交错(行偏振直驱) | 需要新写一个 PostProcess Material把左右眼奇偶行合并输出 |
| 私有协议 | 需要供应商提供 UE5 插件或信号格式文档 |
**代码位置**
```
ue_client/Source/PlanetClient/StereoRenderingManager.h — 第 8-18 行 TODO 注释
ue_client/Source/PlanetClient/StereoRenderingManager.cpp — EnableStereo() 函数
```
**需要同时确认**
- 视频处理器品牌和型号
- 屏幕物理分辨率(用于配置 UE5 输出分辨率)
- 3D 启用时是否需要特殊信号时序(如 3D Frame Packing
---
## TODO-2动捕角色接入
**已确认**
- **本周末前**:供应商交付含动捕插件 + 3D 角色(已配好绑定和重定向)的基础 UE5 工程
- 接入两台摄像头4080 以上显卡即可直接运行动捕调试
- **之后尽快**:动画资产单独交付,复制到工程 Content/ 目录即可直接调用
- **我们的任务**:把他们工程内容迁移进 `ue_client/`,把角色动作映射到地球操作
---
### 阶段 A拿到基础工程后本周末
**迁移步骤**
1. **迁移动捕插件**
- 从供应商工程 `Plugins/` 拷到 `ue_client/Plugins/`
- `PlanetClient.uproject``Plugins` 数组添加插件条目(`Enabled: true`
- `PlanetClient.Build.cs``PublicDependencyModuleNames` 加插件模块名
2. **迁移角色**
- 把角色 Blueprint、动画、骨骼网格从供应商 `Content/` 拷到 `ue_client/Content/`
- 在关卡里放置角色 Actor确认摄像头接入后能正常驱动
3. **接入动作触发(关键,看到工程后确认方式)**
| 需要确认的内容 | 用途 |
|-------------|------|
| 角色动作怎么暴露给外部? | Blueprint Custom Event / AnimNotify / 骨骼姿态变量? |
| 手势集合有哪些? | 填写 `EMotionGesture` 枚举,配置 `FMotionActionMapping` |
| 是否持续输出旋转增量? | 旋转地球用"持续增量"还是"离散手势触发" |
| 插件模块名称(`ModuleName`| 加入 `Build.cs` 依赖 |
**拿到工程后(我来做)**
- 用插件实际 API 实现 `UMotionCaptureReceiver` 子类
-`PlanetPlayerController::BeginPlay` 里取消注释 `BindMotionCaptureEvents()`
**代码位置(接口已预留)**
```
ue_client/Source/PlanetClient/MotionCaptureInterface.h — 基类和手势枚举
ue_client/Source/PlanetClient/PlanetPlayerController.cpp — BindMotionCaptureEvents()
ue_client/Source/PlanetClient/PlanetClient.Build.cs — TODO 注释处加插件模块名
ue_client/PlanetClient.uproject — Plugins 数组加插件条目
```
---
### 阶段 B动画资产到位后
- 把供应商提供的动画资产直接复制到 `ue_client/Content/` 对应目录
-`PlanetDataManager` / 场景 Actor 里引用这些资产即可调用
---
## 答复到位后的操作清单
拿到答案后告诉我,我来:
1. **视频处理器格式** → 配置 `StereoRenderingManager`,把 `bAutoEnableOnPlay` 改为 `true`,写进 setup guide
2. **动捕插件** → 插件放入 `ue_client/Plugins/`,实现 `UMotionCaptureReceiver` 子类对接插件 API更新 `Build.cs``.uproject`

View File

@@ -16,12 +16,19 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.23.0`
- `dev` 当前开发分支历史推导到:`0.27.6`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复Playground 响应式按钮与输入框收起优化 |
| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 |
| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 |
| `0.27.3` | improvement | `dev` | — | TV panel 折叠方向稳定、视频跳动修复、图例折叠按钮修复、搜索图标调整 |
| `0.27.2` | improvement | `dev` | — | 修复 brand copy 宽度问题,提取 --brand-copy-width CSS 变量 |
| `0.27.1` | improvement | `dev` | — | HUD 面板拖拽 L 形边界约束、brand 组件整体缩放、图层面板宽度优化、搜索叉叉修复 |
| `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 |
@@ -71,6 +78,23 @@
| `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 |
| `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 |
| `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 |
| `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 |
| `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

View File

@@ -1,7 +1,8 @@
{
"name": "planet-frontend",
"version": "0.23.0",
"version": "0.27.6",
"private": true,
"packageManager": "bun@1",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"antd": "^5.12.5",

View File

@@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="6.75" stroke="#4DB8FF" stroke-width="2.1"/>
<path d="M5.75 12H18.25" stroke="#4DB8FF" stroke-width="2.1" stroke-linecap="round"/>
<path d="M12 5.8C14.7 7.75 14.7 16.25 12 18.2" stroke="#4DB8FF" stroke-width="2.1" stroke-linecap="round"/>
<path d="M8 16C9.95 14.2 14.05 14.2 16 16" stroke="#4DB8FF" stroke-width="2.1" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 480 B

View File

@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="8" stroke="#4DB8FF" stroke-width="2.2"/>
<path d="M12 10V16" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="12" cy="7.25" r="1.25" fill="#4DB8FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 310 B

View File

@@ -1,10 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 9L5 5" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M5 8V5H8" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 9L19 5" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M16 5H19V8" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 15L5 19" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M5 16V19H8" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 15L19 19" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M16 19H19V16" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 871 B

View File

@@ -1,10 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9.2L9.2 9.2L9.2 6" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 9.2L14.8 9.2L14.8 6" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 14.8L9.2 14.8L9.2 18" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 14.8L14.8 14.8L14.8 18" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 6L10 10" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M18 6L14 10" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M6 18L10 14" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M18 18L14 14" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 927 B

View File

@@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 6.25V17.75" stroke="#4DB8FF" stroke-width="2.4" stroke-linecap="round"/>
<path d="M15 6.25V17.75" stroke="#4DB8FF" stroke-width="2.4" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 278 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 6.5L17.5 12L8.5 17.5V6.5Z" fill="#4DB8FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 163 B

View File

@@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.8 12.6C6.8 8.95 9.75 6 13.4 6C14.9 6 16.24 6.46 17.3 7.28" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M18.85 10.45C19.2 11.15 19.4 11.95 19.4 12.8C19.4 16.45 16.45 19.4 12.8 19.4C10.05 19.4 7.69 17.72 6.7 15.33" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M15.9 5.95H19.2V9.25" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.2 5.95L16.7 8.45" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 631 B

View File

@@ -1,8 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="5" stroke="#4DB8FF" stroke-width="2.2"/>
<path d="M12 3V6.5" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M12 17.5V21" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M3 12H6.5" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M17.5 12H21" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="12" cy="12" r="1.45" fill="#4DB8FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 561 B

View File

@@ -1,11 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="4" height="4" rx="0.8" stroke="#4DB8FF" stroke-width="2"/>
<rect x="4" y="9" width="4" height="6" rx="0.8" stroke="#4DB8FF" stroke-width="2"/>
<rect x="16" y="9" width="4" height="6" rx="0.8" stroke="#4DB8FF" stroke-width="2"/>
<path d="M8 12H10" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
<path d="M14 12H16" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
<path d="M12 8V6" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
<path d="M10.75 6H13.25" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
<path d="M12 14V18" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
<path d="M10.25 18H13.75" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 858 B

View File

@@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10.5" cy="10.5" r="5.75" stroke="#4DB8FF" stroke-width="2.2"/>
<path d="M15.2 15.2L19.25 19.25" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 276 B

View File

@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 18H21" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M4.5 18L9.5 11L12.5 15L16 9L19.5 18" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.25 18L13.05 15.35L14.55 17.25" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 449 B

View File

@@ -1,7 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 17H12" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M7 13.5H15" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M10 10H16" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="17.5" cy="8.5" r="2.2" fill="#4DB8FF"/>
<path d="M15.8 10.2L18.55 7.45" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 501 B

View File

@@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10.5" cy="10.5" r="5.75" stroke="#4DB8FF" stroke-width="2.2"/>
<path d="M15.25 15.25L19.25 19.25" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M10.5 8V13" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M8 10.5H13" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 446 B

View File

@@ -1,4 +1,46 @@
/* base.css - 公共基础样式 */
/* base.css - app shell, tokens, and global primitives */
@property --float-offset {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
:root {
--hud-scale: 1;
--hud-offset: calc(20px * 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.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);
}
* {
margin: 0;
@@ -6,788 +48,103 @@
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
html,
body,
.earth-page {
width: 100%;
height: 100%;
}
/* Ensure [hidden] always wins over component display rules */
[hidden] { display: none !important; }
body.earth-page {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background-color: #0a0a1a;
color: #fff;
overflow: hidden;
}
:root {
--hud-border: rgba(210, 237, 255, 0.32);
--hud-border-hover: rgba(232, 246, 255, 0.48);
--hud-border-active: rgba(245, 251, 255, 0.62);
--glass-fill-top: rgba(255, 255, 255, 0.18);
--glass-fill-bottom: rgba(115, 180, 255, 0.08);
--glass-sheen: rgba(255, 255, 255, 0.34);
--glass-shadow: 0 14px 30px rgba(0, 0, 0, 0.22);
--glass-glow: 0 0 26px rgba(120, 200, 255, 0.16);
}
@property --float-offset {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
#container {
.earth-app {
position: relative;
width: 100vw;
height: 100vh;
}
#container.dragging {
.earth-app.dragging {
cursor: grabbing;
}
/* Bottom Dock */
#right-toolbar-group {
.earth-filters {
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
z-index: 200;
}
#right-toolbar-group,
#info-panel,
#coordinates-display,
#legend,
#earth-stats {
transition:
top 0.45s ease,
right 0.45s ease,
bottom 0.45s ease,
left 0.45s ease,
transform 0.45s ease,
box-shadow 0.45s ease;
}
#info-panel,
#coordinates-display,
#legend,
#earth-stats,
#satellite-info {
position: absolute;
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);
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%);
}
#info-panel::before,
#coordinates-display::before,
#legend::before,
#earth-stats::before,
#satellite-info::before {
content: '';
position: absolute;
inset: 1px 1px 24% 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;
width: 0;
height: 0;
pointer-events: none;
}
#info-panel::after,
#coordinates-display::after,
#legend::after,
#earth-stats::after,
#satellite-info::after {
content: '';
position: absolute;
inset: -1px;
padding: 1.4px;
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;
pointer-events: none;
filter: url(#liquid-glass-distortion) blur(0.35px);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
#info-panel > *,
#coordinates-display > *,
#legend > *,
#earth-stats > *,
#satellite-info > * {
position: relative;
z-index: 1;
}
#loading {
.earth-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.2rem;
color: #4db8ff;
z-index: 100;
z-index: 240;
min-width: min(calc(320px * var(--hud-scale)), 78vw);
padding: calc(26px * var(--hud-scale));
border-radius: calc(18px * var(--hud-scale));
border: 1px solid rgba(77, 184, 255, 0.34);
background:
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.12), transparent 35%),
linear-gradient(180deg, rgba(13, 24, 46, 0.95), rgba(7, 14, 28, 0.94));
box-shadow:
0 0 30px rgba(77, 184, 255, 0.22),
0 16px 40px rgba(0, 0, 0, 0.28);
text-align: center;
background-color: rgba(10, 10, 30, 0.95);
padding: 30px;
border-radius: 10px;
border: 1px solid #4db8ff;
box-shadow: 0 0 30px rgba(77,184,255,0.3);
color: #4db8ff;
}
#loading-spinner {
border: 4px solid rgba(77, 184, 255, 0.3);
.earth-loading-text {
color: #4db8ff;
}
.earth-loading-title {
font-size: calc(1.15rem * var(--hud-scale));
font-weight: 600;
}
.earth-loading-subtitle {
margin-top: calc(10px * var(--hud-scale));
color: #9ab7d4;
font-size: calc(0.84rem * var(--hud-scale));
line-height: 1.45;
}
.earth-loading-spinner {
width: calc(40px * var(--hud-scale));
height: calc(40px * var(--hud-scale));
margin: 0 auto calc(15px * var(--hud-scale));
border: 4px solid rgba(77, 184, 255, 0.28);
border-top: 4px solid #4db8ff;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
0% {
transform: rotate(0deg);
}
.error-message {
color: #ff4444;
margin-top: 10px;
font-size: 0.9rem;
display: none;
padding: 10px;
background-color: rgba(255, 68, 68, 0.1);
border-radius: 5px;
border-left: 3px solid #ff4444;
}
.terrain-controls {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.slider-container {
margin-bottom: 10px;
}
.slider-label {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 0.9rem;
}
input[type="range"] {
width: 100%;
height: 8px;
-webkit-appearance: none;
background: rgba(0, 102, 204, 0.3);
border-radius: 4px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #4db8ff;
cursor: pointer;
box-shadow: 0 0 10px #4db8ff;
}
.status-message {
position: absolute;
top: 20px;
left: 50%;
transform: translate(-50%, -18px);
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
padding: 10px 15px;
z-index: 210;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
font-size: 0.9rem;
display: none;
backdrop-filter: blur(5px);
text-align: center;
min-width: 180px;
opacity: 0;
transition:
transform 0.28s ease,
opacity 0.28s ease;
}
.status-message.visible {
transform: translate(-50%, 0);
opacity: 1;
}
.status-message.success {
color: #44ff44;
border-left: 3px solid #44ff44;
}
.status-message.warning {
color: #ffff44;
border-left: 3px solid #ffff44;
}
.status-message.error {
color: #ff4444;
border-left: 3px solid #ff4444;
}
.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;
pointer-events: none;
z-index: 100;
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
display: none;
user-select: none;
}
/* Floating toolbar dock */
#control-toolbar {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 0;
background: transparent;
border: none;
box-shadow: none;
padding: 0;
}
.toolbar-items {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: nowrap;
}
.floating-popover-group {
position: relative;
}
.floating-popover-group::before {
content: '';
position: absolute;
left: 50%;
bottom: 100%;
transform: translateX(-50%);
width: 56px;
height: 16px;
background: transparent;
}
.floating-popover-group > .stack-toolbar {
position: absolute;
left: 50%;
top: auto;
right: auto;
bottom: calc(100% + 12px);
transform: translate(-50%, 10px);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 0.22s ease,
transform 0.22s ease,
visibility 0.22s ease;
z-index: 220;
}
.toolbar-btn.floating-btn {
width: 42px;
height: 42px;
min-width: 42px;
min-height: 42px;
border-radius: 50%;
overflow: hidden;
}
.liquid-glass-surface {
--elastic-x: 0px;
--elastic-y: 0px;
--tilt-x: 0deg;
--tilt-y: 0deg;
--btn-scale: 1;
--press-offset: 0px;
--float-offset: 0px;
--glow-opacity: 0.24;
--glow-x: 50%;
--glow-y: 22%;
position: relative;
isolation: isolate;
transform-style: preserve-3d;
overflow: hidden;
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.16), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
linear-gradient(180deg, var(--glass-fill-top), var(--glass-fill-bottom)),
rgba(8, 20, 38, 0.22);
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.05),
var(--glass-shadow),
var(--glass-glow);
backdrop-filter: blur(18px) saturate(145%);
-webkit-backdrop-filter: blur(18px) saturate(145%);
transform:
translate3d(var(--elastic-x), calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)), 0)
scale(var(--btn-scale));
transition:
transform 0.22s ease,
box-shadow 0.22s ease,
background 0.22s ease,
opacity 0.18s ease,
border-color 0.22s ease;
animation: floatDock 3.8s ease-in-out infinite;
}
.liquid-glass-surface::before {
content: '';
position: absolute;
inset: 1px 1px 18px 1px;
border-radius: inherit;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 28%, transparent 68%);
opacity: 0.5;
pointer-events: none;
transform:
perspective(120px)
rotateX(calc(var(--tilt-x) * 0.7))
rotateY(calc(var(--tilt-y) * 0.7))
translate3d(calc(var(--elastic-x) * 0.22), calc(var(--elastic-y) * 0.22), 0);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.liquid-glass-surface::after {
content: '';
position: absolute;
inset: -1px;
padding: 1.35px;
border-radius: inherit;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.36), rgba(168, 222, 255, 0.22) 34%, rgba(96, 175, 255, 0.16) 66%, rgba(255, 255, 255, 0.28));
opacity: 0.82;
pointer-events: none;
filter: url(#liquid-glass-distortion) blur(0.35px);
transform:
perspective(120px)
rotateX(calc(var(--tilt-x) * 0.5))
rotateY(calc(var(--tilt-y) * 0.5))
translate3d(calc(var(--elastic-x) * 0.16), calc(var(--elastic-y) * 0.16), 0);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.toolbar-items > :nth-child(2n).floating-btn,
.toolbar-items > :nth-child(2n) .floating-btn {
animation-delay: 0.18s;
}
.toolbar-items > :nth-child(3n).floating-btn,
.toolbar-items > :nth-child(3n) .floating-btn {
animation-delay: 0.34s;
100% {
transform: rotate(360deg);
}
}
@keyframes floatDock {
0%, 100% {
0%,
100% {
--float-offset: 0px;
}
50% {
--float-offset: -4px;
--float-offset: -2px;
}
}
.toolbar-btn {
position: relative;
width: 28px;
height: 28px;
border: none;
border-radius: 0;
background: transparent;
color: #4db8ff;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0;
margin: 0;
overflow: visible;
appearance: none;
-webkit-appearance: none;
}
.toolbar-btn:not(.liquid-glass-surface)::after {
content: none;
}
.toolbar-btn .icon {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
transform: translateZ(0);
transition: transform 0.16s ease, opacity 0.16s ease;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
line-height: 1;
}
.liquid-glass-surface:hover {
--btn-scale: 1.035;
--press-offset: -1px;
--glow-opacity: 0.32;
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.1), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(128, 198, 255, 0.1)),
rgba(8, 20, 38, 0.2);
border-color: var(--hud-border-hover);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(255, 255, 255, 0.08),
0 18px 36px rgba(0, 0, 0, 0.24),
0 0 28px rgba(145, 214, 255, 0.22);
}
.liquid-glass-surface:hover::before {
opacity: 0.62;
transform: scale(1.01);
}
.liquid-glass-surface:hover::after {
opacity: 0.96;
transform: scale(1.01);
}
.liquid-glass-surface:active,
.liquid-glass-surface.is-pressed {
--btn-scale: 0.942;
--press-offset: 2px;
--glow-opacity: 0.2;
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.24), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.14), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(146, 210, 255, 0.16)),
rgba(10, 24, 44, 0.24);
border-color: rgba(240, 249, 255, 0.58);
box-shadow:
inset 0 2px 10px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.16),
0 4px 10px rgba(0, 0, 0, 0.18),
0 0 14px rgba(176, 226, 255, 0.18);
}
.liquid-glass-surface:active::before,
.liquid-glass-surface.is-pressed::before {
opacity: 0.46;
transform: translateY(2px) scale(0.985);
}
.liquid-glass-surface:active::after,
.liquid-glass-surface.is-pressed::after {
opacity: 0.78;
transform: scale(0.985);
}
.liquid-glass-surface:active .icon,
.liquid-glass-surface.is-pressed .icon {
transform: translateY(1.5px);
}
.liquid-glass-surface:active img,
.liquid-glass-surface.is-pressed img,
.liquid-glass-surface:active .material-symbols-rounded,
.liquid-glass-surface.is-pressed .material-symbols-rounded {
transform: translateY(1.5px);
transition: transform 0.16s ease, opacity 0.16s ease;
}
#zoom-control-group #zoom-toolbar .zoom-btn:active,
#zoom-control-group #zoom-toolbar .zoom-btn.is-pressed,
#zoom-control-group #zoom-toolbar .zoom-percent:active,
#zoom-control-group #zoom-toolbar .zoom-percent.is-pressed {
letter-spacing: -0.01em;
}
.liquid-glass-surface.active {
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(118, 200, 255, 0.14)),
rgba(11, 34, 58, 0.26);
border-color: var(--hud-border-active);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
inset 0 0 18px rgba(160, 220, 255, 0.14),
0 18px 34px rgba(0, 0, 0, 0.24),
0 0 30px rgba(145, 214, 255, 0.24);
}
.toolbar-btn svg {
width: 20px;
height: 20px;
stroke: currentColor;
stroke-width: 2.1;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.toolbar-btn .material-symbols-rounded {
font-size: 21px;
line-height: 1;
font-variation-settings:
'FILL' 0,
'wght' 500,
'GRAD' 0,
'opsz' 24;
color: currentColor;
display: inline-flex;
align-items: center;
justify-content: center;
user-select: none;
pointer-events: none;
text-rendering: geometricPrecision;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.toolbar-btn img {
width: 20px;
height: 20px;
display: block;
user-select: none;
pointer-events: none;
shape-rendering: geometricPrecision;
image-rendering: -webkit-optimize-contrast;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
#rotate-toggle .icon-play,
#rotate-toggle.is-stopped .icon-pause,
#layout-toggle .layout-collapse,
#layout-toggle.active .layout-expand {
display: none;
}
#rotate-toggle.is-stopped .icon-play,
#layout-toggle.active .layout-collapse {
display: inline-flex;
}
#zoom-control-group:hover #zoom-toolbar,
#zoom-control-group:focus-within #zoom-toolbar,
#zoom-control-group.open #zoom-toolbar,
#info-control-group:hover #info-toolbar,
#info-control-group:focus-within #info-toolbar,
#info-control-group.open #info-toolbar {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translate(-50%, 0);
}
#zoom-control-group.force-closed #zoom-toolbar,
#info-control-group.force-closed #info-toolbar {
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate(-50%, 8px);
}
#zoom-control-group #zoom-toolbar .zoom-percent {
min-width: 0;
width: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 0;
font-size: 0.68rem;
border-radius: 50%;
color: #4db8ff;
animation: floatDock 3.8s ease-in-out infinite;
animation-delay: 0.18s;
}
#zoom-control-group #zoom-toolbar .zoom-percent:hover {
}
#zoom-control-group #zoom-toolbar,
#info-control-group #info-toolbar {
top: auto;
right: auto;
left: 50%;
bottom: calc(100% + 12px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
#info-toolbar .toolbar-btn:nth-child(1) {
animation-delay: 0.34s;
}
#info-toolbar .toolbar-btn:nth-child(2) {
animation-delay: 0.18s;
}
#info-toolbar .toolbar-btn:nth-child(3) {
animation-delay: 0.1s;
}
#info-toolbar .toolbar-btn:nth-child(4) {
animation-delay: 0s;
}
#zoom-control-group #zoom-toolbar .zoom-btn {
width: 42px;
height: 42px;
min-width: 42px;
border-radius: 50%;
color: #4db8ff;
animation: floatDock 3.8s ease-in-out infinite;
}
#zoom-toolbar .zoom-btn:nth-child(1) {
animation-delay: 0s;
}
#zoom-toolbar .zoom-btn:nth-child(3) {
animation-delay: 0.34s;
}
#zoom-control-group #zoom-toolbar .zoom-btn:hover {
}
#zoom-control-group #zoom-toolbar .zoom-btn:active,
#zoom-control-group #zoom-toolbar .zoom-percent:active {
}
#zoom-control-group #zoom-toolbar .tooltip {
bottom: calc(100% + 10px);
}
#zoom-control-group #zoom-toolbar .tooltip::after {
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4);
}
#container.layout-expanded #info-panel {
top: 20px;
left: 20px;
transform: translate(calc(-100% + 20px), calc(-100% + 20px));
}
#container.layout-expanded #coordinates-display {
top: 20px;
right: 20px;
transform: translate(calc(100% - 20px), calc(-100% + 20px));
}
#container.layout-expanded #legend {
left: 20px;
bottom: 20px;
transform: translate(calc(-100% + 20px), calc(100% - 20px));
}
#container.layout-expanded #earth-stats {
right: 20px;
bottom: 20px;
transform: translate(calc(100% - 20px), calc(100% - 20px));
}
#container.layout-expanded #right-toolbar-group {
bottom: 18px;
transform: translateX(-50%);
}
.toolbar-btn .tooltip {
position: absolute;
bottom: 56px;
left: 50%;
transform: translateX(-50%);
background: rgba(10, 10, 30, 0.95);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
border: 1px solid rgba(77, 184, 255, 0.4);
pointer-events: none;
z-index: 100;
}
.toolbar-btn:hover .tooltip,
.floating-popover-group:hover > .toolbar-btn .tooltip,
.floating-popover-group:focus-within > .toolbar-btn .tooltip {
opacity: 1;
visibility: visible;
bottom: 58px;
}
.toolbar-btn .tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4);
}

View File

@@ -1,42 +1,33 @@
/* coordinates-display */
#coordinates-display {
top: 20px;
right: 20px;
border-radius: 18px;
padding: 10px 15px;
.hud-panel-coordinates {
top: var(--hud-offset);
right: var(--hud-offset);
border-radius: var(--hud-radius);
padding: calc(14px * var(--hud-scale)) var(--hud-panel-padding);
z-index: 10;
font-size: 0.9rem;
min-width: 180px;
font-size: var(--hud-font-size);
min-width: calc(196px * var(--hud-scale));
}
#coordinates-display .coord-item {
margin-bottom: 5px;
display: flex;
justify-content: space-between;
.hud-panel-coordinates .coord-item {
margin-bottom: calc(8px * var(--hud-scale));
}
#coordinates-display .coord-label {
color: #aaa;
}
#coordinates-display .coord-value {
color: #4db8ff;
font-weight: 500;
}
#coordinates-display #zoom-level {
margin-top: 5px;
color: #ffff44;
font-weight: 500;
.hud-panel-coordinates .earth-zoom-level {
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: 1rem;
font-size: calc(0.94rem * var(--hud-scale));
letter-spacing: 0.02em;
}
#coordinates-display .mouse-coords {
font-size: 0.8rem;
color: #aaa;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
.hud-panel-coordinates .earth-mouse-coords {
font-size: var(--hud-font-size-sm);
color: var(--hud-text-muted);
margin-top: calc(8px * var(--hud-scale));
line-height: 1.45;
}

View File

@@ -1,51 +1,133 @@
/* earth-stats */
/* earth-stats.css — compact KPI grid panel */
#earth-stats {
bottom: 20px;
right: 20px;
border-radius: 18px;
padding: 15px;
width: 250px;
.hud-panel-stats {
top: var(--hud-offset);
right: var(--hud-offset);
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: 0.9rem;
overflow: hidden;
}
#earth-stats .stats-item {
margin-bottom: 8px;
/* ── 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;
}
#earth-stats .stats-label {
color: #aaa;
.stats-drag-bar:active {
cursor: grabbing;
}
#earth-stats .stats-value {
color: #4db8ff;
font-weight: 500;
.stats-kicker {
color: var(--hud-text-soft);
font-size: calc(0.64rem * var(--hud-scale));
letter-spacing: 0.16em;
text-transform: uppercase;
}
#satellite-info {
bottom: 20px;
right: 290px;
border-radius: 18px;
padding: 15px;
width: 220px;
z-index: 10;
font-size: 0.85rem;
/* 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));
}
#satellite-info .stats-item {
margin-bottom: 6px;
.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;
justify-content: space-between;
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);
}
#satellite-info .stats-label {
color: #aaa;
/* Right column cells: remove right border */
.stat-cell:nth-child(even) {
border-right: none;
}
#satellite-info .stats-value {
color: #00e5ff;
font-weight: 500;
/* Bottom row cells: remove bottom border */
.stat-cell:nth-last-child(-n+2) {
border-bottom: none;
}
.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)));
}

View File

@@ -0,0 +1,461 @@
/* hud.css - HUD surfaces and shared overlays */
.hud-panel {
position: absolute;
overflow: hidden;
isolation: isolate;
background:
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 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 52% 1px;
border-radius: inherit;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.02) 58%, transparent 100%);
opacity: 0.52;
pointer-events: none;
}
.hud-panel::after {
content: '';
position: absolute;
inset: -1px;
padding: 1px;
border-radius: inherit;
background:
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: blur(0.2px);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
.hud-panel > * {
position: relative;
z-index: 1;
}
.hud-panel-title {
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;
}
.hud-panel-header {
display: flex;
align-items: flex-start;
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 {
margin-bottom: 0;
color: var(--hud-text-soft);
font-size: calc(0.82rem * var(--hud-scale));
}
.hud-panel-drag-handle {
cursor: grab;
user-select: none;
}
.hud-panel-drag-handle:active {
cursor: grabbing;
}
.hud-panel-close {
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: 1px solid transparent;
border-radius: calc(4px * var(--hud-scale));
background: transparent;
color: var(--hud-text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
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 {
font-size: calc(var(--hud-title-size) * 0.8);
line-height: 1;
}
.hud-panel-close:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(225, 239, 255, 0.14);
color: var(--hud-accent-strong);
}
.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.is-layout-animating {
transition: none !important;
}
.hud-panel-hidden {
display: none !important;
}
.hud-panel-row {
display: flex;
justify-content: space-between;
gap: var(--hud-gap-sm);
align-items: baseline;
}
.hud-panel-label {
color: var(--hud-text-soft);
font-size: var(--hud-font-size-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hud-panel-value {
color: var(--hud-text);
font-weight: 600;
}
.hud-error-message {
color: #ff4444;
margin-top: 10px;
font-size: 0.9rem;
display: none;
padding: 10px;
background-color: rgba(255, 68, 68, 0.1);
border-radius: 5px;
border-left: 3px solid #ff4444;
}
.earth-status-message {
position: absolute;
top: 20px;
left: 50%;
transform: translate(-50%, -18px);
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: var(--hud-shadow-soft);
border: 1px solid var(--hud-border);
font-size: 0.9rem;
display: none;
backdrop-filter: blur(10px);
text-align: center;
min-width: 180px;
opacity: 0;
transition:
transform 0.28s ease,
opacity 0.28s ease;
}
.earth-status-message.visible {
transform: translate(-50%, 0);
opacity: 1;
}
.earth-status-message.success {
color: #d8f7df;
border-left: 3px solid #66d18f;
}
.earth-status-message.warning {
color: #fff2c3;
border-left: 3px solid #e4c464;
}
.earth-status-message.error {
color: #ffd4d7;
border-left: 3px solid #ff7b86;
}
.earth-tooltip {
position: absolute;
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: var(--hud-shadow-soft);
display: none;
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.46);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.earth-settings-sheet {
position: fixed;
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(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,
.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 var(--hud-line);
}
.earth-settings-kicker {
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: var(--hud-title);
}
.earth-settings-close {
margin-top: 4px;
}
.earth-settings-content {
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(160, 186, 216, 0.34) 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, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
border-radius: 999px;
}
.earth-settings-section {
padding: 12px 0 20px;
}
.earth-settings-section-title {
margin-bottom: 12px;
color: var(--hud-text-soft);
font-size: var(--hud-kicker-size);
letter-spacing: 0.16em;
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: 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 {
display: flex;
flex-direction: column;
gap: 4px;
}
.earth-settings-item-title {
color: var(--hud-text);
font-size: calc(0.98rem * var(--hud-scale));
font-weight: 600;
}
.earth-settings-item-subtitle {
color: var(--hud-text-muted);
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.08);
border: 1px solid rgba(215, 229, 242, 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: #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(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 {
transform: translateX(18px);
}
@media (max-width: 960px) {
.earth-settings-sheet {
top: 24px;
right: 12px;
left: 12px;
width: auto;
max-width: none;
max-height: calc(100vh - 48px);
}
}
/* .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 */

View File

@@ -1,248 +1,207 @@
/* info-panel */
/* info-panel.css — brand panel + detail card */
#info-panel {
top: 20px;
left: 20px;
border-radius: 18px;
padding: 20px;
width: 320px;
z-index: 10;
}
/* ── Left column wrapper ──────────────────────────────────────── */
#info-panel h1 {
font-size: 1.8rem;
margin-bottom: 5px;
color: #4db8ff;
text-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
text-align: center;
}
#info-panel .subtitle {
margin-bottom: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 12px;
text-align: center;
.earth-left-column {
position: absolute;
top: var(--hud-offset);
left: var(--hud-offset);
display: flex;
flex-direction: column;
z-index: 10;
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));
}
.earth-left-column > * {
position: relative; /* override .hud-panel position:absolute */
pointer-events: auto;
width: 100%;
box-sizing: border-box;
}
/* ── Brand panel ──────────────────────────────────────────────── */
.hud-panel-brand {
--brand-scale: 0.88;
--brand-copy-width: 160px;
border-radius: 0;
padding: calc(18px * var(--hud-scale)) calc(20px * var(--hud-scale));
display: flex;
align-items: center;
gap: 4px;
justify-content: center;
/* Reserve full panel height before brand images load */
min-height: calc(66px * var(--hud-scale));
}
#info-panel .subtitle-main {
color: #d7e7f5;
font-size: 0.95rem;
line-height: 1.35;
.hud-panel-brand .earth-brand {
display: flex;
align-items: center;
gap: calc(10px * var(--hud-scale) * var(--brand-scale));
min-width: 0;
}
.hud-panel-brand .earth-brand__logo {
display: block;
flex: 0 0 auto;
width: calc(128px * var(--hud-scale) * var(--brand-scale));
height: calc(128px * var(--hud-scale) * var(--brand-scale));
object-fit: contain;
}
.hud-panel-brand .earth-brand__copy {
display: flex;
flex: 0 0 auto;
flex-direction: column;
justify-content: center;
gap: calc(5px * var(--hud-scale) * var(--brand-scale));
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
}
.hud-panel-brand .earth-brand__title {
display: block;
width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
max-width: 100%;
height: auto;
min-height: calc(20px * var(--hud-scale) * var(--brand-scale));
object-fit: contain;
}
.hud-panel-brand .earth-brand__meta {
display: flex;
flex-direction: column;
gap: calc(2px * var(--hud-scale) * var(--brand-scale));
width: fit-content;
}
.hud-panel-brand .earth-brand__subtitle {
color: var(--hud-text-muted);
font-size: calc(0.74rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.3;
font-weight: 500;
letter-spacing: 0.02em;
letter-spacing: 0.01em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#info-panel .subtitle-meta {
color: #8ea5bc;
font-size: 0.74rem;
.hud-panel-brand .earth-brand__description {
color: var(--hud-text-soft);
font-size: calc(0.6rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.3;
letter-spacing: 0.08em;
text-transform: uppercase;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#info-panel .cable-info {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
.hud-panel-brand .earth-brand--en {
--brand-copy-width: 172px;
}
#info-panel .cable-info h3 {
color: #4db8ff;
margin-bottom: 8px;
font-size: 1.2rem;
.hud-panel-brand .earth-brand--en .earth-brand__title {
width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
}
#info-panel .cable-property {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 0.9rem;
.hud-panel-brand .earth-brand--en .earth-brand__copy {
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
}
#info-panel .property-label {
color: #aaa;
.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;
}
#info-panel .property-value {
color: #fff;
font-weight: 500;
}
/* ── Info detail panel (floating, positioned near click by JS) ── */
#info-panel .controls {
display: flex;
justify-content: space-between;
margin-top: 20px;
flex-wrap: wrap;
gap: 10px;
}
#info-panel button {
background: linear-gradient(135deg, #0066cc, #004c99);
color: white;
border: none;
padding: 8px 15px;
border-radius: 5px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
flex: 1;
min-width: 120px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
#info-panel button:hover {
background: linear-gradient(135deg, #0088ff, #0066cc);
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,102,204,0.4);
}
#info-panel .zoom-controls {
display: flex;
align-items: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
#info-panel .zoom-buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
margin-top: 10px;
width: 100%;
}
#info-panel .zoom-percent-container {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
#info-panel .zoom-percent {
font-size: 1.4rem;
font-weight: 600;
color: #4db8ff;
min-width: 70px;
text-align: center;
cursor: pointer;
padding: 5px 10px;
border-radius: 5px;
transition: all 0.2s ease;
}
#info-panel .zoom-percent:hover {
background: rgba(77, 184, 255, 0.2);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
}
#info-panel .zoom-buttons .zoom-btn {
width: 36px;
height: 36px;
min-width: 36px;
border: none;
border-radius: 50%;
background: rgba(77, 184, 255, 0.2);
color: #4db8ff;
font-size: 22px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
flex: 0 0 auto;
}
#info-panel .zoom-buttons .zoom-btn:hover {
background: rgba(77, 184, 255, 0.4);
transform: scale(1.1);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
}
#info-panel .zoom-buttons button {
flex: 1;
min-width: 60px;
}
/* Info Card - Unified details panel (inside info-panel) */
.info-card {
margin-top: 15px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(110, 176, 255, 0.04)),
rgba(7, 18, 36, 0.2);
border-radius: 14px;
border: 1px solid rgba(225, 242, 255, 0.12);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 10px 24px rgba(0, 0, 0, 0.16);
.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: 10px 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(77, 184, 255, 0.06));
gap: 8px;
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-xs);
}
.info-card-icon {
font-size: 18px;
font-size: calc(16px * var(--hud-scale));
flex-shrink: 0;
}
.info-card-header h3 {
flex: 1;
margin: 0;
font-size: 1rem;
color: #4db8ff;
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-content {
padding: 10px 12px;
max-height: 40vh;
.info-card-close {
flex-shrink: 0;
}
.info-card-content {
padding: calc(8px * var(--hud-scale)) calc(12px * var(--hud-scale));
max-height: 58vh;
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;
}
#info-card-content::-webkit-scrollbar {
width: 6px;
.info-card-content::-webkit-scrollbar {
width: 4px;
}
#info-card-content::-webkit-scrollbar-track {
.info-card-content::-webkit-scrollbar-track {
background: transparent;
}
#info-card-content::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 237, 255, 0.32), rgba(110, 176, 255, 0.34));
.info-card-content::-webkit-scrollbar-thumb {
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));
}
.info-card-property {
display: flex;
justify-content: space-between;
padding: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
align-items: flex-start;
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);
}
.info-card-property:last-child {
@@ -250,47 +209,50 @@
}
.info-card-label {
color: #aaa;
font-size: 0.85rem;
color: var(--hud-text-soft);
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;
}
.info-card-label:hover {
color: #d9f1ff;
color: var(--hud-text-muted);
}
.info-card-value {
color: #4db8ff;
font-weight: 500;
font-size: 0.9rem;
color: var(--hud-text);
font-weight: 600;
font-size: calc(0.82rem * var(--hud-scale));
line-height: 1.45;
text-align: right;
max-width: 180px;
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);
}

View File

@@ -0,0 +1,281 @@
/* layer-panel.css — layer toggle panel (below brand, in left column) */
.hud-panel-layers {
/* Lives inside .earth-left-column — narrower than brand panel intentionally */
border-radius: 0;
padding: 0;
width: calc(260px * var(--hud-scale));
z-index: 10;
overflow: hidden;
margin-top: calc(12px * 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-text-soft);
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展开时朝上可折叠折叠时朝下可展开 */
.layer-panel-btn .material-symbols-rounded {
transform: rotate(180deg);
}
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
transform: rotate(0deg);
}
/* ── Search bar ───────────────────────────────────────────────── */
.layer-panel-search {
padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale));
border-bottom: 1px solid var(--hud-line);
}
.layer-panel-search-box {
display: flex;
align-items: center;
gap: calc(5px * var(--hud-scale));
padding: calc(5px * var(--hud-scale)) calc(8px * var(--hud-scale));
border: 1px solid rgba(201, 225, 247, 0.14);
border-radius: calc(8px * var(--hud-scale));
background: rgba(255, 255, 255, 0.04);
transition: border-color 0.18s ease;
box-sizing: border-box;
height: calc(38px * var(--hud-scale));
}
.layer-panel-search-box:focus-within {
border-color: rgba(201, 225, 247, 0.28);
}
.layer-panel-search-icon {
flex-shrink: 0;
color: var(--hud-text-soft);
line-height: 1;
pointer-events: none;
}
.layer-panel-search-icon.material-symbols-rounded {
font-size: calc(20px * var(--hud-scale));
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
}
.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. */

View File

@@ -1,59 +1,177 @@
/* legend */
/* legend.css — compact tab-strip legend */
#legend {
bottom: 20px;
left: 20px;
border-radius: 18px;
padding: 15px;
width: 220px;
.hud-panel-legend {
bottom: var(--hud-offset);
left: var(--hud-offset);
border-radius: 0;
padding: 0;
width: min(calc(200px * var(--hud-scale)), calc(100vw - 32px));
z-index: 10;
overflow: hidden;
}
#legend .legend-title {
color: #4db8ff;
margin-bottom: 10px;
font-size: 1.1rem;
/* ── Drag bar ─────────────────────────────────────────────────── */
.legend-bar {
display: flex;
align-items: center;
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;
}
#legend .legend-list {
.legend-bar:active {
cursor: grabbing;
}
/* ── 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);
}
.legend-tab--active {
background: rgba(120, 180, 255, 0.12);
border-color: rgba(120, 180, 255, 0.2);
color: var(--hud-accent-strong);
}
/* ── Bar action buttons ───────────────────────────────────────── */
.legend-bar-actions {
display: flex;
align-items: center;
gap: calc(2px * var(--hud-scale));
flex-shrink: 0;
}
.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-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;
gap: 8px;
max-height: 202px;
padding: calc(4px * var(--hud-scale)) 0;
overflow-y: auto;
padding-right: 4px;
max-height: calc(220px * var(--hud-scale));
scrollbar-width: thin;
scrollbar-color: rgba(160, 220, 255, 0.4) transparent;
scrollbar-color: rgba(160, 186, 216, 0.28) transparent;
}
#legend .legend-list::-webkit-scrollbar {
width: 6px;
}
#legend .legend-list::-webkit-scrollbar-track {
background: transparent;
}
#legend .legend-list::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 237, 255, 0.28), rgba(110, 176, 255, 0.34));
.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 .legend-item {
.legend-item {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 10px;
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(8px * var(--hud-scale));
padding: calc(5px * var(--hud-scale)) calc(10px * var(--hud-scale));
}
#legend .legend-color {
width: 20px;
height: 20px;
border-radius: 6px;
margin-right: 10px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 0 12px rgba(77, 184, 255, 0.18);
.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)));
}

Some files were not shown because too many files have changed in this diff Show More