Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75cb214f23 | ||
|
|
4c21973197 | ||
|
|
51ae5e6ec9 | ||
|
|
1cf1f32ddd | ||
|
|
8f3ab88743 | ||
|
|
f8b43a995b | ||
|
|
d9adaf4134 | ||
|
|
40e51d5b20 | ||
|
|
93c1c1e550 | ||
|
|
48eb13b993 | ||
|
|
11179e7e67 | ||
|
|
07e26d6d5a |
120
.claude/commands/cleanup.md
Normal file
120
.claude/commands/cleanup.md
Normal 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 = 14(4处引用)
|
||||
✓ controls.js — export updateLayerButtonState,移除 main.js 中的重复实现
|
||||
...
|
||||
|
||||
未修改的问题(需人工确认):
|
||||
! foo.js L45 — 注释代码块较长,建议手动确认是否可删除
|
||||
```
|
||||
|
||||
## 约束
|
||||
|
||||
- **禁止**改变函数签名、接口定义、导出 API(除非问题正是私有函数应被 export)
|
||||
- **禁止**添加新功能、新抽象、新参数
|
||||
- **禁止**修改注释内容(只删除注释掉的死代码)
|
||||
- **禁止**修改测试文件逻辑
|
||||
- 如果一个 Magic number 的语义不完全确定,**跳过**,在总结中标记为"需人工确认"
|
||||
146
.claude/commands/release.md
Normal file
146
.claude/commands/release.md
Normal 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 更新,提醒用户手动运行
|
||||
124
.codex/skills/cleanup/SKILL.md
Normal file
124
.codex/skills/cleanup/SKILL.md
Normal 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
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: release-workflow
|
||||
description: Use when the user asks to release, bump version, update changelog/version files, or commit/push a repository release for the Planet repo. Applies the repo's versioning rules, updates all required version-bearing files, updates changelog/version-history, runs minimal relevant validation, and then commits/pushes when requested.
|
||||
---
|
||||
|
||||
# Release Workflow
|
||||
|
||||
Use this skill for release-oriented work in this repository.
|
||||
|
||||
## When To Use
|
||||
|
||||
- The user asks to `发版`
|
||||
- The user asks to bump a version
|
||||
- The user asks to update `CHANGELOG`, `version-history`, or version files as part of a release
|
||||
- The user asks to commit/push a release or a publishable bugfix/feature bundle
|
||||
|
||||
Do not use this skill for ordinary commits that are not being released.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- `feature` -> bump `+0.1.0`
|
||||
- `bugfix` -> bump `+0.0.1`
|
||||
- `docs`, `maintenance`, and `refactor` do not bump by default unless the user explicitly wants a release
|
||||
|
||||
When intent is mixed, prefer the user’s stated release intent. If they ask to release a bugfix bundle, use a patch bump.
|
||||
|
||||
## Required Files
|
||||
|
||||
Every release bump must update these files together:
|
||||
|
||||
- `/home/ray/dev/linkong/planet/VERSION`
|
||||
- `/home/ray/dev/linkong/planet/frontend/package.json`
|
||||
- `/home/ray/dev/linkong/planet/pyproject.toml`
|
||||
- `/home/ray/dev/linkong/planet/uv.lock`
|
||||
- `/home/ray/dev/linkong/planet/docs/CHANGELOG.md`
|
||||
- `/home/ray/dev/linkong/planet/docs/version-history.md`
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect the current worktree and current version.
|
||||
2. Decide the release type from the user request:
|
||||
- feature
|
||||
- bugfix
|
||||
- release without code changes
|
||||
3. Compute the next version.
|
||||
4. Update all required version-bearing files.
|
||||
5. Add a concise but specific changelog entry:
|
||||
- highlights
|
||||
- important added/improved/fixed items
|
||||
- mention the highest-signal files only
|
||||
6. Update `docs/version-history.md`:
|
||||
- current dev version
|
||||
- new timeline row with summary
|
||||
7. Run the smallest relevant validation available.
|
||||
8. Before commit, verify the target version is present in all required files.
|
||||
9. If the user asked for commit/push:
|
||||
- stage the release files and code changes
|
||||
- commit with a conventional message
|
||||
- push to the requested branch, usually `dev`
|
||||
|
||||
## Validation Guidance
|
||||
|
||||
- Prefer scope-matched validation over broad expensive checks
|
||||
- Typical examples:
|
||||
- Python backend edits: `python3 -m py_compile ...`
|
||||
- Frontend edits: use the project-standard frontend build/check if available
|
||||
- If the environment prevents a check, say that explicitly in the final summary
|
||||
|
||||
## Release Checklist
|
||||
|
||||
Before closing the task, confirm:
|
||||
|
||||
- version bump applied consistently
|
||||
- changelog updated
|
||||
- version history updated
|
||||
- generated/runtime artifacts are not accidentally staged
|
||||
- validation status recorded
|
||||
- commit and push completed if requested
|
||||
157
.codex/skills/release/SKILL.md
Normal file
157
.codex/skills/release/SKILL.md
Normal 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
|
||||
10
README.md
10
README.md
@@ -328,11 +328,11 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
|
||||
详细文档:
|
||||
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/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)
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
- [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md)
|
||||
- [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md)
|
||||
|
||||
## 前端页面布局规范
|
||||
|
||||
@@ -346,7 +346,7 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
当前推荐参考实现:
|
||||
|
||||
- [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)
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
完整使用说明见:
|
||||
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
|
||||
当前支持:
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.api.v1 import (
|
||||
collected_data,
|
||||
visualization,
|
||||
bgp,
|
||||
news,
|
||||
system_control,
|
||||
tv,
|
||||
)
|
||||
@@ -35,3 +36,4 @@ 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(news.router, prefix="/news", tags=["news"])
|
||||
|
||||
13
backend/app/api/v1/news.py
Normal file
13
backend/app/api/v1/news.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.services.earth_news import get_earth_news_payload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/earth-feed")
|
||||
async def get_earth_feed(
|
||||
lat: float | None = Query(None, description="Current Earth view center latitude"),
|
||||
lon: float | None = Query(None, description="Current Earth view center longitude"),
|
||||
):
|
||||
return await get_earth_news_payload(lat=lat, lon=lon)
|
||||
490
backend/app/services/earth_news.py
Normal file
490
backend/app/services/earth_news.py
Normal file
@@ -0,0 +1,490 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
import hashlib
|
||||
import html
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)"
|
||||
REQUEST_TIMEOUT = 12.0
|
||||
MAX_ITEMS_PER_SOURCE = 6
|
||||
MAX_ITEMS_TOTAL = 12
|
||||
STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegionProfile:
|
||||
key: str
|
||||
label: str
|
||||
query: str
|
||||
accent: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsFeedSource:
|
||||
id: str
|
||||
name: str
|
||||
region: str
|
||||
feed_url: str
|
||||
homepage_url: str
|
||||
source_type: str = "rss"
|
||||
priority: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedNewsItem:
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
url: str
|
||||
source: str
|
||||
feed_name: str
|
||||
feed_region: str
|
||||
homepage_url: str
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CachedRegionFeed:
|
||||
region: str
|
||||
fetched_at: datetime
|
||||
items: list[ParsedNewsItem]
|
||||
sources: list[NewsFeedSource]
|
||||
|
||||
|
||||
REGION_PROFILES: dict[str, RegionProfile] = {
|
||||
"americas": RegionProfile(
|
||||
key="americas",
|
||||
label="美洲焦点",
|
||||
query='Americas geopolitics OR Latin America OR "United States" OR Canada',
|
||||
accent="#79d3ff",
|
||||
),
|
||||
"europe": RegionProfile(
|
||||
key="europe",
|
||||
label="欧洲焦点",
|
||||
query='Europe geopolitics OR EU OR NATO OR "Eastern Europe"',
|
||||
accent="#8fd4ff",
|
||||
),
|
||||
"middle-east-africa": RegionProfile(
|
||||
key="middle-east-africa",
|
||||
label="中东与非洲焦点",
|
||||
query='"Middle East" OR Africa geopolitics OR Red Sea OR Gulf',
|
||||
accent="#ffb56a",
|
||||
),
|
||||
"asia-pacific": RegionProfile(
|
||||
key="asia-pacific",
|
||||
label="亚太焦点",
|
||||
query='"Asia Pacific" OR Indo-Pacific OR China OR Japan OR Korea OR ASEAN',
|
||||
accent="#78f2cf",
|
||||
),
|
||||
"global": RegionProfile(
|
||||
key="global",
|
||||
label="全球焦点",
|
||||
query='"world news" OR geopolitics OR "global affairs"',
|
||||
accent="#d6e6ff",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
|
||||
return (
|
||||
"https://news.google.com/rss/search?q="
|
||||
+ quote(query, safe="")
|
||||
+ f"&hl={hl}&gl={gl}&ceid={ceid}"
|
||||
)
|
||||
|
||||
|
||||
NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = (
|
||||
NewsFeedSource(
|
||||
id="bbc-world",
|
||||
name="BBC World",
|
||||
region="global",
|
||||
feed_url="https://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
homepage_url="https://www.bbc.com/news/world",
|
||||
priority=10,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="dw-top",
|
||||
name="DW Top Stories",
|
||||
region="europe",
|
||||
feed_url="https://rss.dw.com/rdf/rss-en-top",
|
||||
homepage_url="https://www.dw.com/en/top-stories/s-9097",
|
||||
priority=20,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="global-scan",
|
||||
name="Global Monitor / World",
|
||||
region="global",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["global"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=30,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-americas",
|
||||
name="Global Monitor / Americas",
|
||||
region="americas",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["americas"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-europe",
|
||||
name="Global Monitor / Europe",
|
||||
region="europe",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["europe"].query,
|
||||
hl="en-GB",
|
||||
gl="GB",
|
||||
ceid="GB:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-mea",
|
||||
name="Global Monitor / MEA",
|
||||
region="middle-east-africa",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["middle-east-africa"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-apac",
|
||||
name="Global Monitor / APAC",
|
||||
region="asia-pacific",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["asia-pacific"].query,
|
||||
hl="en-SG",
|
||||
gl="SG",
|
||||
ceid="SG:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_REGION_CACHE: dict[str, CachedRegionFeed] = {}
|
||||
|
||||
|
||||
def determine_focus_region(lat: float | None, lon: float | None) -> str:
|
||||
if lat is None or lon is None:
|
||||
return "global"
|
||||
if -170 <= lon <= -30:
|
||||
return "americas"
|
||||
if -30 < lon <= 45:
|
||||
return "europe" if lat >= 30 else "middle-east-africa"
|
||||
if 45 < lon <= 150:
|
||||
return "middle-east-africa" if lat < 10 else "asia-pacific"
|
||||
return "asia-pacific"
|
||||
|
||||
|
||||
def get_region_profile(region: str) -> RegionProfile:
|
||||
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
|
||||
|
||||
|
||||
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
|
||||
return sorted(
|
||||
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
|
||||
key=lambda source: (source.priority, source.name),
|
||||
)
|
||||
|
||||
|
||||
def _strip_html(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
soup = BeautifulSoup(value, "html.parser")
|
||||
return re.sub(r"\s+", " ", soup.get_text(" ", strip=True)).strip()
|
||||
|
||||
|
||||
def _truncate(value: str, limit: int = 180) -> str:
|
||||
text = value.strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _normalize_source_name(raw: str, fallback: str) -> str:
|
||||
text = html.unescape((raw or "").strip())
|
||||
if " - " in text:
|
||||
return text.split(" - ")[-1].strip() or fallback
|
||||
return text or fallback
|
||||
|
||||
|
||||
def _parse_datetime(raw: str | None) -> datetime | None:
|
||||
if not raw:
|
||||
return None
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
for parser in (
|
||||
lambda value: parsedate_to_datetime(value),
|
||||
lambda value: datetime.fromisoformat(value.replace("Z", "+00:00")),
|
||||
):
|
||||
try:
|
||||
parsed = parser(text)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _extract_item_text(element: ET.Element, *names: str) -> str:
|
||||
for name in names:
|
||||
node = element.find(name)
|
||||
if node is not None and node.text:
|
||||
return node.text.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_feed_entries(xml_text: str, source: NewsFeedSource) -> list[ParsedNewsItem]:
|
||||
root = ET.fromstring(xml_text)
|
||||
items: list[ParsedNewsItem] = []
|
||||
|
||||
rss_items = root.findall("./channel/item")
|
||||
atom_entries = root.findall("{http://www.w3.org/2005/Atom}entry")
|
||||
nodes = rss_items or atom_entries
|
||||
|
||||
for node in nodes[:MAX_ITEMS_PER_SOURCE]:
|
||||
if node.tag.endswith("entry"):
|
||||
title = _extract_item_text(node, "{http://www.w3.org/2005/Atom}title")
|
||||
summary = _extract_item_text(
|
||||
node,
|
||||
"{http://www.w3.org/2005/Atom}summary",
|
||||
"{http://www.w3.org/2005/Atom}content",
|
||||
)
|
||||
link_node = node.find("{http://www.w3.org/2005/Atom}link")
|
||||
link = link_node.get("href", "").strip() if link_node is not None else ""
|
||||
published = _extract_item_text(
|
||||
node,
|
||||
"{http://www.w3.org/2005/Atom}updated",
|
||||
"{http://www.w3.org/2005/Atom}published",
|
||||
)
|
||||
else:
|
||||
title = _extract_item_text(node, "title")
|
||||
summary = _extract_item_text(node, "description", "content")
|
||||
link = _extract_item_text(node, "link")
|
||||
published = _extract_item_text(node, "pubDate", "published", "updated")
|
||||
|
||||
clean_title = html.unescape(title).strip()
|
||||
clean_summary = _truncate(_strip_html(summary), 180)
|
||||
if not clean_title or not link:
|
||||
continue
|
||||
|
||||
item_source = _normalize_source_name(clean_title, source.name)
|
||||
display_title = clean_title
|
||||
if source.source_type == "aggregated" and " - " in clean_title:
|
||||
parts = clean_title.rsplit(" - ", 1)
|
||||
display_title = parts[0].strip()
|
||||
item_source = _normalize_source_name(parts[1], source.name)
|
||||
|
||||
items.append(
|
||||
ParsedNewsItem(
|
||||
id=f"{source.id}:{hashlib.sha1(link.encode('utf-8')).hexdigest()[:12]}",
|
||||
title=display_title,
|
||||
summary=clean_summary,
|
||||
url=link,
|
||||
source=item_source,
|
||||
feed_name=source.name,
|
||||
feed_region=source.region,
|
||||
homepage_url=source.homepage_url,
|
||||
published_at=_parse_datetime(published),
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": source.id,
|
||||
"name": source.name,
|
||||
"region": source.region,
|
||||
"homepage_url": source.homepage_url,
|
||||
}
|
||||
for source in sources
|
||||
]
|
||||
|
||||
|
||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"region": item.feed_region,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
"is_focus_match": item.feed_region == active_region,
|
||||
}
|
||||
|
||||
|
||||
def _build_payload(
|
||||
*,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
active_region: str,
|
||||
items: list[ParsedNewsItem],
|
||||
sources: list[NewsFeedSource],
|
||||
errors: list[str],
|
||||
stale: bool,
|
||||
generated_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
profile = get_region_profile(active_region)
|
||||
timestamp = generated_at or datetime.now(UTC)
|
||||
return {
|
||||
"generated_at": timestamp.isoformat().replace("+00:00", "Z"),
|
||||
"focus": {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"region": active_region,
|
||||
"label": profile.label,
|
||||
"accent": profile.accent,
|
||||
},
|
||||
"sources": _serialize_sources(sources),
|
||||
"items": [_serialize_item(item, active_region=active_region) for item in items],
|
||||
"errors": errors,
|
||||
"stale": stale,
|
||||
}
|
||||
|
||||
|
||||
def _rank_and_trim_items(items: list[ParsedNewsItem], *, active_region: str) -> list[ParsedNewsItem]:
|
||||
deduped: dict[str, ParsedNewsItem] = {}
|
||||
for item in items:
|
||||
key = item.url.strip() or item.title.strip().lower()
|
||||
if key not in deduped:
|
||||
deduped[key] = item
|
||||
|
||||
return sorted(
|
||||
deduped.values(),
|
||||
key=lambda item: (
|
||||
item.feed_region != active_region,
|
||||
item.published_at is None,
|
||||
-(item.published_at.timestamp() if item.published_at else 0),
|
||||
item.feed_name,
|
||||
),
|
||||
)[:MAX_ITEMS_TOTAL]
|
||||
|
||||
|
||||
def _get_cached_region_feed(region: str) -> CachedRegionFeed | None:
|
||||
cached = _REGION_CACHE.get(region)
|
||||
if not cached:
|
||||
return None
|
||||
age_seconds = (datetime.now(UTC) - cached.fetched_at).total_seconds()
|
||||
if age_seconds > STALE_CACHE_MAX_AGE_SECONDS:
|
||||
return None
|
||||
return cached
|
||||
|
||||
|
||||
def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: list[NewsFeedSource]) -> None:
|
||||
_REGION_CACHE[region] = CachedRegionFeed(
|
||||
region=region,
|
||||
fetched_at=datetime.now(UTC),
|
||||
items=list(items),
|
||||
sources=list(sources),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_source(
|
||||
client: httpx.AsyncClient,
|
||||
source: NewsFeedSource,
|
||||
) -> tuple[NewsFeedSource, list[ParsedNewsItem], str | None]:
|
||||
try:
|
||||
response = await client.get(source.feed_url)
|
||||
response.raise_for_status()
|
||||
return source, _parse_feed_entries(response.text, source), None
|
||||
except Exception as exc:
|
||||
return source, [], str(exc)
|
||||
|
||||
|
||||
async def get_earth_news_payload(lat: float | None = None, lon: float | None = None) -> dict[str, Any]:
|
||||
active_region = determine_focus_region(lat, lon)
|
||||
sources = get_sources_for_region(active_region)
|
||||
errors: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
) as client:
|
||||
results = await asyncio.gather(*(_fetch_source(client, source) for source in sources))
|
||||
|
||||
fetched_items: list[ParsedNewsItem] = []
|
||||
for source, items, error in results:
|
||||
if error:
|
||||
errors.append(f"{source.name}: {error}")
|
||||
continue
|
||||
fetched_items.extend(items)
|
||||
|
||||
ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region)
|
||||
if ranked_items:
|
||||
_store_region_cache(active_region, items=ranked_items, sources=sources)
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=ranked_items,
|
||||
sources=sources,
|
||||
errors=errors,
|
||||
stale=False,
|
||||
)
|
||||
|
||||
cached = _get_cached_region_feed(active_region)
|
||||
if cached:
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=cached.items,
|
||||
sources=cached.sources,
|
||||
errors=errors,
|
||||
stale=True,
|
||||
generated_at=cached.fetched_at,
|
||||
)
|
||||
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=[],
|
||||
sources=sources,
|
||||
errors=errors,
|
||||
stale=False,
|
||||
)
|
||||
@@ -5,8 +5,141 @@ 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.28.1] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- 收口 Earth 媒体情报面板命名,明确外层 `media-panel` 与内部 `tv-panel / news-panel` 的职责边界
|
||||
- 整理 `docs/` 目录分组,并将已完成或已废弃的计划文档归档到 `docs/deprecated`
|
||||
|
||||
### 🔧 Improvements
|
||||
- 底部 tab 语义统一为 `media-panel-tabs / media-panel-tab`,并将文案更新为“电视直播 / 态势聚合”
|
||||
- 补充媒体面板、聚合新闻模块的注释说明,减少 `tv-panel` 同时指代外层壳和内层直播 pane 的阅读歧义
|
||||
- 更新 README、AI Provider README 与历史文档互链,适配新的 `docs/agents / docs/earth / docs/frontend / docs/backend / docs/ops / docs/ue5` 分组结构
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复媒体情报面板标题组在头部撑出多余空白的问题,去掉 `hud-panel__title-group` 的无效弹性占位
|
||||
- 修复聚合 tab 头部仍保留冗余固定标题的问题,现在仅显示区域标签
|
||||
|
||||
---
|
||||
|
||||
## [0.28.0] — 2026-04-20
|
||||
|
||||
### ✨ Features
|
||||
- 将 Earth 的“新闻直播”和“全球态势聚合”合并为统一的“媒体情报”面板,支持底部 tab 切换与共享标题栏操作区
|
||||
- 聚合新闻不再单独占据一个 HUD 面板,而是作为媒体情报面板内的第二视图与直播协同呈现
|
||||
|
||||
说明:
|
||||
- 当前结构中,外层 HUD 壳为 `media-panel`,内部 tab 内容区分别为 `tv-panel` 和 `news-panel`
|
||||
|
||||
### 🔧 Improvements
|
||||
- TV / News 面板切换加入底边锚定的 reform 动画,并继续保留拖拽、缩放和共享 HUD 行为收口
|
||||
- 聚合新闻视图新增默认高度约束与内部滚动填充逻辑,避免初始高度过度膨胀
|
||||
- `tv.js`、`news.js` 进一步清理共享 HUDPanel 迁移后的残留逻辑,收紧 tab / resize / reform 相关局部 helper
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复媒体情报面板右下角缩放时高度异常抬升、标题栏被顶出视口的问题
|
||||
- 修复直播/聚合 tab 切换时按钮高亮、内容切换和底边基准表现不一致的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.7] — 2026-04-16
|
||||
|
||||
## [0.27.8] — 2026-04-20
|
||||
|
||||
### 🔧 Improvements
|
||||
- Earth HUD 共享 `HUDPanel` 默认展开/收缩逻辑继续收口,图例与图层面板统一使用同一套边缘阈值与箭头状态机
|
||||
- 保持新闻直播面板现有特例折叠行为不变,避免播放器区域被默认折叠逻辑影响
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复图例与图层面板展开/收缩箭头方向和实际动作不一致的问题
|
||||
- 修复拖动到屏幕底边附近时初始箭头、拖动中箭头和点击后动作不同步的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.7] — 2026-04-16
|
||||
|
||||
### 🔧 Improvements
|
||||
- 用户管理、数据源配置、电视直播源表格统一接入可折叠操作列,窄宽度下自动收起到下拉菜单,减少操作区挤压
|
||||
- 电视直播设置改为表格总览 + 弹窗编辑模式,主表内容更紧凑,适合控制台一屏浏览
|
||||
- Earth TV 面板新增失败源探测与自动回退恢复标记,便于值班时快速识别异常直播源
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 Settings 电视直播源新增后取消编辑会残留未保存草稿的问题
|
||||
- 修复 Settings 删除直播源只改本地状态、刷新后恢复的问题,删除现在会立即持久化
|
||||
- 修复 Users / DataSources / Settings 表格“备注/状态”和“操作”之间的空白占位列问题
|
||||
- 修复 `useCollapsedActions` 未释放 `ResizeObserver` 导致的潜在内存泄漏与重复回调问题
|
||||
|
||||
---
|
||||
|
||||
## [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 补充进拖拽排除列表)
|
||||
- 调整图层搜索框图标尺寸为 20px,BR 缩放角标改为直角 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
|
||||
@@ -80,7 +213,7 @@ Released: 2026-04-12
|
||||
|
||||
- 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.
|
||||
- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/earth/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
|
||||
|
||||
@@ -166,7 +299,7 @@ Released: 2026-04-10
|
||||
|
||||
- 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.
|
||||
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -204,7 +337,7 @@ Released: 2026-04-10
|
||||
### 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.”
|
||||
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/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
|
||||
|
||||
@@ -221,7 +354,7 @@ Released: 2026-04-10
|
||||
- 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.
|
||||
- Improved [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/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
|
||||
|
||||
@@ -327,8 +460,8 @@ Released: 2026-04-09
|
||||
### 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.
|
||||
- Added [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
||||
- Added [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/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
|
||||
|
||||
@@ -433,7 +566,7 @@ Released: 2026-04-07
|
||||
- Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry.
|
||||
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
|
||||
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
|
||||
- Added [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||
- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||
|
||||
### Improved
|
||||
@@ -559,7 +692,7 @@ Released: 2026-04-02
|
||||
|
||||
- Added a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py).
|
||||
- Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels.
|
||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/prefix-geography-plan.md).
|
||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/earth/prefix-geography-plan.md).
|
||||
- Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py).
|
||||
- Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py).
|
||||
- Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures.
|
||||
@@ -574,7 +707,7 @@ Released: 2026-04-02
|
||||
- Improved Earth event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently.
|
||||
- Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime.
|
||||
- Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging.
|
||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -732,7 +865,7 @@ Released: 2026-03-31
|
||||
- Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py).
|
||||
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
|
||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/system-service-control.md).
|
||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/backend/system-service-control.md).
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -935,7 +1068,7 @@ Released: 2026-03-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-module-plan.md).
|
||||
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-module-plan.md).
|
||||
- Added backend TLE helpers in [satellite_tle.py](/home/ray/dev/linkong/planet/backend/app/core/satellite_tle.py).
|
||||
- Added backend support for returning `tle_line1` and `tle_line2` from the satellite visualization API.
|
||||
|
||||
|
||||
647
docs/agents/agent-architecture-plan.md
Normal file
647
docs/agents/agent-architecture-plan.md
Normal 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
|
||||
346
docs/agents/agent-runtime-roadmap.md
Normal file
346
docs/agents/agent-runtime-roadmap.md
Normal 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/agents/aiprovider.md)
|
||||
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/agents/datasource-health-plan.md)
|
||||
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agents/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.
|
||||
333
docs/agents/aiprovider.md
Normal file
333
docs/agents/aiprovider.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# AI Provider Guide
|
||||
|
||||
## Overview
|
||||
|
||||
`aiprovider` is the model-adapter service for Planet.
|
||||
|
||||
It isolates model-vendor details from the main backend so the rest of the system can call a stable business API:
|
||||
|
||||
- Caller service -> `planet backend`
|
||||
- `planet backend` -> `aiprovider`
|
||||
- `aiprovider` -> concrete model provider
|
||||
|
||||
The recommended default is:
|
||||
|
||||
- External and cross-service callers use `planet backend`
|
||||
- Only infrastructure-grade internal jobs call `aiprovider` directly
|
||||
|
||||
## Responsibilities
|
||||
|
||||
`backend` is responsible for:
|
||||
|
||||
- authentication and authorization
|
||||
- business-level request shaping
|
||||
- stable `/api/v1/ai/...` endpoints
|
||||
- internal service-to-service authentication toward `aiprovider`
|
||||
|
||||
`aiprovider` is responsible for:
|
||||
|
||||
- model protocol adaptation
|
||||
- provider selection by `.env`
|
||||
- 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 these provider identities:
|
||||
|
||||
- `openai`
|
||||
- `anthropic`
|
||||
- `minimax`
|
||||
- `ollama`
|
||||
|
||||
Supported request adapters:
|
||||
|
||||
- `openai-completions`
|
||||
- `anthropic-messages`
|
||||
- `ollama-generate`
|
||||
|
||||
Backward-compatible aliases still accepted:
|
||||
|
||||
- `openai_compatible`
|
||||
- `anthropic_compatible`
|
||||
- `claude_compatible`
|
||||
|
||||
Provider mapping:
|
||||
|
||||
- `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
|
||||
|
||||
### Main backend API
|
||||
|
||||
Preferred stable entrypoints:
|
||||
|
||||
- `GET /api/v1/ai/provider/status`
|
||||
- `POST /api/v1/ai/situational-awareness/analyze`
|
||||
|
||||
Authentication:
|
||||
|
||||
- `Authorization: Bearer <jwt>`
|
||||
|
||||
Optional tracing header:
|
||||
|
||||
- `X-Request-ID: <caller-generated-id>`
|
||||
|
||||
The backend will propagate `X-Request-ID` to `aiprovider` and return the same header in the response.
|
||||
|
||||
### AI provider internal API
|
||||
|
||||
Internal-only endpoints:
|
||||
|
||||
- `GET /v1/provider/status`
|
||||
- `POST /v1/analyze`
|
||||
|
||||
Authentication:
|
||||
|
||||
- `X-Provider-Token: <shared-secret>`
|
||||
|
||||
Optional tracing header:
|
||||
|
||||
- `X-Request-ID: <caller-generated-id>`
|
||||
|
||||
## Request Example
|
||||
|
||||
### Call through backend
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/ai/situational-awareness/analyze \
|
||||
-H "Authorization: Bearer <access_token>" \
|
||||
-H "X-Request-ID: bgp-incident-20260407-001" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "BGP异常研判",
|
||||
"objective": "总结当前风险并给出处置建议",
|
||||
"observations": [
|
||||
"collector A 在 5 分钟内出现多次 origin 变更",
|
||||
"异常集中在同一地区前缀"
|
||||
],
|
||||
"constraints": [
|
||||
"不要编造不存在的数据",
|
||||
"区分事实和推断"
|
||||
],
|
||||
"context": {
|
||||
"source": "bgp-monitor",
|
||||
"severity": "high"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Call `aiprovider` directly
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8010/v1/analyze \
|
||||
-H "X-Provider-Token: change_me" \
|
||||
-H "X-Request-ID: ai-batch-job-001" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "链路波动分析",
|
||||
"objective": "给出简要态势摘要和下一步建议",
|
||||
"observations": [
|
||||
"多个节点出现延迟上升"
|
||||
],
|
||||
"constraints": [
|
||||
"不要假设根因已经确认"
|
||||
],
|
||||
"context": {
|
||||
"region": "APAC"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Response Shape
|
||||
|
||||
Both backend and `aiprovider` return the same payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "minimax",
|
||||
"api": "anthropic-messages",
|
||||
"model": "MiniMax-M2.7",
|
||||
"content": "1) 态势摘要 ...",
|
||||
"content_blocks": [],
|
||||
"text_blocks": [],
|
||||
"thinking_blocks": [],
|
||||
"raw_response": {}
|
||||
}
|
||||
```
|
||||
|
||||
Both services also return:
|
||||
|
||||
- `X-Request-ID: <id>`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Backend
|
||||
|
||||
Recommended backend `.env`:
|
||||
|
||||
```env
|
||||
AI_PROVIDER_SERVICE_URL=http://localhost:8010
|
||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
AI_PROVIDER_TIMEOUT_SECONDS=60
|
||||
AI_PROVIDER_RETRY_ATTEMPTS=2
|
||||
```
|
||||
|
||||
Reference file:
|
||||
|
||||
- [backend/.env.example](/home/ray/dev/linkong/planet/backend/.env.example)
|
||||
|
||||
### AI Provider
|
||||
|
||||
Reference file:
|
||||
|
||||
- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example)
|
||||
|
||||
Frontend local reference:
|
||||
|
||||
- [frontend/.env.example](/home/ray/dev/linkong/planet/frontend/.env.example)
|
||||
|
||||
Common settings:
|
||||
|
||||
```env
|
||||
SERVICE_NAME=planet-ai-provider
|
||||
SERVICE_VERSION=0.1.0
|
||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
AI_TIMEOUT_SECONDS=60
|
||||
AI_HTTP_RETRY_ATTEMPTS=2
|
||||
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
|
||||
```
|
||||
|
||||
### OpenAI-compatible example
|
||||
|
||||
```env
|
||||
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
|
||||
```
|
||||
|
||||
### MiniMax CN example
|
||||
|
||||
```env
|
||||
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
|
||||
AI_ANTHROPIC_VERSION=2023-06-01
|
||||
```
|
||||
|
||||
### Ollama example
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
### Single machine
|
||||
|
||||
Recommended local flow:
|
||||
|
||||
- `backend` on `localhost:8000`
|
||||
- `aiprovider` on `localhost:8010`
|
||||
- local model gateway on `localhost:11434` or another local port
|
||||
|
||||
Helpers already included:
|
||||
|
||||
- [planet.sh](/home/ray/dev/linkong/planet/planet.sh)
|
||||
- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml)
|
||||
|
||||
### Multi-machine
|
||||
|
||||
Example topology:
|
||||
|
||||
- app machine: `backend`
|
||||
- AI gateway machine: `aiprovider`
|
||||
- model machine: local model service or cloud proxy
|
||||
|
||||
In that case, this becomes service-to-service HTTP RPC:
|
||||
|
||||
- caller -> backend
|
||||
- backend -> `http://10.0.0.12:8010`
|
||||
- `aiprovider` -> model endpoint
|
||||
|
||||
Recommended cross-machine backend config:
|
||||
|
||||
```env
|
||||
AI_PROVIDER_SERVICE_URL=http://10.0.0.12:8010
|
||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
AI_PROVIDER_TIMEOUT_SECONDS=60
|
||||
AI_PROVIDER_RETRY_ATTEMPTS=2
|
||||
```
|
||||
|
||||
Recommended operating rules:
|
||||
|
||||
- keep `aiprovider` on a private network
|
||||
- protect it with `X-Provider-Token` at minimum
|
||||
- always send `X-Request-ID`
|
||||
- keep callers on the backend API unless they are infrastructure jobs
|
||||
|
||||
## Retry And Failure Behavior
|
||||
|
||||
`backend -> aiprovider`:
|
||||
|
||||
- retries lightweight network / 5xx failures
|
||||
- returns `502` when the provider service is unavailable
|
||||
|
||||
`aiprovider -> model provider`:
|
||||
|
||||
- retries lightweight network / 5xx failures
|
||||
- returns `502` when the model provider is unavailable
|
||||
|
||||
This is intentionally conservative. It avoids masking persistent errors while still absorbing short hiccups.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- `./planet.sh start` now starts `aiprovider` automatically
|
||||
- `./planet.sh restart -a` restarts only `aiprovider`
|
||||
- `./planet.sh log -a` tails `aiprovider` logs
|
||||
- `./planet.sh health` reports `aiprovider` health
|
||||
|
||||
## Recommended Calling Policy
|
||||
|
||||
- Frontend and application services: call `backend`
|
||||
- Scheduled infra jobs and diagnostics: optionally call `aiprovider`
|
||||
- Do not let multiple business services integrate model vendors independently
|
||||
|
||||
That keeps provider switching centralized and avoids model-specific drift across the system.
|
||||
486
docs/agents/datasource-health-plan.md
Normal file
486
docs/agents/datasource-health-plan.md
Normal 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.
|
||||
478
docs/agents/datasource-health-stage2-tasks.md
Normal file
478
docs/agents/datasource-health-stage2-tasks.md
Normal 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
|
||||
309
docs/agents/situational-awareness-foundation-plan.md
Normal file
309
docs/agents/situational-awareness-foundation-plan.md
Normal 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 2:Evidence-first Assessment
|
||||
|
||||
目标:
|
||||
|
||||
- 从“文本摘要”升级成“结构化 assessment”
|
||||
|
||||
工作项:
|
||||
|
||||
- 为 brief/assessment 定义统一 schema
|
||||
- 固化:
|
||||
- summary
|
||||
- key_risks
|
||||
- evidence
|
||||
- confidence
|
||||
- recommendations
|
||||
- missing_data
|
||||
- 页面以结构化区块展示,而不只是大段文本
|
||||
|
||||
完成标准:
|
||||
|
||||
- AI 输出可持久化、可比较、可审计
|
||||
|
||||
## Phase 3:多维证据接入
|
||||
|
||||
目标:
|
||||
|
||||
- 让“态势感知”真正拥有更多维度,而不是只靠 BGP 与系统告警
|
||||
|
||||
优先接入方向:
|
||||
|
||||
- datasource health findings
|
||||
- 流量或业务指标
|
||||
- 区域/资产/链路映射
|
||||
- 外部事件与公告
|
||||
- 业务垂直数据,例如电商分析相关指标
|
||||
|
||||
完成标准:
|
||||
|
||||
- AI 能基于多个维度做交叉说明
|
||||
- 不再只围绕单一模块自说自话
|
||||
|
||||
## Phase 4:Correlation Layer
|
||||
|
||||
目标:
|
||||
|
||||
- 不同来源的信号不再只是并列,而是形成统一的事件关联
|
||||
|
||||
工作项:
|
||||
|
||||
- 统一 signal/finding 模型
|
||||
- 跨模块事件聚合
|
||||
- 证据来源权重
|
||||
- collector bias 与真实热度分离
|
||||
|
||||
完成标准:
|
||||
|
||||
- 系统能回答“这些异常是不是同一件事”
|
||||
- 系统能回答“哪些结论只是观测偏差”
|
||||
|
||||
## Phase 5:Agent-assisted Situational Awareness
|
||||
|
||||
目标:
|
||||
|
||||
- 在证据足够的前提下,再让 agent 负责更复杂的推理与建议
|
||||
|
||||
工作项:
|
||||
|
||||
- 复用现有 agent runtime 规划
|
||||
- 引入 web search / docs fetch / repair proposal 等能力
|
||||
- 但始终坚持:
|
||||
- evidence first
|
||||
- proposal before action
|
||||
- no silent mutation of defaults
|
||||
|
||||
完成标准:
|
||||
|
||||
- agent 成为证据驱动的分析层
|
||||
- 而不是一个“万能猜测层”
|
||||
|
||||
## 设计原则
|
||||
|
||||
### 1. 先底座,后产品化
|
||||
|
||||
先把服务链路和证据模型做好,再做更大的页面表达。
|
||||
|
||||
### 2. 先证据,后判断
|
||||
|
||||
事实输入应先稳定,再让模型做归纳。
|
||||
|
||||
### 3. 先专用 brief,后统一态势层
|
||||
|
||||
先让各业务页有各自可信的 AI 入口,再考虑统一态势页。
|
||||
|
||||
### 4. 先 proposal,后自动动作
|
||||
|
||||
涉及修复、覆盖、写配置、调任务的动作,都应经过 proposal 和审计。
|
||||
|
||||
## 当前建议结论
|
||||
|
||||
对现在这个项目,最合理的定位是:
|
||||
|
||||
- `Playground` 是测试台
|
||||
- `BGP / Alerts` 是第一批业务 AI 入口
|
||||
- `aiprovider + backend AI facade + evidence snapshots` 是核心服务底座
|
||||
|
||||
现阶段不需要追求“已经具备完整态势感知能力”。
|
||||
|
||||
现阶段真正的成功标准是:
|
||||
|
||||
- 这套底座可用
|
||||
- 可回看
|
||||
- 可扩展
|
||||
- 不自欺欺人
|
||||
402
docs/backend/collected-data-history-plan.md
Normal file
402
docs/backend/collected-data-history-plan.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# 采集数据历史快照化改造方案
|
||||
|
||||
## 背景
|
||||
|
||||
当前系统的 `collected_data` 更接近“当前结果表”:
|
||||
|
||||
- 同一个 `source + source_id` 会被更新覆盖
|
||||
- 前端列表页默认读取这张表
|
||||
- `collection_tasks` 只记录任务执行状态,不直接承载数据版本语义
|
||||
|
||||
这套方式适合管理后台,但不利于后续做态势感知、时间回放、趋势分析和版本对比。
|
||||
如果后面需要回答下面这类问题,当前模型会比较吃力:
|
||||
|
||||
- 某条实体在过去 7 天如何变化
|
||||
- 某次采集相比上次新增了什么、删除了什么、值变了什么
|
||||
- 某个时刻地图上“当时的世界状态”是什么
|
||||
- 告警是在第几次采集后触发的
|
||||
|
||||
因此建议把采集数据改造成“历史快照 + 当前视图”模型。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 每次触发采集都保留一份独立快照,历史可追溯。
|
||||
2. 管理后台默认仍然只看“当前最新状态”,不增加使用复杂度。
|
||||
3. 后续支持:
|
||||
- 时间线回放
|
||||
- 两次采集差异对比
|
||||
- 趋势分析
|
||||
- 按快照回溯告警和地图状态
|
||||
4. 尽量兼容现有接口,降低改造成本。
|
||||
|
||||
## 结论
|
||||
|
||||
不建议继续用以下两种单一模式:
|
||||
|
||||
- 直接覆盖旧数据
|
||||
问题:没有历史,无法回溯。
|
||||
|
||||
- 软删除旧数据再全量新增
|
||||
问题:语义不清,历史和“当前无效”混在一起,后续统计复杂。
|
||||
|
||||
推荐方案:
|
||||
|
||||
- 保留历史事实表
|
||||
- 维护当前视图
|
||||
- 每次采集对应一个明确的快照批次
|
||||
|
||||
## 推荐数据模型
|
||||
|
||||
### 方案概览
|
||||
|
||||
建议拆成三层:
|
||||
|
||||
1. `collection_tasks`
|
||||
继续作为采集任务表,表示“这次采集任务”。
|
||||
|
||||
2. `data_snapshots`
|
||||
新增快照表,表示“某个数据源在某次任务中产出的一个快照批次”。
|
||||
|
||||
3. `collected_data`
|
||||
从“当前结果表”升级为“历史事实表”,每一行归属于一个快照。
|
||||
|
||||
同时再提供一个“当前视图”:
|
||||
|
||||
- SQL View / 物化视图 / API 查询层封装均可
|
||||
- 语义是“每个 `source + source_id` 的最新有效记录”
|
||||
|
||||
### 新增表:`data_snapshots`
|
||||
|
||||
建议字段:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
|---|---|---|
|
||||
| `id` | bigint PK | 快照主键 |
|
||||
| `datasource_id` | int | 对应数据源 |
|
||||
| `task_id` | int | 对应采集任务 |
|
||||
| `source` | varchar(100) | 数据源名,如 `top500` |
|
||||
| `snapshot_key` | varchar(100) | 可选,业务快照标识 |
|
||||
| `reference_date` | timestamptz nullable | 这批数据的参考时间 |
|
||||
| `started_at` | timestamptz | 快照开始时间 |
|
||||
| `completed_at` | timestamptz | 快照完成时间 |
|
||||
| `record_count` | int | 快照总记录数 |
|
||||
| `status` | varchar(20) | `running/success/failed/partial` |
|
||||
| `is_current` | bool | 当前是否是该数据源最新快照 |
|
||||
| `parent_snapshot_id` | bigint nullable | 上一版快照,可用于 diff |
|
||||
| `summary` | jsonb | 本次快照统计摘要 |
|
||||
|
||||
说明:
|
||||
|
||||
- `collection_tasks` 偏“执行过程”
|
||||
- `data_snapshots` 偏“数据版本”
|
||||
- 一个任务通常对应一个快照,但保留分层更清晰
|
||||
|
||||
### 升级表:`collected_data`
|
||||
|
||||
建议新增字段:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
|---|---|---|
|
||||
| `snapshot_id` | bigint not null | 归属快照 |
|
||||
| `task_id` | int nullable | 归属任务,便于追查 |
|
||||
| `entity_key` | varchar(255) | 实体稳定键,通常可由 `source + source_id` 派生 |
|
||||
| `is_current` | bool | 当前是否为该实体最新记录 |
|
||||
| `previous_record_id` | bigint nullable | 上一个版本的记录 |
|
||||
| `change_type` | varchar(20) | `created/updated/unchanged/deleted` |
|
||||
| `change_summary` | jsonb | 字段变化摘要 |
|
||||
| `deleted_at` | timestamptz nullable | 对应“本次快照中消失”的实体 |
|
||||
|
||||
保留现有字段:
|
||||
|
||||
- `source`
|
||||
- `source_id`
|
||||
- `data_type`
|
||||
- `name`
|
||||
- `title`
|
||||
- `description`
|
||||
- `country`
|
||||
- `city`
|
||||
- `latitude`
|
||||
- `longitude`
|
||||
- `value`
|
||||
- `unit`
|
||||
- `metadata`
|
||||
- `collected_at`
|
||||
- `reference_date`
|
||||
- `is_valid`
|
||||
|
||||
### 当前视图
|
||||
|
||||
建议新增一个只读视图:
|
||||
|
||||
`current_collected_data`
|
||||
|
||||
语义:
|
||||
|
||||
- 对每个 `source + source_id` 只保留最新一条 `is_current = true` 且 `deleted_at is null` 的记录
|
||||
|
||||
这样:
|
||||
|
||||
- 管理后台继续像现在一样查“当前数据”
|
||||
- 历史分析查 `collected_data`
|
||||
|
||||
## 写入策略
|
||||
|
||||
### 触发按钮语义
|
||||
|
||||
“触发”不再理解为“覆盖旧表”,而是:
|
||||
|
||||
- 启动一次新的采集任务
|
||||
- 生成一个新的快照
|
||||
- 将本次结果写入历史事实表
|
||||
- 再更新当前视图标记
|
||||
|
||||
### 写入流程
|
||||
|
||||
1. 创建 `collection_tasks` 记录,状态 `running`
|
||||
2. 创建 `data_snapshots` 记录,状态 `running`
|
||||
3. 采集器拉取原始数据并标准化
|
||||
4. 为每条记录生成 `entity_key`
|
||||
- 推荐:`{source}:{source_id}`
|
||||
5. 将本次记录批量写入 `collected_data`
|
||||
6. 与上一个快照做比对,计算:
|
||||
- 新增
|
||||
- 更新
|
||||
- 未变
|
||||
- 删除
|
||||
7. 更新本批记录的:
|
||||
- `change_type`
|
||||
- `previous_record_id`
|
||||
- `is_current`
|
||||
8. 将上一批同实体记录的 `is_current` 置为 `false`
|
||||
9. 将本次快照未出现但上一版存在的实体标记为 `deleted`
|
||||
10. 更新 `data_snapshots.status = success`
|
||||
11. 更新 `collection_tasks.status = success`
|
||||
|
||||
### 删除语义
|
||||
|
||||
这里不建议真的删记录。
|
||||
建议采用“逻辑消失”模型:
|
||||
|
||||
- 历史行永远保留
|
||||
- 如果某实体在新快照里消失:
|
||||
- 上一条历史记录补一条“删除状态记录”或标记 `change_type = deleted`
|
||||
- 同时该实体不再出现在当前视图
|
||||
|
||||
这样最适合态势感知。
|
||||
|
||||
## API 改造建议
|
||||
|
||||
### 保持现有接口默认行为
|
||||
|
||||
现有接口:
|
||||
|
||||
- `GET /api/v1/collected`
|
||||
- `GET /api/v1/collected/{id}`
|
||||
- `GET /api/v1/collected/summary`
|
||||
|
||||
建议默认仍返回“当前视图”,避免前端全面重写。
|
||||
|
||||
### 新增历史查询能力
|
||||
|
||||
建议新增参数或新接口:
|
||||
|
||||
#### 1. 当前/历史切换
|
||||
|
||||
`GET /api/v1/collected?mode=current|history`
|
||||
|
||||
- `current`:默认,查当前视图
|
||||
- `history`:查历史事实表
|
||||
|
||||
#### 2. 按快照查询
|
||||
|
||||
`GET /api/v1/collected?snapshot_id=123`
|
||||
|
||||
#### 3. 快照列表
|
||||
|
||||
`GET /api/v1/snapshots`
|
||||
|
||||
支持筛选:
|
||||
|
||||
- `datasource_id`
|
||||
- `source`
|
||||
- `status`
|
||||
- `date_from/date_to`
|
||||
|
||||
#### 4. 快照详情
|
||||
|
||||
`GET /api/v1/snapshots/{id}`
|
||||
|
||||
返回:
|
||||
|
||||
- 快照基础信息
|
||||
- 统计摘要
|
||||
- 与上一版的 diff 摘要
|
||||
|
||||
#### 5. 快照 diff
|
||||
|
||||
`GET /api/v1/snapshots/{id}/diff?base_snapshot_id=122`
|
||||
|
||||
返回:
|
||||
|
||||
- `created`
|
||||
- `updated`
|
||||
- `deleted`
|
||||
- `unchanged`
|
||||
|
||||
## 前端改造建议
|
||||
|
||||
### 1. 数据列表页
|
||||
|
||||
默认仍看当前数据,不改用户使用习惯。
|
||||
|
||||
建议新增:
|
||||
|
||||
- “视图模式”
|
||||
- 当前数据
|
||||
- 历史数据
|
||||
- “快照时间”筛选
|
||||
- “只看变化项”筛选
|
||||
|
||||
### 2. 数据详情页
|
||||
|
||||
详情页建议展示:
|
||||
|
||||
- 当前记录基础信息
|
||||
- 元数据动态字段
|
||||
- 所属快照
|
||||
- 上一版本对比入口
|
||||
- 历史版本时间线
|
||||
|
||||
### 3. 数据源管理页
|
||||
|
||||
“触发”按钮文案建议改成更准确的:
|
||||
|
||||
- `立即采集`
|
||||
|
||||
并在详情里补:
|
||||
|
||||
- 最近一次快照时间
|
||||
- 最近一次快照记录数
|
||||
- 最近一次变化数
|
||||
|
||||
## 迁移方案
|
||||
|
||||
### Phase 1:兼容式落地
|
||||
|
||||
目标:先保留当前页面可用。
|
||||
|
||||
改动:
|
||||
|
||||
1. 新增 `data_snapshots`
|
||||
2. 给 `collected_data` 增加:
|
||||
- `snapshot_id`
|
||||
- `task_id`
|
||||
- `entity_key`
|
||||
- `is_current`
|
||||
- `previous_record_id`
|
||||
- `change_type`
|
||||
- `change_summary`
|
||||
- `deleted_at`
|
||||
3. 现有数据全部补成一个“初始化快照”
|
||||
4. 现有 `/collected` 默认改查当前视图
|
||||
|
||||
优点:
|
||||
|
||||
- 前端几乎无感
|
||||
- 风险最小
|
||||
|
||||
### Phase 2:启用差异计算
|
||||
|
||||
目标:采集后可知道本次改了什么。
|
||||
|
||||
改动:
|
||||
|
||||
1. 写入时做新旧快照比对
|
||||
2. 写 `change_type`
|
||||
3. 生成快照摘要
|
||||
|
||||
### Phase 3:前端态势感知能力
|
||||
|
||||
目标:支持历史回放和趋势分析。
|
||||
|
||||
改动:
|
||||
|
||||
1. 快照时间线
|
||||
2. 版本 diff 页面
|
||||
3. 地图时间回放
|
||||
4. 告警和快照关联
|
||||
|
||||
## 唯一性与索引建议
|
||||
|
||||
### 建议保留的业务唯一性
|
||||
|
||||
在“同一个快照内部”,建议唯一:
|
||||
|
||||
- `(snapshot_id, source, source_id)`
|
||||
|
||||
不要在整张历史表上强加:
|
||||
|
||||
- `(source, source_id)` 唯一
|
||||
|
||||
因为历史表本来就应该允许同一实体跨快照存在多条版本。
|
||||
|
||||
### 建议索引
|
||||
|
||||
- `idx_collected_data_snapshot_id`
|
||||
- `idx_collected_data_source_source_id`
|
||||
- `idx_collected_data_entity_key`
|
||||
- `idx_collected_data_is_current`
|
||||
- `idx_collected_data_reference_date`
|
||||
- `idx_snapshots_source_completed_at`
|
||||
|
||||
## 风险点
|
||||
|
||||
1. 存储量会明显增加
|
||||
- 需要评估保留周期
|
||||
- 可以考虑冷热分层
|
||||
|
||||
2. 写入复杂度上升
|
||||
- 需要批量 upsert / diff 逻辑
|
||||
|
||||
3. 当前接口语义会从“表”变成“视图”
|
||||
- 文档必须同步
|
||||
|
||||
4. 某些采集器缺稳定 `source_id`
|
||||
- 需要补齐实体稳定键策略
|
||||
|
||||
## 对当前项目的具体建议
|
||||
|
||||
结合当前代码,推荐这样落地:
|
||||
|
||||
### 短期
|
||||
|
||||
1. 先设计并落表:
|
||||
- `data_snapshots`
|
||||
- `collected_data` 新字段
|
||||
2. 采集完成后每次新增快照
|
||||
3. `/api/v1/collected` 默认查 `is_current = true`
|
||||
|
||||
### 中期
|
||||
|
||||
1. 在 `BaseCollector._save_data()` 中改成:
|
||||
- 生成快照
|
||||
- 批量写历史
|
||||
- 标记当前
|
||||
2. 将 `CollectionTask.id` 关联到 `snapshot.task_id`
|
||||
|
||||
### 长期
|
||||
|
||||
1. 地图接口支持按 `snapshot_id` 查询
|
||||
2. 仪表盘支持“最近一次快照变化量”
|
||||
3. 告警支持绑定到快照版本
|
||||
|
||||
## 最终建议
|
||||
|
||||
最终建议采用:
|
||||
|
||||
- 历史事实表:保存每次采集结果
|
||||
- 当前视图:服务管理后台默认查询
|
||||
- 快照表:承载版本批次和 diff 语义
|
||||
|
||||
这样既能保留历史,又不会把当前页面全部推翻重做,是最适合后续做态势感知的一条路径。
|
||||
263
docs/backend/collectors.md
Normal file
263
docs/backend/collectors.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# 数据采集系统 (Collectors)
|
||||
|
||||
## 一、系统架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 数据采集系统架构 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ TOP500 │ │ Epoch AI │ │ HuggingFace │ │
|
||||
│ │ 采集器 │ │ 采集器 │ │ 采集器 │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ └───────────────────┼───────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ BaseCollector │◄── 基类 (统一处理) │
|
||||
│ │ run() 方法 │ │
|
||||
│ └─────────┬───────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────┼─────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ fetch() │ │transform()│ │ _save_data│ │
|
||||
│ │ 获取原始数据 │ │ 数据转换 │ │ 保存到DB │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ CollectedData 表 │◄── 统一存储 │
|
||||
│ └─────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Scheduler (APScheduler) │ │
|
||||
│ │ 定时任务调度: 每4小时/6小时/12小时/1天 自动执行 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 二、工作流程 (Pipeline)
|
||||
|
||||
```python
|
||||
# 1. Scheduler 触发 (定时 或 手动触发)
|
||||
# ↓
|
||||
|
||||
# 2. run() 方法执行完整流水线
|
||||
async def run(self, db):
|
||||
# 2.1 检查采集器是否启用
|
||||
if not collector_registry.is_active(self.name):
|
||||
return {"status": "skipped"}
|
||||
|
||||
# 2.2 记录任务开始
|
||||
task = CollectionTask(status="running")
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
# 2.3 FETCH - 获取原始数据 (由子类实现)
|
||||
raw_data = await self.fetch()
|
||||
|
||||
# 2.4 TRANSFORM - 转换为统一格式
|
||||
data = self.transform(raw_data)
|
||||
|
||||
# 2.5 SAVE - 保存到数据库
|
||||
records_count = await self._save_data(db, data)
|
||||
|
||||
# 2.6 记录任务完成
|
||||
task.status = "success"
|
||||
task.records_processed = records_count
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
**核心文件**: `backend/app/services/collectors/base.py`
|
||||
|
||||
## 三、采集器列表
|
||||
|
||||
| 采集器 | 数据类型 | 数据内容 | 采集频率 |
|
||||
|--------|----------|----------|----------|
|
||||
| TOP500 | supercomputer | 全球超级计算机排名 (算力、性能) | 4小时 |
|
||||
| Epoch AI | gpu_cluster | GPU算力集群信息 | 6小时 |
|
||||
| HuggingFace Models | model | AI模型信息 | 12小时 |
|
||||
| HuggingFace Datasets | dataset | 数据集信息 | 12小时 |
|
||||
| HuggingFace Spaces | space | Demo应用 | 1天 |
|
||||
| PeeringDB | ixp/network/facility | 互联网交换点/网络/机房 | 1-2天 |
|
||||
| TeleGeography | submarine_cable | 海底光缆信息 | 7天 |
|
||||
|
||||
## 四、数据格式 (统一存储到 CollectedData 表)
|
||||
|
||||
```python
|
||||
# 每个采集器 parse_response() 返回格式
|
||||
{
|
||||
"source_id": "top500_1", # 原始系统ID (必填)
|
||||
"name": "El Capitan", # 名称 (必填)
|
||||
"description": "系统描述...", # 描述
|
||||
"country": "United States", # 国家
|
||||
"city": "Livermore, CA", # 城市
|
||||
"latitude": "37.6819", # 纬度 (字符串)
|
||||
"longitude": "-121.7681", # 经度 (字符串)
|
||||
"value": "1742.00", # 性能值 (如算力)
|
||||
"unit": "PFlop/s", # 单位
|
||||
"metadata": { # 额外数据 (JSON)
|
||||
"rank": 1,
|
||||
"r_peak": 2746.38,
|
||||
"cores": 11039616
|
||||
},
|
||||
"reference_date": "2025-11-01" # 数据参考日期
|
||||
}
|
||||
```
|
||||
|
||||
## 五、数据库表结构
|
||||
|
||||
**CollectedData 表** (`collected_data`)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | SERIAL | 主键 |
|
||||
| source | VARCHAR(100) | 数据源名称 (top500, huggingface等) |
|
||||
| source_id | VARCHAR(100) | 原始数据ID |
|
||||
| data_type | VARCHAR(50) | 数据类型 (supercomputer, model等) |
|
||||
| name | VARCHAR(500) | 名称 |
|
||||
| title | VARCHAR(500) | 标题 |
|
||||
| description | TEXT | 描述 |
|
||||
| country | VARCHAR(100) | 国家 |
|
||||
| city | VARCHAR(100) | 城市 |
|
||||
| latitude | VARCHAR(50) | 纬度 |
|
||||
| longitude | VARCHAR(50) | 经度 |
|
||||
| value | VARCHAR(100) | 性能值 |
|
||||
| unit | VARCHAR(20) | 单位 |
|
||||
| metadata | JSONB | 额外元数据 |
|
||||
| collected_at | TIMESTAMP | 采集时间 |
|
||||
| reference_date | TIMESTAMP | 数据参考日期 |
|
||||
| is_valid | INTEGER | 是否有效 |
|
||||
|
||||
**核心文件**: `backend/app/models/collected_data.py`
|
||||
|
||||
## 六、TOP500 采集器示例 (完整流程)
|
||||
|
||||
```python
|
||||
# 1. fetch() - 从网页获取HTML
|
||||
async def fetch(self):
|
||||
url = "https://top500.org/lists/top500/list/2025/11/"
|
||||
response = await client.get(url)
|
||||
return response.text # 返回HTML
|
||||
|
||||
# 2. parse_response() - 解析HTML为统一格式
|
||||
def parse_response(self, html):
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
table = soup.find("table")
|
||||
|
||||
for row in table.find_all("tr")[1:]: # 跳过表头
|
||||
cells = row.find_all("td")
|
||||
|
||||
entry = {
|
||||
"source_id": f"top500_{cells[0].text}", # "top500_1"
|
||||
"name": cells[1].text.strip(), # "El Capitan"
|
||||
"country": cells[2].text.strip(), # "United States"
|
||||
"city": "", # 城市
|
||||
"latitude": "", # 需进一步解析
|
||||
"longitude": "",
|
||||
"value": "1742.00", # Rmax
|
||||
"unit": "PFlop/s",
|
||||
"metadata": {
|
||||
"rank": 1,
|
||||
"cores": "11340000"
|
||||
},
|
||||
"reference_date": "2025-11-01"
|
||||
}
|
||||
data.append(entry)
|
||||
|
||||
return data
|
||||
|
||||
# 3. run() 自动调用 _save_data() 保存到数据库
|
||||
```
|
||||
|
||||
**核心文件**: `backend/app/services/collectors/top500.py`
|
||||
|
||||
## 七、调度机制
|
||||
|
||||
```python
|
||||
# 启动时注册所有采集器到定时任务
|
||||
def start_scheduler():
|
||||
for name, collector in collectors.items():
|
||||
if collector_registry.is_active(name):
|
||||
scheduler.add_job(
|
||||
run_collector_task,
|
||||
trigger=IntervalTrigger(hours=collector.frequency_hours),
|
||||
id=name,
|
||||
name=name
|
||||
)
|
||||
```
|
||||
|
||||
| 采集器 | 采集频率 |
|
||||
|--------|----------|
|
||||
| TOP500 | 每4小时 |
|
||||
| Epoch AI | 每6小时 |
|
||||
| HuggingFace | 每12小时 |
|
||||
| PeeringDB | 每1-2天 |
|
||||
| TeleGeography | 每7天 |
|
||||
|
||||
**核心文件**: `backend/app/services/scheduler.py`
|
||||
|
||||
## 八、相关代码文件
|
||||
|
||||
```
|
||||
backend/app/services/collectors/
|
||||
├── base.py # 基类: run() 流水线, _save_data() 保存
|
||||
├── registry.py # 采集器注册表
|
||||
├── scheduler.py # 定时任务调度 (APScheduler)
|
||||
├── top500.py # TOP500采集器
|
||||
├── epoch_ai.py # Epoch AI采集器
|
||||
├── huggingface.py # HuggingFace采集器
|
||||
├── peeringdb.py # PeeringDB采集器
|
||||
└── telegeraphy.py # TeleGeography海底光缆采集器
|
||||
|
||||
backend/app/models/
|
||||
└── collected_data.py # 统一数据模型
|
||||
```
|
||||
|
||||
## 九、数据使用场景
|
||||
|
||||
采集的数据最终会:
|
||||
|
||||
1. **可视化展示** - 在UE5大屏上显示超级计算机、GPU集群、海底光缆的地理位置
|
||||
2. **态势分析** - 统计全球算力分布、增长趋势
|
||||
3. **告警系统** - 检测重要节点变化
|
||||
|
||||
## 十、采集器注册机制
|
||||
|
||||
采集器在应用启动时自动注册:
|
||||
|
||||
```python
|
||||
# backend/app/services/collectors/__init__.py
|
||||
|
||||
collector_registry.register(TOP500Collector())
|
||||
collector_registry.register(EpochAIGPUCollector())
|
||||
collector_registry.register(HuggingFaceModelCollector())
|
||||
collector_registry.register(HuggingFaceDatasetCollector())
|
||||
collector_registry.register(HuggingFaceSpacesCollector())
|
||||
collector_registry.register(PeeringDBIXPCollector())
|
||||
collector_registry.register(PeeringDBNetworkCollector())
|
||||
collector_registry.register(PeeringDBFacilityCollector())
|
||||
collector_registry.register(TeleGeographyCableCollector())
|
||||
collector_registry.register(TeleGeographyLandingPointCollector())
|
||||
collector_registry.register(TeleGeographyCableSystemCollector())
|
||||
```
|
||||
|
||||
**核心文件**: `backend/app/services/collectors/registry.py`
|
||||
|
||||
## 十一、触发采集
|
||||
|
||||
### 方式一:定时触发
|
||||
系统启动时,APScheduler会自动根据各采集器的`frequency_hours`设置定时任务。
|
||||
|
||||
### 方式二:手动触发 API
|
||||
|
||||
```bash
|
||||
# 触发TOP500采集
|
||||
curl -X POST http://localhost:8000/api/v1/datasources/1/trigger \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
**核心文件**: `backend/app/api/v1/datasources.py`
|
||||
347
docs/backend/system-service-control.md
Normal file
347
docs/backend/system-service-control.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# System Service Control
|
||||
|
||||
This document defines the fixed mapping between admin control-plane actions and
|
||||
the existing `planet.sh` service-management commands.
|
||||
|
||||
The goal is to reuse the current operational script semantics without exposing
|
||||
arbitrary shell execution to the frontend or API callers.
|
||||
|
||||
## Scope
|
||||
|
||||
- This mapping is for admin-side operational controls only.
|
||||
- The control plane must submit a fixed action name, not a raw shell command.
|
||||
- The backend is responsible for translating an allowed action into a fixed
|
||||
`planet.sh` invocation.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Only whitelist actions may be executed.
|
||||
- The frontend must never send arbitrary shell strings.
|
||||
- The backend must build command arguments from a fixed mapping table.
|
||||
- High-risk actions should be restricted to `super_admin`.
|
||||
- Prefer partial restarts over full-stack restarts when UI continuity matters.
|
||||
|
||||
## Action Mapping
|
||||
|
||||
| Action name | Intended use | `planet.sh` command | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `restart-backend` | Restart backend API only | `./planet.sh restart -b` | Recommended first implementation for UI-triggered restart flows. |
|
||||
| `restart-database` | Restart PostgreSQL and Redis containers | `./planet.sh restart -d` | Useful when database/cache services need a controlled bounce without restarting the UI. |
|
||||
| `restart-system` | Restart the whole application stack | `./planet.sh restart` | Frontend continuity breaks briefly; UI should switch to guided recovery mode. |
|
||||
| `restart-frontend` | Restart frontend dev server only | `./planet.sh restart -f` | Use with caution; UI continuity is weaker than backend-only restart. |
|
||||
| `restart-backend-port` | Restart backend on a specific port | `./planet.sh restart -b <port>` | Port must be backend-validated before execution. |
|
||||
| `restart-frontend-port` | Restart frontend on a specific port | `./planet.sh restart -f <port>` | Port must be backend-validated before execution. |
|
||||
| `health-check` | Read current service health | `./planet.sh health` | Safe read-only operational action. |
|
||||
| `show-logs-backend` | Inspect backend logs | `./planet.sh log -b` | Best used for CLI/operator tooling, not normal Web UI streaming. |
|
||||
| `show-logs-frontend` | Inspect frontend logs | `./planet.sh log -f` | Best used for CLI/operator tooling, not normal Web UI streaming. |
|
||||
|
||||
## Not Exposed In UI By Default
|
||||
|
||||
The following existing script capabilities should not be exposed directly in the
|
||||
Web UI unless there is an explicit product need and an additional safety review:
|
||||
|
||||
- `./planet.sh restart`
|
||||
- `./planet.sh start`
|
||||
- `./planet.sh stop`
|
||||
- `./planet.sh createuser`
|
||||
- any future raw shell passthrough
|
||||
|
||||
Reason:
|
||||
|
||||
- full restart can break the current control session;
|
||||
- stop/start have larger blast radius;
|
||||
- user creation is not a service-control operation;
|
||||
- raw shell passthrough creates unnecessary privilege risk.
|
||||
|
||||
## Recommended First-Phase UI Contract
|
||||
|
||||
### Frontend action payload
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "restart-backend"
|
||||
}
|
||||
```
|
||||
|
||||
### Backend command resolution
|
||||
|
||||
```text
|
||||
restart-backend -> ["./planet.sh", "restart", "-b"]
|
||||
restart-database -> ["./planet.sh", "restart", "-d"]
|
||||
restart-system -> ["./planet.sh", "restart"]
|
||||
restart-frontend -> ["./planet.sh", "restart", "-f"]
|
||||
health-check -> ["./planet.sh", "health"]
|
||||
```
|
||||
|
||||
## API Draft
|
||||
|
||||
### Primary Endpoint
|
||||
|
||||
- `POST /api/v1/system/restart-tasks`
|
||||
|
||||
Purpose:
|
||||
|
||||
- create a controlled restart task;
|
||||
- resolve a whitelist action into a fixed `planet.sh` command;
|
||||
- hand execution off to an external runner or detached subprocess.
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "restart-backend"
|
||||
}
|
||||
```
|
||||
|
||||
Optional future shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "restart-backend-port",
|
||||
"port": 8000
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "restart_20260331_153000_ab12cd",
|
||||
"action": "restart-backend",
|
||||
"status": "queued",
|
||||
"stage": "accepted",
|
||||
"message": "Restart task accepted"
|
||||
}
|
||||
```
|
||||
|
||||
### Task Query Endpoint
|
||||
|
||||
- `GET /api/v1/system/restart-tasks/{task_id}`
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "restart_20260331_153000_ab12cd",
|
||||
"action": "restart-backend",
|
||||
"status": "queued",
|
||||
"stage": "accepted",
|
||||
"message": "Waiting for execution",
|
||||
"requested_by": {
|
||||
"id": 1,
|
||||
"username": "admin"
|
||||
},
|
||||
"created_at": "2026-03-31T15:30:00+08:00",
|
||||
"updated_at": "2026-03-31T15:30:02+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### Optional Log Endpoint
|
||||
|
||||
- `GET /api/v1/system/restart-tasks/{task_id}/logs`
|
||||
|
||||
Suggested response:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "restart_20260331_153000_ab12cd",
|
||||
"lines": [
|
||||
"accepted restart-backend request",
|
||||
"spawning restart command",
|
||||
"waiting for backend shutdown",
|
||||
"waiting for backend health recovery"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This log endpoint is optional for phase one. The first version can work with
|
||||
task state plus `/health` polling alone.
|
||||
|
||||
## Task State Model
|
||||
|
||||
### Status
|
||||
|
||||
- `queued`
|
||||
- `running`
|
||||
- `succeeded`
|
||||
- `failed`
|
||||
- `timeout`
|
||||
|
||||
### Stage
|
||||
|
||||
- `accepted`
|
||||
- `spawning`
|
||||
- `stopping`
|
||||
- `starting`
|
||||
- `waiting_for_health`
|
||||
- `healthy`
|
||||
- `failed`
|
||||
|
||||
### Interpretation
|
||||
|
||||
- `status` is the high-level terminal or non-terminal state.
|
||||
- `stage` is the operator-facing execution phase for the UI.
|
||||
- `message` is the short human-readable line shown in the modal or full-screen
|
||||
overlay.
|
||||
|
||||
## Permission Model
|
||||
|
||||
- `restart-backend` should require `super_admin`.
|
||||
- Permission checks should follow the same role pattern already used in
|
||||
[users.py](/home/ray/dev/linkong/planet/backend/app/api/v1/users.py).
|
||||
- Frontend visibility may hide controls for non-`super_admin`, but backend must
|
||||
still enforce authorization.
|
||||
|
||||
## Storage Model
|
||||
|
||||
Recommended first implementation:
|
||||
|
||||
- store restart task state in Redis;
|
||||
- keep task lifetime short;
|
||||
- keep recent logs as a bounded list.
|
||||
|
||||
Suggested keys:
|
||||
|
||||
- `system:restart_task:{task_id}`
|
||||
- `system:restart_task:{task_id}:logs`
|
||||
|
||||
Suggested stored fields:
|
||||
|
||||
- `task_id`
|
||||
- `action`
|
||||
- `status`
|
||||
- `stage`
|
||||
- `message`
|
||||
- `requested_by_id`
|
||||
- `requested_by_username`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
## Execution Model
|
||||
|
||||
The request-handling API process should not depend on itself surviving long
|
||||
enough to stream the whole restart output.
|
||||
|
||||
Recommended execution flow:
|
||||
|
||||
1. validate caller and action
|
||||
2. create task state in Redis
|
||||
3. resolve action to fixed `planet.sh` argv
|
||||
4. spawn detached executor
|
||||
5. return `task_id`
|
||||
6. executor updates task state while restart is in progress
|
||||
7. frontend polls health and/or task state until recovery
|
||||
|
||||
Recommended command resolution examples:
|
||||
|
||||
```text
|
||||
restart-backend -> ["./planet.sh", "restart", "-b"]
|
||||
restart-frontend -> ["./planet.sh", "restart", "-f"]
|
||||
restart-backend-port -> ["./planet.sh", "restart", "-b", "<port>"]
|
||||
health-check -> ["./planet.sh", "health"]
|
||||
```
|
||||
|
||||
## Frontend Polling Flow
|
||||
|
||||
Recommended first-phase UX:
|
||||
|
||||
1. user clicks `重启后端`
|
||||
2. confirmation modal explains temporary unavailability
|
||||
3. frontend calls `POST /api/v1/system/restart-tasks`
|
||||
4. UI enters blocking restart state
|
||||
5. frontend polls `/health` every `1-2s`
|
||||
6. temporary request failures are treated as expected
|
||||
7. after `2-3` consecutive successful health checks, frontend reloads page
|
||||
|
||||
Optional richer polling:
|
||||
|
||||
1. poll task status endpoint while backend is still reachable
|
||||
2. switch to `/health` recovery polling after disconnect begins
|
||||
3. refresh page after health recovery
|
||||
|
||||
## Frontend State Machine
|
||||
|
||||
- `idle`
|
||||
- `confirming`
|
||||
- `submitting`
|
||||
- `waiting_for_shutdown`
|
||||
- `waiting_for_recovery`
|
||||
- `recovered`
|
||||
- `failed`
|
||||
- `timeout`
|
||||
|
||||
Suggested UI messages:
|
||||
|
||||
- `已发送重启指令`
|
||||
- `正在停止后端服务`
|
||||
- `正在等待服务恢复`
|
||||
- `服务已恢复,正在刷新页面`
|
||||
- `恢复超时,请手动检查服务状态`
|
||||
|
||||
## Phase-One Recommendation
|
||||
|
||||
Implement only the following in phase one:
|
||||
|
||||
- `restart-backend`
|
||||
- `super_admin` permission gate
|
||||
- task creation endpoint
|
||||
- Redis-backed task state
|
||||
- frontend confirmation modal
|
||||
- frontend `/health` polling
|
||||
- automatic page reload after recovery
|
||||
|
||||
Do not implement in phase one:
|
||||
|
||||
- full `./planet.sh restart`
|
||||
- raw shell command passthrough
|
||||
- arbitrary service control
|
||||
- full terminal stdout streaming
|
||||
- multi-action concurrent restart queueing
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Backend
|
||||
|
||||
1. add a dedicated system-control API module under `backend/app/api/v1/`
|
||||
2. add a whitelist-based action resolver for `planet.sh`
|
||||
3. store restart task state in Redis
|
||||
4. add detached restart-runner script execution
|
||||
5. expose:
|
||||
- `POST /api/v1/system/restart-tasks`
|
||||
- `GET /api/v1/system/restart-tasks/{task_id}`
|
||||
- optional task log endpoint
|
||||
6. enforce `super_admin` permission on all restart-task endpoints
|
||||
|
||||
### Frontend
|
||||
|
||||
1. add a `重启后端` control on the dashboard for `super_admin`
|
||||
2. show a confirmation modal before dispatch
|
||||
3. after submission, switch modal into blocking restart state
|
||||
4. poll `/health` until backend recovery is confirmed
|
||||
5. auto-refresh page after consecutive successful health checks
|
||||
6. show short stage-oriented logs instead of raw terminal streaming
|
||||
|
||||
### Operational Notes
|
||||
|
||||
1. phase one should target backend-only restart
|
||||
2. frontend restart should remain out of scope initially
|
||||
3. command execution must always originate from repository root
|
||||
4. only fixed action names may cross the API boundary
|
||||
|
||||
## Validation Requirements
|
||||
|
||||
- Reject any action not present in the whitelist.
|
||||
- If a port-bearing action is added, validate the port as an integer in
|
||||
`1..65535`.
|
||||
- Resolve commands from the repository root so `planet.sh` runs with a stable
|
||||
working directory.
|
||||
- Record the requested action, operator identity, execution start time, and
|
||||
result.
|
||||
|
||||
## Implementation Guidance
|
||||
|
||||
- For UI-triggered restart flows, prefer `restart-backend` first.
|
||||
- Do not rely on the current API request process to stream full restart output
|
||||
after it triggers its own restart.
|
||||
- Use a task record plus polling/health-check recovery flow instead of raw
|
||||
terminal streaming as the primary UX.
|
||||
48
docs/backend/system-settings-plan.md
Normal file
48
docs/backend/system-settings-plan.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 系统配置中心开发计划
|
||||
|
||||
## 目标
|
||||
|
||||
将当前仅保存于内存中的“系统配置”页面升级为真正可用的配置中心,优先服务以下两类能力:
|
||||
|
||||
1. 系统级配置持久化
|
||||
2. 采集调度配置管理
|
||||
|
||||
## 第一阶段范围
|
||||
|
||||
### 1. 系统配置持久化
|
||||
|
||||
- 新增 `system_settings` 表,用于保存分类配置
|
||||
- 将系统、通知、安全配置从进程内存迁移到数据库
|
||||
- 提供统一读取接口,页面刷新和服务重启后保持不丢失
|
||||
|
||||
### 2. 采集调度配置接入真实数据源
|
||||
|
||||
- 统一内置采集器默认定义
|
||||
- 启动时自动初始化 `data_sources` 表
|
||||
- 配置页允许修改:
|
||||
- 是否启用
|
||||
- 采集频率(分钟)
|
||||
- 优先级
|
||||
- 修改后实时同步到调度器
|
||||
|
||||
### 3. 前端配置页重构
|
||||
|
||||
- 将当前通用模板页调整为项目专用配置中心
|
||||
- 增加“采集调度”Tab
|
||||
- 保留“系统显示 / 通知 / 安全”三类配置
|
||||
- 将设置页正式接入主路由
|
||||
|
||||
## 非本阶段内容
|
||||
|
||||
- 邮件发送能力本身
|
||||
- 配置审计历史
|
||||
- 敏感凭证加密管理
|
||||
- 多租户或按角色细粒度配置
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 设置项修改后重启服务仍然存在
|
||||
- 配置页可以查看并修改所有内置采集器的启停与采集频率
|
||||
- 调整采集频率后,调度器任务随之更新
|
||||
- `/settings` 页面可从主导航进入并正常工作
|
||||
|
||||
17
docs/deprecated/README.md
Normal file
17
docs/deprecated/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Deprecated Docs
|
||||
|
||||
这个目录用于存放两类文档:
|
||||
|
||||
1. 已经完成、主要保留为历史记录的实施计划
|
||||
2. 已被现有实现或新方案替代的旧计划
|
||||
|
||||
放到这里并不代表这些文档“错误”,而是表示:
|
||||
|
||||
- 它们不再适合作为当前开发的主指导文档
|
||||
- 如果要了解历史决策、演进路径或旧设计背景,仍然可以参考
|
||||
|
||||
当前归档原则:
|
||||
|
||||
- 明确写明“已完成”的计划,优先归档
|
||||
- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档
|
||||
- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/`
|
||||
207
docs/deprecated/collected-data-column-removal-plan.md
Normal file
207
docs/deprecated/collected-data-column-removal-plan.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# collected_data 强耦合列拆除计划
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `collected_data` 同时承担了两类职责:
|
||||
|
||||
1. 通用采集事实表
|
||||
2. 少数数据源的宽表字段承载
|
||||
|
||||
典型强耦合列包括:
|
||||
|
||||
- `country`
|
||||
- `city`
|
||||
- `latitude`
|
||||
- `longitude`
|
||||
- `value`
|
||||
- `unit`
|
||||
|
||||
以及 API 层临时平铺出来的:
|
||||
|
||||
- `cores`
|
||||
- `rmax`
|
||||
- `rpeak`
|
||||
- `power`
|
||||
|
||||
这些字段并不适合作为统一事实表的长期 schema。
|
||||
推荐方向是:
|
||||
|
||||
- 表内保留通用稳定字段
|
||||
- 业务差异字段全部归入 `metadata`
|
||||
- API 和前端动态读取 `metadata`
|
||||
|
||||
## 拆除目标
|
||||
|
||||
最终希望 `collected_data` 只保留:
|
||||
|
||||
- `id`
|
||||
- `snapshot_id`
|
||||
- `task_id`
|
||||
- `source`
|
||||
- `source_id`
|
||||
- `entity_key`
|
||||
- `data_type`
|
||||
- `name`
|
||||
- `title`
|
||||
- `description`
|
||||
- `metadata`
|
||||
- `collected_at`
|
||||
- `reference_date`
|
||||
- `is_valid`
|
||||
- `is_current`
|
||||
- `previous_record_id`
|
||||
- `change_type`
|
||||
- `change_summary`
|
||||
- `deleted_at`
|
||||
|
||||
## 计划阶段
|
||||
|
||||
### Phase 1:读取层去依赖
|
||||
|
||||
目标:
|
||||
|
||||
- API / 可视化 / 前端不再优先依赖宽列表字段
|
||||
- 所有动态字段优先从 `metadata` 取
|
||||
|
||||
当前已完成:
|
||||
|
||||
- 新写入数据时,将 `country/city/latitude/longitude/value/unit` 自动镜像到 `metadata`
|
||||
- `/api/v1/collected` 优先从 `metadata` 取动态字段
|
||||
- `visualization` 接口优先从 `metadata` 取动态字段
|
||||
- 国家筛选已改成只走 `metadata->>'country'`
|
||||
- `CollectedData.to_dict()` 已切到 metadata-first
|
||||
- 变更比较逻辑已切到 metadata-first
|
||||
- 已新增历史回填脚本:
|
||||
[scripts/backfill_collected_data_metadata.py](/home/ray/dev/linkong/planet/scripts/backfill_collected_data_metadata.py)
|
||||
- 已新增删列脚本:
|
||||
[scripts/drop_collected_data_legacy_columns.py](/home/ray/dev/linkong/planet/scripts/drop_collected_data_legacy_columns.py)
|
||||
|
||||
涉及文件:
|
||||
|
||||
- [backend/app/core/collected_data_fields.py](/home/ray/dev/linkong/planet/backend/app/core/collected_data_fields.py)
|
||||
- [backend/app/services/collectors/base.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/base.py)
|
||||
- [backend/app/api/v1/collected_data.py](/home/ray/dev/linkong/planet/backend/app/api/v1/collected_data.py)
|
||||
- [backend/app/api/v1/visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py)
|
||||
|
||||
### Phase 2:写入层去依赖
|
||||
|
||||
目标:
|
||||
|
||||
- 采集器内部不再把这些字段当作数据库一级列来理解
|
||||
- 统一只写:
|
||||
- 通用主字段
|
||||
- `metadata`
|
||||
|
||||
建议动作:
|
||||
|
||||
1. Collector 内部仍可使用 `country/city/value` 这种临时字段作为采集过程变量
|
||||
2. 进入 `BaseCollector._save_data()` 后统一归档到 `metadata`
|
||||
3. `CollectedData` 模型中的强耦合列已从 ORM 移除,写入统一归档到 `metadata`
|
||||
|
||||
### Phase 3:数据库删列
|
||||
|
||||
目标:
|
||||
|
||||
- 从 `collected_data` 真正移除以下列:
|
||||
- `country`
|
||||
- `city`
|
||||
- `latitude`
|
||||
- `longitude`
|
||||
- `value`
|
||||
- `unit`
|
||||
|
||||
注意:
|
||||
|
||||
- `cores / rmax / rpeak / power` 当前本来就在 `metadata` 里,不是表列
|
||||
- 这四个主要是 API 平铺字段,不需要数据库删列
|
||||
|
||||
## 当前阻塞点
|
||||
|
||||
在正式删列前,还需要确认这些地方已经完全不再直接依赖数据库列:
|
||||
|
||||
### 1. `CollectedData.to_dict()`
|
||||
|
||||
文件:
|
||||
|
||||
- [backend/app/models/collected_data.py](/home/ray/dev/linkong/planet/backend/app/models/collected_data.py)
|
||||
|
||||
状态:
|
||||
|
||||
- 已完成
|
||||
|
||||
### 2. 差异计算逻辑
|
||||
|
||||
文件:
|
||||
|
||||
- [backend/app/services/collectors/base.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/base.py)
|
||||
|
||||
状态:
|
||||
|
||||
- 已完成
|
||||
- 当前已改成比较归一化后的 metadata-first payload
|
||||
|
||||
### 3. 历史数据回填
|
||||
|
||||
问题:
|
||||
|
||||
- 老数据可能只有列值,没有对应 `metadata`
|
||||
|
||||
当前方案:
|
||||
|
||||
- 在删列前执行一次回填脚本:
|
||||
- [scripts/backfill_collected_data_metadata.py](/home/ray/dev/linkong/planet/scripts/backfill_collected_data_metadata.py)
|
||||
|
||||
### 4. 导出格式兼容
|
||||
|
||||
文件:
|
||||
|
||||
- [backend/app/api/v1/collected_data.py](/home/ray/dev/linkong/planet/backend/app/api/v1/collected_data.py)
|
||||
|
||||
现状:
|
||||
|
||||
- CSV/JSON 导出已基本切成 metadata-first
|
||||
|
||||
建议:
|
||||
|
||||
- 删列前再回归检查一次导出字段是否一致
|
||||
|
||||
## 推荐执行顺序
|
||||
|
||||
1. 保持新数据写入时 `metadata` 完整
|
||||
2. 把模型和 diff 逻辑完全切成 metadata-first
|
||||
3. 写一条历史回填脚本
|
||||
4. 回填后观察一轮
|
||||
5. 正式执行删列迁移
|
||||
|
||||
## 推荐迁移 SQL
|
||||
|
||||
仅在确认全部读取链路已去依赖后执行:
|
||||
|
||||
```sql
|
||||
ALTER TABLE collected_data
|
||||
DROP COLUMN IF EXISTS country,
|
||||
DROP COLUMN IF EXISTS city,
|
||||
DROP COLUMN IF EXISTS latitude,
|
||||
DROP COLUMN IF EXISTS longitude,
|
||||
DROP COLUMN IF EXISTS value,
|
||||
DROP COLUMN IF EXISTS unit;
|
||||
```
|
||||
|
||||
## 风险提示
|
||||
|
||||
1. 地图类接口对经纬度最敏感
|
||||
必须确保所有地图需要的记录,其 `metadata.latitude/longitude` 已回填完整。
|
||||
|
||||
2. 历史老数据如果没有回填,删列后会直接丢失这些信息。
|
||||
|
||||
3. 某些 collector 可能仍隐式依赖这些宽字段做差异比较,删列前必须做一次全量回归。
|
||||
|
||||
## 当前判断
|
||||
|
||||
当前项目已经完成“代码去依赖 + 历史回填 + readiness 检查”。
|
||||
下一步执行顺序建议固定为:
|
||||
|
||||
1. 先部署当前代码版本并重启后端
|
||||
2. 再做一轮功能回归
|
||||
3. 最后执行:
|
||||
`uv run python scripts/drop_collected_data_legacy_columns.py`
|
||||
210
docs/deprecated/earth-module-plan.md
Normal file
210
docs/deprecated/earth-module-plan.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Earth 模块整治计划
|
||||
|
||||
## 背景
|
||||
|
||||
`planet` 前端中的 Earth 模块是当前最重要的大屏 3D 星球展示能力,但它仍以 legacy iframe 页面形式存在:
|
||||
|
||||
- React 页面入口仅为 [Earth.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Earth/Earth.tsx)
|
||||
- 实际 3D 实现位于 [frontend/public/earth](/home/ray/dev/linkong/planet/frontend/public/earth)
|
||||
|
||||
当前模块已经具备基础展示能力,但在生命周期、性能、可恢复性、可维护性方面存在明显隐患,不适合长期无人值守的大屏场景直接扩展。
|
||||
|
||||
## 目标
|
||||
|
||||
本计划的目标不是立刻重写 Earth,而是分阶段把它从“能跑的 legacy 展示页”提升到“可稳定运行、可持续演进的大屏核心模块”。
|
||||
|
||||
核心目标:
|
||||
|
||||
1. 先止血,解决资源泄漏、重载污染、假性卡顿等稳定性问题
|
||||
2. 再梳理数据加载、交互和渲染循环,降低性能风险
|
||||
3. 最后逐步从 iframe legacy 向可控模块化架构迁移
|
||||
|
||||
## 现阶段主要问题
|
||||
|
||||
### 1. 生命周期缺失
|
||||
|
||||
- 没有统一 `destroy()` / 卸载清理逻辑
|
||||
- `requestAnimationFrame`
|
||||
- `window/document/dom listeners`
|
||||
- `THREE` geometry / material / texture
|
||||
- 运行时全局状态
|
||||
都没有系统回收
|
||||
|
||||
### 2. 数据重载不完整
|
||||
|
||||
- `reloadData()` 没有彻底清理旧场景对象
|
||||
- cable、landing point、satellite 相关缓存与对象存在累积风险
|
||||
|
||||
### 3. 渲染与命中检测成本高
|
||||
|
||||
- 鼠标移动时频繁创建 `Raycaster` / `Vector2`
|
||||
- cable 命中前会重复做 bounding box 计算
|
||||
- 卫星每帧计算量偏高
|
||||
|
||||
### 4. 状态管理分裂
|
||||
|
||||
- 大量依赖 `window.*` 全局桥接
|
||||
- 模块之间靠隐式共享状态通信
|
||||
- React 外层无法有效感知 Earth 内部状态
|
||||
|
||||
### 5. 错误恢复弱
|
||||
|
||||
- 数据加载失败主要依赖 `console` 和轻提示
|
||||
- 缺少统一重试、降级、局部失败隔离机制
|
||||
|
||||
## 分阶段计划
|
||||
|
||||
## Phase 1:稳定性止血
|
||||
|
||||
目标:
|
||||
|
||||
- 不改视觉主形态
|
||||
- 优先解决泄漏、卡死、重载污染
|
||||
|
||||
### 任务
|
||||
|
||||
1. 补 Earth 生命周期管理
|
||||
|
||||
- 为 [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 增加:
|
||||
- `init()`
|
||||
- `destroy()`
|
||||
- `reloadData()`
|
||||
三类明确入口
|
||||
- 统一记录并释放:
|
||||
- animation frame id
|
||||
- interval / timeout
|
||||
- DOM 事件监听
|
||||
- `window` 暴露对象
|
||||
|
||||
2. 增加场景对象清理层
|
||||
|
||||
- 为 cable / landing point / satellite sprite / orbit line 提供统一清理函数
|
||||
- reload 前先 dispose 旧对象,再重新加载
|
||||
|
||||
3. 增加 stale 状态恢复
|
||||
|
||||
- 页面重新进入时,先清理上一次遗留选择态、hover 态、锁定态
|
||||
- 避免 iframe reload 后出现旧状态残留
|
||||
|
||||
4. 加强失败提示
|
||||
|
||||
- 电缆、登陆点、卫星加载拆分为独立状态
|
||||
- 某一类数据失败时,其它类型仍可继续显示
|
||||
- 提供明确的页面内提示而不是只打 console
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 页面重复进入 / 离开后内存不持续上涨
|
||||
- 连续多次点“重新加载数据”后对象数量不异常增加
|
||||
- 单一数据源加载失败时页面不整体失效
|
||||
|
||||
## Phase 2:性能优化
|
||||
|
||||
目标:
|
||||
|
||||
- 控制鼠标交互和动画循环成本
|
||||
- 提升大屏长时间运行的稳定帧率
|
||||
|
||||
### 任务
|
||||
|
||||
1. 复用交互对象
|
||||
|
||||
- 复用 `Raycaster`、`Vector2`、中间 `Vector3`
|
||||
- 避免 `mousemove` 热路径中频繁 new 对象
|
||||
|
||||
2. 优化 cable 命中逻辑
|
||||
|
||||
- 提前缓存 cable 中心点 / bounding 数据
|
||||
- 移除 `mousemove` 内重复 `computeBoundingBox()`
|
||||
- 必要时增加分层命中:
|
||||
- 先粗筛
|
||||
- 再精确相交
|
||||
|
||||
3. 改造动画循环
|
||||
|
||||
- 使用真实 `deltaTime`
|
||||
- 把卫星位置更新、呼吸动画、视觉状态更新拆成独立阶段
|
||||
- 为不可见对象减少无意义更新
|
||||
|
||||
4. 卫星轨迹与预测轨道优化
|
||||
|
||||
- 评估轨迹更新频率
|
||||
- 对高开销几何计算增加缓存
|
||||
- 限制预测轨道生成频次
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 鼠标移动时不明显掉帧
|
||||
- 中高数据量下动画速度不受帧率明显影响
|
||||
- 长时间运行 CPU/GPU 占用更平稳
|
||||
|
||||
## Phase 3:架构收编
|
||||
|
||||
目标:
|
||||
|
||||
- 降低 legacy iframe 架构带来的维护成本
|
||||
- 让 React 主应用重新获得对 Earth 模块的控制力
|
||||
|
||||
### 任务
|
||||
|
||||
1. 抽离 Earth App Shell
|
||||
|
||||
- 将数据加载、错误状态、控制面板状态抽到更明确的模块边界
|
||||
- 减少 `window.*` 全局依赖
|
||||
|
||||
2. 规范模块通信
|
||||
|
||||
- 统一 `main / controls / cables / satellites / ui` 的状态流
|
||||
- 明确只读配置、运行时状态、渲染对象的职责分层
|
||||
|
||||
3. 评估去 iframe 迁移
|
||||
|
||||
- 中期可以保留 public/legacy 资源目录
|
||||
- 但逐步把 Earth 作为前端内嵌模块而不是完全孤立页面
|
||||
|
||||
### 验收标准
|
||||
|
||||
- Earth 内部状态不再大量依赖全局变量
|
||||
- React 外层可以感知 Earth 加载状态和错误状态
|
||||
- 后续功能开发不再必须修改多个 legacy 文件才能完成
|
||||
|
||||
## 优先级建议
|
||||
|
||||
### P0
|
||||
|
||||
- 生命周期清理
|
||||
- reload 清理
|
||||
- stale 状态恢复
|
||||
|
||||
### P1
|
||||
|
||||
- 命中检测优化
|
||||
- 动画 `deltaTime`
|
||||
- 数据加载失败隔离
|
||||
|
||||
### P2
|
||||
|
||||
- 全局状态收编
|
||||
- iframe 架构迁移
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 先做 Phase 1
|
||||
2. 再做交互热路径与动画循环优化
|
||||
3. 最后再考虑架构迁移
|
||||
|
||||
## 风险提示
|
||||
|
||||
1. Earth 是 legacy 模块,修复时容易牵一发而动全身
|
||||
2. 如果不先补清理逻辑,后续所有性能优化收益都会被泄漏问题吃掉
|
||||
3. 如果过早重写而不先止血,短期会影响现有演示稳定性
|
||||
|
||||
## 当前建议
|
||||
|
||||
最值得马上启动的是一个小范围稳定性 sprint:
|
||||
|
||||
- 生命周期清理
|
||||
- reload 全量清理
|
||||
- 错误状态隔离
|
||||
|
||||
这个阶段不追求“更炫”,先追求“更稳”。稳定下来之后,再进入性能和架构层的优化。
|
||||
117
docs/deprecated/earth-tv-live-module-plan.md
Normal file
117
docs/deprecated/earth-tv-live-module-plan.md
Normal 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 4:Earth HUD 集成
|
||||
|
||||
- 在 `显示控制` 子菜单加入电视按钮
|
||||
- 新增 TV HUD 面板:
|
||||
- 可拖拽
|
||||
- 可关闭
|
||||
- 支持显示/隐藏状态同步
|
||||
- 参与布局最大化与恢复布局
|
||||
- 面板内容至少包含:
|
||||
- 当前频道标题
|
||||
- 源切换下拉菜单
|
||||
- 刷新按钮
|
||||
- 打开官网按钮
|
||||
- 播放区域
|
||||
|
||||
### Phase 5:播放策略
|
||||
|
||||
- 第一版优先支持 `iframe`/嵌入页类直播源
|
||||
- 为未来扩展保留:
|
||||
- `hls`
|
||||
- `video`
|
||||
- `external`
|
||||
- 如果默认源不可用:
|
||||
- 优先回退到标记为 `is_fallback=true` 的源
|
||||
- 若无明确回退源,则回退到第一个可用源
|
||||
- 面板内要有清晰的加载、错误、回退提示
|
||||
|
||||
### Phase 6:打磨与清理
|
||||
|
||||
- 统一 HUD 风格
|
||||
- 小屏下限制窗口尺寸并启用内部滚动
|
||||
- 避免窗口超出屏幕
|
||||
- 补最小验证
|
||||
- 清理临时代码、重复样式和无用资源
|
||||
|
||||
## 首版交付定义
|
||||
|
||||
当以下条件满足时,认为首版可用:
|
||||
|
||||
- 后台可以配置新闻直播源
|
||||
- Earth 可以读取并显示默认直播源
|
||||
- 工具栏可打开电视窗口
|
||||
- 电视窗口可拖拽、可关闭
|
||||
- 央视 `CCTV-4` 作为默认兜底源可被使用
|
||||
- 代码结构已为后续采集器接入预留统一接口
|
||||
165
docs/deprecated/hud-panel-component-plan.md
Normal file
165
docs/deprecated/hud-panel-component-plan.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# HUD Panel Component Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Unify Earth HUD panels into a reusable component layer so new panels can share:
|
||||
|
||||
- a consistent shell
|
||||
- a consistent header
|
||||
- a consistent action-button system
|
||||
- a consistent body and collapse pattern
|
||||
|
||||
## Scope
|
||||
|
||||
Target panels:
|
||||
|
||||
- `tv-panel`
|
||||
- `news-panel`
|
||||
- `legend`
|
||||
- `layer-panel`
|
||||
- `earth-stats`
|
||||
- `info-card`
|
||||
- settings modal header/actions
|
||||
|
||||
## Component Model
|
||||
|
||||
### Base shell
|
||||
|
||||
- `.hud-panel`
|
||||
- `.hud-panel--compact`
|
||||
- `.hud-panel--media`
|
||||
- `.hud-panel--collapsed`
|
||||
- `.hud-panel-hidden`
|
||||
- `.hud-panel.is-dragging`
|
||||
- `.hud-panel.is-layout-animating`
|
||||
|
||||
### Header
|
||||
|
||||
- `.hud-panel__header`
|
||||
- `.hud-panel__title-group`
|
||||
- `.hud-panel__title`
|
||||
- `.hud-panel__subtitle`
|
||||
- `.hud-panel__chip`
|
||||
- `.hud-panel__actions`
|
||||
|
||||
Header baseline rule:
|
||||
|
||||
- Header title styling is fixed by the component layer and should not drift per panel
|
||||
- Title font size, font weight, letter spacing, line height, text color, and vertical alignment come from the shared header tokens and structure
|
||||
- Header divider, border treatment, inner spacing, and title-to-actions alignment are part of the same shared baseline
|
||||
- Panel-specific header differences should be limited to explicit variants such as `compact` or `media`, or token overrides with documented intent
|
||||
- “Looks close enough” local header overrides should be treated as temporary compatibility code and removed during migration
|
||||
|
||||
### Actions
|
||||
|
||||
- `.hud-panel__action`
|
||||
- `.hud-panel__action--icon`
|
||||
- `.hud-panel__action--collapse`
|
||||
- `.hud-panel__action--close`
|
||||
- `.hud-panel__action--refresh`
|
||||
- `.hud-panel__action--external`
|
||||
|
||||
Action-button baseline rule:
|
||||
|
||||
- Header action buttons must have one fixed default style baseline across all HUD panels
|
||||
- Default width behavior, padding, icon size, radius, alignment, hover, and active feedback all come from `.hud-panel__action`
|
||||
- Panel-specific differences must be expressed through explicit variants or token overrides, not ad-hoc local button rewrites
|
||||
- `close` buttons are part of the same default action system and must not silently fall back to a separate legacy box model
|
||||
|
||||
### Body
|
||||
|
||||
- `.hud-panel__body`
|
||||
- `.hud-panel__body--scroll`
|
||||
- `.hud-panel__body--collapsible`
|
||||
|
||||
### Collapse behavior
|
||||
|
||||
- `.hud-panel--collapsed`
|
||||
- `.hud-panel--expand-up`
|
||||
- `.hud-panel--expand-down`
|
||||
|
||||
Adaptive collapse / expand rule:
|
||||
|
||||
- HUD panels support two expansion directions:
|
||||
- top-to-bottom expansion
|
||||
- bottom-to-top expansion
|
||||
- Expansion direction should be decided at runtime from available viewport space rather than hardcoded per panel
|
||||
- Use:
|
||||
- `d` = available distance from the header anchor to the viewport bottom edge
|
||||
- `h` = expected expanded panel height
|
||||
- buffer = `20px`
|
||||
- Collapsed-state direction rule:
|
||||
- if `d > h + 20px`, the next action direction is `expand-up`
|
||||
- if `d <= h + 20px`, the next action direction is `expand-down`
|
||||
- To avoid jitter around the threshold, the shared controller should keep a small hysteresis band:
|
||||
- if the current direction is already `up`, keep it until `d <= h`
|
||||
- if the current direction is already `down`, keep it until `d > h + 20px`
|
||||
- The opposite edge is still a safety guard:
|
||||
- if the chosen side cannot fit at all, fall back to the other side if it can fit
|
||||
- if neither side fully fits, choose the side with more space and let the body scroll
|
||||
- If neither direction fully fits, choose the direction with more available space and let the body scroll
|
||||
- Collapse icon direction must match the active expansion direction so the icon always describes the real open/close motion
|
||||
- The collapse icon describes the next action, not the current state
|
||||
- This mapping is fixed component behavior and must not drift per panel:
|
||||
- collapsed + expand-down => `expand_more`
|
||||
- expanded + expand-down => `expand_less`
|
||||
- collapsed + expand-up => `expand_less`
|
||||
- expanded + expand-up => `expand_more`
|
||||
- Panels must not combine icon-name swapping with extra CSS rotation for the same collapse control
|
||||
- Expansion direction and icon direction must come from one shared source of truth in the component controller
|
||||
- The direction decision should be recomputed when opening, resizing the viewport, or restoring a dragged panel near another edge
|
||||
|
||||
## Tokens
|
||||
|
||||
Promote panel differences into CSS variables instead of duplicating selectors:
|
||||
|
||||
- `--hud-panel-padding`
|
||||
- `--hud-header-padding`
|
||||
- `--hud-header-gap`
|
||||
- `--hud-action-padding`
|
||||
- `--hud-action-gap`
|
||||
- `--hud-action-icon-size`
|
||||
- `--hud-body-gap`
|
||||
- `--hud-body-max-height`
|
||||
- `--hud-chip-radius`
|
||||
- `--hud-title-font-size`
|
||||
- `--hud-title-font-weight`
|
||||
- `--hud-title-letter-spacing`
|
||||
- `--hud-title-line-height`
|
||||
- `--hud-title-color`
|
||||
- `--hud-header-border-color`
|
||||
- `--hud-header-divider-opacity`
|
||||
- `--hud-expand-direction`
|
||||
|
||||
## Migration Order
|
||||
|
||||
1. Build the shared component layer in `frontend/public/earth/css/hud.css`
|
||||
2. Migrate `tv-panel` and `news-panel` first as the reference implementation
|
||||
3. Migrate `legend` and `layer-panel` into a compact variant
|
||||
4. Migrate `earth-stats` and `info-card`
|
||||
5. Align settings modal header/actions with the same action system
|
||||
6. Remove legacy one-off button selectors after verification
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not change panel behavior and data flow during the first pass
|
||||
- Keep old class names temporarily as compatibility hooks
|
||||
- Prefer variable overrides over per-panel reimplementation
|
||||
- Treat header action-button default styling as fixed component API, not per-panel design space
|
||||
- Treat header title typography, border, and divider styling as fixed component API, not per-panel design space
|
||||
- Treat collapse direction as a component behavior contract, not a one-off panel trick
|
||||
- Treat collapse icon semantics as a component behavior contract, not a per-panel visual preference
|
||||
- Verify header alignment and drag/collapse behavior after each migration batch
|
||||
|
||||
## First Implementation Batch
|
||||
|
||||
Batch 1 should only do:
|
||||
|
||||
- shared header structure
|
||||
- shared action-button system
|
||||
- shared title typography and header border/divider baseline
|
||||
- shared collapsible body pattern
|
||||
- adaptive collapse direction logic and direction-aware collapse icons
|
||||
- migration of `tv-panel` and `news-panel`
|
||||
|
||||
That keeps risk low while giving the rest of the HUD a stable target to migrate toward.
|
||||
355
docs/earth/bgp-context.md
Normal file
355
docs/earth/bgp-context.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# BGP Context
|
||||
|
||||
## Current Goal
|
||||
|
||||
The BGP module is being evolved from an anomaly-only demo into a layered observability pipeline:
|
||||
|
||||
`raw observations -> enrichment -> detectors -> incidents -> console/Earth visualization`
|
||||
|
||||
The practical product goal is no longer just to "show incidents on the globe". The current product objective is:
|
||||
|
||||
1. keep BGP visually present on Earth even when incident density is low
|
||||
2. make incidents clearly feel like a higher-confidence layer than anomalies
|
||||
3. show that the observation network is still active even when there are no active incidents
|
||||
|
||||
In practice, that means Earth should behave like an observability surface, not only an incident map:
|
||||
|
||||
- `collectors` show that observation is happening
|
||||
- `activity` shows where routing state is currently active or noisy
|
||||
- `incidents` become the highest-confidence focus layer
|
||||
|
||||
## Current Backend Architecture
|
||||
|
||||
### Data Layers
|
||||
|
||||
1. `BGPObservation`
|
||||
- File: `backend/app/models/bgp_observation.py`
|
||||
- Purpose: store normalized raw routing observations from live/history sources.
|
||||
- Typical fields:
|
||||
- `source`
|
||||
- `collector`
|
||||
- `peer_asn`
|
||||
- `peer_ip`
|
||||
- `prefix`
|
||||
- `event_type`
|
||||
- `as_path`
|
||||
- `origin_asn`
|
||||
- `next_hop`
|
||||
- `communities`
|
||||
- `observed_at`
|
||||
- `raw_payload`
|
||||
- `collector_geo`
|
||||
- `ingest_batch_id`
|
||||
|
||||
2. `BGPAnomaly`
|
||||
- File: `backend/app/models/bgp_anomaly.py`
|
||||
- Purpose: hold atomic detector outputs.
|
||||
- Current detector output types include:
|
||||
- `origin_change`
|
||||
- `more_specific_burst`
|
||||
- `mass_withdrawal`
|
||||
|
||||
3. `BGPIncident`
|
||||
- File: `backend/app/models/bgp_incident.py`
|
||||
- Purpose: aggregate atomic anomalies into incident-level objects for humans and the UI.
|
||||
|
||||
### Pipeline
|
||||
|
||||
Main flow is currently anchored in:
|
||||
|
||||
- `backend/app/services/collectors/bgp_common.py`
|
||||
- `backend/app/services/bgp_enrichment.py`
|
||||
- `backend/app/services/bgp_detectors.py`
|
||||
- `backend/app/services/bgp_incidents.py`
|
||||
|
||||
Operational flow:
|
||||
|
||||
1. collectors fetch raw BGP data
|
||||
2. `normalize_bgp_event()` standardizes payloads
|
||||
3. observations are persisted to `bgp_observations`
|
||||
4. enrichment augments events with analysis context
|
||||
5. detectors create `bgp_anomalies`
|
||||
6. incident aggregation rolls anomalies up into `bgp_incidents`
|
||||
|
||||
### Current Ingest Sources
|
||||
|
||||
1. `RIPE RIS Live`
|
||||
- Collector file: `backend/app/services/collectors/ris_live.py`
|
||||
- Used for realtime observation flow.
|
||||
|
||||
2. `CAIDA BGPStream Backfill`
|
||||
- Collector file: `backend/app/services/collectors/bgpstream.py`
|
||||
- Used as history/backfill entry point.
|
||||
|
||||
## Current Enrichment Status
|
||||
|
||||
Implemented enrichment skeleton in:
|
||||
|
||||
- `backend/app/services/bgp_enrichment.py`
|
||||
|
||||
Current enrichments:
|
||||
|
||||
- prefix family / prefix length
|
||||
- supernet / more-specific derivation
|
||||
- deduplicated AS path
|
||||
- path prepending hints
|
||||
- collector region info
|
||||
- prefix baseline hints
|
||||
- new-origin detection
|
||||
- ASN organization profile from PeeringDB where available
|
||||
- prefix scope / impacted region hints
|
||||
- prefix geography source priority:
|
||||
- `OpenGeoFeed` (override/high confidence)
|
||||
- `IPtoASN` (country-range baseline)
|
||||
- `NRO delegated stats` (registry-allocation fallback)
|
||||
|
||||
Current limitation:
|
||||
|
||||
- `RPKI` is still placeholder-only and returns `unknown`
|
||||
- no real ROA validation source is integrated yet
|
||||
- `inetnum` / `inet6num` whois fallback is still pending
|
||||
|
||||
## Current API Surface
|
||||
|
||||
Primary API file:
|
||||
|
||||
- `backend/app/api/v1/bgp.py`
|
||||
|
||||
Available endpoints:
|
||||
|
||||
- `/api/v1/bgp/events`
|
||||
- `/api/v1/bgp/events/summary`
|
||||
- `/api/v1/bgp/events/{id}`
|
||||
- `/api/v1/bgp/anomalies`
|
||||
- `/api/v1/bgp/anomalies/summary`
|
||||
- `/api/v1/bgp/anomalies/{id}`
|
||||
- `/api/v1/bgp/incidents`
|
||||
- `/api/v1/bgp/incidents/summary`
|
||||
- `/api/v1/bgp/incidents/{id}`
|
||||
|
||||
Visualization GeoJSON endpoints:
|
||||
|
||||
- `backend/app/api/v1/visualization.py`
|
||||
- `/api/v1/visualization/geo/bgp-collectors`
|
||||
- `/api/v1/visualization/geo/bgp-anomalies`
|
||||
- `/api/v1/visualization/geo/bgp-incidents`
|
||||
|
||||
## Current Earth Behavior
|
||||
|
||||
Relevant files:
|
||||
|
||||
- `frontend/public/earth/js/bgp.js`
|
||||
- `frontend/public/earth/js/main.js`
|
||||
- `frontend/public/earth/js/info-card.js`
|
||||
- `frontend/public/earth/js/constants.js`
|
||||
- `frontend/public/earth/index.html`
|
||||
|
||||
Current design:
|
||||
|
||||
1. Collectors are always shown when BGP is enabled.
|
||||
2. Incident markers are now the primary Earth BGP markers.
|
||||
3. If there are no incidents, Earth falls back to anomaly markers.
|
||||
4. If there are no anomalies either, collectors still provide presence.
|
||||
5. A dedicated `activity layer` now adds:
|
||||
- per-collector recent 15-minute activity halos
|
||||
- clustered regional activity hints derived from active collectors
|
||||
6. Incident markers now use:
|
||||
- symbol-driven event cores
|
||||
- outward ring pulses
|
||||
- reduced diffuse glow compared with older Earth builds
|
||||
5. The right-side stats now show:
|
||||
- BGP events
|
||||
- collector count
|
||||
- BGP status summary
|
||||
|
||||
This is directionally correct, but still incomplete for low-event-density periods. Right now Earth can still feel too quiet when incidents are sparse because the system lacks a dedicated `activity layer` between raw observation and incident focus.
|
||||
|
||||
Current BGP status strategy:
|
||||
|
||||
- incidents present: show active incident count
|
||||
- no incidents but anomalies present: show active anomaly count, plus active observation regions when available
|
||||
- no incidents/anomalies but activity present: show `观测网络运行中`
|
||||
- no incidents/anomalies but collectors present: show `观测网络运行中 · 当前未发现聚合级事件`
|
||||
- no BGP data at all: show `暂无观测数据`
|
||||
|
||||
Earth info-card strategy:
|
||||
|
||||
- `bgp` card is now incident-centric in wording
|
||||
- `bgp_collector` card shows collector location and current event count
|
||||
|
||||
## Current Product Gap
|
||||
|
||||
The main product gap is not architecture correctness. It is low-density visualization strategy.
|
||||
|
||||
Current reality:
|
||||
|
||||
- incident count is naturally much lower than anomaly count
|
||||
- that is expected, because incidents are aggregated and de-noised
|
||||
- but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer
|
||||
|
||||
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-region-aggregation-plan.md).
|
||||
|
||||
So the immediate next milestone is:
|
||||
|
||||
`event map -> observability map`
|
||||
|
||||
That means Earth needs three simultaneously readable layers:
|
||||
|
||||
1. `observation layer`
|
||||
- collectors
|
||||
- recent collector activity
|
||||
- baseline coverage
|
||||
2. `activity layer`
|
||||
- recent event density
|
||||
- anomaly/noise hotspots
|
||||
- regional activity scoring
|
||||
- incident presence bonus
|
||||
3. `incident layer`
|
||||
- sparse but highly legible, high-confidence event objects
|
||||
- symbol-driven markers
|
||||
- outward ring pulse instead of broad diffuse glow
|
||||
|
||||
## Incident Visual Direction
|
||||
|
||||
The Earth `incident` layer should not read like a large glowing patch. It should read like a compact, high-confidence event focus.
|
||||
|
||||
Design principles:
|
||||
|
||||
1. `incident` markers should use a strong primary symbol
|
||||
- the symbol shape should carry type meaning where possible
|
||||
- examples:
|
||||
- `origin_change`: triangle-like warning marker
|
||||
- `mass_withdrawal`: alert/exclamation-style marker
|
||||
- `more_specific_burst`: split/radiating marker
|
||||
|
||||
2. emphasis should come from outward ring pulses, not area flooding
|
||||
- use a compact hot core
|
||||
- use one or more expanding ring pulses
|
||||
- avoid broad luminous blobs that make the event center feel vague
|
||||
|
||||
3. `collector` and `incident` must stay visually distinct
|
||||
- collectors are observation infrastructure
|
||||
- incidents are extracted event focus
|
||||
- collector activity should stay quieter than incident pulse language
|
||||
|
||||
4. calm periods still need observability presence
|
||||
- collectors and activity layers should keep the map alive
|
||||
- once incidents appear, they should clearly dominate nearby BGP visuals
|
||||
|
||||
5. incident geography should become `prefix-centric`
|
||||
- collectors should remain evidence sources, not the primary event location
|
||||
- preferred geography priority:
|
||||
- `prefix_geography`
|
||||
- `prefix_scope`
|
||||
- `ASN organization region`
|
||||
- `collector centroid` as final fallback
|
||||
- `prefix_scope` should remain an observation-derived scope hint
|
||||
- a new `prefix_geography` layer should be introduced for actual prefix-centric placement
|
||||
|
||||
Reference inspiration:
|
||||
|
||||
- `World Monitor`
|
||||
- sparse event symbols
|
||||
- compact centers
|
||||
- ring-like outward pulses
|
||||
- stronger incident legibility than diffuse glow
|
||||
|
||||
## Current Console Behavior
|
||||
|
||||
Relevant page:
|
||||
|
||||
- `frontend/src/pages/BGP/BGP.tsx`
|
||||
|
||||
Current BGP console page has three levels:
|
||||
|
||||
1. observation summary
|
||||
- total events
|
||||
- collector count
|
||||
- prefix count
|
||||
|
||||
2. incident summary and incident table
|
||||
|
||||
3. anomaly detail table plus recent observation events
|
||||
|
||||
This means the BGP page still has useful signal even when there are zero anomalies.
|
||||
|
||||
## Known Product/Engineering Boundaries
|
||||
|
||||
1. The current system is still closer to an event board than a full BGP sensing platform.
|
||||
2. RIS coverage still needs to expand beyond narrow subscription scope.
|
||||
3. BGPStream history is still not full MRT-to-prefix decoded analytics.
|
||||
4. Collector geography still depends heavily on static RIPE RIS mappings.
|
||||
5. Incident-to-cable/IXP/region association is still weak and early-stage.
|
||||
6. Earth currently visualizes logical observation/impact structure, not true physical traffic paths.
|
||||
|
||||
## Test Status
|
||||
|
||||
BGP-specific tests live in:
|
||||
|
||||
- `backend/tests/test_bgp.py`
|
||||
|
||||
Verified status at this point:
|
||||
|
||||
- `25 passed` for `backend/tests/test_bgp.py`
|
||||
- `62 passed` for `backend/tests`
|
||||
|
||||
Covered areas include:
|
||||
|
||||
- normalization
|
||||
- observation serialization
|
||||
- enrichment
|
||||
- detectors, including route leak candidate and path flap
|
||||
- incident aggregation
|
||||
- batch anomaly creation
|
||||
- BGP events/incidents API
|
||||
- summary endpoints
|
||||
|
||||
## Most Relevant Files
|
||||
|
||||
Backend:
|
||||
|
||||
- `backend/app/models/bgp_observation.py`
|
||||
- `backend/app/models/bgp_anomaly.py`
|
||||
- `backend/app/models/bgp_incident.py`
|
||||
- `backend/app/services/collectors/bgp_common.py`
|
||||
- `backend/app/services/bgp_enrichment.py`
|
||||
- `backend/app/services/bgp_detectors.py`
|
||||
- `backend/app/services/bgp_incidents.py`
|
||||
- `backend/app/api/v1/bgp.py`
|
||||
- `backend/app/api/v1/visualization.py`
|
||||
|
||||
Frontend:
|
||||
|
||||
- `frontend/src/pages/BGP/BGP.tsx`
|
||||
- `frontend/public/earth/js/bgp.js`
|
||||
- `frontend/public/earth/js/main.js`
|
||||
- `frontend/public/earth/js/info-card.js`
|
||||
- `frontend/public/earth/js/constants.js`
|
||||
- `frontend/public/earth/index.html`
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
### Next Backend / Detection Priority
|
||||
|
||||
1. Integrate real RPKI validation data.
|
||||
2. Expand realtime collector coverage and include withdrawals more broadly.
|
||||
3. Continue refining route leak and path instability detectors with stronger heuristics.
|
||||
|
||||
### Next Correlation / Storytelling Priority
|
||||
|
||||
4. Strengthen incident aggregation semantics and titles.
|
||||
5. Add weak correlation from incidents to:
|
||||
- cable corridors
|
||||
- landing points
|
||||
- IXPs
|
||||
- other traffic anomaly sources
|
||||
6. Refine Earth hover/click handoff between collectors and incidents.
|
||||
|
||||
### Next Visualization Priority
|
||||
|
||||
7. Refine regional activity scoring so the activity layer is informative without becoming noisy.
|
||||
8. Add more incident symbol types as new detectors land.
|
||||
9. Add a real prefix geography source:
|
||||
- `IPtoASN / IPtoCountry` as the first practical dataset
|
||||
- `OpenGeoFeed` as a higher-quality override layer
|
||||
- registry/whois only as fallback
|
||||
296
docs/earth/bgp-earth-rendering-plan.md
Normal file
296
docs/earth/bgp-earth-rendering-plan.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# BGP Earth Rendering Plan
|
||||
|
||||
## Goal
|
||||
|
||||
This document defines how the BGP `region activity layer` and `incident layer` should coexist on Earth without conflicting.
|
||||
|
||||
The main question it answers is:
|
||||
|
||||
- how to add a regional observability background layer
|
||||
- without weakening the current incident-first event focus
|
||||
|
||||
## Core Principle
|
||||
|
||||
The Earth design should follow a strict semantic hierarchy:
|
||||
|
||||
- `collector layer` = observation infrastructure
|
||||
- `region activity layer` = background situational awareness
|
||||
- `incident layer` = focal high-confidence event objects
|
||||
|
||||
In short:
|
||||
|
||||
- collectors prove the network is observing
|
||||
- regions show where routing behavior is active or abnormal
|
||||
- incidents show the concrete event worth clicking
|
||||
|
||||
Region aggregation is therefore not a replacement for incident rendering.
|
||||
It is the context layer that makes sparse incident markers legible.
|
||||
|
||||
## Rendering Hierarchy
|
||||
|
||||
Recommended visual stack order:
|
||||
|
||||
1. collector network / collector halos
|
||||
2. region activity glow
|
||||
3. incident markers and incident pulses
|
||||
|
||||
This ordering should always hold.
|
||||
|
||||
Why:
|
||||
|
||||
- collectors should stay visible but quiet
|
||||
- regions should create ambient activity presence
|
||||
- incidents must remain the first thing users notice as a concrete event
|
||||
|
||||
## Role Separation
|
||||
|
||||
### Region Layer
|
||||
|
||||
The region layer answers:
|
||||
|
||||
- where is routing activity building up
|
||||
- where is there current noise or instability
|
||||
- which part of the world is currently worth looking at
|
||||
|
||||
The region layer should feel:
|
||||
|
||||
- broad
|
||||
- ambient
|
||||
- low-frequency
|
||||
- contextual
|
||||
|
||||
### Incident Layer
|
||||
|
||||
The incident layer answers:
|
||||
|
||||
- which exact event should the user inspect
|
||||
- where is the highest-confidence routing event located right now
|
||||
|
||||
The incident layer should feel:
|
||||
|
||||
- sharp
|
||||
- compact
|
||||
- high-contrast
|
||||
- intentionally clickable
|
||||
|
||||
## Non-Conflict Rules
|
||||
|
||||
To avoid visual and semantic conflict, these implementation rules should be treated as hard constraints:
|
||||
|
||||
1. region markers must not use the same symbol language as incidents
|
||||
2. region emphasis must stay weaker than incident emphasis
|
||||
3. region animation frequency must stay lower than incident animation frequency
|
||||
4. incident markers must always render above region glows
|
||||
5. region layer should support the event, not compete with it
|
||||
|
||||
If a user notices the region layer first but misses the incident marker, the region layer is too strong.
|
||||
|
||||
If a user only sees isolated incident points and cannot feel broader activity context, the region layer is too weak.
|
||||
|
||||
## Region Rendering Rules
|
||||
|
||||
The region layer should not be rendered as a second kind of incident point.
|
||||
|
||||
Recommended representation:
|
||||
|
||||
- diffuse glow
|
||||
- halo
|
||||
- low-detail pulse
|
||||
- soft center, not a sharp icon
|
||||
|
||||
### Status Mapping
|
||||
|
||||
#### `observing`
|
||||
|
||||
- weak glow
|
||||
- cool color, such as cyan or blue
|
||||
- little to no pulse
|
||||
- purpose: keep the globe alive during calm periods
|
||||
|
||||
#### `anomaly`
|
||||
|
||||
- stronger glow
|
||||
- warmer color, such as amber
|
||||
- gentle breathing or low-frequency pulse
|
||||
- purpose: show that a region is experiencing abnormal routing noise
|
||||
|
||||
#### `incident`
|
||||
|
||||
- strongest regional background emphasis
|
||||
- still clearly weaker than the incident marker itself
|
||||
- purpose: lift the surrounding area so the focal event does not feel isolated
|
||||
|
||||
### Region Visual Characteristics
|
||||
|
||||
Recommended properties:
|
||||
|
||||
- large radius
|
||||
- low opacity
|
||||
- soft edge
|
||||
- low-contrast outline or no outline
|
||||
- low pulse amplitude
|
||||
|
||||
Avoid:
|
||||
|
||||
- sharp symbol shapes
|
||||
- strong icon silhouettes
|
||||
- bright hard-edged centers
|
||||
- incident-like pulse language
|
||||
|
||||
## Incident Rendering Rules
|
||||
|
||||
The incident layer should remain visually sharper and more explicit than region activity.
|
||||
|
||||
Recommended qualities:
|
||||
|
||||
- clear event symbol
|
||||
- compact hot core
|
||||
- one or two outward ring pulses
|
||||
- high contrast
|
||||
- clear click target
|
||||
|
||||
The incident layer should read as:
|
||||
|
||||
- focal
|
||||
- deliberate
|
||||
- high-confidence
|
||||
|
||||
while the region layer should read as:
|
||||
|
||||
- contextual
|
||||
- ambient
|
||||
- supporting
|
||||
|
||||
## Region And Incident In The Same Area
|
||||
|
||||
When a region contains one or more incidents:
|
||||
|
||||
- the region glow may intensify
|
||||
- but the incident marker must remain the dominant local feature
|
||||
|
||||
Interpretation should be:
|
||||
|
||||
- `region` says this area is in an event state
|
||||
- `incident marker` says this is the concrete event object
|
||||
|
||||
So a region with `incident` status is not itself the event marker.
|
||||
It is the background state around the event.
|
||||
|
||||
## Interaction Model
|
||||
|
||||
Interaction should also preserve hierarchy.
|
||||
|
||||
### Click Region
|
||||
|
||||
Open a regional situation view, such as:
|
||||
|
||||
- region name
|
||||
- observation count
|
||||
- anomaly count
|
||||
- incident count
|
||||
- affected prefix count
|
||||
- affected ASN count
|
||||
- recent incidents in the region
|
||||
|
||||
### Click Incident
|
||||
|
||||
Keep the current incident-focused detail interaction.
|
||||
|
||||
This creates a natural two-step flow:
|
||||
|
||||
1. region gives context
|
||||
2. incident gives detail
|
||||
|
||||
## Layer Relationship To Existing BGP Elements
|
||||
|
||||
### Collector Layer
|
||||
|
||||
Collectors should remain:
|
||||
|
||||
- quieter than regions
|
||||
- more infrastructural than semantic
|
||||
- proof of coverage, not proof of incident
|
||||
|
||||
### Region Layer
|
||||
|
||||
Regions should become:
|
||||
|
||||
- the main ambient activity layer
|
||||
- the bridge between collectors and incidents
|
||||
- the answer to low-density map quietness
|
||||
|
||||
### Incident Layer
|
||||
|
||||
Incidents should remain:
|
||||
|
||||
- the most legible event layer
|
||||
- sparse but dominant
|
||||
- compact and symbol-driven
|
||||
|
||||
## Practical Visual Test
|
||||
|
||||
Use this test when tuning the Earth implementation:
|
||||
|
||||
### Calm Period
|
||||
|
||||
Expected result:
|
||||
|
||||
- collectors visible
|
||||
- some weak region glows present
|
||||
- no region feels alarm-heavy
|
||||
- globe still feels alive
|
||||
|
||||
### Anomaly Period
|
||||
|
||||
Expected result:
|
||||
|
||||
- one or more regions brighten noticeably
|
||||
- user can sense the active area before clicking
|
||||
- still no confusion between region background and incident objects
|
||||
|
||||
### Incident Period
|
||||
|
||||
Expected result:
|
||||
|
||||
- region provides broader context
|
||||
- incident marker is the first explicit focal object the eye lands on
|
||||
- user can immediately tell both:
|
||||
- which region is active
|
||||
- which specific event to inspect
|
||||
|
||||
## Failure Modes To Avoid
|
||||
|
||||
### Region Too Strong
|
||||
|
||||
Symptoms:
|
||||
|
||||
- incident markers disappear into the glow
|
||||
- users treat the region center as the main event
|
||||
- the map feels like area flooding instead of event focus
|
||||
|
||||
### Region Too Weak
|
||||
|
||||
Symptoms:
|
||||
|
||||
- incident markers still feel isolated
|
||||
- low-incident periods still look visually empty
|
||||
- users cannot tell where routing activity is generally happening
|
||||
|
||||
### Region Uses Incident Language
|
||||
|
||||
Symptoms:
|
||||
|
||||
- region and incident both look like event markers
|
||||
- users cannot distinguish context from event
|
||||
|
||||
## Final Design Rule
|
||||
|
||||
The desired reading order is:
|
||||
|
||||
1. see the specific incident marker
|
||||
2. feel the active region around it
|
||||
3. understand that collectors and background activity keep the globe alive even during quieter periods
|
||||
|
||||
In one sentence:
|
||||
|
||||
`incident is the point; region is the field.`
|
||||
487
docs/earth/bgp-observability-plan.md
Normal file
487
docs/earth/bgp-observability-plan.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# BGP Observability Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Build a global routing observability capability on top of:
|
||||
|
||||
- [RIPE RIS Live](https://ris-live.ripe.net/)
|
||||
- [CAIDA BGPStream data access overview](https://bgpstream.caida.org/docs/overview/data-access)
|
||||
|
||||
The target is to support:
|
||||
|
||||
- real-time routing event ingestion
|
||||
- historical replay and baseline analysis
|
||||
- anomaly detection
|
||||
- Earth big-screen visualization
|
||||
|
||||
## Important Scope Note
|
||||
|
||||
These data sources expose the BGP control plane, not user traffic itself.
|
||||
|
||||
That means the system can infer:
|
||||
|
||||
- route propagation direction
|
||||
- prefix reachability changes
|
||||
- AS path changes
|
||||
- visibility changes across collectors
|
||||
|
||||
But it cannot directly measure:
|
||||
|
||||
- exact application traffic volume
|
||||
- exact user packet path
|
||||
- real bandwidth consumption between countries or operators
|
||||
|
||||
Product wording should therefore use phrases like:
|
||||
|
||||
- global routing propagation
|
||||
- route visibility
|
||||
- control-plane anomalies
|
||||
- suspected path diversion
|
||||
|
||||
Instead of claiming direct traffic measurement.
|
||||
|
||||
## Data Source Roles
|
||||
|
||||
### RIS Live
|
||||
|
||||
Use RIS Live as the real-time feed.
|
||||
|
||||
Recommended usage:
|
||||
|
||||
- subscribe to update streams over WebSocket
|
||||
- ingest announcements and withdrawals continuously
|
||||
- trigger low-latency alerts
|
||||
|
||||
Best suited for:
|
||||
|
||||
- hijack suspicion
|
||||
- withdrawal bursts
|
||||
- real-time path changes
|
||||
- live Earth event overlay
|
||||
|
||||
### BGPStream
|
||||
|
||||
Use BGPStream as the historical and replay layer.
|
||||
|
||||
Recommended usage:
|
||||
|
||||
- backfill time windows
|
||||
- build normal baselines
|
||||
- compare current events against history
|
||||
- support investigations and playback
|
||||
|
||||
Best suited for:
|
||||
|
||||
- historical anomaly confirmation
|
||||
- baseline path frequency
|
||||
- visibility baselines
|
||||
- postmortem analysis
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["RIS Live WebSocket"] --> B["Realtime Collector"]
|
||||
C["BGPStream Historical Access"] --> D["Backfill Collector"]
|
||||
B --> E["Normalization Layer"]
|
||||
D --> E
|
||||
E --> F["data_snapshots"]
|
||||
E --> G["collected_data"]
|
||||
E --> H["bgp_anomalies"]
|
||||
H --> I["Alerts API"]
|
||||
G --> J["Visualization API"]
|
||||
H --> J
|
||||
J --> K["Earth Big Screen"]
|
||||
```
|
||||
|
||||
## Storage Design
|
||||
|
||||
The current project already has:
|
||||
|
||||
- [data_snapshot.py](/home/ray/dev/linkong/planet/backend/app/models/data_snapshot.py)
|
||||
- [collected_data.py](/home/ray/dev/linkong/planet/backend/app/models/collected_data.py)
|
||||
|
||||
So the lowest-risk path is:
|
||||
|
||||
1. keep raw and normalized BGP events in `collected_data`
|
||||
2. use `data_snapshots` to group each ingest window
|
||||
3. add a dedicated anomaly table for higher-value derived events
|
||||
|
||||
## Proposed Data Types
|
||||
|
||||
### `collected_data`
|
||||
|
||||
Use these `source` values:
|
||||
|
||||
- `ris_live_bgp`
|
||||
- `bgpstream_bgp`
|
||||
|
||||
Use these `data_type` values:
|
||||
|
||||
- `bgp_update`
|
||||
- `bgp_rib`
|
||||
- `bgp_visibility`
|
||||
- `bgp_path_change`
|
||||
|
||||
Recommended stable fields:
|
||||
|
||||
- `source`
|
||||
- `source_id`
|
||||
- `entity_key`
|
||||
- `data_type`
|
||||
- `name`
|
||||
- `reference_date`
|
||||
- `metadata`
|
||||
|
||||
Recommended `entity_key` strategy:
|
||||
|
||||
- event entity: `collector|peer|prefix|event_time`
|
||||
- prefix state entity: `collector|peer|prefix`
|
||||
- origin state entity: `prefix|origin_asn`
|
||||
|
||||
### `metadata` schema for raw events
|
||||
|
||||
Store the normalized event payload in `metadata`:
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "ris-live",
|
||||
"collector": "rrc00",
|
||||
"peer_asn": 3333,
|
||||
"peer_ip": "2001:db8::1",
|
||||
"event_type": "announcement",
|
||||
"prefix": "203.0.113.0/24",
|
||||
"origin_asn": 64496,
|
||||
"as_path": [3333, 64500, 64496],
|
||||
"communities": ["3333:100", "64500:1"],
|
||||
"next_hop": "192.0.2.1",
|
||||
"med": 0,
|
||||
"local_pref": null,
|
||||
"timestamp": "2026-03-26T08:00:00Z",
|
||||
"raw_message": {}
|
||||
}
|
||||
```
|
||||
|
||||
### New anomaly table
|
||||
|
||||
Add a new table, recommended name: `bgp_anomalies`
|
||||
|
||||
Suggested columns:
|
||||
|
||||
- `id`
|
||||
- `snapshot_id`
|
||||
- `task_id`
|
||||
- `source`
|
||||
- `anomaly_type`
|
||||
- `severity`
|
||||
- `status`
|
||||
- `entity_key`
|
||||
- `prefix`
|
||||
- `origin_asn`
|
||||
- `new_origin_asn`
|
||||
- `peer_scope`
|
||||
- `started_at`
|
||||
- `ended_at`
|
||||
- `confidence`
|
||||
- `summary`
|
||||
- `evidence`
|
||||
- `created_at`
|
||||
|
||||
This table should represent derived intelligence, not raw updates.
|
||||
|
||||
## Collector Design
|
||||
|
||||
## 1. `RISLiveCollector`
|
||||
|
||||
Responsibility:
|
||||
|
||||
- maintain WebSocket connection
|
||||
- subscribe to relevant message types
|
||||
- normalize messages
|
||||
- write event batches into snapshots
|
||||
- optionally emit derived anomalies in near real time
|
||||
|
||||
Suggested runtime mode:
|
||||
|
||||
- long-running background task
|
||||
|
||||
Suggested snapshot strategy:
|
||||
|
||||
- one snapshot per rolling time window
|
||||
- for example every 1 minute or every 5 minutes
|
||||
|
||||
## 2. `BGPStreamBackfillCollector`
|
||||
|
||||
Responsibility:
|
||||
|
||||
- fetch historical data windows
|
||||
- normalize to the same schema as real-time data
|
||||
- build baselines
|
||||
- re-run anomaly rules on past windows if needed
|
||||
|
||||
Suggested runtime mode:
|
||||
|
||||
- scheduled task
|
||||
- or ad hoc task for investigations
|
||||
|
||||
Suggested snapshot strategy:
|
||||
|
||||
- one snapshot per historical query window
|
||||
|
||||
## Normalization Rules
|
||||
|
||||
Normalize both sources into the same internal event model.
|
||||
|
||||
Required normalized fields:
|
||||
|
||||
- `collector`
|
||||
- `peer_asn`
|
||||
- `peer_ip`
|
||||
- `event_type`
|
||||
- `prefix`
|
||||
- `origin_asn`
|
||||
- `as_path`
|
||||
- `timestamp`
|
||||
|
||||
Derived normalized fields:
|
||||
|
||||
- `as_path_length`
|
||||
- `country_guess`
|
||||
- `prefix_length`
|
||||
- `is_more_specific`
|
||||
- `visibility_weight`
|
||||
|
||||
## Anomaly Detection Rules
|
||||
|
||||
Start with these five rules first.
|
||||
|
||||
### 1. Origin ASN Change
|
||||
|
||||
Trigger when:
|
||||
|
||||
- the same prefix is announced by a new origin ASN not seen in the baseline window
|
||||
|
||||
Use for:
|
||||
|
||||
- hijack suspicion
|
||||
- origin drift detection
|
||||
|
||||
### 2. More-Specific Burst
|
||||
|
||||
Trigger when:
|
||||
|
||||
- a more-specific prefix appears suddenly
|
||||
- especially from an unexpected origin ASN
|
||||
|
||||
Use for:
|
||||
|
||||
- subprefix hijack suspicion
|
||||
|
||||
### 3. Mass Withdrawal
|
||||
|
||||
Trigger when:
|
||||
|
||||
- the same prefix or ASN sees many withdrawals across collectors within a short window
|
||||
|
||||
Use for:
|
||||
|
||||
- outage suspicion
|
||||
- regional incident detection
|
||||
|
||||
### 4. Path Deviation
|
||||
|
||||
Trigger when:
|
||||
|
||||
- AS path length jumps sharply
|
||||
- or a rarely seen transit ASN appears
|
||||
- or path frequency drops below baseline norms
|
||||
|
||||
Use for:
|
||||
|
||||
- route leak suspicion
|
||||
- unusual path diversion
|
||||
|
||||
### 5. Visibility Drop
|
||||
|
||||
Trigger when:
|
||||
|
||||
- a prefix is visible from far fewer collectors/peers than its baseline
|
||||
|
||||
Use for:
|
||||
|
||||
- regional reachability degradation
|
||||
|
||||
## Baseline Strategy
|
||||
|
||||
Use BGPStream historical data to build:
|
||||
|
||||
- common origin ASN per prefix
|
||||
- common AS path patterns
|
||||
- collector visibility distribution
|
||||
- normal withdrawal frequency
|
||||
|
||||
Recommended baseline windows:
|
||||
|
||||
- short baseline: last 24 hours
|
||||
- medium baseline: last 7 days
|
||||
- long baseline: last 30 days
|
||||
|
||||
The first implementation can start with only the 7-day baseline.
|
||||
|
||||
## API Design
|
||||
|
||||
### Raw event API
|
||||
|
||||
Add endpoints like:
|
||||
|
||||
- `GET /api/v1/bgp/events`
|
||||
- `GET /api/v1/bgp/events/{id}`
|
||||
|
||||
Suggested filters:
|
||||
|
||||
- `prefix`
|
||||
- `origin_asn`
|
||||
- `peer_asn`
|
||||
- `collector`
|
||||
- `event_type`
|
||||
- `time_from`
|
||||
- `time_to`
|
||||
- `source`
|
||||
|
||||
### Anomaly API
|
||||
|
||||
Add endpoints like:
|
||||
|
||||
- `GET /api/v1/bgp/anomalies`
|
||||
- `GET /api/v1/bgp/anomalies/{id}`
|
||||
- `GET /api/v1/bgp/anomalies/summary`
|
||||
|
||||
Suggested filters:
|
||||
|
||||
- `severity`
|
||||
- `anomaly_type`
|
||||
- `status`
|
||||
- `prefix`
|
||||
- `origin_asn`
|
||||
- `time_from`
|
||||
- `time_to`
|
||||
|
||||
### Visualization API
|
||||
|
||||
Add an Earth-oriented endpoint like:
|
||||
|
||||
- `GET /api/v1/visualization/geo/bgp-anomalies`
|
||||
|
||||
Recommended feature shapes:
|
||||
|
||||
- point: collector locations
|
||||
- arc: inferred propagation or suspicious path edge
|
||||
- pulse point: active anomaly hotspot
|
||||
|
||||
## Earth Big-Screen Design
|
||||
|
||||
Recommended layers:
|
||||
|
||||
### Layer 1: Collector layer
|
||||
|
||||
Show known collector locations and current activity intensity.
|
||||
|
||||
### Layer 2: Route propagation arcs
|
||||
|
||||
Use arcs for:
|
||||
|
||||
- origin ASN country to collector country
|
||||
- or collector-to-collector visibility edges
|
||||
|
||||
Important note:
|
||||
|
||||
This is an inferred propagation view, not real packet flow.
|
||||
|
||||
### Layer 3: Active anomaly overlay
|
||||
|
||||
Show:
|
||||
|
||||
- hijack suspicion in red
|
||||
- mass withdrawal in orange
|
||||
- visibility drop in yellow
|
||||
- path deviation in blue
|
||||
|
||||
### Layer 4: Time playback
|
||||
|
||||
Use `data_snapshots` to replay:
|
||||
|
||||
- minute-by-minute route changes
|
||||
- anomaly expansion
|
||||
- recovery timeline
|
||||
|
||||
## Alerting Strategy
|
||||
|
||||
Map anomaly severity to the current alert system.
|
||||
|
||||
Recommended severity mapping:
|
||||
|
||||
- `critical`
|
||||
- likely hijack
|
||||
- very large withdrawal burst
|
||||
- `high`
|
||||
- clear origin change
|
||||
- large visibility drop
|
||||
- `medium`
|
||||
- unusual path change
|
||||
- moderate more-specific burst
|
||||
- `low`
|
||||
- weak or localized anomalies
|
||||
|
||||
## Delivery Plan
|
||||
|
||||
### Phase 1
|
||||
|
||||
- add `RISLiveCollector`
|
||||
- normalize updates into `collected_data`
|
||||
- create `bgp_anomalies`
|
||||
- implement 3 rules:
|
||||
- origin change
|
||||
- more-specific burst
|
||||
- mass withdrawal
|
||||
|
||||
### Phase 2
|
||||
|
||||
- add `BGPStreamBackfillCollector`
|
||||
- build 7-day baseline
|
||||
- implement:
|
||||
- path deviation
|
||||
- visibility drop
|
||||
|
||||
### Phase 3
|
||||
|
||||
- add Earth visualization layer
|
||||
- add time playback
|
||||
- add anomaly filtering and drilldown
|
||||
|
||||
## Practical Implementation Notes
|
||||
|
||||
- Start with IPv4 first, then add IPv6 after the event schema is stable.
|
||||
- Store the original raw payload in `metadata.raw_message` for traceability.
|
||||
- Deduplicate events by a stable hash of collector, peer, prefix, type, and timestamp.
|
||||
- Keep anomaly generation idempotent so replay and backfill do not create duplicate alerts.
|
||||
- Expect noisy data and partial views; confidence scoring matters.
|
||||
|
||||
## Recommended First Patch Set
|
||||
|
||||
The first code milestone should include:
|
||||
|
||||
1. `backend/app/services/collectors/ris_live.py`
|
||||
2. `backend/app/services/collectors/bgpstream.py`
|
||||
3. `backend/app/models/bgp_anomaly.py`
|
||||
4. `backend/app/api/v1/bgp.py`
|
||||
5. `backend/app/api/v1/visualization.py`
|
||||
add BGP anomaly geo endpoint
|
||||
6. `frontend/src/pages`
|
||||
add a BGP anomaly list or summary page
|
||||
7. `frontend/public/earth/js`
|
||||
add BGP anomaly rendering layer
|
||||
|
||||
## Sources
|
||||
|
||||
- [RIPE RIS Live](https://ris-live.ripe.net/)
|
||||
- [CAIDA BGPStream Data Access Overview](https://bgpstream.caida.org/docs/overview/data-access)
|
||||
422
docs/earth/bgp-region-aggregation-plan.md
Normal file
422
docs/earth/bgp-region-aggregation-plan.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# BGP Region Aggregation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
This document refines the current BGP `activity layer` into an implementation-ready regional aggregation design.
|
||||
|
||||
Primary product goal:
|
||||
|
||||
- turn sparse prefix-level observations, anomalies, and incidents into a readable `regional observability layer`
|
||||
- keep Earth visually alive during low-incident periods
|
||||
- make `incident markers` remain the highest-confidence foreground layer instead of replacing them
|
||||
|
||||
This layer is not a new collector, detector, or raw storage table.
|
||||
It is an aggregation/view-model layer:
|
||||
|
||||
`observations -> enrichment -> anomalies/incidents -> geography mapping -> region aggregation -> Earth/UI activity layer`
|
||||
|
||||
## Why This Layer Exists
|
||||
|
||||
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md):
|
||||
|
||||
- incident density is naturally low
|
||||
- anomaly density is higher, but still not enough to keep the globe expressive all the time
|
||||
- collector presence alone proves coverage, but does not communicate `where routing is currently active or noisy`
|
||||
|
||||
So the missing middle layer is:
|
||||
|
||||
- `collectors` show that observation exists
|
||||
- `regions` show where activity is building up
|
||||
- `incidents` show the specific high-confidence focus events
|
||||
|
||||
## Scope
|
||||
|
||||
This plan is specifically for:
|
||||
|
||||
- a backend aggregation service
|
||||
- a summary API for console/stats
|
||||
- a GeoJSON API for Earth rendering
|
||||
- an Earth background activity layer that supports, but does not replace, incident markers
|
||||
|
||||
This plan does not attempt to solve:
|
||||
|
||||
- exact prefix geolocation quality
|
||||
- polygon-heavy geopolitical visualization
|
||||
- persistent materialized region tables in v1
|
||||
|
||||
## Region Layer Definition
|
||||
|
||||
Recommended conceptual model:
|
||||
|
||||
- `region layer` = background situational awareness
|
||||
- `incident layer` = focal event markers
|
||||
|
||||
That means:
|
||||
|
||||
- region activity should answer `where is routing behavior currently active or abnormal`
|
||||
- incident markers should answer `which concrete event should the user click`
|
||||
|
||||
## Recommended Output Model
|
||||
|
||||
Suggested backend output object:
|
||||
|
||||
## `BGPRegionActivity`
|
||||
|
||||
```json
|
||||
{
|
||||
"region_key": "sea",
|
||||
"region_name": "Southeast Asia",
|
||||
"center_lat": 1.3521,
|
||||
"center_lon": 103.8198,
|
||||
"observation_count": 128,
|
||||
"anomaly_count": 9,
|
||||
"incident_count": 2,
|
||||
"activity_score": 17.6,
|
||||
"status": "incident",
|
||||
"affected_prefix_count": 14,
|
||||
"affected_asn_count": 6,
|
||||
"collector_count": 5,
|
||||
"first_seen_at": "2026-04-02T10:00:00Z",
|
||||
"last_seen_at": "2026-04-02T10:12:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Fields To Keep In MVP
|
||||
|
||||
- `region_key`
|
||||
- `region_name`
|
||||
- `center_lat`
|
||||
- `center_lon`
|
||||
- `observation_count`
|
||||
- `anomaly_count`
|
||||
- `incident_count`
|
||||
- `activity_score`
|
||||
- `status`
|
||||
- `affected_prefix_count`
|
||||
- `affected_asn_count`
|
||||
- `collector_count`
|
||||
- `first_seen_at`
|
||||
- `last_seen_at`
|
||||
|
||||
### Fields To Delay
|
||||
|
||||
These are useful, but not required for the first implementation:
|
||||
|
||||
- `bounding_box`
|
||||
- `top_incident_types`
|
||||
- `top_prefixes`
|
||||
- polygon geometry
|
||||
|
||||
## Region Definition Strategy
|
||||
|
||||
### Recommendation
|
||||
|
||||
Use a static region-definition table first.
|
||||
|
||||
Examples:
|
||||
|
||||
- `north_america`
|
||||
- `south_america`
|
||||
- `western_europe`
|
||||
- `eastern_europe`
|
||||
- `east_asia`
|
||||
- `southeast_asia`
|
||||
- `south_asia`
|
||||
- `middle_east`
|
||||
- `north_africa`
|
||||
- `sub_saharan_africa`
|
||||
- `oceania`
|
||||
|
||||
Why this is the right v1 choice:
|
||||
|
||||
- stable UI semantics
|
||||
- strong readability on Earth
|
||||
- easier debugging and explanation
|
||||
- lower implementation cost than geohash or H3 grids
|
||||
|
||||
### Not Recommended For V1
|
||||
|
||||
- geohash cell aggregation
|
||||
- H3 aggregation
|
||||
- fine-grained lat/lon bucket maps
|
||||
|
||||
Those are more flexible, but they make the map feel fragmented and less explainable.
|
||||
|
||||
## Geography Mapping Strategy
|
||||
|
||||
Do not reduce the implementation to only `prefix -> exact geo`.
|
||||
|
||||
The region layer should follow the same geography-priority logic already implied by the current BGP direction:
|
||||
|
||||
1. `prefix_geography`
|
||||
2. `prefix_scope`
|
||||
3. `ASN organization region`
|
||||
4. `collector centroid` fallback
|
||||
|
||||
This matters because exact prefix geography will often be incomplete or approximate.
|
||||
The region layer should stay robust even when only partial enrichment is available.
|
||||
|
||||
## Backend Design
|
||||
|
||||
Recommended new service file:
|
||||
|
||||
- [backend/app/services/bgp_regions.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_regions.py)
|
||||
|
||||
Suggested responsibilities:
|
||||
|
||||
- `map_record_to_region(...)`
|
||||
- `aggregate_region_activity(...)`
|
||||
- `build_region_geojson(...)`
|
||||
- `resolve_activity_status(...)`
|
||||
- `compute_activity_score(...)`
|
||||
|
||||
### Data Source Inputs
|
||||
|
||||
Use a recent rolling window, default `15 minutes`, and aggregate from:
|
||||
|
||||
- `BGPObservation`
|
||||
- `BGPAnomaly`
|
||||
- active `BGPIncident`
|
||||
|
||||
### Aggregation Flow
|
||||
|
||||
1. query observations in the time window
|
||||
2. query anomalies in the same window
|
||||
3. query active incidents in the same window or active status set
|
||||
4. resolve each record to a best-effort region
|
||||
5. accumulate per-region counters
|
||||
6. compute score and status
|
||||
7. return region activity list
|
||||
|
||||
## Status Model
|
||||
|
||||
Recommended status buckets:
|
||||
|
||||
- `idle`
|
||||
- `observing`
|
||||
- `anomaly`
|
||||
- `incident`
|
||||
|
||||
Suggested rule:
|
||||
|
||||
```text
|
||||
if incident_count > 0: incident
|
||||
elif anomaly_count > 0: anomaly
|
||||
elif observation_count > 0: observing
|
||||
else: idle
|
||||
```
|
||||
|
||||
This aligns well with the current Earth status language and keeps the visual mapping simple.
|
||||
|
||||
## Activity Score
|
||||
|
||||
The score should be a tunable heuristic, not a fixed truth model.
|
||||
|
||||
Recommended v1 formula:
|
||||
|
||||
```text
|
||||
activity_score =
|
||||
min(observation_count, 50) * 0.03
|
||||
+ anomaly_count * 1.2
|
||||
+ incident_count * 5.0
|
||||
```
|
||||
|
||||
Why cap observations:
|
||||
|
||||
- observation volume is usually much larger than anomaly or incident volume
|
||||
- uncapped observation counts would overwhelm the score
|
||||
- capped observation counts preserve baseline presence without drowning real abnormality
|
||||
|
||||
### Practical Guidance
|
||||
|
||||
- treat coefficients as configuration-like constants
|
||||
- expect to retune after looking at real data
|
||||
- keep `incident` weight dominant
|
||||
|
||||
## API Design
|
||||
|
||||
### 1. Summary/List API
|
||||
|
||||
Suggested endpoint:
|
||||
|
||||
- `/api/v1/bgp/regions/activity`
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"window_minutes": 15,
|
||||
"regions": []
|
||||
}
|
||||
```
|
||||
|
||||
Use cases:
|
||||
|
||||
- BGP console summaries
|
||||
- right-side Earth stats
|
||||
- future region list panels
|
||||
|
||||
### 2. GeoJSON API
|
||||
|
||||
Suggested endpoint:
|
||||
|
||||
- `/api/v1/visualization/geo/bgp-regions`
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": []
|
||||
}
|
||||
```
|
||||
|
||||
Each feature should include:
|
||||
|
||||
- `geometry`
|
||||
- v1: `Point`
|
||||
- later: optional `Polygon`
|
||||
- `properties`
|
||||
- `region_key`
|
||||
- `region_name`
|
||||
- `status`
|
||||
- `activity_score`
|
||||
- `observation_count`
|
||||
- `anomaly_count`
|
||||
- `incident_count`
|
||||
- `affected_prefix_count`
|
||||
- `affected_asn_count`
|
||||
- `collector_count`
|
||||
|
||||
## Earth Rendering Plan
|
||||
|
||||
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-earth-rendering-plan.md).
|
||||
|
||||
### Layer Relationship
|
||||
|
||||
- `region layer` = ambient background activity
|
||||
- `incident marker` = focal event object
|
||||
|
||||
Do not replace incident markers with region markers.
|
||||
|
||||
### Region Visual Rules
|
||||
|
||||
Suggested mapping:
|
||||
|
||||
- `observing`
|
||||
- weak glow
|
||||
- low pulse or no pulse
|
||||
- `anomaly`
|
||||
- stronger glow
|
||||
- more visible pulse
|
||||
- `incident`
|
||||
- strongest regional emphasis
|
||||
- but still visually secondary to the incident marker itself
|
||||
|
||||
### Region Labels
|
||||
|
||||
Good v2 enhancement:
|
||||
|
||||
- show region name
|
||||
- show counts like `2 incidents / 5 anomalies`
|
||||
|
||||
This is useful, but should come after the core aggregation and Earth glow layer are working.
|
||||
|
||||
## Interaction Model
|
||||
|
||||
### Click Region
|
||||
|
||||
Recommended detail payload:
|
||||
|
||||
- region name
|
||||
- observation/anomaly/incident counts in the selected window
|
||||
- affected prefix count
|
||||
- affected ASN count
|
||||
- collector count
|
||||
- recent incidents in the region
|
||||
|
||||
### Click Incident
|
||||
|
||||
Keep the current incident-detail flow.
|
||||
|
||||
Interaction should feel hierarchical:
|
||||
|
||||
1. region gives situational context
|
||||
2. incident gives event focus
|
||||
|
||||
## MVP Implementation Order
|
||||
|
||||
### Step 1
|
||||
|
||||
Define static `REGIONS` in code or config.
|
||||
|
||||
### Step 2
|
||||
|
||||
Map geography-enriched BGP records into regions using the fallback chain.
|
||||
|
||||
### Step 3
|
||||
|
||||
Aggregate recent window counts:
|
||||
|
||||
- `observation_count`
|
||||
- `anomaly_count`
|
||||
- `incident_count`
|
||||
|
||||
### Step 4
|
||||
|
||||
Compute `activity_score` and `status`.
|
||||
|
||||
### Step 5
|
||||
|
||||
Expose:
|
||||
|
||||
- `/api/v1/bgp/regions/activity`
|
||||
- `/api/v1/visualization/geo/bgp-regions`
|
||||
|
||||
### Step 6
|
||||
|
||||
Render region glows on Earth behind incident markers.
|
||||
|
||||
## Out Of Scope For MVP
|
||||
|
||||
- persistent materialized region tables
|
||||
- geohash or H3 support
|
||||
- polygon-filled regional overlays
|
||||
- detailed top-prefix ranking in the first release
|
||||
- complicated scoring personalization
|
||||
|
||||
## Risks And Constraints
|
||||
|
||||
### Geography Quality
|
||||
|
||||
Prefix geography is approximate and incomplete.
|
||||
The region layer must tolerate fallback-based placement.
|
||||
|
||||
### Query Cost
|
||||
|
||||
Dynamic aggregation is the right v1 choice, but repeated short-window queries may eventually need:
|
||||
|
||||
- in-process caching
|
||||
- scheduled pre-aggregation
|
||||
- materialized summaries
|
||||
|
||||
### UI Overcrowding
|
||||
|
||||
If region glow, collector activity, and incidents all become too strong at once, Earth readability will regress.
|
||||
The region layer must remain supportive, not dominant.
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
The current BGP roadmap should explicitly add:
|
||||
|
||||
- `region aggregation` as the concrete implementation of the missing `activity layer`
|
||||
|
||||
The recommended product interpretation is:
|
||||
|
||||
- `collectors` prove observation coverage
|
||||
- `regions` communicate live routing activity and abnormality
|
||||
- `incidents` remain the clearest high-confidence event objects
|
||||
|
||||
In one sentence:
|
||||
|
||||
`region aggregation is not a replacement for incidents; it is the situational background that makes sparse incidents feel legible on Earth.`
|
||||
436
docs/earth/earth-celestial-background-plan.md
Normal file
436
docs/earth/earth-celestial-background-plan.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# Earth 天球背景与日月位置实施方案
|
||||
|
||||
## 目标
|
||||
|
||||
为 Earth 大屏增加一套真正可用的天文背景层,覆盖三件事:
|
||||
|
||||
1. 用真实天球背景替换当前随机星点
|
||||
2. 在当前时间下显示太阳与月亮的相对位置
|
||||
3. 让太阳方向同时驱动地球受光,形成更可信的昼夜关系
|
||||
|
||||
本方案优先追求:
|
||||
|
||||
- 与当前 Three.js Earth 架构兼容
|
||||
- 风险可控
|
||||
- 先落地一版真实感明显提升的 V1
|
||||
- 为后续更严格的天文参考系升级预留余地
|
||||
|
||||
## 当前现状
|
||||
|
||||
当前 Earth 的基础条件已经具备:
|
||||
|
||||
- 地球、云层、地形、网格都基于 Three.js,主渲染入口在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- 地球实体创建在 [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- 当前所谓“宇宙背景”只是 `createStars()` 生成的随机星点,不是真实星图
|
||||
- Earth 已有倾角常量 `EARTH_CONFIG.tiltRad`,位于 [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- 主循环 `animate()` 已稳定运行,可在其中接入天体更新逻辑
|
||||
|
||||
这意味着:
|
||||
|
||||
- 不需要重写 Earth
|
||||
- 可以在现有 scene/world 层新增一个 celestial layer
|
||||
- 第一阶段不必拆 Earth / satellite / cable 的参考系
|
||||
|
||||
## 总体策略
|
||||
|
||||
采用“两层现实”设计:
|
||||
|
||||
### 1. 世界层(world-space celestial layer)
|
||||
|
||||
用于放置:
|
||||
|
||||
- 天球背景
|
||||
- 太阳
|
||||
- 月亮
|
||||
- 太阳光方向
|
||||
|
||||
这些对象不挂在 `earthObj` 上,而是直接放在 `scene` 中。
|
||||
|
||||
### 2. 地球层(earth-fixed layer)
|
||||
|
||||
继续保持当前结构:
|
||||
|
||||
- 海缆
|
||||
- 登陆点
|
||||
- 卫星点与轨迹
|
||||
- BGP 覆盖
|
||||
- 地球纹理、云层、地形
|
||||
|
||||
这些对象继续挂在 `earthObj` 下,不打断现有交互。
|
||||
|
||||
## 为什么先这样做
|
||||
|
||||
当前用户交互是“拖动地球本体”,而不是“移动相机绕惯性系观测”。
|
||||
如果现在直接做严格惯性参考系改造,会同时影响:
|
||||
|
||||
- `earthObj.rotation`
|
||||
- 卫星轨迹与锁定逻辑
|
||||
- 海缆与登陆点附着关系
|
||||
- resetView / autoRotate / hover / click 等交互链路
|
||||
|
||||
所以第一阶段只做:
|
||||
|
||||
- 真正的天空
|
||||
- 真正的日月方向
|
||||
- 不碰现有 Earth 附着对象的语义
|
||||
|
||||
## 推荐技术选型
|
||||
|
||||
### 天文计算库
|
||||
|
||||
推荐:
|
||||
|
||||
- [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
||||
|
||||
原因:
|
||||
|
||||
- 有 JavaScript 版本
|
||||
- 支持 Sun / Moon 的矢量与坐标变换
|
||||
- 精度、可扩展性都比轻量太阳高度角库更适合本项目
|
||||
- 后续若要加行星、月相、黄道、赤道网,也能继续沿用
|
||||
|
||||
不作为主选的库:
|
||||
|
||||
- [SunCalc](https://github.com/mourner/suncalc)
|
||||
|
||||
原因:
|
||||
|
||||
- 更偏本地观察者视角的太阳/月亮高度角
|
||||
- 用于“地面日出日落”很好
|
||||
- 但不如 Astronomy Engine 适合做真实天球与后续空间参考系扩展
|
||||
|
||||
### Three.js 表现层
|
||||
|
||||
推荐组合:
|
||||
|
||||
- 天球:内翻球壳 + 星图纹理
|
||||
- 太阳:`THREE.Sprite`
|
||||
- 月亮:`THREE.Sprite` 或小型 `THREE.Mesh`
|
||||
- 太阳光:`THREE.DirectionalLight`
|
||||
|
||||
参考:
|
||||
|
||||
- [Three.js SpriteMaterial](https://threejs.org/docs/pages/SpriteMaterial.html)
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
## Phase 1:真实天球背景
|
||||
|
||||
### 目标
|
||||
|
||||
用真实全天星图替换当前随机星点背景。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 新增一张全天星图纹理
|
||||
|
||||
建议路径:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
|
||||
|
||||
纹理要求:
|
||||
|
||||
- 等距矩形投影
|
||||
- 赤经/赤纬坐标展开
|
||||
- 无地平线、无地景遮挡
|
||||
- 尽量深色、弱干扰,适合大屏 HUD 叠加
|
||||
|
||||
2. 新增天球球壳
|
||||
|
||||
新增模块:
|
||||
|
||||
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
|
||||
|
||||
建议接口:
|
||||
|
||||
```js
|
||||
export function initCelestialLayer(scene)
|
||||
export function updateCelestialLayer(date, camera, earth)
|
||||
export function disposeCelestialLayer()
|
||||
```
|
||||
|
||||
3. 实现一个大半径内翻球体
|
||||
|
||||
建议参数:
|
||||
|
||||
- 半径:`600 ~ 900`
|
||||
- 材质:`MeshBasicMaterial`
|
||||
- `side: THREE.BackSide`
|
||||
- 不受场景光照影响
|
||||
- 始终围绕场景中心
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 初始加载后背景不再是随机星点
|
||||
- 旋转地球时,背景保持为稳定天球而不是跟地球一起转
|
||||
- 不明显干扰海缆/卫星/BGP 的前景识别
|
||||
|
||||
## Phase 2:太阳与月亮真实位置
|
||||
|
||||
### 目标
|
||||
|
||||
在当前 UTC 时间下,计算太阳与月亮在天球中的方向,并显示出来。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 在 `celestial.js` 内封装天体位置计算
|
||||
|
||||
建议函数:
|
||||
|
||||
```js
|
||||
function getSunDirection(date)
|
||||
function getMoonDirection(date)
|
||||
```
|
||||
|
||||
输出统一为 world-space `THREE.Vector3`
|
||||
|
||||
2. 太阳显示
|
||||
|
||||
- 一个暖色发光 sprite
|
||||
- 比月亮更大、更亮
|
||||
- 可选添加柔和 halo
|
||||
|
||||
3. 月亮显示
|
||||
|
||||
- 一个较小 sprite 或 sphere
|
||||
- 灰白偏冷色
|
||||
- 后续 Phase 3 再做月相
|
||||
|
||||
4. 更新频率
|
||||
|
||||
不要每帧重新做完整天文计算,建议:
|
||||
|
||||
- 每 30 秒或 60 秒重算一次真实位置
|
||||
- 渲染帧内做平滑过渡
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 页面可见太阳与月亮两个对象
|
||||
- 时间变化时位置会更新
|
||||
- 日月不会跟随地球局部旋转而错误附着
|
||||
|
||||
## Phase 3:太阳驱动地球受光
|
||||
|
||||
### 目标
|
||||
|
||||
让地球光照方向与太阳方向一致,不再使用写死的固定主光。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 替换或接管当前主定向光
|
||||
|
||||
当前 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 中 `addLights()` 里用了固定方向的 `DirectionalLight`。
|
||||
|
||||
建议改为:
|
||||
|
||||
- 保留环境补光
|
||||
- 主太阳光方向由 `sunDirection` 决定
|
||||
|
||||
2. 太阳光参数建议
|
||||
|
||||
- `DirectionalLight` 颜色偏暖白
|
||||
- 强度略高于当前主光
|
||||
- 保留一个弱背光作为氛围补偿,避免背面过死黑
|
||||
|
||||
3. 先不做物理级大气散射
|
||||
|
||||
第一版只要求:
|
||||
|
||||
- 亮面与暗面方向真实
|
||||
- 云层和大气仍保持当前风格
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 地球明暗面会随太阳方向改变
|
||||
- 太阳 sprite 和地球亮面方向一致
|
||||
- 不破坏现有海缆、卫星、BGP 的可见性
|
||||
|
||||
## Phase 4:月相与天文细节增强
|
||||
|
||||
### 目标
|
||||
|
||||
在日月真实位置基础上增加更强的“天文可信度”。
|
||||
|
||||
### 可选项
|
||||
|
||||
1. 月相
|
||||
|
||||
- 根据日月夹角计算 illuminated fraction
|
||||
- 用月相纹理或 shader 表达盈亏
|
||||
|
||||
2. 赤道/黄道辅助线
|
||||
|
||||
- 可作为开发调试层,不默认显示
|
||||
|
||||
3. 太阳 terminator 增强
|
||||
|
||||
- 给地球夜面加入更自然的 night tint
|
||||
- 未来可叠加城市夜光纹理
|
||||
|
||||
4. 天文时间入口
|
||||
|
||||
- 设置中加入“当前时刻 / 指定时刻 / 加速时间”模式
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 月亮不再只是一个静态圆点
|
||||
- 后续扩展行星或观测模式时无需推倒重来
|
||||
|
||||
## Phase 5:严格参考系升级(可选,不作为 V1 必做)
|
||||
|
||||
### 目标
|
||||
|
||||
把 Earth 从“用户旋转球体”升级为“真实地球姿态 + 用户观察姿态”的双层模型。
|
||||
|
||||
### 需要处理的问题
|
||||
|
||||
- 地球自转角与 UTC 的一致性
|
||||
- 赤道坐标系、地固坐标系、相机交互层分离
|
||||
- 卫星轨道显示与 Earth 旋转同步关系
|
||||
- resetView 和 autoRotate 的语义重定
|
||||
|
||||
### 风险
|
||||
|
||||
这一步会影响:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
- [frontend/public/earth/js/cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
||||
- [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
|
||||
因此不建议与 V1 同时推进。
|
||||
|
||||
## 代码改造清单
|
||||
|
||||
## 1. 新增文件
|
||||
|
||||
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 管理天球背景、太阳、月亮
|
||||
- 对外暴露 init/update/dispose
|
||||
|
||||
## 2. 修改 `constants.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
|
||||
新增:
|
||||
|
||||
```js
|
||||
export const CELESTIAL_CONFIG = {
|
||||
sphereRadius: 800,
|
||||
updateIntervalMs: 60000,
|
||||
sunSpriteScale: 28,
|
||||
moonSpriteScale: 16,
|
||||
sunLightIntensity: 1.25,
|
||||
ambientIntensity: 0.28,
|
||||
backLightIntensity: 0.18,
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 修改 `main.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
|
||||
主要改动:
|
||||
|
||||
1. `init()` 中:
|
||||
- 初始化 celestial layer
|
||||
2. `addLights()` 中:
|
||||
- 把固定太阳光改成可更新的 celestial sun light
|
||||
3. `animate()` 中:
|
||||
- 每帧调 `updateCelestialLayer()`
|
||||
4. `destroy()` 中:
|
||||
- 清理 celestial 资源
|
||||
|
||||
## 4. 修改 `earth.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
|
||||
主要改动:
|
||||
|
||||
- `createStars()` 逐步退役
|
||||
- 第一阶段可先保留作为 fallback
|
||||
- 当真实星图加载成功后,不再显示随机星点
|
||||
|
||||
## 5. 新增资源
|
||||
|
||||
目录建议:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/`
|
||||
|
||||
建议至少包含:
|
||||
|
||||
- `starmap_equatorial_4k.jpg`
|
||||
- `sun_glow.png`
|
||||
- `moon_albedo_2k.jpg`
|
||||
|
||||
## 数据流设计
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["main.js:init()"] --> B["initCelestialLayer(scene)"]
|
||||
B --> C["创建天球球壳"]
|
||||
B --> D["创建太阳 sprite + 主定向光"]
|
||||
B --> E["创建月亮 sprite"]
|
||||
|
||||
F["animate()"] --> G["updateCelestialLayer(now, camera, earth)"]
|
||||
G --> H["Astronomy Engine 计算 Sun/Moon 方向"]
|
||||
H --> I["更新 sun sprite / moon sprite 位置"]
|
||||
H --> J["更新太阳 DirectionalLight 方向"]
|
||||
J --> K["地球昼夜方向变化"]
|
||||
```
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
### 1. 星图投影方向容易反
|
||||
|
||||
这会表现为:
|
||||
|
||||
- 星图左右镜像
|
||||
- 赤经方向颠倒
|
||||
- 日月位置和背景对不上
|
||||
|
||||
建议:
|
||||
|
||||
- 先做一个开发调试模式
|
||||
- 显示赤经/赤纬参考点,快速校正纹理朝向
|
||||
|
||||
### 2. 不要让天球跟随 Earth 旋转
|
||||
|
||||
天球背景和日月必须属于 scene/world,而不是 `earthObj`。
|
||||
|
||||
### 3. 不要每帧做重型天文计算
|
||||
|
||||
真实位置更新应节流,否则会浪费 CPU。
|
||||
|
||||
### 4. 月亮先求“方向正确”,再求“月相精致”
|
||||
|
||||
月相属于第二步优化,不应阻塞 V1 上线。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 新建 `celestial.js`
|
||||
2. 用星图球壳替换随机星点
|
||||
3. 接入 Astronomy Engine
|
||||
4. 加太阳/月亮 sprite
|
||||
5. 用太阳方向驱动主光
|
||||
6. 再决定要不要做月相和更严格参考系
|
||||
|
||||
## 最终建议
|
||||
|
||||
对于当前 Planet Earth,最稳妥的方案是:
|
||||
|
||||
- 先做真实天球背景
|
||||
- 再做真实太阳/月亮方向
|
||||
- 再让太阳驱动地球受光
|
||||
- 暂时不做 Earth 参考系重构
|
||||
|
||||
这样可以在不破坏现有 Earth 交互和图层系统的前提下,显著提升空间感、真实感和演示说服力。
|
||||
97
docs/earth/news-live-streams-collector-format.md
Normal file
97
docs/earth/news-live-streams-collector-format.md
Normal 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 中文国际`
|
||||
216
docs/earth/prefix-geography-plan.md
Normal file
216
docs/earth/prefix-geography-plan.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Prefix Geography Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Make Earth BGP incidents `prefix-centric` instead of `collector-centric`.
|
||||
|
||||
The map should primarily answer:
|
||||
|
||||
- where a prefix-related event is likely centered
|
||||
- which regions the prefix is likely associated with
|
||||
- which collectors observed the event as evidence
|
||||
|
||||
It should not continue to imply that the event is located at the collector itself unless no better geography is available.
|
||||
|
||||
## Why Current Geography Is Not Enough
|
||||
|
||||
Current incident geography can still collapse back to collector-derived regions because:
|
||||
|
||||
1. `prefix_scope` is currently built mostly from observed collector regions and historical observation regions.
|
||||
2. `origin_asn_profile` currently comes from `peeringdb_network`, which is useful for ASN footprint hints but not sufficient as a primary prefix location source.
|
||||
3. `collector centroid` is still a common fallback and therefore dominates sparse incidents.
|
||||
|
||||
This makes Earth feel like a collector map with event decorations instead of a prefix impact map.
|
||||
|
||||
## Data Source Layers
|
||||
|
||||
Prefix geography should be built from four layers, ordered by confidence.
|
||||
|
||||
### Layer 1. Prefix-to-country / prefix-to-region
|
||||
|
||||
This is the primary source layer and the current missing piece.
|
||||
|
||||
Recommended sources:
|
||||
|
||||
1. `IPtoASN / IPtoCountry`
|
||||
- URL: <https://iptoasn.com/>
|
||||
- Good fit for this project because it provides downloadable IPv4/IPv6 range-to-ASN and range-to-country mappings.
|
||||
- Best use:
|
||||
- map a prefix to country code
|
||||
- enrich prefixes with coarse regional placement
|
||||
|
||||
2. `OpenGeoFeed`
|
||||
- URL: <https://opengeofeed.org/faq/>
|
||||
- Best use:
|
||||
- override coarse country mappings when the prefix holder publishes a geofeed
|
||||
- provide a more realistic deployment/service region than whois-style registration country
|
||||
|
||||
### Layer 2. Registry allocation fallback
|
||||
|
||||
Use these only as fallback signals, not as a ground-truth physical location.
|
||||
|
||||
Candidate inputs:
|
||||
|
||||
- RIR delegated stats
|
||||
- `inetnum` / `inet6num` whois
|
||||
|
||||
Best use:
|
||||
|
||||
- detect registration country / allocation region
|
||||
- provide fallback when no direct prefix geolocation dataset is available
|
||||
|
||||
### Layer 3. ASN footprint hints
|
||||
|
||||
Existing in this project:
|
||||
|
||||
- `peeringdb_network`
|
||||
- `peeringdb_facility`
|
||||
- `peeringdb_ixp`
|
||||
|
||||
Best use:
|
||||
|
||||
- derive ASN city/country footprint
|
||||
- identify likely exchange/facility regions
|
||||
- act as secondary evidence when prefix-specific geography is unavailable
|
||||
|
||||
### Layer 4. Observation evidence
|
||||
|
||||
Existing in this project:
|
||||
|
||||
- `RIPE RIS Live`
|
||||
- `CAIDA BGPStream Backfill`
|
||||
|
||||
Best use:
|
||||
|
||||
- prove who observed the event
|
||||
- derive affected observation regions
|
||||
- support impact evidence
|
||||
|
||||
This should remain the final fallback and evidence layer, not the primary event geography.
|
||||
|
||||
## Recommended Geography Priority
|
||||
|
||||
The backend should compute incident geography with this order:
|
||||
|
||||
1. `prefix_geography`
|
||||
- prefix-to-country / region / geofeed-backed result
|
||||
2. `asn_region`
|
||||
- ASN organization / facility / IXP footprint
|
||||
3. `collector_centroid`
|
||||
- observed collector regions only as final fallback
|
||||
|
||||
Returned GeoJSON should keep exposing the selected mode through:
|
||||
|
||||
- `geography_mode = prefix_geography | asn_region | collector_centroid`
|
||||
|
||||
## Proposed Backend Changes
|
||||
|
||||
### 1. Add a dedicated prefix geography dataset
|
||||
|
||||
New datasource candidates:
|
||||
|
||||
- `ip2asn_prefix_geo`
|
||||
- optionally `opengeofeed_prefix_geo`
|
||||
|
||||
Suggested storage model:
|
||||
|
||||
- keep downloaded rows in `CollectedData` first for speed of integration
|
||||
- later move to a dedicated table if lookup volume grows
|
||||
|
||||
Minimum normalized fields:
|
||||
|
||||
- `range_start`
|
||||
- `range_end`
|
||||
- `prefix`
|
||||
- `country`
|
||||
- `continent`
|
||||
- `asn`
|
||||
- `as_name`
|
||||
- `source`
|
||||
- `confidence`
|
||||
|
||||
### 2. Add prefix geography enrichment
|
||||
|
||||
Extend:
|
||||
|
||||
- `backend/app/services/bgp_enrichment.py`
|
||||
|
||||
New enrichment payload should include:
|
||||
|
||||
- `prefix_geography`
|
||||
- `country`
|
||||
- `continent`
|
||||
- `regions`
|
||||
- `source`
|
||||
- `confidence`
|
||||
|
||||
This should be separate from the current `prefix_scope`.
|
||||
|
||||
Suggested distinction:
|
||||
|
||||
- `prefix_scope`
|
||||
- observation-derived scope hint
|
||||
- `prefix_geography`
|
||||
- prefix-centric geography estimate
|
||||
|
||||
### 3. Update incident visualization geography selection
|
||||
|
||||
Extend:
|
||||
|
||||
- `backend/app/api/v1/visualization.py`
|
||||
|
||||
Selection order:
|
||||
|
||||
1. `prefix_geography.regions`
|
||||
2. ASN geography hints from PeeringDB-derived profile
|
||||
3. observation-derived `affected_regions`
|
||||
|
||||
### 4. Keep evidence visible in the frontend
|
||||
|
||||
Earth should distinguish:
|
||||
|
||||
- event center = prefix geography estimate
|
||||
- evidence lines / collectors = observation proof
|
||||
|
||||
This keeps the event meaningful for non-expert users without losing collector evidence.
|
||||
|
||||
## Earth UX Result
|
||||
|
||||
After this change, a user should see:
|
||||
|
||||
- an incident marker near the estimated affected prefix region
|
||||
- collectors as supporting evidence, not as the event center itself
|
||||
- cables / landing points / nearby infrastructure as weak correlation around the estimated region
|
||||
|
||||
This makes BGP incidents readable as “where the event is likely happening or affecting”, instead of “which station saw it”.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1
|
||||
|
||||
1. Add `IPtoASN / IPtoCountry` datasource support
|
||||
2. Normalize rows into lookup-friendly format
|
||||
3. Enrich BGP events with `prefix_geography`
|
||||
4. Switch incident geography priority to prefer `prefix_geography`
|
||||
|
||||
### Phase 2
|
||||
|
||||
5. Add `OpenGeoFeed` support
|
||||
6. Let geofeed override coarse country-level prefix geography
|
||||
7. Add confidence scoring per geography source
|
||||
|
||||
### Phase 3
|
||||
|
||||
8. Add RIR / whois fallback
|
||||
9. Add better ASN regional footprint from PeeringDB facilities / IXPs
|
||||
10. Refine Earth visual semantics for prefix geography vs observation evidence
|
||||
|
||||
## Recommendation
|
||||
|
||||
The best next engineering move is:
|
||||
|
||||
1. integrate `IPtoASN / IPtoCountry`
|
||||
2. model `prefix_geography` separately from `prefix_scope`
|
||||
3. only then continue refining incident map placement
|
||||
|
||||
Without this layer, any further Earth tuning will still be constrained by collector-centric data.
|
||||
361
docs/frontend/ai-playground-development-plan.md
Normal file
361
docs/frontend/ai-playground-development-plan.md
Normal 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/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/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/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/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. 在输出结构中单独增加:
|
||||
- 区域态势
|
||||
- 证据来源
|
||||
- 观测偏差说明
|
||||
- 缺失区域证据
|
||||
309
docs/frontend/frontend-layout-guidelines.md
Normal file
309
docs/frontend/frontend-layout-guidelines.md
Normal 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 时是否仍可操作
|
||||
- 非表格 tab(Markdown、帮助说明、日志)是否有独立且合理的滚动策略
|
||||
|
||||
## 落地顺序
|
||||
|
||||
后续新增或重构后台页时,优先按这个顺序设计:
|
||||
|
||||
1. 先定义主工作区
|
||||
2. 再确定哪些模块必须常驻可见
|
||||
3. 最后再做样式和视觉层次
|
||||
|
||||
简单说:
|
||||
|
||||
- 先保证空间分配正确
|
||||
- 再处理滚动边界
|
||||
- 最后再做美化
|
||||
165
docs/hud-panel-component-plan.md
Normal file
165
docs/hud-panel-component-plan.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# HUD Panel Component Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Unify Earth HUD panels into a reusable component layer so new panels can share:
|
||||
|
||||
- a consistent shell
|
||||
- a consistent header
|
||||
- a consistent action-button system
|
||||
- a consistent body and collapse pattern
|
||||
|
||||
## Scope
|
||||
|
||||
Target panels:
|
||||
|
||||
- `tv-panel`
|
||||
- `news-panel`
|
||||
- `legend`
|
||||
- `layer-panel`
|
||||
- `earth-stats`
|
||||
- `info-card`
|
||||
- settings modal header/actions
|
||||
|
||||
## Component Model
|
||||
|
||||
### Base shell
|
||||
|
||||
- `.hud-panel`
|
||||
- `.hud-panel--compact`
|
||||
- `.hud-panel--media`
|
||||
- `.hud-panel--collapsed`
|
||||
- `.hud-panel-hidden`
|
||||
- `.hud-panel.is-dragging`
|
||||
- `.hud-panel.is-layout-animating`
|
||||
|
||||
### Header
|
||||
|
||||
- `.hud-panel__header`
|
||||
- `.hud-panel__title-group`
|
||||
- `.hud-panel__title`
|
||||
- `.hud-panel__subtitle`
|
||||
- `.hud-panel__chip`
|
||||
- `.hud-panel__actions`
|
||||
|
||||
Header baseline rule:
|
||||
|
||||
- Header title styling is fixed by the component layer and should not drift per panel
|
||||
- Title font size, font weight, letter spacing, line height, text color, and vertical alignment come from the shared header tokens and structure
|
||||
- Header divider, border treatment, inner spacing, and title-to-actions alignment are part of the same shared baseline
|
||||
- Panel-specific header differences should be limited to explicit variants such as `compact` or `media`, or token overrides with documented intent
|
||||
- “Looks close enough” local header overrides should be treated as temporary compatibility code and removed during migration
|
||||
|
||||
### Actions
|
||||
|
||||
- `.hud-panel__action`
|
||||
- `.hud-panel__action--icon`
|
||||
- `.hud-panel__action--collapse`
|
||||
- `.hud-panel__action--close`
|
||||
- `.hud-panel__action--refresh`
|
||||
- `.hud-panel__action--external`
|
||||
|
||||
Action-button baseline rule:
|
||||
|
||||
- Header action buttons must have one fixed default style baseline across all HUD panels
|
||||
- Default width behavior, padding, icon size, radius, alignment, hover, and active feedback all come from `.hud-panel__action`
|
||||
- Panel-specific differences must be expressed through explicit variants or token overrides, not ad-hoc local button rewrites
|
||||
- `close` buttons are part of the same default action system and must not silently fall back to a separate legacy box model
|
||||
|
||||
### Body
|
||||
|
||||
- `.hud-panel__body`
|
||||
- `.hud-panel__body--scroll`
|
||||
- `.hud-panel__body--collapsible`
|
||||
|
||||
### Collapse behavior
|
||||
|
||||
- `.hud-panel--collapsed`
|
||||
- `.hud-panel--expand-up`
|
||||
- `.hud-panel--expand-down`
|
||||
|
||||
Adaptive collapse / expand rule:
|
||||
|
||||
- HUD panels support two expansion directions:
|
||||
- top-to-bottom expansion
|
||||
- bottom-to-top expansion
|
||||
- Expansion direction should be decided at runtime from available viewport space rather than hardcoded per panel
|
||||
- Use:
|
||||
- `d` = available distance from the header anchor to the viewport bottom edge
|
||||
- `h` = expected expanded panel height
|
||||
- buffer = `20px`
|
||||
- Collapsed-state direction rule:
|
||||
- if `d > h + 20px`, the next action direction is `expand-up`
|
||||
- if `d <= h + 20px`, the next action direction is `expand-down`
|
||||
- To avoid jitter around the threshold, the shared controller should keep a small hysteresis band:
|
||||
- if the current direction is already `up`, keep it until `d <= h`
|
||||
- if the current direction is already `down`, keep it until `d > h + 20px`
|
||||
- The opposite edge is still a safety guard:
|
||||
- if the chosen side cannot fit at all, fall back to the other side if it can fit
|
||||
- if neither side fully fits, choose the side with more space and let the body scroll
|
||||
- If neither direction fully fits, choose the direction with more available space and let the body scroll
|
||||
- Collapse icon direction must match the active expansion direction so the icon always describes the real open/close motion
|
||||
- The collapse icon describes the next action, not the current state
|
||||
- This mapping is fixed component behavior and must not drift per panel:
|
||||
- collapsed + expand-down => `expand_more`
|
||||
- expanded + expand-down => `expand_less`
|
||||
- collapsed + expand-up => `expand_less`
|
||||
- expanded + expand-up => `expand_more`
|
||||
- Panels must not combine icon-name swapping with extra CSS rotation for the same collapse control
|
||||
- Expansion direction and icon direction must come from one shared source of truth in the component controller
|
||||
- The direction decision should be recomputed when opening, resizing the viewport, or restoring a dragged panel near another edge
|
||||
|
||||
## Tokens
|
||||
|
||||
Promote panel differences into CSS variables instead of duplicating selectors:
|
||||
|
||||
- `--hud-panel-padding`
|
||||
- `--hud-header-padding`
|
||||
- `--hud-header-gap`
|
||||
- `--hud-action-padding`
|
||||
- `--hud-action-gap`
|
||||
- `--hud-action-icon-size`
|
||||
- `--hud-body-gap`
|
||||
- `--hud-body-max-height`
|
||||
- `--hud-chip-radius`
|
||||
- `--hud-title-font-size`
|
||||
- `--hud-title-font-weight`
|
||||
- `--hud-title-letter-spacing`
|
||||
- `--hud-title-line-height`
|
||||
- `--hud-title-color`
|
||||
- `--hud-header-border-color`
|
||||
- `--hud-header-divider-opacity`
|
||||
- `--hud-expand-direction`
|
||||
|
||||
## Migration Order
|
||||
|
||||
1. Build the shared component layer in `frontend/public/earth/css/hud.css`
|
||||
2. Migrate `tv-panel` and `news-panel` first as the reference implementation
|
||||
3. Migrate `legend` and `layer-panel` into a compact variant
|
||||
4. Migrate `earth-stats` and `info-card`
|
||||
5. Align settings modal header/actions with the same action system
|
||||
6. Remove legacy one-off button selectors after verification
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not change panel behavior and data flow during the first pass
|
||||
- Keep old class names temporarily as compatibility hooks
|
||||
- Prefer variable overrides over per-panel reimplementation
|
||||
- Treat header action-button default styling as fixed component API, not per-panel design space
|
||||
- Treat header title typography, border, and divider styling as fixed component API, not per-panel design space
|
||||
- Treat collapse direction as a component behavior contract, not a one-off panel trick
|
||||
- Treat collapse icon semantics as a component behavior contract, not a per-panel visual preference
|
||||
- Verify header alignment and drag/collapse behavior after each migration batch
|
||||
|
||||
## First Implementation Batch
|
||||
|
||||
Batch 1 should only do:
|
||||
|
||||
- shared header structure
|
||||
- shared action-button system
|
||||
- shared title typography and header border/divider baseline
|
||||
- shared collapsible body pattern
|
||||
- adaptive collapse direction logic and direction-aware collapse icons
|
||||
- migration of `tv-panel` and `news-panel`
|
||||
|
||||
That keeps risk low while giving the rest of the HUD a stable target to migrate toward.
|
||||
105
docs/ops/docker-compose-buildx-upgrade.md
Normal file
105
docs/ops/docker-compose-buildx-upgrade.md
Normal 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 .
|
||||
```
|
||||
981
docs/ue5/ue5_mvp_fused_plan.md
Normal file
981
docs/ue5/ue5_mvp_fused_plan.md
Normal file
@@ -0,0 +1,981 @@
|
||||
# 智能星球 UE5 客户端一期实施方案(融合版)
|
||||
|
||||
> 版本:v2.0
|
||||
> 日期:2026-04-14
|
||||
> 目标:把现有 Web Earth 项目,平滑推进到 **UE5 可用 MVP 客户端**
|
||||
> 适用对象:**UE 零基础新手**
|
||||
> 输出结果:一份 **能直接照着做** 的实施手册
|
||||
> 策略:**保留原 MVP 方案里适合入门的部分,吸收更稳的工程做法,降低你第一次做 UE 时踩坑概率**
|
||||
|
||||
---
|
||||
|
||||
## 一、这份融合版方案解决什么问题
|
||||
|
||||
你原来的 MVP 方案是靠谱的,优点很明显:
|
||||
|
||||
- 范围克制
|
||||
- 适合新手入门
|
||||
- 目标明确
|
||||
- 能较快做出“看得见、点得到”的成果
|
||||
|
||||
但它也有几个风险:
|
||||
|
||||
- 默认 `localhost` 一定通,这在 WSL2 + Windows + Docker 环境里不一定成立
|
||||
- 默认 UE 蓝图里直接做 HTTP + JSON 解析会很顺,这一步其实很容易卡
|
||||
- 默认“一上来就接真实后端”,新手会同时踩 UE、Cesium、网络、JSON、蓝图五个坑
|
||||
- 时间估计略乐观
|
||||
|
||||
所以这份融合版方案的核心思路是:
|
||||
|
||||
## 核心原则
|
||||
|
||||
**先做“本地数据可交互地球”,再做“真实后端对接”。**
|
||||
|
||||
也就是把一期再拆成两个更稳的里程碑:
|
||||
|
||||
### 里程碑 A:本地演示版
|
||||
先不接后端,只做:
|
||||
|
||||
- UE5 项目能打开
|
||||
- Cesium 地球能显示
|
||||
- 本地 JSON 里的点能正确落到地球
|
||||
- 点击点能弹信息卡
|
||||
- HUD 能正常显示假状态
|
||||
|
||||
### 里程碑 B:后端接入版
|
||||
在 A 的基础上再做:
|
||||
|
||||
- HTTP 拉取真实后端数据
|
||||
- 显示真实 TOP500 数据
|
||||
- 显示后端在线状态
|
||||
- 为后续扩展海缆/BGP/卫星打基础
|
||||
|
||||
这样做的好处是:
|
||||
|
||||
- 把问题拆开
|
||||
- 更容易调试
|
||||
- 更适合 UE 新手
|
||||
- 不会因为后端联调没通就把整个 UE 开发节奏打断
|
||||
|
||||
---
|
||||
|
||||
# 二、一期目标:做什么,不做什么
|
||||
|
||||
## 这次一期一定要做的
|
||||
|
||||
做一个 **可用的 UE5 客户端 MVP**,达到以下 6 项:
|
||||
|
||||
1. 能打开 UE 项目并看到 3D 地球
|
||||
2. 能在地球上显示超算数据点
|
||||
3. 能点击数据点弹出信息卡
|
||||
4. 能显示一个基础 HUD
|
||||
5. 能通过 HTTP 接入后端数据
|
||||
6. 能打包成 Windows 可执行程序
|
||||
|
||||
---
|
||||
|
||||
## 这次一期先不做的
|
||||
|
||||
这些全部放到后续阶段:
|
||||
|
||||
- 海缆路径渲染
|
||||
- 卫星轨迹与卫星图层
|
||||
- BGP 图层
|
||||
- WebSocket 实时更新
|
||||
- 粒子特效大升级
|
||||
- 自动巡航
|
||||
- 多屏/3D 偏振/大屏联动
|
||||
|
||||
一句话:
|
||||
|
||||
**一期不是“把 Web Earth 全搬到 UE”,而是“证明 UE 客户端链路能跑通”。**
|
||||
|
||||
---
|
||||
|
||||
# 三、UE 专有名词字典(零基础版)
|
||||
|
||||
这部分你最好先读一遍。后面所有步骤都围绕这些词。
|
||||
|
||||
## 1. Actor
|
||||
**Actor = 场景里的一个对象**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- 一个地球控制器
|
||||
- 一个超算点
|
||||
- 一台相机
|
||||
- 一条海缆
|
||||
|
||||
这些在 UE 里都可以是 Actor。
|
||||
|
||||
---
|
||||
|
||||
## 2. Component
|
||||
**Component = 挂在 Actor 身上的功能零件**
|
||||
|
||||
比如一个超算点 Actor,可能有:
|
||||
|
||||
- 一个球形外观
|
||||
- 一个碰撞盒
|
||||
- 一个标签
|
||||
- 一个发光效果
|
||||
|
||||
这些零件就是 Component。
|
||||
|
||||
一句话:
|
||||
|
||||
**Actor 是整台机器,Component 是机器上的零件。**
|
||||
|
||||
---
|
||||
|
||||
## 3. Blueprint(蓝图)
|
||||
**Blueprint = UE 的可视化编程系统**
|
||||
|
||||
你不用先写代码,而是把很多“逻辑节点”拖出来,用线连接起来。
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- 前端里的函数 + 事件监听
|
||||
- 只不过不是写文本代码,而是连线
|
||||
|
||||
---
|
||||
|
||||
## 4. Level / Map(关卡)
|
||||
**Level = 一个场景文件**
|
||||
|
||||
你可以把它理解成 Three.js 的一个 Scene。
|
||||
|
||||
本期只需要一个主场景:
|
||||
|
||||
- `Main`
|
||||
|
||||
---
|
||||
|
||||
## 5. Widget / UMG
|
||||
**Widget = UI 组件**
|
||||
**UMG = UE 的 UI 编辑系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 信息卡
|
||||
- 状态栏
|
||||
- 右上角连接状态
|
||||
- 图例
|
||||
- HUD 面板
|
||||
|
||||
这些都用 Widget 做。
|
||||
|
||||
---
|
||||
|
||||
## 6. Material(材质)
|
||||
**Material = 决定物体外观的系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 球体是什么颜色
|
||||
- 是否发光
|
||||
- 是否透明
|
||||
- 是否随性能大小变亮
|
||||
|
||||
这些都由材质控制。
|
||||
|
||||
---
|
||||
|
||||
## 7. Static Mesh
|
||||
**Static Mesh = 不会变形的 3D 模型**
|
||||
|
||||
比如:
|
||||
|
||||
- 球
|
||||
- 立方体
|
||||
- 平面
|
||||
- 某个固定模型
|
||||
|
||||
超算点一期里可以先直接用球体 Static Mesh。
|
||||
|
||||
---
|
||||
|
||||
## 8. Pawn
|
||||
**Pawn = 玩家控制的对象**
|
||||
|
||||
一期里你可以把它理解成:
|
||||
|
||||
- 带相机的飞行控制器
|
||||
|
||||
---
|
||||
|
||||
## 9. PlayerController
|
||||
**PlayerController = 处理输入的对象**
|
||||
|
||||
比如:
|
||||
|
||||
- 鼠标点击
|
||||
- 拖拽
|
||||
- 滚轮缩放
|
||||
|
||||
这些都由 PlayerController 或其相关逻辑来处理。
|
||||
|
||||
---
|
||||
|
||||
## 10. GameMode
|
||||
**GameMode = 游戏/场景的主规则配置入口**
|
||||
|
||||
它决定:
|
||||
|
||||
- 默认用哪个 Pawn
|
||||
- 默认用哪个 PlayerController
|
||||
|
||||
你可以把它理解成“主入口配置”。
|
||||
|
||||
---
|
||||
|
||||
## 11. Viewport
|
||||
**Viewport = 你看 3D 场景的窗口**
|
||||
|
||||
就是 UE 编辑器中间那块 3D 视图。
|
||||
|
||||
---
|
||||
|
||||
## 12. Outliner
|
||||
**Outliner = 当前场景对象列表**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- Scene 树
|
||||
- DOM 树
|
||||
- 资源树
|
||||
|
||||
---
|
||||
|
||||
## 13. Details Panel
|
||||
**Details Panel = 选中对象后的属性面板**
|
||||
|
||||
相当于“右侧属性编辑器”。
|
||||
|
||||
---
|
||||
|
||||
## 14. Cesium for Unreal
|
||||
**Cesium for Unreal = UE 里的地球插件**
|
||||
|
||||
它负责:
|
||||
|
||||
- 真实地球
|
||||
- 卫星影像
|
||||
- 地形
|
||||
- 经纬度坐标和 UE 世界坐标的转换
|
||||
|
||||
如果没有它,你得自己处理地球和坐标系统,会非常难。
|
||||
|
||||
---
|
||||
|
||||
## 15. Struct(结构体)
|
||||
**Struct = 数据结构定义**
|
||||
|
||||
你可以把它理解成 TypeScript 里的 `interface`。
|
||||
|
||||
比如:
|
||||
|
||||
```ts
|
||||
interface ComputePoint {
|
||||
id: string
|
||||
name: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
performance: number
|
||||
}
|
||||
```
|
||||
|
||||
在 UE 里这类东西叫 Struct。
|
||||
|
||||
---
|
||||
|
||||
## 16. Event Dispatcher
|
||||
**Event Dispatcher = 事件分发器**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- EventEmitter
|
||||
- 发布订阅
|
||||
|
||||
比如:
|
||||
|
||||
“数据加载完毕”这个事件,就可以分发给其他蓝图。
|
||||
|
||||
---
|
||||
|
||||
## 17. Spline
|
||||
**Spline = 一条平滑曲线**
|
||||
|
||||
后面做海缆、轨迹时非常有用。
|
||||
一期可以先知道这个词,不一定马上用。
|
||||
|
||||
---
|
||||
|
||||
## 18. Niagara
|
||||
**Niagara = UE 粒子特效系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 流光
|
||||
- 光晕
|
||||
- 拖尾
|
||||
- 火花
|
||||
|
||||
一期先不重点碰它。
|
||||
|
||||
---
|
||||
|
||||
# 四、你的真实开发策略:两阶段起步
|
||||
|
||||
这是这份融合版和原方案最大的区别。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 A:本地演示版(先脱离后端)
|
||||
|
||||
### 目标
|
||||
先把下面这些完全打通:
|
||||
|
||||
- UE 项目启动正常
|
||||
- Cesium 地球正常
|
||||
- 相机可操作
|
||||
- 本地 JSON 文件能生成地球标记点
|
||||
- 点击点能弹信息卡
|
||||
- HUD 能显示假数据
|
||||
|
||||
### 为什么一定要先做这个
|
||||
因为如果你一上来就接真实后端,你会同时碰到:
|
||||
|
||||
- WSL2 到 Windows 网络
|
||||
- Docker 端口映射
|
||||
- UE HTTP 请求
|
||||
- 蓝图 JSON 解析
|
||||
- Cesium 坐标转换
|
||||
- 标记点生成
|
||||
|
||||
新手很容易直接乱掉。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 B:后端接入版(再联调)
|
||||
|
||||
### 目标
|
||||
在 A 的基础上,加上:
|
||||
|
||||
- HTTP 拉真实后端数据
|
||||
- 显示真实 TOP500 点
|
||||
- 右上角显示后端在线状态
|
||||
- 为后续做更多图层留下数据接入层
|
||||
|
||||
---
|
||||
|
||||
# 五、环境准备
|
||||
|
||||
## 1. 你要安装的软件
|
||||
|
||||
### Epic Games Launcher
|
||||
用来下载和启动 UE。
|
||||
|
||||
### Unreal Engine 5.4
|
||||
建议直接用 5.4 稳定版。
|
||||
|
||||
### Visual Studio 2022
|
||||
虽然一期主要用 Blueprint,但 UE 的很多项目依赖 VS 环境。
|
||||
|
||||
安装组件:
|
||||
- Desktop development with C++
|
||||
- Game development with C++
|
||||
|
||||
### Git
|
||||
用来管理文档和后续工程。
|
||||
|
||||
### Cesium for Unreal
|
||||
用来做地球。
|
||||
|
||||
---
|
||||
|
||||
## 2. 你的环境约束
|
||||
|
||||
你现在是:
|
||||
|
||||
- 后端可能跑在 WSL2 / Docker
|
||||
- UE 必须跑在 Windows
|
||||
|
||||
所以你的真实运行方式通常会是:
|
||||
|
||||
- **Windows** 运行 UE5
|
||||
- **WSL2** 运行后端
|
||||
- 两者通过 HTTP 通信
|
||||
|
||||
这里最关键的一条是:
|
||||
|
||||
**不要默认 `localhost` 一定能通,必须先在 Windows 浏览器里验证。**
|
||||
|
||||
---
|
||||
|
||||
# 六、推荐的项目结构
|
||||
|
||||
## UE 项目目录内的 Content 结构
|
||||
|
||||
```text
|
||||
Content/
|
||||
Blueprints/
|
||||
Data/
|
||||
Widgets/
|
||||
Materials/
|
||||
Levels/
|
||||
FX/
|
||||
Textures/
|
||||
```
|
||||
|
||||
建议说明:
|
||||
|
||||
- `Blueprints/` 放逻辑蓝图
|
||||
- `Data/` 放本地 JSON、DataTable、Struct
|
||||
- `Widgets/` 放 UI
|
||||
- `Materials/` 放材质
|
||||
- `Levels/` 放场景
|
||||
- `FX/` 放特效
|
||||
- `Textures/` 放贴图
|
||||
|
||||
---
|
||||
|
||||
# 七、一期最小蓝图清单
|
||||
|
||||
一期只需要这几个核心蓝图。
|
||||
|
||||
## 1. `BP_GlobeCamera`
|
||||
作用:相机控制器
|
||||
|
||||
负责:
|
||||
- 鼠标拖拽旋转
|
||||
- 滚轮缩放
|
||||
- 初始视角控制
|
||||
|
||||
---
|
||||
|
||||
## 2. `BP_PlanetGameMode`
|
||||
作用:指定默认的 Pawn 等
|
||||
|
||||
---
|
||||
|
||||
## 3. `BP_DataLoader`
|
||||
作用:负责读数据
|
||||
|
||||
一期建议支持两种来源:
|
||||
|
||||
- 本地 JSON
|
||||
- HTTP 接口
|
||||
|
||||
这样调试更稳。
|
||||
|
||||
---
|
||||
|
||||
## 4. `BP_ComputePoint`
|
||||
作用:一个超算点的显示对象
|
||||
|
||||
负责:
|
||||
- 接收一条数据
|
||||
- 放到正确经纬度位置
|
||||
- 显示外观
|
||||
- 处理点击
|
||||
|
||||
---
|
||||
|
||||
## 5. `WBP_InfoCard`
|
||||
作用:点开后显示详情
|
||||
|
||||
显示:
|
||||
- 名称
|
||||
- 国家
|
||||
- 算力
|
||||
- 可选显示更多字段
|
||||
|
||||
---
|
||||
|
||||
## 6. `WBP_StatusBar`
|
||||
作用:右上角状态栏
|
||||
|
||||
显示:
|
||||
- 后端在线/离线
|
||||
- 当前加载条数
|
||||
- 当前模式(本地数据 / 真实后端)
|
||||
|
||||
---
|
||||
|
||||
# 八、数据层设计
|
||||
|
||||
一期不要一开始就完全照搬后端返回结构。
|
||||
你要先定义一个 UE 友好的结构。
|
||||
|
||||
## `S_ComputePoint`
|
||||
|
||||
字段建议:
|
||||
|
||||
- `PointId`:字符串,唯一 ID
|
||||
- `Name`:字符串
|
||||
- `Latitude`:浮点
|
||||
- `Longitude`:浮点
|
||||
- `Performance`:浮点
|
||||
- `CoreCount`:整数
|
||||
- `Country`:字符串
|
||||
- `Source`:字符串
|
||||
|
||||
这个结构同时适用于:
|
||||
|
||||
- 本地 JSON
|
||||
- 后端 API 返回结果转换后的对象
|
||||
|
||||
---
|
||||
|
||||
# 九、最稳的执行路线
|
||||
|
||||
下面是整个实施计划最重要的部分。
|
||||
|
||||
---
|
||||
|
||||
# Phase 0:安装和验证环境
|
||||
|
||||
## 目标
|
||||
确保你能:
|
||||
|
||||
- 安装 UE5.4
|
||||
- 启用 Cesium
|
||||
- 能打开一个空项目
|
||||
- 能在 Windows 浏览器访问你的后端
|
||||
|
||||
## 验收
|
||||
满足以下 4 条:
|
||||
|
||||
- UE 能打开
|
||||
- Cesium 能启用
|
||||
- 项目能创建
|
||||
- Windows 浏览器能访问后端 summary 接口
|
||||
|
||||
如果第 4 条做不到,不要继续推进真实接口联调。
|
||||
|
||||
---
|
||||
|
||||
# Phase 1:创建项目并把地球显示出来
|
||||
|
||||
## 目标
|
||||
打开项目后,能看到一个真实地球。
|
||||
|
||||
## 操作顺序
|
||||
|
||||
1. 新建 UE5 Blank Blueprint 项目
|
||||
2. 创建 `Main` 场景
|
||||
3. 启用 Cesium
|
||||
4. 添加:
|
||||
- `Cesium World Terrain`
|
||||
- `Cesium Sun Sky`
|
||||
- `CesiumGeoreference`
|
||||
5. 调整视角,让你能看到整个地球
|
||||
|
||||
## 验收
|
||||
能录一段短视频,里面能看到地球和镜头移动。
|
||||
|
||||
---
|
||||
|
||||
# Phase 2:做相机控制
|
||||
|
||||
## 目标
|
||||
让地球可以:
|
||||
|
||||
- 鼠标拖拽旋转
|
||||
- 滚轮缩放
|
||||
|
||||
## 说明
|
||||
这里可以沿用原 MVP 方案的思路:
|
||||
|
||||
- `BP_GlobeCamera` 作为 Pawn
|
||||
- Spring Arm + Camera 组成相机结构
|
||||
- 用输入控制旋转和缩放
|
||||
|
||||
## 注意
|
||||
这一版相机只是“一期可用版”,不是最终镜头系统。
|
||||
|
||||
## 验收
|
||||
按 Play 后:
|
||||
|
||||
- 地球可旋转
|
||||
- 可缩放
|
||||
- 不会直接飞走或抖动失控
|
||||
|
||||
---
|
||||
|
||||
# Phase 3:先喂本地 JSON 数据
|
||||
|
||||
这是融合版方案里最关键的改动。
|
||||
|
||||
## 目标
|
||||
不接后端,先验证:
|
||||
|
||||
- 数据结构正常
|
||||
- JSON 能读
|
||||
- 点能生成
|
||||
- 点击交互正常
|
||||
|
||||
## 为什么先这么做
|
||||
因为这样可以把问题收缩成 3 件事:
|
||||
|
||||
- Cesium 坐标转换
|
||||
- 点渲染
|
||||
- UI 弹窗
|
||||
|
||||
不牵涉后端联调。
|
||||
|
||||
## 本地 JSON 示例格式
|
||||
|
||||
建议放在 `Content/Data/compute_points.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PointId": "top500_1",
|
||||
"Name": "Frontier",
|
||||
"Latitude": 35.93,
|
||||
"Longitude": -84.31,
|
||||
"Performance": 1194.0,
|
||||
"CoreCount": 8730624,
|
||||
"Country": "US",
|
||||
"Source": "top500"
|
||||
},
|
||||
{
|
||||
"PointId": "top500_2",
|
||||
"Name": "Fugaku",
|
||||
"Latitude": 34.69,
|
||||
"Longitude": 135.19,
|
||||
"Performance": 442.0,
|
||||
"CoreCount": 7630848,
|
||||
"Country": "JP",
|
||||
"Source": "top500"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 推荐做法
|
||||
先做一个“本地模式”开关。
|
||||
|
||||
在 `BP_DataLoader` 里支持:
|
||||
|
||||
- Mode = LocalJson
|
||||
- Mode = HttpApi
|
||||
|
||||
先永远跑 `LocalJson`。
|
||||
|
||||
## 验收
|
||||
你应该能看到:
|
||||
|
||||
- 多个点出现在地球上
|
||||
- 大致位置正确
|
||||
- 点击能弹信息卡
|
||||
|
||||
---
|
||||
|
||||
# Phase 4:做超算点蓝图
|
||||
|
||||
## 目标
|
||||
完成 `BP_ComputePoint`
|
||||
|
||||
每个点要实现:
|
||||
|
||||
- 接收一条 `S_ComputePoint`
|
||||
- 经度纬度转成 UE 世界坐标
|
||||
- 在地球上显示为一个可见的发光球
|
||||
- 支持被点击
|
||||
|
||||
## 显示建议
|
||||
|
||||
### 外观
|
||||
先用最简单的球体 Static Mesh。
|
||||
|
||||
### 材质
|
||||
做一个发光材质:
|
||||
|
||||
- 红橙色
|
||||
- 自发光
|
||||
- 不追求复杂效果
|
||||
|
||||
### 大小
|
||||
球体要足够大,确保在地球尺度下看得见。
|
||||
|
||||
### 高度
|
||||
不要贴地表太近,建议悬浮在地表上方一个固定高度。
|
||||
|
||||
## 验收
|
||||
同一批数据点在地球上的位置大体合理。
|
||||
|
||||
---
|
||||
|
||||
# Phase 5:做信息卡
|
||||
|
||||
## 目标
|
||||
点击一个点后,弹出一个简单的信息卡。
|
||||
|
||||
## `WBP_InfoCard` 要显示的内容
|
||||
建议只显示最关键的 3 个字段:
|
||||
|
||||
- 名称
|
||||
- 国家
|
||||
- 算力
|
||||
|
||||
一期先不要堆太多字段。
|
||||
|
||||
## 验收
|
||||
点击点 → 卡片出现
|
||||
点击关闭 → 卡片消失
|
||||
|
||||
---
|
||||
|
||||
# Phase 6:做基础 HUD
|
||||
|
||||
## 目标
|
||||
屏幕上始终有一个简单状态栏。
|
||||
|
||||
## `WBP_StatusBar` 显示内容建议
|
||||
- 当前模式:Local / HTTP
|
||||
- 已加载数据点数量
|
||||
- 后端状态:Unknown / Online / Offline
|
||||
|
||||
在本地模式阶段,状态可以先写死或显示 `Local Demo`。
|
||||
|
||||
## 验收
|
||||
不点击任何点时,屏幕右上角也有“系统正在工作”的感觉。
|
||||
|
||||
---
|
||||
|
||||
# Phase 7:再接真实后端
|
||||
|
||||
这是第二阶段开始。
|
||||
|
||||
## 目标
|
||||
把数据源从本地 JSON 切到 HTTP。
|
||||
|
||||
## 正确做法
|
||||
不要把 `BP_DataLoader` 重写。
|
||||
而是让它支持:
|
||||
|
||||
- LocalJsonLoader
|
||||
- HttpLoader
|
||||
|
||||
也就是:
|
||||
|
||||
**显示层不变,只替换数据来源。**
|
||||
|
||||
## 最重要的接口原则
|
||||
如果后端已有接口字段非常杂,不一定要 UE 直接吃。
|
||||
可以加一个“更适合 UE 的轻量接口”。
|
||||
|
||||
例如:
|
||||
|
||||
`/api/v1/ue/bootstrap/top500`
|
||||
|
||||
返回尽量扁平的数据:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PointId": "top500_1",
|
||||
"Name": "Frontier",
|
||||
"Latitude": 35.93,
|
||||
"Longitude": -84.31,
|
||||
"Performance": 1194.0,
|
||||
"CoreCount": 8730624,
|
||||
"Country": "US",
|
||||
"Source": "top500"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 为什么推荐 UE 轻量接口
|
||||
因为 UE 不适合像前端 React 那样,层层解包一大堆复杂 JSON。
|
||||
|
||||
---
|
||||
|
||||
# Phase 8:做连接状态检测
|
||||
|
||||
## 目标
|
||||
让 HUD 能显示:
|
||||
|
||||
- 在线
|
||||
- 离线
|
||||
- 本地模式
|
||||
|
||||
## 正确实现思路
|
||||
建议用一个很小的状态请求,比如:
|
||||
|
||||
- summary 接口
|
||||
- health 接口
|
||||
- 或 UE 专用 ping 接口
|
||||
|
||||
不要让状态检测去依赖一个超大的数据接口。
|
||||
|
||||
## 验收
|
||||
后端关掉时,状态栏能明显变成 Offline。
|
||||
|
||||
---
|
||||
|
||||
# Phase 9:打包发布
|
||||
|
||||
## 目标
|
||||
把项目打包成 Windows 可执行程序。
|
||||
|
||||
## 注意
|
||||
打包是一期必须尝试的,但不要让它阻塞前面所有开发。
|
||||
|
||||
也就是说:
|
||||
|
||||
- 编辑器里没稳定跑通前,不要反复纠结打包
|
||||
- 等 LocalJson 版和 HTTP 版都能在编辑器 Play 模式稳定运行后,再打包
|
||||
|
||||
## 验收
|
||||
双击 exe 可以运行,进入地球场景并正常展示数据。
|
||||
|
||||
---
|
||||
|
||||
# 十、建议的 14 天执行计划
|
||||
|
||||
这版比原 MVP 的时间估计更保守,也更适合新手。
|
||||
|
||||
## 第 1 天
|
||||
- 安装 UE5.4
|
||||
- 安装 Cesium
|
||||
- 创建空项目
|
||||
- 创建 Main 场景
|
||||
|
||||
## 第 2 天
|
||||
- 启用 Cesium
|
||||
- 把地球跑起来
|
||||
- 保存项目结构
|
||||
|
||||
## 第 3 天
|
||||
- 做 `BP_GlobeCamera`
|
||||
- 跑通旋转和缩放
|
||||
|
||||
## 第 4 天
|
||||
- 建 `S_ComputePoint`
|
||||
- 准备本地 JSON 文件
|
||||
- 做 `BP_DataLoader` 的本地模式
|
||||
|
||||
## 第 5 天
|
||||
- 做 `BP_ComputePoint`
|
||||
- 本地 JSON 批量生成点
|
||||
|
||||
## 第 6 天
|
||||
- 调整点大小、颜色、高度
|
||||
- 检查经纬度位置是否大致正确
|
||||
|
||||
## 第 7 天
|
||||
- 做 `WBP_InfoCard`
|
||||
- 跑通点击点弹卡片
|
||||
|
||||
## 第 8 天
|
||||
- 做 `WBP_StatusBar`
|
||||
- 显示本地模式状态和点数量
|
||||
|
||||
## 第 9 天
|
||||
- Windows 浏览器验证后端接口
|
||||
- 准备 HTTP 版加载逻辑
|
||||
|
||||
## 第 10 天
|
||||
- 实现 HTTP 拉真实数据
|
||||
- 先在日志里确认数据到了
|
||||
|
||||
## 第 11 天
|
||||
- 把 HTTP 数据接到点渲染
|
||||
- 切换 Local / HTTP 两种模式
|
||||
|
||||
## 第 12 天
|
||||
- 做连接状态 Online / Offline
|
||||
- 补错误提示
|
||||
|
||||
## 第 13 天
|
||||
- 测试完整链路
|
||||
- 修点选、缩放、HUD 细节
|
||||
|
||||
## 第 14 天
|
||||
- 进行第一次打包
|
||||
- 在 Windows 下运行 exe 验证
|
||||
|
||||
---
|
||||
|
||||
# 十一、这份方案和原 MVP 方案怎么融合
|
||||
|
||||
下面是合并关系。
|
||||
|
||||
## 保留原 MVP 方案的部分
|
||||
这些内容很好,建议继续用:
|
||||
|
||||
- 术语表
|
||||
- Phase 结构化写法
|
||||
- `BP_GlobeCamera`
|
||||
- `BP_ComputePoint`
|
||||
- `WBP_InfoCard`
|
||||
- `WBP_StatusBar`
|
||||
- 相机、点、信息卡、状态栏这 4 个核心对象
|
||||
- “先别做海缆、卫星、BGP”的范围控制
|
||||
|
||||
## 用融合版修正的部分
|
||||
这些是这份新文档加进去的:
|
||||
|
||||
- 两阶段起步:先本地 JSON,再真实后端
|
||||
- 不默认 `localhost` 一定通
|
||||
- 推荐做 UE 轻量接口,而不是死扛原始接口
|
||||
- 把打包放到后段,而不是过早纠结
|
||||
- 时间预估更保守
|
||||
- 明确“一期只是证明链路跑通”
|
||||
|
||||
---
|
||||
|
||||
# 十二、验收清单
|
||||
|
||||
## 环境
|
||||
- [ ] UE5.4 安装成功
|
||||
- [ ] Cesium 插件启用成功
|
||||
- [ ] Windows 能访问后端接口
|
||||
|
||||
## 本地演示版
|
||||
- [ ] 地球渲染正常
|
||||
- [ ] 鼠标可旋转和缩放
|
||||
- [ ] 本地 JSON 数据能生成点
|
||||
- [ ] 点的位置大体正确
|
||||
- [ ] 点击点能弹信息卡
|
||||
- [ ] HUD 可显示本地模式和点数量
|
||||
|
||||
## 后端接入版
|
||||
- [ ] HTTP 能拉取真实数据
|
||||
- [ ] HTTP 数据能生成点
|
||||
- [ ] HUD 能显示 Online/Offline
|
||||
- [ ] 切换 Local / HTTP 模式不崩
|
||||
- [ ] exe 能打包并运行
|
||||
|
||||
---
|
||||
|
||||
# 十三、后续路线(MVP 之后)
|
||||
|
||||
当这一期做完后,下一步顺序建议是:
|
||||
|
||||
1. 海缆路径
|
||||
2. 卫星点或轨迹
|
||||
3. 更稳的相机与巡航
|
||||
4. WebSocket 增量更新
|
||||
5. BGP 区域态势
|
||||
6. BGP 事件点
|
||||
7. 更强的粒子和视觉风格
|
||||
|
||||
也就是说:
|
||||
|
||||
**先补“静态层和镜头层”,再补“高频实时层”。**
|
||||
|
||||
---
|
||||
|
||||
# 十四、一句话总结
|
||||
|
||||
这份融合版方案的核心就是:
|
||||
|
||||
**保留原 MVP 的入门友好度,但改成“先本地 JSON、再真实后端”的两阶段实施路线,让你第一次做 UE 时更稳、更容易成功。**
|
||||
|
||||
如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是:
|
||||
|
||||
**在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。**
|
||||
981
docs/ue5_mvp_fused_plan.md
Normal file
981
docs/ue5_mvp_fused_plan.md
Normal file
@@ -0,0 +1,981 @@
|
||||
# 智能星球 UE5 客户端一期实施方案(融合版)
|
||||
|
||||
> 版本:v2.0
|
||||
> 日期:2026-04-14
|
||||
> 目标:把现有 Web Earth 项目,平滑推进到 **UE5 可用 MVP 客户端**
|
||||
> 适用对象:**UE 零基础新手**
|
||||
> 输出结果:一份 **能直接照着做** 的实施手册
|
||||
> 策略:**保留原 MVP 方案里适合入门的部分,吸收更稳的工程做法,降低你第一次做 UE 时踩坑概率**
|
||||
|
||||
---
|
||||
|
||||
## 一、这份融合版方案解决什么问题
|
||||
|
||||
你原来的 MVP 方案是靠谱的,优点很明显:
|
||||
|
||||
- 范围克制
|
||||
- 适合新手入门
|
||||
- 目标明确
|
||||
- 能较快做出“看得见、点得到”的成果
|
||||
|
||||
但它也有几个风险:
|
||||
|
||||
- 默认 `localhost` 一定通,这在 WSL2 + Windows + Docker 环境里不一定成立
|
||||
- 默认 UE 蓝图里直接做 HTTP + JSON 解析会很顺,这一步其实很容易卡
|
||||
- 默认“一上来就接真实后端”,新手会同时踩 UE、Cesium、网络、JSON、蓝图五个坑
|
||||
- 时间估计略乐观
|
||||
|
||||
所以这份融合版方案的核心思路是:
|
||||
|
||||
## 核心原则
|
||||
|
||||
**先做“本地数据可交互地球”,再做“真实后端对接”。**
|
||||
|
||||
也就是把一期再拆成两个更稳的里程碑:
|
||||
|
||||
### 里程碑 A:本地演示版
|
||||
先不接后端,只做:
|
||||
|
||||
- UE5 项目能打开
|
||||
- Cesium 地球能显示
|
||||
- 本地 JSON 里的点能正确落到地球
|
||||
- 点击点能弹信息卡
|
||||
- HUD 能正常显示假状态
|
||||
|
||||
### 里程碑 B:后端接入版
|
||||
在 A 的基础上再做:
|
||||
|
||||
- HTTP 拉取真实后端数据
|
||||
- 显示真实 TOP500 数据
|
||||
- 显示后端在线状态
|
||||
- 为后续扩展海缆/BGP/卫星打基础
|
||||
|
||||
这样做的好处是:
|
||||
|
||||
- 把问题拆开
|
||||
- 更容易调试
|
||||
- 更适合 UE 新手
|
||||
- 不会因为后端联调没通就把整个 UE 开发节奏打断
|
||||
|
||||
---
|
||||
|
||||
# 二、一期目标:做什么,不做什么
|
||||
|
||||
## 这次一期一定要做的
|
||||
|
||||
做一个 **可用的 UE5 客户端 MVP**,达到以下 6 项:
|
||||
|
||||
1. 能打开 UE 项目并看到 3D 地球
|
||||
2. 能在地球上显示超算数据点
|
||||
3. 能点击数据点弹出信息卡
|
||||
4. 能显示一个基础 HUD
|
||||
5. 能通过 HTTP 接入后端数据
|
||||
6. 能打包成 Windows 可执行程序
|
||||
|
||||
---
|
||||
|
||||
## 这次一期先不做的
|
||||
|
||||
这些全部放到后续阶段:
|
||||
|
||||
- 海缆路径渲染
|
||||
- 卫星轨迹与卫星图层
|
||||
- BGP 图层
|
||||
- WebSocket 实时更新
|
||||
- 粒子特效大升级
|
||||
- 自动巡航
|
||||
- 多屏/3D 偏振/大屏联动
|
||||
|
||||
一句话:
|
||||
|
||||
**一期不是“把 Web Earth 全搬到 UE”,而是“证明 UE 客户端链路能跑通”。**
|
||||
|
||||
---
|
||||
|
||||
# 三、UE 专有名词字典(零基础版)
|
||||
|
||||
这部分你最好先读一遍。后面所有步骤都围绕这些词。
|
||||
|
||||
## 1. Actor
|
||||
**Actor = 场景里的一个对象**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- 一个地球控制器
|
||||
- 一个超算点
|
||||
- 一台相机
|
||||
- 一条海缆
|
||||
|
||||
这些在 UE 里都可以是 Actor。
|
||||
|
||||
---
|
||||
|
||||
## 2. Component
|
||||
**Component = 挂在 Actor 身上的功能零件**
|
||||
|
||||
比如一个超算点 Actor,可能有:
|
||||
|
||||
- 一个球形外观
|
||||
- 一个碰撞盒
|
||||
- 一个标签
|
||||
- 一个发光效果
|
||||
|
||||
这些零件就是 Component。
|
||||
|
||||
一句话:
|
||||
|
||||
**Actor 是整台机器,Component 是机器上的零件。**
|
||||
|
||||
---
|
||||
|
||||
## 3. Blueprint(蓝图)
|
||||
**Blueprint = UE 的可视化编程系统**
|
||||
|
||||
你不用先写代码,而是把很多“逻辑节点”拖出来,用线连接起来。
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- 前端里的函数 + 事件监听
|
||||
- 只不过不是写文本代码,而是连线
|
||||
|
||||
---
|
||||
|
||||
## 4. Level / Map(关卡)
|
||||
**Level = 一个场景文件**
|
||||
|
||||
你可以把它理解成 Three.js 的一个 Scene。
|
||||
|
||||
本期只需要一个主场景:
|
||||
|
||||
- `Main`
|
||||
|
||||
---
|
||||
|
||||
## 5. Widget / UMG
|
||||
**Widget = UI 组件**
|
||||
**UMG = UE 的 UI 编辑系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 信息卡
|
||||
- 状态栏
|
||||
- 右上角连接状态
|
||||
- 图例
|
||||
- HUD 面板
|
||||
|
||||
这些都用 Widget 做。
|
||||
|
||||
---
|
||||
|
||||
## 6. Material(材质)
|
||||
**Material = 决定物体外观的系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 球体是什么颜色
|
||||
- 是否发光
|
||||
- 是否透明
|
||||
- 是否随性能大小变亮
|
||||
|
||||
这些都由材质控制。
|
||||
|
||||
---
|
||||
|
||||
## 7. Static Mesh
|
||||
**Static Mesh = 不会变形的 3D 模型**
|
||||
|
||||
比如:
|
||||
|
||||
- 球
|
||||
- 立方体
|
||||
- 平面
|
||||
- 某个固定模型
|
||||
|
||||
超算点一期里可以先直接用球体 Static Mesh。
|
||||
|
||||
---
|
||||
|
||||
## 8. Pawn
|
||||
**Pawn = 玩家控制的对象**
|
||||
|
||||
一期里你可以把它理解成:
|
||||
|
||||
- 带相机的飞行控制器
|
||||
|
||||
---
|
||||
|
||||
## 9. PlayerController
|
||||
**PlayerController = 处理输入的对象**
|
||||
|
||||
比如:
|
||||
|
||||
- 鼠标点击
|
||||
- 拖拽
|
||||
- 滚轮缩放
|
||||
|
||||
这些都由 PlayerController 或其相关逻辑来处理。
|
||||
|
||||
---
|
||||
|
||||
## 10. GameMode
|
||||
**GameMode = 游戏/场景的主规则配置入口**
|
||||
|
||||
它决定:
|
||||
|
||||
- 默认用哪个 Pawn
|
||||
- 默认用哪个 PlayerController
|
||||
|
||||
你可以把它理解成“主入口配置”。
|
||||
|
||||
---
|
||||
|
||||
## 11. Viewport
|
||||
**Viewport = 你看 3D 场景的窗口**
|
||||
|
||||
就是 UE 编辑器中间那块 3D 视图。
|
||||
|
||||
---
|
||||
|
||||
## 12. Outliner
|
||||
**Outliner = 当前场景对象列表**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- Scene 树
|
||||
- DOM 树
|
||||
- 资源树
|
||||
|
||||
---
|
||||
|
||||
## 13. Details Panel
|
||||
**Details Panel = 选中对象后的属性面板**
|
||||
|
||||
相当于“右侧属性编辑器”。
|
||||
|
||||
---
|
||||
|
||||
## 14. Cesium for Unreal
|
||||
**Cesium for Unreal = UE 里的地球插件**
|
||||
|
||||
它负责:
|
||||
|
||||
- 真实地球
|
||||
- 卫星影像
|
||||
- 地形
|
||||
- 经纬度坐标和 UE 世界坐标的转换
|
||||
|
||||
如果没有它,你得自己处理地球和坐标系统,会非常难。
|
||||
|
||||
---
|
||||
|
||||
## 15. Struct(结构体)
|
||||
**Struct = 数据结构定义**
|
||||
|
||||
你可以把它理解成 TypeScript 里的 `interface`。
|
||||
|
||||
比如:
|
||||
|
||||
```ts
|
||||
interface ComputePoint {
|
||||
id: string
|
||||
name: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
performance: number
|
||||
}
|
||||
```
|
||||
|
||||
在 UE 里这类东西叫 Struct。
|
||||
|
||||
---
|
||||
|
||||
## 16. Event Dispatcher
|
||||
**Event Dispatcher = 事件分发器**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- EventEmitter
|
||||
- 发布订阅
|
||||
|
||||
比如:
|
||||
|
||||
“数据加载完毕”这个事件,就可以分发给其他蓝图。
|
||||
|
||||
---
|
||||
|
||||
## 17. Spline
|
||||
**Spline = 一条平滑曲线**
|
||||
|
||||
后面做海缆、轨迹时非常有用。
|
||||
一期可以先知道这个词,不一定马上用。
|
||||
|
||||
---
|
||||
|
||||
## 18. Niagara
|
||||
**Niagara = UE 粒子特效系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 流光
|
||||
- 光晕
|
||||
- 拖尾
|
||||
- 火花
|
||||
|
||||
一期先不重点碰它。
|
||||
|
||||
---
|
||||
|
||||
# 四、你的真实开发策略:两阶段起步
|
||||
|
||||
这是这份融合版和原方案最大的区别。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 A:本地演示版(先脱离后端)
|
||||
|
||||
### 目标
|
||||
先把下面这些完全打通:
|
||||
|
||||
- UE 项目启动正常
|
||||
- Cesium 地球正常
|
||||
- 相机可操作
|
||||
- 本地 JSON 文件能生成地球标记点
|
||||
- 点击点能弹信息卡
|
||||
- HUD 能显示假数据
|
||||
|
||||
### 为什么一定要先做这个
|
||||
因为如果你一上来就接真实后端,你会同时碰到:
|
||||
|
||||
- WSL2 到 Windows 网络
|
||||
- Docker 端口映射
|
||||
- UE HTTP 请求
|
||||
- 蓝图 JSON 解析
|
||||
- Cesium 坐标转换
|
||||
- 标记点生成
|
||||
|
||||
新手很容易直接乱掉。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 B:后端接入版(再联调)
|
||||
|
||||
### 目标
|
||||
在 A 的基础上,加上:
|
||||
|
||||
- HTTP 拉真实后端数据
|
||||
- 显示真实 TOP500 点
|
||||
- 右上角显示后端在线状态
|
||||
- 为后续做更多图层留下数据接入层
|
||||
|
||||
---
|
||||
|
||||
# 五、环境准备
|
||||
|
||||
## 1. 你要安装的软件
|
||||
|
||||
### Epic Games Launcher
|
||||
用来下载和启动 UE。
|
||||
|
||||
### Unreal Engine 5.4
|
||||
建议直接用 5.4 稳定版。
|
||||
|
||||
### Visual Studio 2022
|
||||
虽然一期主要用 Blueprint,但 UE 的很多项目依赖 VS 环境。
|
||||
|
||||
安装组件:
|
||||
- Desktop development with C++
|
||||
- Game development with C++
|
||||
|
||||
### Git
|
||||
用来管理文档和后续工程。
|
||||
|
||||
### Cesium for Unreal
|
||||
用来做地球。
|
||||
|
||||
---
|
||||
|
||||
## 2. 你的环境约束
|
||||
|
||||
你现在是:
|
||||
|
||||
- 后端可能跑在 WSL2 / Docker
|
||||
- UE 必须跑在 Windows
|
||||
|
||||
所以你的真实运行方式通常会是:
|
||||
|
||||
- **Windows** 运行 UE5
|
||||
- **WSL2** 运行后端
|
||||
- 两者通过 HTTP 通信
|
||||
|
||||
这里最关键的一条是:
|
||||
|
||||
**不要默认 `localhost` 一定能通,必须先在 Windows 浏览器里验证。**
|
||||
|
||||
---
|
||||
|
||||
# 六、推荐的项目结构
|
||||
|
||||
## UE 项目目录内的 Content 结构
|
||||
|
||||
```text
|
||||
Content/
|
||||
Blueprints/
|
||||
Data/
|
||||
Widgets/
|
||||
Materials/
|
||||
Levels/
|
||||
FX/
|
||||
Textures/
|
||||
```
|
||||
|
||||
建议说明:
|
||||
|
||||
- `Blueprints/` 放逻辑蓝图
|
||||
- `Data/` 放本地 JSON、DataTable、Struct
|
||||
- `Widgets/` 放 UI
|
||||
- `Materials/` 放材质
|
||||
- `Levels/` 放场景
|
||||
- `FX/` 放特效
|
||||
- `Textures/` 放贴图
|
||||
|
||||
---
|
||||
|
||||
# 七、一期最小蓝图清单
|
||||
|
||||
一期只需要这几个核心蓝图。
|
||||
|
||||
## 1. `BP_GlobeCamera`
|
||||
作用:相机控制器
|
||||
|
||||
负责:
|
||||
- 鼠标拖拽旋转
|
||||
- 滚轮缩放
|
||||
- 初始视角控制
|
||||
|
||||
---
|
||||
|
||||
## 2. `BP_PlanetGameMode`
|
||||
作用:指定默认的 Pawn 等
|
||||
|
||||
---
|
||||
|
||||
## 3. `BP_DataLoader`
|
||||
作用:负责读数据
|
||||
|
||||
一期建议支持两种来源:
|
||||
|
||||
- 本地 JSON
|
||||
- HTTP 接口
|
||||
|
||||
这样调试更稳。
|
||||
|
||||
---
|
||||
|
||||
## 4. `BP_ComputePoint`
|
||||
作用:一个超算点的显示对象
|
||||
|
||||
负责:
|
||||
- 接收一条数据
|
||||
- 放到正确经纬度位置
|
||||
- 显示外观
|
||||
- 处理点击
|
||||
|
||||
---
|
||||
|
||||
## 5. `WBP_InfoCard`
|
||||
作用:点开后显示详情
|
||||
|
||||
显示:
|
||||
- 名称
|
||||
- 国家
|
||||
- 算力
|
||||
- 可选显示更多字段
|
||||
|
||||
---
|
||||
|
||||
## 6. `WBP_StatusBar`
|
||||
作用:右上角状态栏
|
||||
|
||||
显示:
|
||||
- 后端在线/离线
|
||||
- 当前加载条数
|
||||
- 当前模式(本地数据 / 真实后端)
|
||||
|
||||
---
|
||||
|
||||
# 八、数据层设计
|
||||
|
||||
一期不要一开始就完全照搬后端返回结构。
|
||||
你要先定义一个 UE 友好的结构。
|
||||
|
||||
## `S_ComputePoint`
|
||||
|
||||
字段建议:
|
||||
|
||||
- `PointId`:字符串,唯一 ID
|
||||
- `Name`:字符串
|
||||
- `Latitude`:浮点
|
||||
- `Longitude`:浮点
|
||||
- `Performance`:浮点
|
||||
- `CoreCount`:整数
|
||||
- `Country`:字符串
|
||||
- `Source`:字符串
|
||||
|
||||
这个结构同时适用于:
|
||||
|
||||
- 本地 JSON
|
||||
- 后端 API 返回结果转换后的对象
|
||||
|
||||
---
|
||||
|
||||
# 九、最稳的执行路线
|
||||
|
||||
下面是整个实施计划最重要的部分。
|
||||
|
||||
---
|
||||
|
||||
# Phase 0:安装和验证环境
|
||||
|
||||
## 目标
|
||||
确保你能:
|
||||
|
||||
- 安装 UE5.4
|
||||
- 启用 Cesium
|
||||
- 能打开一个空项目
|
||||
- 能在 Windows 浏览器访问你的后端
|
||||
|
||||
## 验收
|
||||
满足以下 4 条:
|
||||
|
||||
- UE 能打开
|
||||
- Cesium 能启用
|
||||
- 项目能创建
|
||||
- Windows 浏览器能访问后端 summary 接口
|
||||
|
||||
如果第 4 条做不到,不要继续推进真实接口联调。
|
||||
|
||||
---
|
||||
|
||||
# Phase 1:创建项目并把地球显示出来
|
||||
|
||||
## 目标
|
||||
打开项目后,能看到一个真实地球。
|
||||
|
||||
## 操作顺序
|
||||
|
||||
1. 新建 UE5 Blank Blueprint 项目
|
||||
2. 创建 `Main` 场景
|
||||
3. 启用 Cesium
|
||||
4. 添加:
|
||||
- `Cesium World Terrain`
|
||||
- `Cesium Sun Sky`
|
||||
- `CesiumGeoreference`
|
||||
5. 调整视角,让你能看到整个地球
|
||||
|
||||
## 验收
|
||||
能录一段短视频,里面能看到地球和镜头移动。
|
||||
|
||||
---
|
||||
|
||||
# Phase 2:做相机控制
|
||||
|
||||
## 目标
|
||||
让地球可以:
|
||||
|
||||
- 鼠标拖拽旋转
|
||||
- 滚轮缩放
|
||||
|
||||
## 说明
|
||||
这里可以沿用原 MVP 方案的思路:
|
||||
|
||||
- `BP_GlobeCamera` 作为 Pawn
|
||||
- Spring Arm + Camera 组成相机结构
|
||||
- 用输入控制旋转和缩放
|
||||
|
||||
## 注意
|
||||
这一版相机只是“一期可用版”,不是最终镜头系统。
|
||||
|
||||
## 验收
|
||||
按 Play 后:
|
||||
|
||||
- 地球可旋转
|
||||
- 可缩放
|
||||
- 不会直接飞走或抖动失控
|
||||
|
||||
---
|
||||
|
||||
# Phase 3:先喂本地 JSON 数据
|
||||
|
||||
这是融合版方案里最关键的改动。
|
||||
|
||||
## 目标
|
||||
不接后端,先验证:
|
||||
|
||||
- 数据结构正常
|
||||
- JSON 能读
|
||||
- 点能生成
|
||||
- 点击交互正常
|
||||
|
||||
## 为什么先这么做
|
||||
因为这样可以把问题收缩成 3 件事:
|
||||
|
||||
- Cesium 坐标转换
|
||||
- 点渲染
|
||||
- UI 弹窗
|
||||
|
||||
不牵涉后端联调。
|
||||
|
||||
## 本地 JSON 示例格式
|
||||
|
||||
建议放在 `Content/Data/compute_points.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PointId": "top500_1",
|
||||
"Name": "Frontier",
|
||||
"Latitude": 35.93,
|
||||
"Longitude": -84.31,
|
||||
"Performance": 1194.0,
|
||||
"CoreCount": 8730624,
|
||||
"Country": "US",
|
||||
"Source": "top500"
|
||||
},
|
||||
{
|
||||
"PointId": "top500_2",
|
||||
"Name": "Fugaku",
|
||||
"Latitude": 34.69,
|
||||
"Longitude": 135.19,
|
||||
"Performance": 442.0,
|
||||
"CoreCount": 7630848,
|
||||
"Country": "JP",
|
||||
"Source": "top500"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 推荐做法
|
||||
先做一个“本地模式”开关。
|
||||
|
||||
在 `BP_DataLoader` 里支持:
|
||||
|
||||
- Mode = LocalJson
|
||||
- Mode = HttpApi
|
||||
|
||||
先永远跑 `LocalJson`。
|
||||
|
||||
## 验收
|
||||
你应该能看到:
|
||||
|
||||
- 多个点出现在地球上
|
||||
- 大致位置正确
|
||||
- 点击能弹信息卡
|
||||
|
||||
---
|
||||
|
||||
# Phase 4:做超算点蓝图
|
||||
|
||||
## 目标
|
||||
完成 `BP_ComputePoint`
|
||||
|
||||
每个点要实现:
|
||||
|
||||
- 接收一条 `S_ComputePoint`
|
||||
- 经度纬度转成 UE 世界坐标
|
||||
- 在地球上显示为一个可见的发光球
|
||||
- 支持被点击
|
||||
|
||||
## 显示建议
|
||||
|
||||
### 外观
|
||||
先用最简单的球体 Static Mesh。
|
||||
|
||||
### 材质
|
||||
做一个发光材质:
|
||||
|
||||
- 红橙色
|
||||
- 自发光
|
||||
- 不追求复杂效果
|
||||
|
||||
### 大小
|
||||
球体要足够大,确保在地球尺度下看得见。
|
||||
|
||||
### 高度
|
||||
不要贴地表太近,建议悬浮在地表上方一个固定高度。
|
||||
|
||||
## 验收
|
||||
同一批数据点在地球上的位置大体合理。
|
||||
|
||||
---
|
||||
|
||||
# Phase 5:做信息卡
|
||||
|
||||
## 目标
|
||||
点击一个点后,弹出一个简单的信息卡。
|
||||
|
||||
## `WBP_InfoCard` 要显示的内容
|
||||
建议只显示最关键的 3 个字段:
|
||||
|
||||
- 名称
|
||||
- 国家
|
||||
- 算力
|
||||
|
||||
一期先不要堆太多字段。
|
||||
|
||||
## 验收
|
||||
点击点 → 卡片出现
|
||||
点击关闭 → 卡片消失
|
||||
|
||||
---
|
||||
|
||||
# Phase 6:做基础 HUD
|
||||
|
||||
## 目标
|
||||
屏幕上始终有一个简单状态栏。
|
||||
|
||||
## `WBP_StatusBar` 显示内容建议
|
||||
- 当前模式:Local / HTTP
|
||||
- 已加载数据点数量
|
||||
- 后端状态:Unknown / Online / Offline
|
||||
|
||||
在本地模式阶段,状态可以先写死或显示 `Local Demo`。
|
||||
|
||||
## 验收
|
||||
不点击任何点时,屏幕右上角也有“系统正在工作”的感觉。
|
||||
|
||||
---
|
||||
|
||||
# Phase 7:再接真实后端
|
||||
|
||||
这是第二阶段开始。
|
||||
|
||||
## 目标
|
||||
把数据源从本地 JSON 切到 HTTP。
|
||||
|
||||
## 正确做法
|
||||
不要把 `BP_DataLoader` 重写。
|
||||
而是让它支持:
|
||||
|
||||
- LocalJsonLoader
|
||||
- HttpLoader
|
||||
|
||||
也就是:
|
||||
|
||||
**显示层不变,只替换数据来源。**
|
||||
|
||||
## 最重要的接口原则
|
||||
如果后端已有接口字段非常杂,不一定要 UE 直接吃。
|
||||
可以加一个“更适合 UE 的轻量接口”。
|
||||
|
||||
例如:
|
||||
|
||||
`/api/v1/ue/bootstrap/top500`
|
||||
|
||||
返回尽量扁平的数据:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PointId": "top500_1",
|
||||
"Name": "Frontier",
|
||||
"Latitude": 35.93,
|
||||
"Longitude": -84.31,
|
||||
"Performance": 1194.0,
|
||||
"CoreCount": 8730624,
|
||||
"Country": "US",
|
||||
"Source": "top500"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 为什么推荐 UE 轻量接口
|
||||
因为 UE 不适合像前端 React 那样,层层解包一大堆复杂 JSON。
|
||||
|
||||
---
|
||||
|
||||
# Phase 8:做连接状态检测
|
||||
|
||||
## 目标
|
||||
让 HUD 能显示:
|
||||
|
||||
- 在线
|
||||
- 离线
|
||||
- 本地模式
|
||||
|
||||
## 正确实现思路
|
||||
建议用一个很小的状态请求,比如:
|
||||
|
||||
- summary 接口
|
||||
- health 接口
|
||||
- 或 UE 专用 ping 接口
|
||||
|
||||
不要让状态检测去依赖一个超大的数据接口。
|
||||
|
||||
## 验收
|
||||
后端关掉时,状态栏能明显变成 Offline。
|
||||
|
||||
---
|
||||
|
||||
# Phase 9:打包发布
|
||||
|
||||
## 目标
|
||||
把项目打包成 Windows 可执行程序。
|
||||
|
||||
## 注意
|
||||
打包是一期必须尝试的,但不要让它阻塞前面所有开发。
|
||||
|
||||
也就是说:
|
||||
|
||||
- 编辑器里没稳定跑通前,不要反复纠结打包
|
||||
- 等 LocalJson 版和 HTTP 版都能在编辑器 Play 模式稳定运行后,再打包
|
||||
|
||||
## 验收
|
||||
双击 exe 可以运行,进入地球场景并正常展示数据。
|
||||
|
||||
---
|
||||
|
||||
# 十、建议的 14 天执行计划
|
||||
|
||||
这版比原 MVP 的时间估计更保守,也更适合新手。
|
||||
|
||||
## 第 1 天
|
||||
- 安装 UE5.4
|
||||
- 安装 Cesium
|
||||
- 创建空项目
|
||||
- 创建 Main 场景
|
||||
|
||||
## 第 2 天
|
||||
- 启用 Cesium
|
||||
- 把地球跑起来
|
||||
- 保存项目结构
|
||||
|
||||
## 第 3 天
|
||||
- 做 `BP_GlobeCamera`
|
||||
- 跑通旋转和缩放
|
||||
|
||||
## 第 4 天
|
||||
- 建 `S_ComputePoint`
|
||||
- 准备本地 JSON 文件
|
||||
- 做 `BP_DataLoader` 的本地模式
|
||||
|
||||
## 第 5 天
|
||||
- 做 `BP_ComputePoint`
|
||||
- 本地 JSON 批量生成点
|
||||
|
||||
## 第 6 天
|
||||
- 调整点大小、颜色、高度
|
||||
- 检查经纬度位置是否大致正确
|
||||
|
||||
## 第 7 天
|
||||
- 做 `WBP_InfoCard`
|
||||
- 跑通点击点弹卡片
|
||||
|
||||
## 第 8 天
|
||||
- 做 `WBP_StatusBar`
|
||||
- 显示本地模式状态和点数量
|
||||
|
||||
## 第 9 天
|
||||
- Windows 浏览器验证后端接口
|
||||
- 准备 HTTP 版加载逻辑
|
||||
|
||||
## 第 10 天
|
||||
- 实现 HTTP 拉真实数据
|
||||
- 先在日志里确认数据到了
|
||||
|
||||
## 第 11 天
|
||||
- 把 HTTP 数据接到点渲染
|
||||
- 切换 Local / HTTP 两种模式
|
||||
|
||||
## 第 12 天
|
||||
- 做连接状态 Online / Offline
|
||||
- 补错误提示
|
||||
|
||||
## 第 13 天
|
||||
- 测试完整链路
|
||||
- 修点选、缩放、HUD 细节
|
||||
|
||||
## 第 14 天
|
||||
- 进行第一次打包
|
||||
- 在 Windows 下运行 exe 验证
|
||||
|
||||
---
|
||||
|
||||
# 十一、这份方案和原 MVP 方案怎么融合
|
||||
|
||||
下面是合并关系。
|
||||
|
||||
## 保留原 MVP 方案的部分
|
||||
这些内容很好,建议继续用:
|
||||
|
||||
- 术语表
|
||||
- Phase 结构化写法
|
||||
- `BP_GlobeCamera`
|
||||
- `BP_ComputePoint`
|
||||
- `WBP_InfoCard`
|
||||
- `WBP_StatusBar`
|
||||
- 相机、点、信息卡、状态栏这 4 个核心对象
|
||||
- “先别做海缆、卫星、BGP”的范围控制
|
||||
|
||||
## 用融合版修正的部分
|
||||
这些是这份新文档加进去的:
|
||||
|
||||
- 两阶段起步:先本地 JSON,再真实后端
|
||||
- 不默认 `localhost` 一定通
|
||||
- 推荐做 UE 轻量接口,而不是死扛原始接口
|
||||
- 把打包放到后段,而不是过早纠结
|
||||
- 时间预估更保守
|
||||
- 明确“一期只是证明链路跑通”
|
||||
|
||||
---
|
||||
|
||||
# 十二、验收清单
|
||||
|
||||
## 环境
|
||||
- [ ] UE5.4 安装成功
|
||||
- [ ] Cesium 插件启用成功
|
||||
- [ ] Windows 能访问后端接口
|
||||
|
||||
## 本地演示版
|
||||
- [ ] 地球渲染正常
|
||||
- [ ] 鼠标可旋转和缩放
|
||||
- [ ] 本地 JSON 数据能生成点
|
||||
- [ ] 点的位置大体正确
|
||||
- [ ] 点击点能弹信息卡
|
||||
- [ ] HUD 可显示本地模式和点数量
|
||||
|
||||
## 后端接入版
|
||||
- [ ] HTTP 能拉取真实数据
|
||||
- [ ] HTTP 数据能生成点
|
||||
- [ ] HUD 能显示 Online/Offline
|
||||
- [ ] 切换 Local / HTTP 模式不崩
|
||||
- [ ] exe 能打包并运行
|
||||
|
||||
---
|
||||
|
||||
# 十三、后续路线(MVP 之后)
|
||||
|
||||
当这一期做完后,下一步顺序建议是:
|
||||
|
||||
1. 海缆路径
|
||||
2. 卫星点或轨迹
|
||||
3. 更稳的相机与巡航
|
||||
4. WebSocket 增量更新
|
||||
5. BGP 区域态势
|
||||
6. BGP 事件点
|
||||
7. 更强的粒子和视觉风格
|
||||
|
||||
也就是说:
|
||||
|
||||
**先补“静态层和镜头层”,再补“高频实时层”。**
|
||||
|
||||
---
|
||||
|
||||
# 十四、一句话总结
|
||||
|
||||
这份融合版方案的核心就是:
|
||||
|
||||
**保留原 MVP 的入门友好度,但改成“先本地 JSON、再真实后端”的两阶段实施路线,让你第一次做 UE 时更稳、更容易成功。**
|
||||
|
||||
如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是:
|
||||
|
||||
**在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。**
|
||||
@@ -16,12 +16,22 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.27.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.28.1`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.28.1` | bugfix | `dev` | `pending` | 收口 Earth 媒体情报面板命名与 tab 文案,整理 docs 分组并归档已完成/废弃计划文档 |
|
||||
| `0.28.0` | feature | `dev` | `pending` | 合并 Earth 媒体情报面板,整合新闻直播与态势聚合 tab,并稳定 TV/news 的 reform、resize 与共享 HUD 行为 |
|
||||
| `0.27.8` | bugfix | `dev` | `pending` | 统一 Earth HUD 默认折叠逻辑,修复图例与图层面板箭头和底边阈值行为 |
|
||||
| `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 |
|
||||
| `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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.27.0",
|
||||
"version": "0.28.1",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
--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-panel-header-title-size: calc(0.82rem * 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);
|
||||
@@ -55,6 +56,9 @@ body,
|
||||
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;
|
||||
|
||||
@@ -28,20 +28,22 @@
|
||||
|
||||
.stats-kicker {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.64rem * var(--hud-scale));
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 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));
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.stats-drag-bar .hud-panel-close .material-symbols-rounded {
|
||||
font-size: calc(12px * var(--hud-scale));
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── 2-column KPI grid ────────────────────────────────────────── */
|
||||
|
||||
@@ -65,20 +65,6 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.hud-panel-drag-handle {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
@@ -88,19 +74,72 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.hud-panel__header,
|
||||
.hud-panel-header {
|
||||
--hud-header-padding: 0 0 var(--hud-gap-sm);
|
||||
--hud-header-gap: var(--hud-gap-sm);
|
||||
--hud-header-border-color: var(--hud-line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--hud-header-gap);
|
||||
margin-bottom: var(--hud-gap-sm);
|
||||
padding: var(--hud-header-padding);
|
||||
border-bottom: 1px solid var(--hud-header-border-color);
|
||||
}
|
||||
|
||||
.hud-panel__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.hud-panel__title,
|
||||
.hud-panel__header .hud-panel-title,
|
||||
.hud-panel-header .hud-panel-title {
|
||||
margin: 0;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hud-panel__subtitle {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hud-panel__chip {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.hud-panel__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hud-panel__action,
|
||||
.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;
|
||||
--hud-action-padding: calc(7px * var(--hud-scale));
|
||||
--hud-action-icon-size: calc(16px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
padding: var(--hud-action-padding);
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: auto;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
@@ -110,17 +149,51 @@
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.hud-panel__action .material-symbols-rounded,
|
||||
.hud-panel-close .material-symbols-rounded {
|
||||
font-size: calc(var(--hud-title-size) * 0.8);
|
||||
font-size: var(--hud-action-icon-size);
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-panel-close:hover {
|
||||
.hud-panel__action:hover:not(:disabled),
|
||||
.hud-panel-close:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.14);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.hud-panel__action:disabled,
|
||||
.hud-panel-close:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hud-panel__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hud-panel__body--collapsible {
|
||||
--hud-body-collapse-gap: var(--hud-gap-sm);
|
||||
--hud-body-max-height: 1000px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
max-height: var(--hud-body-max-height);
|
||||
transition:
|
||||
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease,
|
||||
margin 0.22s ease;
|
||||
}
|
||||
|
||||
.hud-panel--collapsed .hud-panel__body--collapsible {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-top: calc(-1 * var(--hud-body-collapse-gap));
|
||||
}
|
||||
|
||||
.hud-panel.is-dragging {
|
||||
transition: none !important;
|
||||
box-shadow:
|
||||
@@ -311,10 +384,22 @@
|
||||
.earth-settings-title {
|
||||
margin: 4px 0 0;
|
||||
color: var(--hud-title);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-settings-close {
|
||||
margin-top: 4px;
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-close .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-content {
|
||||
@@ -456,4 +541,4 @@
|
||||
/* .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 */
|
||||
/* .hud-panel-media layout-expanded rule lives in tv-panel.css */
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
/* ── Brand panel ──────────────────────────────────────────────── */
|
||||
|
||||
.hud-panel-brand {
|
||||
--brand-scale: 0.88;
|
||||
--brand-copy-width: 160px;
|
||||
border-radius: 0;
|
||||
padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale));
|
||||
padding: calc(18px * var(--hud-scale)) calc(20px * var(--hud-scale));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -36,52 +38,49 @@
|
||||
.hud-panel-brand .earth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
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));
|
||||
height: calc(128px * var(--hud-scale));
|
||||
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: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: calc(5px * var(--hud-scale));
|
||||
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(160px * var(--hud-scale)));
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
font-size: calc(0.74rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
/* Prevent text from pushing brand wider than logo column */
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
@@ -89,18 +88,24 @@
|
||||
|
||||
.hud-panel-brand .earth-brand__description {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.6rem * var(--hud-scale));
|
||||
font-size: calc(0.6rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.08em;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand--en {
|
||||
--brand-copy-width: 172px;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__title {
|
||||
width: min(100%, calc(172px * var(--hud-scale)));
|
||||
width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__copy {
|
||||
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__subtitle,
|
||||
@@ -155,7 +160,7 @@
|
||||
.info-card-header h3 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: calc(0.92rem * var(--hud-scale));
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
color: var(--hud-title);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
@@ -165,6 +170,15 @@
|
||||
|
||||
.info-card-close {
|
||||
flex-shrink: 0;
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-close .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-content {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/* layer-panel.css — layer toggle panel (below brand, in left column) */
|
||||
|
||||
.hud-panel-layers {
|
||||
/* Lives inside .earth-left-column — position is relative via column rule */
|
||||
/* Lives inside .earth-left-column — narrower than brand panel intentionally */
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
width: calc(260px * var(--hud-scale));
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
margin-top: calc(6px * var(--hud-scale));
|
||||
margin-top: calc(12px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── Header / drag handle ─────────────────────────────────────── */
|
||||
@@ -38,8 +38,8 @@
|
||||
.layer-panel-title {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
color: var(--hud-title);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
@@ -51,55 +51,71 @@
|
||||
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;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
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;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-panel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--hud-text);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.14);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.layer-panel-btn .material-symbols-rounded {
|
||||
font-size: calc(14px * var(--hud-scale));
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
/* Chevron rotates when collapsed */
|
||||
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
transition: color 0.18s ease;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
/* ── 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(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
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;
|
||||
font-size: calc(14px * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer-panel-search-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;
|
||||
|
||||
@@ -27,38 +27,38 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ── Mode tabs ────────────────────────────────────────────────── */
|
||||
/* ── Current mode label ───────────────────────────────────────── */
|
||||
|
||||
.legend-tabs {
|
||||
.legend-current {
|
||||
display: flex;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
align-items: center;
|
||||
gap: calc(6px * 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;
|
||||
.legend-title {
|
||||
flex: 0 0 auto;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--hud-text);
|
||||
}
|
||||
|
||||
.legend-tab--active {
|
||||
.legend-current-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
border: 1px solid rgba(120, 180, 255, 0.2);
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
border-color: rgba(120, 180, 255, 0.2);
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Bar action buttons ───────────────────────────────────────── */
|
||||
@@ -66,7 +66,7 @@
|
||||
.legend-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -74,54 +74,17 @@
|
||||
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;
|
||||
--hud-body-collapse-gap: calc(4px * var(--hud-scale));
|
||||
--hud-body-max-height: calc(220px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── Item list ────────────────────────────────────────────────── */
|
||||
|
||||
182
frontend/public/earth/css/news-panel.css
Normal file
182
frontend/public/earth/css/news-panel.css
Normal file
@@ -0,0 +1,182 @@
|
||||
/* news-panel.css */
|
||||
|
||||
.news-panel-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.news-region-chip {
|
||||
--news-accent: #d6e6ff;
|
||||
border: 1px solid color-mix(in srgb, var(--news-accent) 46%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: calc(3px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
color: color-mix(in srgb, var(--news-accent) 82%, white);
|
||||
background: color-mix(in srgb, var(--news-accent) 12%, transparent);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.news-panel-subtitle {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-panel-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-panel-focus {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
padding: calc(12px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
background:
|
||||
radial-gradient(circle at 16% 18%, rgba(123, 205, 255, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(104, 166, 232, 0.04));
|
||||
border: 1px solid rgba(205, 231, 255, 0.08);
|
||||
}
|
||||
|
||||
.news-focus-kicker,
|
||||
.news-board-status {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.news-focus-label {
|
||||
margin-top: calc(4px * var(--hud-scale));
|
||||
color: var(--hud-text);
|
||||
font-size: calc(1rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.news-focus-coords {
|
||||
margin-top: calc(3px * var(--hud-scale));
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-source-count {
|
||||
align-self: start;
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-board {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-board-list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
padding-right: calc(4px * var(--hud-scale));
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 220, 255, 0.36) transparent;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(210, 237, 255, 0.24), rgba(110, 176, 255, 0.28));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.news-story-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
text-decoration: none;
|
||||
padding: calc(12px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
border: 1px solid rgba(201, 225, 247, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(92, 151, 218, 0.03));
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.news-story-card:hover {
|
||||
border-color: rgba(214, 235, 255, 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(92, 151, 218, 0.06));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.news-story-card--focus {
|
||||
border-color: rgba(127, 219, 255, 0.22);
|
||||
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
|
||||
}
|
||||
|
||||
.news-story-meta,
|
||||
.news-story-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.news-story-source,
|
||||
.news-story-time,
|
||||
.news-story-tag {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-story-source {
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.news-story-title {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.9rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.news-story-summary {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.news-story-tag {
|
||||
border-radius: 999px;
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.news-board-empty {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
line-height: 1.5;
|
||||
padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale));
|
||||
}
|
||||
@@ -1,22 +1,98 @@
|
||||
/* tv-panel */
|
||||
/* media-panel
|
||||
* Outer HUD shell: #media-panel
|
||||
* Inner live pane: #tv-panel
|
||||
* Inner news pane: #news-panel
|
||||
*/
|
||||
|
||||
.hud-panel-tv {
|
||||
.hud-panel-media {
|
||||
bottom: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
width: calc(420px * var(--hud-scale));
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - (2 * var(--hud-offset)));
|
||||
min-width: calc(300px * var(--hud-scale));
|
||||
min-height: calc(340px * var(--hud-scale));
|
||||
padding: calc(18px * var(--hud-scale));
|
||||
padding: calc(10px * var(--hud-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
z-index: 18;
|
||||
}
|
||||
|
||||
.tv-panel-header-copy {
|
||||
display: grid;
|
||||
gap: calc(3px * var(--hud-scale));
|
||||
.hud-panel-media[data-active-tab="news"]:not([data-resized="true"]) {
|
||||
max-height: min(
|
||||
var(--tv-news-default-max-height, calc(100vh - (2 * var(--hud-offset)))),
|
||||
calc(100vh - (2 * var(--hud-offset)))
|
||||
);
|
||||
}
|
||||
|
||||
.hud-panel-media.is-reforming {
|
||||
transition:
|
||||
height 0.24s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
top 0.24s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: height, top;
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__header {
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__title-group {
|
||||
flex: 0 0 auto;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.tv-panel-header-title {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__header .hud-panel__action,
|
||||
.hud-panel-media .hud-panel__header .hud-panel-close {
|
||||
cursor: pointer;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__header .tv-panel-select,
|
||||
.hud-panel-media .hud-panel__header .media-panel-tab {
|
||||
cursor: pointer;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.tv-panel-header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tv-panel-header-controls--news {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tv-panel-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tv-tab-pane {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tv-panel-toolbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tv-panel-status {
|
||||
@@ -26,13 +102,6 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tv-panel-controls {
|
||||
display: flex;
|
||||
gap: var(--hud-gap-sm);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tv-panel-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -53,48 +122,19 @@
|
||||
color: #eef5fc;
|
||||
}
|
||||
|
||||
.tv-panel-actions {
|
||||
display: flex;
|
||||
gap: var(--hud-gap-xs);
|
||||
.tv-panel-meta-wrap {
|
||||
overflow: hidden;
|
||||
max-height: calc(120px * var(--hud-scale));
|
||||
opacity: 1;
|
||||
transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease;
|
||||
}
|
||||
|
||||
.tv-panel-action {
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: calc(12px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--hud-text);
|
||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-action--icon {
|
||||
padding: calc(9px * var(--hud-scale));
|
||||
border-radius: calc(10px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-action--icon .material-symbols-rounded {
|
||||
font-size: calc(18px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
.tv-panel-meta-wrap.is-collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tv-panel-action:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.2);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.tv-panel-action:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
margin-top: calc(-1 * var(--hud-gap-sm));
|
||||
margin-bottom: calc(-1 * var(--hud-gap-sm));
|
||||
}
|
||||
|
||||
.tv-panel-meta {
|
||||
@@ -129,7 +169,7 @@
|
||||
|
||||
.tv-panel-player {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
flex: 1 0 auto;
|
||||
min-height: calc(220px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
@@ -163,45 +203,131 @@
|
||||
background: #050a14;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle {
|
||||
position: absolute;
|
||||
right: calc(8px * var(--hud-scale));
|
||||
bottom: calc(8px * var(--hud-scale));
|
||||
width: calc(18px * var(--hud-scale));
|
||||
height: calc(18px * var(--hud-scale));
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: nwse-resize;
|
||||
z-index: 2;
|
||||
.media-panel-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle::before {
|
||||
.media-panel-tab {
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: calc(12px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--hud-text-muted);
|
||||
padding: calc(9px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.media-panel-tab:hover {
|
||||
color: var(--hud-text);
|
||||
border-color: rgba(214, 235, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.media-panel-tab--active {
|
||||
color: var(--hud-accent-strong);
|
||||
border-color: rgba(120, 180, 255, 0.24);
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
}
|
||||
|
||||
.tv-tab-pane[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Multi-edge resize handles ───────────────────────────────── */
|
||||
|
||||
.tv-panel-edge {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="r"] {
|
||||
right: 0;
|
||||
top: calc(12px * var(--hud-scale));
|
||||
bottom: calc(12px * var(--hud-scale));
|
||||
width: calc(6px * var(--hud-scale));
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="b"] {
|
||||
bottom: 0;
|
||||
left: calc(12px * var(--hud-scale));
|
||||
right: calc(12px * var(--hud-scale));
|
||||
height: calc(6px * var(--hud-scale));
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="l"] {
|
||||
left: 0;
|
||||
top: calc(12px * var(--hud-scale));
|
||||
bottom: calc(12px * var(--hud-scale));
|
||||
width: calc(6px * var(--hud-scale));
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="br"] {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="bl"] {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
/* 右下角视觉标记 */
|
||||
.tv-panel-edge[data-edge="br"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-right: 2px solid rgba(223, 235, 248, 0.46);
|
||||
border-bottom: 2px solid rgba(223, 235, 248, 0.46);
|
||||
border-bottom-right-radius: calc(10px * var(--hud-scale));
|
||||
opacity: 0.78;
|
||||
transition: opacity 0.18s ease, border-color 0.18s ease;
|
||||
inset: calc(4px * var(--hud-scale));
|
||||
border-right: 2px solid rgba(223, 235, 248, 0.4);
|
||||
border-bottom: 2px solid rgba(223, 235, 248, 0.4);
|
||||
transition: border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle:hover::before {
|
||||
opacity: 1;
|
||||
border-color: rgba(244, 249, 255, 0.78);
|
||||
.tv-panel-edge[data-edge="br"]:hover::before {
|
||||
border-color: rgba(244, 249, 255, 0.75);
|
||||
}
|
||||
|
||||
.hud-panel-tv.is-resizing {
|
||||
.hud-panel-media.is-resizing {
|
||||
transition: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) {
|
||||
/* Whole panel is draggable; player overrides back to default */
|
||||
.hud-panel-media:not(.is-resizing) {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.hud-panel-media.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.hud-panel-media .tv-panel-player {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Disable iframe/video pointer capture while dragging so mouse events pass through */
|
||||
.hud-panel-media.is-dragging .tv-panel-iframe,
|
||||
.hud-panel-media.is-dragging .tv-panel-video {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-media:not([data-dragged="true"]) {
|
||||
bottom: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||
}
|
||||
|
||||
/* TV panel keeps its fixed width on all screen sizes.
|
||||
/* Media panel keeps its fixed width on all screen sizes.
|
||||
Responsive stretching removed — width only changes if user manually resizes. */
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<link rel="stylesheet" href="css/legend.css">
|
||||
<link rel="stylesheet" href="css/earth-stats.css">
|
||||
<link rel="stylesheet" href="css/tv-panel.css">
|
||||
<link rel="stylesheet" href="css/news-panel.css">
|
||||
<link rel="stylesheet" href="css/layer-panel.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
|
||||
@@ -50,7 +51,7 @@
|
||||
</defs>
|
||||
</svg>
|
||||
<div id="container" class="earth-app">
|
||||
<div class="earth-left-column">
|
||||
<div id="left-column" class="earth-left-column">
|
||||
<div id="brand-panel" class="hud-panel hud-panel-brand">
|
||||
<div id="brand-root"></div>
|
||||
</div>
|
||||
@@ -61,7 +62,7 @@
|
||||
<span class="material-symbols-rounded layer-panel-icon">layers</span>
|
||||
<span class="layer-panel-title">图层</span>
|
||||
<button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_more</span>
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -69,18 +70,20 @@
|
||||
<div class="layer-panel-body" id="layer-panel-body">
|
||||
<!-- Search -->
|
||||
<div class="layer-panel-search">
|
||||
<span class="material-symbols-rounded layer-panel-search-icon">search</span>
|
||||
<input
|
||||
type="search"
|
||||
id="layer-search-input"
|
||||
class="layer-panel-search-input"
|
||||
placeholder="搜索图层..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button id="layer-search-clear" class="layer-panel-btn layer-search-clear" type="button" aria-label="清除搜索" title="清除" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
<div class="layer-panel-search-box">
|
||||
<span class="material-symbols-rounded layer-panel-search-icon">search</span>
|
||||
<input
|
||||
type="text"
|
||||
id="layer-search-input"
|
||||
class="layer-panel-search-input"
|
||||
placeholder="搜索图层..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button id="layer-search-clear" class="layer-panel-btn layer-search-clear" type="button" aria-label="清除搜索" title="清除" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layer rows -->
|
||||
@@ -143,20 +146,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating detail panel — positioned near click by JS -->
|
||||
<div id="info-panel" class="hud-panel hud-panel-info hud-panel-draggable" aria-live="polite">
|
||||
<div id="info-card" class="info-card">
|
||||
<div class="info-card-header hud-panel-drag-handle">
|
||||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||
<h3 id="info-card-title">详情</h3>
|
||||
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="info-card-content" class="info-card-content"></div>
|
||||
</div>
|
||||
<div id="error-message" class="hud-error-message"></div>
|
||||
</div>
|
||||
<div id="error-message" class="hud-error-message"></div>
|
||||
|
||||
<div id="right-toolbar-group" class="earth-toolbar-group">
|
||||
<div id="control-toolbar" class="earth-toolbar">
|
||||
@@ -182,6 +172,12 @@
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
|
||||
</button>
|
||||
<button id="toggle-news" class="floating-btn liquid-glass-surface earth-toolbar-btn active" title="全球态势新闻">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">newspaper</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开态势新闻</span>
|
||||
</button>
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
@@ -228,24 +224,23 @@
|
||||
|
||||
|
||||
<div id="legend" class="hud-panel hud-panel-legend hud-panel-draggable" data-panel-key="legend">
|
||||
<!-- Drag bar: mode tabs + collapse + close -->
|
||||
<!-- Drag bar: current mode + collapse + close -->
|
||||
<div class="legend-bar hud-panel-drag-handle">
|
||||
<div class="legend-tabs" id="legend-tabs">
|
||||
<button class="legend-tab legend-tab--active" data-legend-mode="cables">海缆</button>
|
||||
<button class="legend-tab" data-legend-mode="satellites">卫星</button>
|
||||
<button class="legend-tab" data-legend-mode="bgp">BGP</button>
|
||||
<div class="legend-current" id="legend-current">
|
||||
<span class="legend-title">图例</span>
|
||||
<span id="legend-current-label" class="legend-current-label">海缆</span>
|
||||
</div>
|
||||
<div class="legend-bar-actions">
|
||||
<button id="legend-collapse" class="legend-bar-btn" title="折叠">
|
||||
<button id="legend-collapse" class="legend-bar-btn hud-panel__action hud-panel__action--collapse" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
<button class="legend-bar-btn hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
|
||||
<button class="legend-bar-btn hud-panel__action hud-panel__action--close hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Collapsible list -->
|
||||
<div id="legend-body" class="legend-body">
|
||||
<div id="legend-body" class="legend-body hud-panel__body hud-panel__body--collapsible">
|
||||
<div class="legend-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,52 +294,103 @@
|
||||
<span id="camera-distance" hidden></span>
|
||||
</div>
|
||||
|
||||
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel">
|
||||
<div class="hud-panel-header hud-panel-drag-handle">
|
||||
<div class="tv-panel-header-copy">
|
||||
<h3 class="hud-panel-title">新闻直播</h3>
|
||||
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||
<div id="media-panel" class="hud-panel hud-panel-media hud-panel-draggable" data-panel-key="media-panel" data-drag-self="true">
|
||||
<div class="hud-panel__header hud-panel-drag-handle">
|
||||
<div class="hud-panel__title-group">
|
||||
<span class="hud-panel-title hud-panel__title tv-panel-header-title">媒体情报</span>
|
||||
</div>
|
||||
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tv-panel-controls">
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<div class="tv-panel-actions">
|
||||
<button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
<div id="tv-header-controls-live" class="tv-panel-header-controls tv-panel-header-controls--live">
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<div class="tv-panel-toolbar-actions">
|
||||
<button id="tv-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="tv-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="访问官网" aria-label="访问官网">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
<button id="tv-meta-toggle" class="hud-panel__action hud-panel__action--collapse tv-panel-meta-toggle" type="button" title="折叠新闻直播内容" aria-label="折叠新闻直播内容">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tv-header-controls-news" class="tv-panel-header-controls tv-panel-header-controls--news" hidden>
|
||||
<div class="news-panel-title-row">
|
||||
<span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span>
|
||||
</div>
|
||||
<div class="tv-panel-toolbar-actions">
|
||||
<button id="news-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新新闻源" aria-label="刷新新闻源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="news-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="打开源站" aria-label="打开源站">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hud-panel__actions">
|
||||
<button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="media-panel" aria-label="关闭媒体情报面板">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tv-panel-meta">
|
||||
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
|
||||
<div class="tv-panel-content">
|
||||
<section id="tv-panel" class="tv-tab-pane tv-tab-pane--active" aria-labelledby="tv-tab-live">
|
||||
<div class="tv-panel-meta-wrap" id="tv-meta-wrap">
|
||||
<div class="tv-panel-meta" id="tv-panel-meta">
|
||||
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tv-panel-player">
|
||||
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="tv-iframe"
|
||||
class="tv-panel-iframe"
|
||||
hidden
|
||||
title="新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="news-panel" class="tv-tab-pane tv-tab-pane--news" aria-labelledby="tv-tab-news" hidden>
|
||||
<div id="news-panel-body" class="news-panel-body">
|
||||
<div class="news-panel-subtitle">跟随地球正面视角自动切换区域新闻</div>
|
||||
|
||||
<div class="news-panel-focus">
|
||||
<div>
|
||||
<div class="news-focus-kicker">当前关注区域</div>
|
||||
<div id="news-focus-label" class="news-focus-label">全球焦点</div>
|
||||
<div id="news-focus-coords" class="news-focus-coords">跟随当前视角自动聚焦</div>
|
||||
</div>
|
||||
<div id="news-source-count" class="news-source-count">0 路聚合源</div>
|
||||
</div>
|
||||
|
||||
<div class="news-board">
|
||||
<div id="news-board-status" class="news-board-status">正在准备全球态势新闻...</div>
|
||||
<div id="news-board-list" class="news-board-list"></div>
|
||||
<div id="news-board-empty" class="news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
|
||||
</div>
|
||||
</div>
|
||||
<a id="news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="tv-panel-player">
|
||||
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="tv-iframe"
|
||||
class="tv-panel-iframe"
|
||||
hidden
|
||||
title="新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||
|
||||
<div class="media-panel-tabs" role="tablist" aria-label="媒体情报切换">
|
||||
<button id="tv-tab-live" class="media-panel-tab media-panel-tab--active" type="button" role="tab" aria-selected="true" aria-controls="tv-panel">电视直播</button>
|
||||
<button id="tv-tab-news" class="media-panel-tab" type="button" role="tab" aria-selected="false" aria-controls="news-panel">态势聚合</button>
|
||||
</div>
|
||||
<button
|
||||
id="tv-resize-handle"
|
||||
class="tv-panel-resize-handle"
|
||||
type="button"
|
||||
aria-label="调整电视直播窗口大小"
|
||||
title="调整大小"
|
||||
></button>
|
||||
<div class="tv-panel-edge" data-edge="r"></div>
|
||||
<div class="tv-panel-edge" data-edge="b"></div>
|
||||
<div class="tv-panel-edge" data-edge="l"></div>
|
||||
<div class="tv-panel-edge" data-edge="br"></div>
|
||||
<div class="tv-panel-edge" data-edge="bl"></div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="earth-loading">
|
||||
@@ -406,7 +452,7 @@
|
||||
<span class="earth-settings-item-subtitle">控制新闻直播窗口显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="tv-panel">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="media-panel">
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
83
frontend/public/earth/js/controls.js
vendored
83
frontend/public/earth/js/controls.js
vendored
@@ -18,6 +18,11 @@ import {
|
||||
import { getShowCables } from "./cables.js";
|
||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||
import { ensureTVPanelReady } from "./tv.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
import {
|
||||
ensureNewsPanelReady,
|
||||
updateNewsToggleUI,
|
||||
} from "./news.js";
|
||||
|
||||
export let autoRotate = true;
|
||||
export let zoomLevel = 1.0;
|
||||
@@ -30,7 +35,7 @@ let cleanupFns = [];
|
||||
const HUD_PANEL_IDS = [
|
||||
"legend",
|
||||
"earth-stats",
|
||||
"tv-panel",
|
||||
"media-panel",
|
||||
"layer-toggles",
|
||||
];
|
||||
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
|
||||
@@ -89,8 +94,9 @@ function setHudPanelVisibility(panelId, visible) {
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
syncSettingsToggle(panelId, visible);
|
||||
if (panelId === "tv-panel") {
|
||||
if (panelId === "media-panel") {
|
||||
updateTVToggleUI(visible);
|
||||
updateNewsToggleUI(visible);
|
||||
if (visible) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
@@ -174,7 +180,9 @@ function setupDraggableHudPanels() {
|
||||
if (!app || draggablePanels.length === 0) return;
|
||||
|
||||
draggablePanels.forEach((panel) => {
|
||||
const handle = panel.querySelector(".hud-panel-drag-handle");
|
||||
const handle = panel.dataset.dragSelf === "true"
|
||||
? panel
|
||||
: panel.querySelector(".hud-panel-drag-handle");
|
||||
if (!handle) return;
|
||||
|
||||
let isDragging = false;
|
||||
@@ -193,14 +201,31 @@ function setupDraggableHudPanels() {
|
||||
if (!isDragging) return;
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const nextLeft = Math.min(
|
||||
const brandPanel = document.getElementById("brand-panel");
|
||||
const brandRect = brandPanel ? brandPanel.getBoundingClientRect() : null;
|
||||
const brandBottom = brandRect ? brandRect.bottom - appRect.top : 0;
|
||||
const brandRight = brandRect ? brandRect.right - appRect.left : 0;
|
||||
|
||||
let nextLeft = Math.min(
|
||||
Math.max(startLeft + (event.clientX - startPointerX), 0),
|
||||
appRect.width - panelRect.width,
|
||||
);
|
||||
const nextTop = Math.min(
|
||||
let nextTop = Math.min(
|
||||
Math.max(startTop + (event.clientY - startPointerY), 0),
|
||||
appRect.height - panelRect.height,
|
||||
);
|
||||
// Brand 面板形成 L 形禁区:panel 不能进入 brand 左上角矩形区域。
|
||||
// 当两个轴同时越界时,比较两侧超出量——哪侧需要的调整量更小就卡哪侧。
|
||||
// 从右侧滑入 → leftAdjust 小 → 卡右边;从下方滑入 → topAdjust 小 → 卡底边。
|
||||
if (brandRect && nextLeft < brandRight && nextTop < brandBottom) {
|
||||
const leftAdjust = brandRight - nextLeft;
|
||||
const topAdjust = brandBottom - nextTop;
|
||||
if (leftAdjust <= topAdjust) {
|
||||
nextLeft = brandRight;
|
||||
} else {
|
||||
nextTop = brandBottom;
|
||||
}
|
||||
}
|
||||
|
||||
panel.style.left = `${nextLeft}px`;
|
||||
panel.style.top = `${nextTop}px`;
|
||||
@@ -211,7 +236,7 @@ function setupDraggableHudPanels() {
|
||||
};
|
||||
|
||||
bindListener(handle, "pointerdown", (event) => {
|
||||
if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close")) return;
|
||||
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .media-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
|
||||
isDragging = true;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
@@ -221,6 +246,8 @@ function setupDraggableHudPanels() {
|
||||
// If panel is inside a flow container (not a direct child of app), reparent
|
||||
// it so absolute positioning is relative to the app container.
|
||||
if (panel.parentElement !== app) {
|
||||
panel.dataset.originalParentId = panel.parentElement?.id || "";
|
||||
panel.dataset.originalNextSiblingId = panel.nextElementSibling?.id || "";
|
||||
const capturedWidth = panelRect.width;
|
||||
panel.style.position = "absolute";
|
||||
panel.style.width = `${capturedWidth}px`;
|
||||
@@ -596,13 +623,20 @@ function setupLayerPanel() {
|
||||
const emptyState = document.getElementById("layer-panel-empty");
|
||||
if (!panel) return;
|
||||
|
||||
const layerPanel = createHUDPanel({
|
||||
panel,
|
||||
header: ".layer-panel-header",
|
||||
body: "#layer-panel-body",
|
||||
collapseBtn,
|
||||
collapsedClass: "layer-panel--collapsed",
|
||||
preferredDirection: "down",
|
||||
expandLabel: "展开图层列表",
|
||||
collapseLabel: "折叠图层列表",
|
||||
});
|
||||
|
||||
bindListener(collapseBtn, "click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isCollapsed = panel.classList.toggle("layer-panel--collapsed");
|
||||
collapseBtn.title = isCollapsed ? "展开" : "折叠";
|
||||
collapseBtn.setAttribute("aria-label", isCollapsed ? "展开图层列表" : "折叠图层列表");
|
||||
const icon = collapseBtn.querySelector(".material-symbols-rounded");
|
||||
if (icon) icon.textContent = isCollapsed ? "expand_less" : "expand_more";
|
||||
layerPanel.setCollapsed(!layerPanel.isCollapsed());
|
||||
});
|
||||
|
||||
if (searchInput) {
|
||||
@@ -773,13 +807,17 @@ function setupTerrainControls() {
|
||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||
});
|
||||
|
||||
const tvVisible = !document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden");
|
||||
updateTVToggleUI(tvVisible);
|
||||
if (tvVisible) {
|
||||
const mediaVisible = !document.getElementById("media-panel")?.classList.contains("hud-panel-hidden");
|
||||
updateTVToggleUI(mediaVisible);
|
||||
if (mediaVisible) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
});
|
||||
}
|
||||
updateNewsToggleUI(mediaVisible);
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化态势新闻内容失败:", error);
|
||||
});
|
||||
updateLayoutUI(container);
|
||||
}
|
||||
|
||||
@@ -912,11 +950,28 @@ function updateLayoutUI(container) {
|
||||
}
|
||||
|
||||
function resetPanelInlineLayout(panel) {
|
||||
const originalParentId = panel.dataset.originalParentId;
|
||||
if (originalParentId) {
|
||||
const originalParent = document.getElementById(originalParentId);
|
||||
if (originalParent) {
|
||||
const nextId = panel.dataset.originalNextSiblingId;
|
||||
const nextSibling = nextId ? document.getElementById(nextId) : null;
|
||||
if (nextSibling) {
|
||||
originalParent.insertBefore(panel, nextSibling);
|
||||
} else {
|
||||
originalParent.appendChild(panel);
|
||||
}
|
||||
}
|
||||
delete panel.dataset.originalParentId;
|
||||
delete panel.dataset.originalNextSiblingId;
|
||||
}
|
||||
panel.style.left = "";
|
||||
panel.style.top = "";
|
||||
panel.style.right = "";
|
||||
panel.style.bottom = "";
|
||||
panel.style.transform = "";
|
||||
panel.style.position = "";
|
||||
panel.style.width = "";
|
||||
delete panel.dataset.dragged;
|
||||
}
|
||||
|
||||
|
||||
233
frontend/public/earth/js/hud-panels.js
Normal file
233
frontend/public/earth/js/hud-panels.js
Normal file
@@ -0,0 +1,233 @@
|
||||
const DEFAULT_COLLAPSED_CLASS = "hud-panel--collapsed";
|
||||
const DEFAULT_HIDDEN_CLASS = "hud-panel-hidden";
|
||||
|
||||
function getHudScale() {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const scale = parseFloat(rootStyle.getPropertyValue("--hud-scale"));
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
}
|
||||
|
||||
function getEdgeFlipThresholdPx() {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const hudOffset = parseFloat(rootStyle.getPropertyValue("--hud-offset"));
|
||||
if (Number.isFinite(hudOffset) && hudOffset > 0) {
|
||||
return hudOffset;
|
||||
}
|
||||
return 20 * getHudScale();
|
||||
}
|
||||
|
||||
function clampExpandDirection(direction) {
|
||||
return direction === "up" ? "up" : "down";
|
||||
}
|
||||
|
||||
function resolveElement(target, root = document) {
|
||||
if (!target) return null;
|
||||
if (target instanceof HTMLElement) return target;
|
||||
if (typeof target === "string") {
|
||||
return root.querySelector(target);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickExpandDirection({
|
||||
panelRect,
|
||||
preferredDirection,
|
||||
}) {
|
||||
const spaceBelow = Math.max(0, window.innerHeight - panelRect.bottom);
|
||||
const edgeFlipThresholdPx = getEdgeFlipThresholdPx();
|
||||
const preferred = clampExpandDirection(preferredDirection);
|
||||
|
||||
// Pure edge-threshold contract:
|
||||
// - d < threshold => "up" family
|
||||
// - d >= threshold => "down" family
|
||||
// Do not pre-flip early based on expanded height.
|
||||
if (spaceBelow <= edgeFlipThresholdPx) return "up";
|
||||
if (spaceBelow > edgeFlipThresholdPx) return "down";
|
||||
return preferred;
|
||||
}
|
||||
|
||||
function getCollapseButtonState({ collapsed, direction, expandLabel, collapseLabel }) {
|
||||
// The arrow always describes the next action and must stay aligned with the
|
||||
// real expansion direction chosen by the controller. Panels should not add
|
||||
// their own extra CSS rotation on top of this mapping.
|
||||
if (collapsed) {
|
||||
return {
|
||||
title: expandLabel,
|
||||
icon: direction === "up" ? "expand_less" : "expand_more",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: collapseLabel,
|
||||
icon: direction === "up" ? "expand_more" : "expand_less",
|
||||
};
|
||||
}
|
||||
|
||||
export function createHUDPanel({
|
||||
panel,
|
||||
header,
|
||||
body,
|
||||
collapseBtn,
|
||||
bodyCollapsedClass = "",
|
||||
preferredDirection = "down",
|
||||
collapsedClass = DEFAULT_COLLAPSED_CLASS,
|
||||
hiddenClass = DEFAULT_HIDDEN_CLASS,
|
||||
expandLabel = "展开",
|
||||
collapseLabel = "折叠",
|
||||
}) {
|
||||
const panelEl = resolveElement(panel);
|
||||
const headerEl = resolveElement(header, panelEl ?? document);
|
||||
const bodyEl = resolveElement(body, panelEl ?? document);
|
||||
const collapseBtnEl = resolveElement(collapseBtn, panelEl ?? document);
|
||||
|
||||
if (!(panelEl instanceof HTMLElement) || !(headerEl instanceof HTMLElement) || !(bodyEl instanceof HTMLElement)) {
|
||||
return {
|
||||
panel: panelEl,
|
||||
header: headerEl,
|
||||
body: bodyEl,
|
||||
collapseBtn: collapseBtnEl,
|
||||
setCollapsed() {},
|
||||
setVisible() {},
|
||||
syncLayout() {},
|
||||
destroy() {},
|
||||
isCollapsed() {
|
||||
return false;
|
||||
},
|
||||
isVisible() {
|
||||
return false;
|
||||
},
|
||||
getExpandDirection() {
|
||||
return clampExpandDirection(preferredDirection);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let currentDirection = clampExpandDirection(preferredDirection);
|
||||
|
||||
const shouldAnchorBottomDuringToggle = () =>
|
||||
panelEl.dataset.dragged === "true" && typeof panelEl.style.top === "string" && panelEl.style.top !== "";
|
||||
|
||||
const compensateTopForBottomAnchor = (beforeBottom) => {
|
||||
if (!shouldAnchorBottomDuringToggle()) return;
|
||||
const afterBottom = panelEl.getBoundingClientRect().bottom;
|
||||
const delta = afterBottom - beforeBottom;
|
||||
if (delta === 0) return;
|
||||
panelEl.style.top = `${parseFloat(panelEl.style.top) - delta}px`;
|
||||
};
|
||||
|
||||
const syncDirection = () => {
|
||||
const expandedHeight = Math.max(bodyEl.scrollHeight, bodyEl.getBoundingClientRect().height);
|
||||
const nextDirection = pickExpandDirection({
|
||||
panelRect: panelEl.getBoundingClientRect(),
|
||||
preferredDirection,
|
||||
});
|
||||
|
||||
currentDirection = nextDirection;
|
||||
panelEl.classList.toggle("hud-panel--expand-up", nextDirection === "up");
|
||||
panelEl.classList.toggle("hud-panel--expand-down", nextDirection !== "up");
|
||||
panelEl.dataset.expandDirection = nextDirection;
|
||||
};
|
||||
|
||||
const syncButton = () => {
|
||||
if (!(collapseBtnEl instanceof HTMLElement)) return;
|
||||
const iconEl = collapseBtnEl.querySelector(".material-symbols-rounded");
|
||||
const { title, icon } = getCollapseButtonState({
|
||||
collapsed: panelEl.classList.contains(collapsedClass),
|
||||
direction: currentDirection,
|
||||
expandLabel,
|
||||
collapseLabel,
|
||||
});
|
||||
|
||||
collapseBtnEl.title = title;
|
||||
collapseBtnEl.setAttribute("aria-label", title);
|
||||
collapseBtnEl.dataset.expandDirection = currentDirection;
|
||||
if (iconEl) {
|
||||
iconEl.textContent = icon;
|
||||
}
|
||||
};
|
||||
|
||||
const syncLayout = () => {
|
||||
syncDirection();
|
||||
syncButton();
|
||||
};
|
||||
|
||||
const setCollapsed = (collapsed) => {
|
||||
syncDirection();
|
||||
const nextCollapsed = Boolean(collapsed);
|
||||
const shouldCompensate = currentDirection === "up" && shouldAnchorBottomDuringToggle();
|
||||
const bottomBefore = shouldCompensate ? panelEl.getBoundingClientRect().bottom : 0;
|
||||
|
||||
if (shouldCompensate) {
|
||||
bodyEl.style.transition = "none";
|
||||
}
|
||||
|
||||
panelEl.classList.toggle(collapsedClass, nextCollapsed);
|
||||
if (bodyCollapsedClass) {
|
||||
bodyEl.classList.toggle(bodyCollapsedClass, nextCollapsed);
|
||||
}
|
||||
|
||||
if (shouldCompensate) {
|
||||
void panelEl.offsetHeight;
|
||||
compensateTopForBottomAnchor(bottomBefore);
|
||||
requestAnimationFrame(() => {
|
||||
bodyEl.style.transition = "";
|
||||
});
|
||||
}
|
||||
|
||||
syncButton();
|
||||
};
|
||||
|
||||
const setVisible = (visible) => {
|
||||
panelEl.classList.toggle(hiddenClass, !visible);
|
||||
if (visible) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewportChange = () => {
|
||||
if (!panelEl.classList.contains(hiddenClass)) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = () => {
|
||||
if (!panelEl.classList.contains(hiddenClass) && panelEl.classList.contains("is-dragging")) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
document.addEventListener("pointerup", handleViewportChange);
|
||||
document.addEventListener("pointermove", handlePointerMove);
|
||||
|
||||
syncLayout();
|
||||
requestAnimationFrame(syncLayout);
|
||||
|
||||
return {
|
||||
panel: panelEl,
|
||||
header: headerEl,
|
||||
body: bodyEl,
|
||||
collapseBtn: collapseBtnEl,
|
||||
setCollapsed,
|
||||
setVisible,
|
||||
syncLayout,
|
||||
destroy() {
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
document.removeEventListener("pointerup", handleViewportChange);
|
||||
document.removeEventListener("pointermove", handlePointerMove);
|
||||
},
|
||||
isCollapsed() {
|
||||
return panelEl.classList.contains(collapsedClass);
|
||||
},
|
||||
isVisible() {
|
||||
return !panelEl.classList.contains(hiddenClass);
|
||||
},
|
||||
getExpandDirection() {
|
||||
return currentDirection;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setupCollapsibleHudPanel(options) {
|
||||
return createHUDPanel(options);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { showStatusMessage } from './ui.js';
|
||||
|
||||
let currentType = null;
|
||||
let cardMounted = false;
|
||||
|
||||
const CARD_CONFIG = {
|
||||
cable: {
|
||||
@@ -105,6 +106,138 @@ function getPanel() {
|
||||
return document.getElementById('info-panel');
|
||||
}
|
||||
|
||||
function setupInfoCardDrag(panel) {
|
||||
const app = document.getElementById('container');
|
||||
if (!app) return;
|
||||
|
||||
const handle = panel.querySelector('.hud-panel-drag-handle');
|
||||
if (!handle) return;
|
||||
|
||||
let isDragging = false;
|
||||
let startPointerX = 0;
|
||||
let startPointerY = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
|
||||
const stopDragging = () => {
|
||||
isDragging = false;
|
||||
panel.classList.remove('is-dragging');
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
const onMove = (event) => {
|
||||
if (!isDragging) return;
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const nextLeft = Math.min(
|
||||
Math.max(startLeft + (event.clientX - startPointerX), 0),
|
||||
appRect.width - panelRect.width,
|
||||
);
|
||||
const nextTop = Math.min(
|
||||
Math.max(startTop + (event.clientY - startPointerY), 0),
|
||||
appRect.height - panelRect.height,
|
||||
);
|
||||
panel.style.left = `${nextLeft}px`;
|
||||
panel.style.top = `${nextTop}px`;
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
||||
isDragging = true;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
startLeft = panelRect.left - appRect.left;
|
||||
startTop = panelRect.top - appRect.top;
|
||||
panel.style.left = `${startLeft}px`;
|
||||
panel.style.top = `${startTop}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.classList.add('is-dragging');
|
||||
document.body.style.userSelect = 'none';
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', stopDragging);
|
||||
handle.addEventListener('pointercancel', stopDragging);
|
||||
handle.addEventListener('lostpointercapture', stopDragging);
|
||||
}
|
||||
|
||||
function mountCard() {
|
||||
if (cardMounted) return;
|
||||
|
||||
const container = document.getElementById('container');
|
||||
if (!container) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'info-panel';
|
||||
panel.className = 'hud-panel hud-panel-info';
|
||||
panel.setAttribute('aria-live', 'polite');
|
||||
panel.innerHTML = `
|
||||
<div id="info-card" class="info-card">
|
||||
<div class="info-card-header hud-panel-drag-handle">
|
||||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||
<h3 id="info-card-title">详情</h3>
|
||||
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="info-card-content" class="info-card-content"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(panel);
|
||||
|
||||
const card = panel.querySelector('#info-card');
|
||||
const content = panel.querySelector('#info-card-content');
|
||||
|
||||
// Prevent pointer events from reaching the earth canvas
|
||||
const stopEvent = (event) => { event.stopPropagation(); };
|
||||
[
|
||||
'mousemove', 'mousedown', 'mouseup', 'click', 'dblclick', 'wheel',
|
||||
'pointerdown', 'pointerup', 'pointermove',
|
||||
'touchstart', 'touchmove', 'touchend',
|
||||
].forEach((evt) => card.addEventListener(evt, stopEvent, { passive: false }));
|
||||
|
||||
// Close button
|
||||
const closeBtn = card.querySelector('.info-card-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
hideInfoCard();
|
||||
});
|
||||
}
|
||||
|
||||
// Copy value on label click
|
||||
content.addEventListener('click', async (event) => {
|
||||
const label = event.target.closest('.info-card-label');
|
||||
if (!label) return;
|
||||
|
||||
const property = label.closest('.info-card-property');
|
||||
const valueEl = property?.querySelector('.info-card-value');
|
||||
const value = valueEl?.textContent?.trim();
|
||||
|
||||
if (!value || value === '-') {
|
||||
showStatusMessage('无可复制内容', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
showStatusMessage(`已复制${label.textContent}:${value}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
showStatusMessage('复制失败', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
setupInfoCardDrag(panel);
|
||||
|
||||
cardMounted = true;
|
||||
}
|
||||
|
||||
function positionPanel(panel, x, y) {
|
||||
if (!panel) return;
|
||||
const margin = 12;
|
||||
@@ -142,71 +275,8 @@ function hidePanel() {
|
||||
if (panel) panel.classList.remove('is-visible');
|
||||
}
|
||||
|
||||
export function initInfoCard() {
|
||||
const card = document.getElementById('info-card');
|
||||
const content = document.getElementById('info-card-content');
|
||||
if (!card || !content) return;
|
||||
|
||||
if (card.dataset.interactionBound !== 'true') {
|
||||
const stopEvent = (event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
[
|
||||
'mousemove',
|
||||
'mousedown',
|
||||
'mouseup',
|
||||
'click',
|
||||
'dblclick',
|
||||
'wheel',
|
||||
'pointerdown',
|
||||
'pointerup',
|
||||
'pointermove',
|
||||
'touchstart',
|
||||
'touchmove',
|
||||
'touchend',
|
||||
].forEach((eventName) => {
|
||||
card.addEventListener(eventName, stopEvent, { passive: false });
|
||||
});
|
||||
|
||||
// Close button wires the panel hide
|
||||
const closeBtn = card.querySelector('.info-card-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
hideInfoCard();
|
||||
});
|
||||
}
|
||||
|
||||
card.dataset.interactionBound = 'true';
|
||||
}
|
||||
|
||||
if (content.dataset.copyBound === 'true') return;
|
||||
|
||||
content.addEventListener('click', async (event) => {
|
||||
const label = event.target.closest('.info-card-label');
|
||||
if (!label) return;
|
||||
|
||||
const property = label.closest('.info-card-property');
|
||||
const valueEl = property?.querySelector('.info-card-value');
|
||||
const value = valueEl?.textContent?.trim();
|
||||
|
||||
if (!value || value === '-') {
|
||||
showStatusMessage('无可复制内容', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
showStatusMessage(`已复制${label.textContent}:${value}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
showStatusMessage('复制失败', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
content.dataset.copyBound = 'true';
|
||||
}
|
||||
// No-op: event binding now happens lazily in mountCard()
|
||||
export function initInfoCard() {}
|
||||
|
||||
export function setInfoCardNoBorder(noBorder = true) {
|
||||
const card = document.getElementById('info-card');
|
||||
@@ -222,6 +292,8 @@ export function showInfoCard(type, data, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
mountCard();
|
||||
|
||||
currentType = type;
|
||||
const card = document.getElementById('info-card');
|
||||
const icon = document.getElementById('info-card-icon');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
const LEGEND_MODES = {
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
@@ -5,6 +7,7 @@ const LEGEND_MODES = {
|
||||
};
|
||||
|
||||
let currentLegendMode = "cables";
|
||||
let legendPanel = null;
|
||||
let legendItemsByMode = {
|
||||
cables: [],
|
||||
satellites: [],
|
||||
@@ -12,34 +15,33 @@ let legendItemsByMode = {
|
||||
};
|
||||
|
||||
export function initLegend() {
|
||||
// Tab click → switch mode
|
||||
const tabsEl = document.getElementById("legend-tabs");
|
||||
if (tabsEl) {
|
||||
tabsEl.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".legend-tab");
|
||||
if (!btn) return;
|
||||
const mode = btn.dataset.legendMode;
|
||||
if (mode) setLegendMode(mode);
|
||||
});
|
||||
}
|
||||
|
||||
// Collapse toggle
|
||||
const collapseBtn = document.getElementById("legend-collapse");
|
||||
const legend = document.getElementById("legend");
|
||||
if (collapseBtn && legend) {
|
||||
legendPanel = createHUDPanel({
|
||||
panel: legend,
|
||||
header: ".legend-bar",
|
||||
body: "#legend-body",
|
||||
collapseBtn,
|
||||
preferredDirection: "down",
|
||||
expandLabel: "展开图例",
|
||||
collapseLabel: "折叠图例",
|
||||
});
|
||||
|
||||
collapseBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
legend.classList.toggle("legend--collapsed");
|
||||
legendPanel?.setCollapsed(!(legendPanel?.isCollapsed() ?? false));
|
||||
});
|
||||
}
|
||||
|
||||
syncCurrentLabel(currentLegendMode);
|
||||
renderLegend(currentLegendMode);
|
||||
}
|
||||
|
||||
export function setLegendMode(mode) {
|
||||
const nextMode = LEGEND_MODES[mode] ? mode : "cables";
|
||||
currentLegendMode = nextMode;
|
||||
syncTabs(nextMode);
|
||||
syncCurrentLabel(nextMode);
|
||||
renderLegend(nextMode);
|
||||
}
|
||||
|
||||
@@ -59,11 +61,10 @@ export function setLegendItems(mode, items) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncTabs(mode) {
|
||||
const tabs = document.querySelectorAll("#legend-tabs .legend-tab");
|
||||
tabs.forEach((tab) => {
|
||||
tab.classList.toggle("legend-tab--active", tab.dataset.legendMode === mode);
|
||||
});
|
||||
function syncCurrentLabel(mode) {
|
||||
const labelEl = document.getElementById("legend-current-label");
|
||||
if (!labelEl) return;
|
||||
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
||||
}
|
||||
|
||||
function renderLegend(mode) {
|
||||
|
||||
@@ -127,6 +127,7 @@ import {
|
||||
} from "./legend.js";
|
||||
import { mountBrand } from "./brand.js";
|
||||
import { initTVPanel } from "./tv.js";
|
||||
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
|
||||
|
||||
export let scene;
|
||||
export let camera;
|
||||
@@ -173,6 +174,7 @@ const scratchCableCenter = new THREE.Vector3();
|
||||
const scratchCableDirection = new THREE.Vector3();
|
||||
const scratchBGPDirection = new THREE.Vector3();
|
||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||
const scratchViewCenterWorld = new THREE.Vector3();
|
||||
|
||||
const cleanupFns = [];
|
||||
const DRAG_SMOOTHING_FACTOR = 0.18;
|
||||
@@ -192,8 +194,8 @@ const HUD_INTERACTIVE_SELECTORS = [
|
||||
"#legend *",
|
||||
"#earth-stats",
|
||||
"#earth-stats *",
|
||||
"#tv-panel",
|
||||
"#tv-panel *",
|
||||
"#media-panel",
|
||||
"#media-panel *",
|
||||
];
|
||||
|
||||
function bindListener(target, eventName, handler, options) {
|
||||
@@ -871,6 +873,20 @@ function updateStatsSummary() {
|
||||
});
|
||||
}
|
||||
|
||||
function getCurrentViewCenterCoords() {
|
||||
const earth = getEarth();
|
||||
if (!earth || !camera) return null;
|
||||
|
||||
scratchViewCenterWorld
|
||||
.copy(camera.position)
|
||||
.sub(earth.position)
|
||||
.normalize()
|
||||
.multiplyScalar(CONFIG.earthRadius);
|
||||
|
||||
earth.worldToLocal(scratchViewCenterWorld);
|
||||
return vector3ToLatLon(scratchViewCenterWorld);
|
||||
}
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
console.error("全局错误:", event.error);
|
||||
});
|
||||
@@ -889,6 +905,7 @@ export function init() {
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
initTVPanel();
|
||||
initNewsPanel();
|
||||
|
||||
scene = new THREE.Scene();
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
@@ -1693,6 +1710,7 @@ function animate() {
|
||||
updateSatellitePositions(deltaTime);
|
||||
updateBreathingPhase(deltaTime);
|
||||
updateRelatedSatelliteHighlights();
|
||||
updateNewsViewFocus(getCurrentViewCenterCoords());
|
||||
|
||||
const satPositions = getSatellitePositions();
|
||||
if (
|
||||
|
||||
338
frontend/public/earth/js/news.js
Normal file
338
frontend/public/earth/js/news.js
Normal file
@@ -0,0 +1,338 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { getActiveTVTab, openTVPanelTab, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
|
||||
|
||||
// News aggregation now lives inside the shared media panel:
|
||||
// - outer shell: #media-panel
|
||||
// - this module renders into inner pane: #news-panel
|
||||
|
||||
const EARTH_NEWS_API = "/api/v1/news/earth-feed";
|
||||
const FOCUS_UPDATE_INTERVAL_MS = 4000;
|
||||
const DATA_REFRESH_INTERVAL_MS = 180000;
|
||||
const MIN_REGION_SWITCH_INTERVAL_MS = 2500;
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
let initialized = false;
|
||||
let refreshPromise = null;
|
||||
let payload = null;
|
||||
let lastFocus = null;
|
||||
let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
function getElements() {
|
||||
return {
|
||||
toggleBtn: document.getElementById("toggle-news"),
|
||||
refreshBtn: document.getElementById("news-refresh"),
|
||||
openBtn: document.getElementById("news-open-external"),
|
||||
status: document.getElementById("news-board-status"),
|
||||
focusLabel: document.getElementById("news-focus-label"),
|
||||
focusCoords: document.getElementById("news-focus-coords"),
|
||||
sourceCount: document.getElementById("news-source-count"),
|
||||
regionChip: document.getElementById("news-region-chip"),
|
||||
board: document.getElementById("news-board-list"),
|
||||
empty: document.getElementById("news-board-empty"),
|
||||
feedAnchor: document.getElementById("news-feed-anchor"),
|
||||
};
|
||||
}
|
||||
|
||||
function formatCoord(value, positiveLabel, negativeLabel) {
|
||||
const abs = Math.abs(value).toFixed(1);
|
||||
return `${abs}°${value >= 0 ? positiveLabel : negativeLabel}`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(raw) {
|
||||
if (!raw) return "刚刚同步";
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return "刚刚同步";
|
||||
|
||||
const diff = Date.now() - date.getTime();
|
||||
const minutes = Math.max(1, Math.round(diff / 60000));
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days} 天前`;
|
||||
}
|
||||
|
||||
export function updateNewsToggleUI(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
const active = visible && getActiveTVTab() === "news";
|
||||
toggleBtn.classList.toggle("active", active);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = active ? "关闭态势新闻" : "打开态势新闻";
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmptyState(message) {
|
||||
const { board, empty, status, openBtn } = getElements();
|
||||
if (board) board.innerHTML = "";
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = message;
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = "等待聚合新闻源";
|
||||
}
|
||||
if (openBtn) openBtn.disabled = true;
|
||||
}
|
||||
|
||||
function renderPayload(nextPayload) {
|
||||
payload = nextPayload;
|
||||
const {
|
||||
board,
|
||||
empty,
|
||||
status,
|
||||
focusLabel,
|
||||
focusCoords,
|
||||
sourceCount,
|
||||
regionChip,
|
||||
openBtn,
|
||||
feedAnchor,
|
||||
} = getElements();
|
||||
|
||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
|
||||
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
|
||||
const focus = nextPayload?.focus || {};
|
||||
|
||||
focusLabel.textContent = focus.label || "全球焦点";
|
||||
regionChip.textContent = focus.region || "global";
|
||||
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
||||
|
||||
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
|
||||
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
|
||||
} else {
|
||||
focusCoords.textContent = "跟随当前视角自动聚焦";
|
||||
}
|
||||
|
||||
sourceCount.textContent = `${sources.length} 路聚合源`;
|
||||
if (nextPayload?.stale) {
|
||||
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length} 条`;
|
||||
} else {
|
||||
status.textContent = nextPayload?.errors?.length
|
||||
? `已聚合 ${items.length} 条,部分源不可用`
|
||||
: `已聚合 ${items.length} 条态势新闻`;
|
||||
}
|
||||
|
||||
if (feedAnchor) {
|
||||
const matchedSource = sources.find((source) => source.region === focus.region) || sources[0];
|
||||
feedAnchor.href = matchedSource?.homepage_url || "https://news.google.com/";
|
||||
}
|
||||
|
||||
if (openBtn) {
|
||||
openBtn.disabled = !feedAnchor?.href;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
board.innerHTML = "";
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = "当前未拉到可用新闻,请稍后刷新或切换视角区域。";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = true;
|
||||
|
||||
board.innerHTML = items
|
||||
.map((item) => {
|
||||
const cardClass = item.is_focus_match
|
||||
? "news-story-card news-story-card--focus"
|
||||
: "news-story-card";
|
||||
const summary = item.summary
|
||||
? `<div class="news-story-summary">${item.summary}</div>`
|
||||
: "";
|
||||
return `
|
||||
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<div class="news-story-meta">
|
||||
<span class="news-story-source">${item.source}</span>
|
||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||
</div>
|
||||
<div class="news-story-title">${item.title}</div>
|
||||
${summary}
|
||||
<div class="news-story-tags">
|
||||
<span class="news-story-tag">${item.region}</span>
|
||||
<span class="news-story-tag">${item.feed_name}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function fetchNews(lat, lon) {
|
||||
const url = new URL(EARTH_NEWS_API, window.location.origin);
|
||||
if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4));
|
||||
if (typeof lon === "number") url.searchParams.set("lon", lon.toFixed(4));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
window.clearTimeout(timeoutId);
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`新闻源请求失败: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function refreshNews(lat, lon, { silent = false } = {}) {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = "正在同步全球态势新闻...";
|
||||
}
|
||||
|
||||
refreshPromise = fetchNews(lat, lon)
|
||||
.then((nextPayload) => {
|
||||
renderPayload(nextPayload);
|
||||
lastFetchAt = Date.now();
|
||||
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试";
|
||||
}
|
||||
}
|
||||
return nextPayload;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("加载 Earth RSS 新闻失败:", error);
|
||||
const message = error?.name === "AbortError"
|
||||
? "新闻聚合请求超时,请稍后重试"
|
||||
: `新闻聚合暂时不可用: ${error?.message || "未知错误"}`;
|
||||
if (!payload) {
|
||||
renderEmptyState(message);
|
||||
} else if (!silent) {
|
||||
showStatusMessage("态势新闻同步失败", "error");
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function shouldRefreshForFocus(lat, lon, region) {
|
||||
const now = Date.now();
|
||||
if (!lastFocus) return true;
|
||||
if (region !== lastFocus.region && now - lastRegionSwitchAt > MIN_REGION_SWITCH_INTERVAL_MS) {
|
||||
lastRegionSwitchAt = now;
|
||||
return true;
|
||||
}
|
||||
if (now - lastFetchAt > DATA_REFRESH_INTERVAL_MS) return true;
|
||||
if (now - (lastFocus.updatedAt || 0) < FOCUS_UPDATE_INTERVAL_MS) return false;
|
||||
const latDrift = Math.abs((lat || 0) - (lastFocus.lat || 0));
|
||||
const lonDrift = Math.abs((lon || 0) - (lastFocus.lon || 0));
|
||||
return latDrift >= 18 || lonDrift >= 25;
|
||||
}
|
||||
|
||||
function inferRegion(lat, lon) {
|
||||
if (typeof lat !== "number" || typeof lon !== "number") return "global";
|
||||
if (lon >= -170 && lon <= -30) return "americas";
|
||||
if (lon > -30 && lon <= 45) return lat >= 30 ? "europe" : "middle-east-africa";
|
||||
if (lon > 45 && lon <= 150) return lat < 10 ? "middle-east-africa" : "asia-pacific";
|
||||
return "asia-pacific";
|
||||
}
|
||||
|
||||
function openCurrentSourceHomepage() {
|
||||
const { feedAnchor } = getElements();
|
||||
if (feedAnchor?.href) {
|
||||
window.open(feedAnchor.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
export function updateNewsViewFocus(coords) {
|
||||
if (!initialized) return;
|
||||
if (!coords || typeof coords.lat !== "number" || typeof coords.lon !== "number") return;
|
||||
|
||||
const region = inferRegion(coords.lat, coords.lon);
|
||||
const nextFocus = {
|
||||
lat: coords.lat,
|
||||
lon: coords.lon,
|
||||
region,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region);
|
||||
lastFocus = nextFocus;
|
||||
if (shouldRefresh) {
|
||||
refreshNews(coords.lat, coords.lon, { silent: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureNewsPanelReady() {
|
||||
if (!initialized) {
|
||||
initNewsPanel();
|
||||
}
|
||||
if (!payload) {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function initNewsPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { toggleBtn, refreshBtn, openBtn } = getElements();
|
||||
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
|
||||
const openNewsTab = async () => {
|
||||
openTVPanelTab("news");
|
||||
updateNewsToggleUI(true);
|
||||
try {
|
||||
await ensureNewsPanelReady();
|
||||
} catch {
|
||||
// surface already handled
|
||||
}
|
||||
};
|
||||
|
||||
toggleBtn?.addEventListener("click", async () => {
|
||||
const visible = isTVPanelVisible();
|
||||
const active = visible && getActiveTVTab() === "news";
|
||||
|
||||
if (!visible) {
|
||||
await openNewsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
await openNewsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
setTVPanelVisible(false);
|
||||
updateNewsToggleUI(false);
|
||||
});
|
||||
|
||||
window.addEventListener("earth:tv-tab-change", () => {
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
});
|
||||
window.addEventListener("earth:tv-visibility-change", (event) => {
|
||||
updateNewsToggleUI(Boolean(event.detail?.visible));
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
showStatusMessage("态势新闻已刷新", "info");
|
||||
} catch {
|
||||
showStatusMessage("态势新闻刷新失败", "error");
|
||||
}
|
||||
});
|
||||
|
||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||
|
||||
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import Hls from "hls.js";
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
// Naming convention:
|
||||
// - #media-panel is the outer HUD shell, responsible for drag/resize/show-hide
|
||||
// - #tv-panel is the inner live tab pane
|
||||
// - #news-panel is the inner aggregation-news tab pane
|
||||
|
||||
const TV_STREAMS_API = "/api/v1/tv/streams";
|
||||
const TV_PROXY_API = "/api/v1/tv/proxy";
|
||||
@@ -20,6 +26,22 @@ let initialized = false;
|
||||
let refreshPromise = null;
|
||||
let hlsPlayer = null;
|
||||
let hlsRecoveryAttempts = 0;
|
||||
let metaAutoCollapseTimer = null;
|
||||
let mediaPanel = null;
|
||||
let activeTab = "live";
|
||||
const failedSourceIds = new Set();
|
||||
let probeTimer = null;
|
||||
let reformCleanupTimer = null;
|
||||
|
||||
const META_AUTO_COLLAPSE_DELAY = 2500;
|
||||
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
|
||||
const DEFAULT_HUD_OFFSET_PX = 20;
|
||||
const MIN_NEWS_TAB_HEIGHT_PX = 280;
|
||||
const PANEL_RESIZE_MARGIN_PX = 12;
|
||||
const TV_PANEL_MIN_WIDTH_PX = 360;
|
||||
const TV_PANEL_MIN_HEIGHT_PX = 340;
|
||||
const REFORM_CLEANUP_MS = 280;
|
||||
const REFORM_RESTORE_ANCHOR_DATA_KEY = "reformRestoreAnchor";
|
||||
|
||||
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
|
||||
const HLS_RETRY_CONFIG = {
|
||||
@@ -31,9 +53,9 @@ const HLS_RETRY_CONFIG = {
|
||||
|
||||
function getElements() {
|
||||
return {
|
||||
panel: document.getElementById("tv-panel"),
|
||||
// Outer media shell node.
|
||||
panel: document.getElementById("media-panel"),
|
||||
toggleBtn: document.getElementById("toggle-tv"),
|
||||
resizeHandle: document.getElementById("tv-resize-handle"),
|
||||
select: document.getElementById("tv-source-select"),
|
||||
title: document.getElementById("tv-source-title"),
|
||||
meta: document.getElementById("tv-source-meta"),
|
||||
@@ -45,9 +67,59 @@ function getElements() {
|
||||
empty: document.getElementById("tv-empty-state"),
|
||||
refreshBtn: document.getElementById("tv-refresh"),
|
||||
openBtn: document.getElementById("tv-open-external"),
|
||||
metaWrap: document.getElementById("tv-meta-wrap"),
|
||||
metaToggle: document.getElementById("tv-meta-toggle"),
|
||||
liveHeaderControls: document.getElementById("tv-header-controls-live"),
|
||||
newsHeaderControls: document.getElementById("tv-header-controls-news"),
|
||||
liveTabBtn: document.getElementById("tv-tab-live"),
|
||||
newsTabBtn: document.getElementById("tv-tab-news"),
|
||||
// Inner tab panes.
|
||||
livePane: document.getElementById("tv-panel"),
|
||||
newsPane: document.getElementById("news-panel"),
|
||||
};
|
||||
}
|
||||
|
||||
function setMetaCollapsed(collapsed) {
|
||||
mediaPanel?.setCollapsed(collapsed);
|
||||
}
|
||||
|
||||
function syncPanelActiveTab(tab = activeTab) {
|
||||
const { panel } = getElements();
|
||||
if (panel instanceof HTMLElement) {
|
||||
panel.dataset.activeTab = tab;
|
||||
}
|
||||
}
|
||||
|
||||
function syncNewsDefaultMaxHeight() {
|
||||
const { panel } = getElements();
|
||||
if (!(panel instanceof HTMLElement)) return;
|
||||
|
||||
const earthStats = document.getElementById("earth-stats");
|
||||
const hudOffset = Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-offset"),
|
||||
);
|
||||
const resolvedOffset = Number.isFinite(hudOffset) ? hudOffset : DEFAULT_HUD_OFFSET_PX;
|
||||
|
||||
if (!(earthStats instanceof HTMLElement)) {
|
||||
panel.style.removeProperty("--tv-news-default-max-height");
|
||||
return;
|
||||
}
|
||||
|
||||
const statsRect = earthStats.getBoundingClientRect();
|
||||
const availableHeight = Math.max(
|
||||
Math.round(MIN_NEWS_TAB_HEIGHT_PX * getHudScale()),
|
||||
Math.floor(window.innerHeight - resolvedOffset - statsRect.bottom),
|
||||
);
|
||||
|
||||
panel.style.setProperty("--tv-news-default-max-height", `${availableHeight}px`);
|
||||
}
|
||||
|
||||
function autoExpandMeta() {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
setMetaCollapsed(false);
|
||||
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
|
||||
}
|
||||
|
||||
function clearPanelPositioningForResize(panel) {
|
||||
panel.style.left = `${panel.offsetLeft}px`;
|
||||
panel.style.top = `${panel.offsetTop}px`;
|
||||
@@ -65,85 +137,101 @@ function getHudScale() {
|
||||
}
|
||||
|
||||
function setupResizeHandle() {
|
||||
const { panel, resizeHandle } = getElements();
|
||||
const { panel } = getElements();
|
||||
const container = document.getElementById("container");
|
||||
if (!(panel instanceof HTMLElement) || !(resizeHandle instanceof HTMLElement) || !(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
|
||||
|
||||
let resizing = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startWidth = 0;
|
||||
let startHeight = 0;
|
||||
let activeEdge = "";
|
||||
const resizeStart = {
|
||||
pointerX: 0,
|
||||
pointerY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
resizing = false;
|
||||
activeEdge = "";
|
||||
panel.classList.remove("is-resizing");
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
resizeHandle.addEventListener("pointerdown", (event) => {
|
||||
if (document.getElementById("container")?.classList.contains("layout-expanded")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizing = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
|
||||
clearPanelPositioningForResize(panel);
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
startWidth = rect.width;
|
||||
startHeight = rect.height;
|
||||
panel.classList.add("is-resizing");
|
||||
document.body.style.userSelect = "none";
|
||||
resizeHandle.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
|
||||
resizeHandle.addEventListener("pointermove", (event) => {
|
||||
const onMove = (event) => {
|
||||
if (!resizing) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const currentLeft = panelRect.left - containerRect.left;
|
||||
const currentTop = panelRect.top - containerRect.top;
|
||||
const hudScale = getHudScale();
|
||||
const minWidth = Math.max(320, Math.round(360 * hudScale));
|
||||
const minHeight = Math.max(260, Math.round(340 * hudScale));
|
||||
const maxWidth = Math.max(minWidth, containerRect.width - currentLeft - 12);
|
||||
const maxHeight = Math.max(minHeight, containerRect.height - currentTop - 12);
|
||||
const nextWidth = Math.min(
|
||||
maxWidth,
|
||||
Math.max(minWidth, startWidth + (event.clientX - startX)),
|
||||
);
|
||||
const nextHeight = Math.min(
|
||||
maxHeight,
|
||||
Math.max(minHeight, startHeight + (event.clientY - startY)),
|
||||
);
|
||||
const minWidth = Math.max(320, Math.round(TV_PANEL_MIN_WIDTH_PX * hudScale));
|
||||
const minHeight = Math.max(260, Math.round(TV_PANEL_MIN_HEIGHT_PX * hudScale));
|
||||
const dx = event.clientX - resizeStart.pointerX;
|
||||
const dy = event.clientY - resizeStart.pointerY;
|
||||
|
||||
panel.style.width = `${nextWidth}px`;
|
||||
panel.style.minHeight = `${nextHeight}px`;
|
||||
if (activeEdge.includes("r")) {
|
||||
const maxW = containerRect.width - resizeStart.left - PANEL_RESIZE_MARGIN_PX;
|
||||
panel.style.width = `${Math.min(maxW, Math.max(minWidth, resizeStart.width + dx))}px`;
|
||||
}
|
||||
if (activeEdge.includes("l")) {
|
||||
const newW = Math.max(minWidth, resizeStart.width - dx);
|
||||
panel.style.width = `${newW}px`;
|
||||
panel.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`;
|
||||
}
|
||||
if (activeEdge.includes("b")) {
|
||||
const maxH = containerRect.height - resizeStart.top - PANEL_RESIZE_MARGIN_PX;
|
||||
panel.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`;
|
||||
}
|
||||
};
|
||||
|
||||
panel.querySelectorAll(".tv-panel-edge[data-edge]").forEach((edgeEl) => {
|
||||
edgeEl.addEventListener("pointerdown", (event) => {
|
||||
if (container.classList.contains("layout-expanded")) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
activeEdge = edgeEl.dataset.edge ?? "";
|
||||
resizing = true;
|
||||
resizeStart.pointerX = event.clientX;
|
||||
resizeStart.pointerY = event.clientY;
|
||||
|
||||
clearPanelPositioningForResize(panel);
|
||||
panel.dataset.resized = "true";
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const cRect = container.getBoundingClientRect();
|
||||
resizeStart.width = rect.width;
|
||||
resizeStart.height = rect.height;
|
||||
resizeStart.left = rect.left - cRect.left;
|
||||
resizeStart.top = rect.top - cRect.top;
|
||||
panel.style.width = `${resizeStart.width}px`;
|
||||
panel.style.height = `${resizeStart.height}px`;
|
||||
panel.style.minHeight = "";
|
||||
|
||||
panel.classList.add("is-resizing");
|
||||
document.body.style.userSelect = "none";
|
||||
edgeEl.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
|
||||
edgeEl.addEventListener("pointermove", onMove);
|
||||
edgeEl.addEventListener("pointerup", stopResize);
|
||||
edgeEl.addEventListener("pointercancel", stopResize);
|
||||
edgeEl.addEventListener("lostpointercapture", stopResize);
|
||||
});
|
||||
|
||||
resizeHandle.addEventListener("pointerup", stopResize);
|
||||
resizeHandle.addEventListener("pointercancel", stopResize);
|
||||
resizeHandle.addEventListener("lostpointercapture", stopResize);
|
||||
}
|
||||
|
||||
function updateToggleButton(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
toggleBtn.classList.toggle("active", visible);
|
||||
const active = visible && activeTab === "live";
|
||||
toggleBtn.classList.toggle("active", active);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = visible ? "关闭新闻直播" : "打开新闻直播";
|
||||
tooltip.textContent = active ? "关闭新闻直播" : "打开新闻直播";
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingsToggle(visible) {
|
||||
const input = document.querySelector('[data-settings-panel="tv-panel"]');
|
||||
const input = document.querySelector('[data-settings-panel="media-panel"]');
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.checked = visible;
|
||||
}
|
||||
@@ -152,9 +240,154 @@ function syncSettingsToggle(visible) {
|
||||
function setPanelVisible(visible) {
|
||||
const { panel } = getElements();
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
mediaPanel?.setVisible(visible);
|
||||
updateToggleButton(visible);
|
||||
syncSettingsToggle(visible);
|
||||
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
|
||||
detail: { visible },
|
||||
}));
|
||||
}
|
||||
|
||||
export function setTVPanelVisible(visible) {
|
||||
setPanelVisible(visible);
|
||||
}
|
||||
|
||||
function clearReformState() {
|
||||
const { panel } = getElements();
|
||||
if (!(panel instanceof HTMLElement)) return;
|
||||
panel.classList.remove("is-reforming");
|
||||
panel.style.height = "";
|
||||
if (panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] === "true") {
|
||||
panel.style.top = "";
|
||||
panel.style.bottom = "";
|
||||
delete panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY];
|
||||
}
|
||||
if (reformCleanupTimer) {
|
||||
clearTimeout(reformCleanupTimer);
|
||||
reformCleanupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function animateTabReform(applyChange) {
|
||||
const { panel } = getElements();
|
||||
const container = document.getElementById("container");
|
||||
if (!(panel instanceof HTMLElement)) {
|
||||
applyChange();
|
||||
return;
|
||||
}
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
applyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel.classList.contains("is-dragging") || panel.classList.contains("is-resizing")) {
|
||||
applyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
clearReformState();
|
||||
|
||||
const reformStartRect = panel.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const reformSnapshot = {
|
||||
height: reformStartRect.height,
|
||||
anchoredBottom: reformStartRect.bottom - containerRect.top,
|
||||
shouldRestoreDefaultAnchoring: panel.dataset.dragged !== "true",
|
||||
};
|
||||
|
||||
if (reformSnapshot.shouldRestoreDefaultAnchoring) {
|
||||
panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] = "true";
|
||||
panel.style.bottom = "auto";
|
||||
}
|
||||
|
||||
panel.style.top = `${reformSnapshot.anchoredBottom - reformSnapshot.height}px`;
|
||||
panel.style.height = `${reformSnapshot.height}px`;
|
||||
panel.classList.add("is-reforming");
|
||||
void panel.offsetHeight;
|
||||
|
||||
applyChange();
|
||||
|
||||
panel.style.height = "auto";
|
||||
const targetHeight = panel.getBoundingClientRect().height;
|
||||
panel.style.height = `${reformSnapshot.height}px`;
|
||||
void panel.offsetHeight;
|
||||
const targetTop = reformSnapshot.anchoredBottom - targetHeight;
|
||||
|
||||
const finalizeReform = () => {
|
||||
panel.removeEventListener("transitionend", handleReformTransitionEnd);
|
||||
clearReformState();
|
||||
};
|
||||
|
||||
const handleReformTransitionEnd = (event) => {
|
||||
if (event.target === panel && event.propertyName === "height") {
|
||||
finalizeReform();
|
||||
}
|
||||
};
|
||||
|
||||
panel.addEventListener("transitionend", handleReformTransitionEnd);
|
||||
reformCleanupTimer = window.setTimeout(finalizeReform, REFORM_CLEANUP_MS);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
panel.style.top = `${targetTop}px`;
|
||||
panel.style.height = `${targetHeight}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function updateTabState(target, isActive, activeClassName = "") {
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
target.hidden = !isActive;
|
||||
if (activeClassName) {
|
||||
target.classList.toggle(activeClassName, isActive);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTabButtonState(button, isActive) {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
button.classList.toggle("media-panel-tab--active", isActive);
|
||||
button.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
}
|
||||
|
||||
function setActiveTab(tab) {
|
||||
const nextTab = tab === "news" ? "news" : "live";
|
||||
if (activeTab === nextTab) return;
|
||||
|
||||
animateTabReform(() => {
|
||||
activeTab = nextTab;
|
||||
syncPanelActiveTab(nextTab);
|
||||
syncNewsDefaultMaxHeight();
|
||||
const {
|
||||
liveTabBtn,
|
||||
newsTabBtn,
|
||||
liveHeaderControls,
|
||||
newsHeaderControls,
|
||||
livePane,
|
||||
newsPane,
|
||||
} = getElements();
|
||||
|
||||
updateTabButtonState(liveTabBtn, nextTab === "live");
|
||||
updateTabButtonState(newsTabBtn, nextTab === "news");
|
||||
updateTabState(liveHeaderControls, nextTab === "live");
|
||||
updateTabState(newsHeaderControls, nextTab === "news");
|
||||
updateTabState(livePane, nextTab === "live", "tv-tab-pane--active");
|
||||
updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active");
|
||||
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
|
||||
detail: { tab: nextTab },
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function openTVPanelTab(tab = "live") {
|
||||
setPanelVisible(true);
|
||||
if (activeTab === tab) return;
|
||||
setActiveTab(tab);
|
||||
}
|
||||
|
||||
export function isTVPanelVisible() {
|
||||
return mediaPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden");
|
||||
}
|
||||
|
||||
export function getActiveTVTab() {
|
||||
return activeTab;
|
||||
}
|
||||
|
||||
function getEmbeddedUrl(source) {
|
||||
@@ -338,7 +571,7 @@ function attachVideoSource(video, source) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!showEmbeddedFallback(source)) {
|
||||
if (!showEmbeddedFallback(source) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
@@ -371,6 +604,59 @@ function findSourceById(sourceId) {
|
||||
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
|
||||
}
|
||||
|
||||
function markSourceFailed(sourceId) {
|
||||
if (!sourceId) return;
|
||||
failedSourceIds.add(sourceId);
|
||||
renderSourceOptions();
|
||||
if (!probeTimer) {
|
||||
probeTimer = setInterval(probeFailedSources, PROBE_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSourceFailed(sourceId) {
|
||||
if (!failedSourceIds.has(sourceId)) return;
|
||||
failedSourceIds.delete(sourceId);
|
||||
renderSourceOptions();
|
||||
if (failedSourceIds.size === 0 && probeTimer) {
|
||||
clearInterval(probeTimer);
|
||||
probeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeFailedSources() {
|
||||
if (failedSourceIds.size === 0) {
|
||||
clearInterval(probeTimer);
|
||||
probeTimer = null;
|
||||
return;
|
||||
}
|
||||
for (const sourceId of [...failedSourceIds]) {
|
||||
const source = findSourceById(sourceId);
|
||||
if (!source) { failedSourceIds.delete(sourceId); continue; }
|
||||
const probeUrl = source.stream_url || source.embed_url;
|
||||
if (!probeUrl) continue;
|
||||
try {
|
||||
const resp = await fetch(probeUrl, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (resp.ok) clearSourceFailed(sourceId);
|
||||
} catch {
|
||||
// 仍然失效,保持标记
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryFallbackSource() {
|
||||
const fallback = tvPayload?.fallback_source;
|
||||
if (!fallback || fallback.id === currentSourceId) return false;
|
||||
markSourceFailed(currentSourceId);
|
||||
currentSourceId = fallback.id;
|
||||
const { select } = getElements();
|
||||
if (select) select.value = currentSourceId;
|
||||
renderSource(fallback);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getCurrentSource() {
|
||||
return findSourceById(currentSourceId);
|
||||
}
|
||||
@@ -433,10 +719,11 @@ function renderSourceOptions() {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
sources.forEach((source) => {
|
||||
const marker = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
||||
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
||||
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
|
||||
const option = document.createElement("option");
|
||||
option.value = source.id;
|
||||
option.textContent = `${source.name}${marker}`;
|
||||
option.textContent = `${source.name}${defaultMark}${failMark}`;
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
|
||||
@@ -502,6 +789,7 @@ function renderSource(source) {
|
||||
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
|
||||
);
|
||||
updateOpenButton(source);
|
||||
autoExpandMeta();
|
||||
}
|
||||
|
||||
function resolveInitialSourceId() {
|
||||
@@ -567,22 +855,54 @@ export function initTVPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements();
|
||||
const {
|
||||
select,
|
||||
refreshBtn,
|
||||
iframe,
|
||||
video,
|
||||
toggleBtn,
|
||||
panel,
|
||||
metaToggle,
|
||||
liveTabBtn,
|
||||
newsTabBtn,
|
||||
} = getElements();
|
||||
|
||||
updateToggleButton(!panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden"));
|
||||
if (panel && metaToggle) {
|
||||
mediaPanel = createHUDPanel({
|
||||
panel,
|
||||
header: ".hud-panel__header",
|
||||
body: "#tv-meta-wrap",
|
||||
collapseBtn: metaToggle,
|
||||
bodyCollapsedClass: "is-collapsed",
|
||||
preferredDirection: "up",
|
||||
expandLabel: "展开新闻直播信息",
|
||||
collapseLabel: "折叠新闻直播信息",
|
||||
});
|
||||
}
|
||||
|
||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
|
||||
toggleBtn?.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true;
|
||||
setPanelVisible(nextVisible);
|
||||
if (nextVisible) {
|
||||
const currentlyVisible = mediaPanel?.isVisible() ?? false;
|
||||
if (!currentlyVisible) {
|
||||
setPanelVisible(true);
|
||||
setActiveTab("live");
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("新闻直播窗口已打开", "info");
|
||||
} else {
|
||||
showStatusMessage("新闻直播窗口已关闭", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab !== "live") {
|
||||
setActiveTab("live");
|
||||
showStatusMessage("已切换到新闻直播", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelVisible(false);
|
||||
showStatusMessage("新闻直播窗口已关闭", "info");
|
||||
});
|
||||
|
||||
select?.addEventListener("change", (event) => {
|
||||
@@ -592,26 +912,46 @@ export function initTVPanel() {
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
});
|
||||
|
||||
metaToggle?.addEventListener("click", () => {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", () => {
|
||||
refreshTVPanel();
|
||||
});
|
||||
|
||||
liveTabBtn?.addEventListener("click", () => {
|
||||
setActiveTab("live");
|
||||
});
|
||||
newsTabBtn?.addEventListener("click", () => {
|
||||
setActiveTab("news");
|
||||
});
|
||||
|
||||
iframe?.addEventListener("load", () => {
|
||||
if (iframe.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
||||
});
|
||||
|
||||
video?.addEventListener("loadedmetadata", () => {
|
||||
if (video.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
||||
});
|
||||
|
||||
video?.addEventListener("error", () => {
|
||||
const currentSource = getCurrentSource();
|
||||
if (!showEmbeddedFallback(currentSource)) {
|
||||
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
|
||||
setupResizeHandle();
|
||||
syncPanelActiveTab("live");
|
||||
syncNewsDefaultMaxHeight();
|
||||
setActiveTab("live");
|
||||
|
||||
window.addEventListener("resize", syncNewsDefaultMaxHeight);
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ function Scrollbar({
|
||||
{scrollbar.y.visible ? (
|
||||
<div
|
||||
className="scrollbar__thumb scrollbar__thumb--y"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
height: `${scrollbar.y.thumbSize}px`,
|
||||
transform: `translateY(${scrollbar.y.thumbOffset}px)`,
|
||||
@@ -284,6 +285,7 @@ function Scrollbar({
|
||||
{scrollbar.x.visible ? (
|
||||
<div
|
||||
className="scrollbar__thumb scrollbar__thumb--x"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
width: `${scrollbar.x.thumbSize}px`,
|
||||
transform: `translateX(${scrollbar.x.thumbOffset}px)`,
|
||||
|
||||
37
frontend/src/components/Scrollbar/TableScrollRegion.tsx
Normal file
37
frontend/src/components/Scrollbar/TableScrollRegion.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||
|
||||
import ScrollbarOverlay from './ScrollbarOverlay'
|
||||
|
||||
interface TableScrollRegionProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
targetSelector?: string
|
||||
}
|
||||
|
||||
const TableScrollRegion = forwardRef<HTMLDivElement, TableScrollRegionProps>(function TableScrollRegion(
|
||||
{
|
||||
children,
|
||||
className = '',
|
||||
style,
|
||||
targetSelector = '.ant-table-body',
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useImperativeHandle(ref, () => containerRef.current as HTMLDivElement, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={['table-scroll-region', className].filter(Boolean).join(' ')}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
<ScrollbarOverlay containerRef={containerRef} targetSelector={targetSelector} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default TableScrollRegion
|
||||
26
frontend/src/components/TableActions/TableActions.tsx
Normal file
26
frontend/src/components/TableActions/TableActions.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Dropdown } from 'antd'
|
||||
import { MoreOutlined } from '@ant-design/icons'
|
||||
|
||||
interface Props {
|
||||
collapsed: boolean
|
||||
items: MenuProps['items']
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** onCell style for action columns — prevents overflow ellipsis and text wrapping */
|
||||
export const actionCellProps = {
|
||||
style: { whiteSpace: 'nowrap' as const, textOverflow: 'clip' as const },
|
||||
}
|
||||
|
||||
export function TableActions({ collapsed, items, children }: Props) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Dropdown trigger={['click']} menu={{ items }}>
|
||||
<Button type="text" size="small" icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
return <div style={{ display: 'inline-flex', gap: 4 }}>{children}</div>
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { useCollapsedActions } from './useCollapsedActions'
|
||||
export { useWebSocket } from './useWebSocket'
|
||||
|
||||
39
frontend/src/hooks/useCollapsedActions.ts
Normal file
39
frontend/src/hooks/useCollapsedActions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 监听容器宽度,宽时展开操作按钮,窄时收入 Dropdown。
|
||||
* @param threshold 折叠阈值(px),默认 700
|
||||
* @returns [collapsed, callbackRef]
|
||||
*/
|
||||
export function useCollapsedActions(threshold = 700) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const observerRef = useRef<ResizeObserver | null>(null)
|
||||
const elementRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const ref = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
elementRef.current = el
|
||||
|
||||
if (!el || typeof ResizeObserver === 'undefined') return
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setCollapsed(entry.contentRect.width < threshold)
|
||||
})
|
||||
observer.observe(el)
|
||||
observerRef.current = observer
|
||||
},
|
||||
[threshold],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
elementRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return [collapsed, ref] as const
|
||||
}
|
||||
@@ -140,7 +140,8 @@ body {
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.18s ease, background 0.18s ease;
|
||||
outline: none;
|
||||
transition: opacity 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.scrollbar__thumb::after {
|
||||
@@ -173,6 +174,7 @@ body {
|
||||
}
|
||||
|
||||
.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb,
|
||||
.scrollbar:focus-within .scrollbar__track--visible .scrollbar__thumb,
|
||||
.scrollbar__track--visible .scrollbar__thumb,
|
||||
.scrollbar__track--dragging .scrollbar__thumb {
|
||||
opacity: 1;
|
||||
@@ -182,12 +184,15 @@ body {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.scrollbar__thumb:hover {
|
||||
background: rgba(216, 226, 240, 0.68);
|
||||
.scrollbar__thumb:hover,
|
||||
.scrollbar__thumb:focus-visible {
|
||||
background: rgba(125, 146, 174, 0.82);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.scrollbar__track--dragging .scrollbar__thumb {
|
||||
background: rgba(226, 235, 246, 0.82);
|
||||
background: rgba(92, 115, 146, 0.9);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.dashboard-brand {
|
||||
@@ -385,6 +390,10 @@ body {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.playground-chat__service-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playground-card__icon-button:hover {
|
||||
color: #1677ff !important;
|
||||
background: rgba(22, 119, 255, 0.08) !important;
|
||||
@@ -398,9 +407,11 @@ body {
|
||||
|
||||
.playground-card__scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.playground-card__scroll .scrollbar__viewport {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
}
|
||||
|
||||
.playground-card__scroll::-webkit-scrollbar,
|
||||
@@ -527,6 +538,21 @@ body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.playground-chat__input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playground-chat__input-row .playground-chat__input.ant-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playground-chat__input-wrap--expanded .playground-chat__input-row .playground-chat__send-button.ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playground-chat__input.ant-input {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
@@ -536,6 +562,15 @@ body {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.playground-chat__input-wrap--expanded .playground-chat__input.ant-input {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.playground-chat__input-wrap:not(.playground-chat__input-wrap--expanded) .playground-chat__input.ant-input {
|
||||
margin-top: 0;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.playground-chat__input.ant-input:focus,
|
||||
.playground-chat__input.ant-input-focused {
|
||||
box-shadow: none;
|
||||
@@ -1251,25 +1286,21 @@ body {
|
||||
.playground-result-modal__content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 6px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
}
|
||||
|
||||
.playground-result-modal__content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
.playground-result-modal__content .scrollbar__viewport {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.playground-result-modal__content::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.82);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
.playground-result__blocks-scroll .scrollbar__viewport {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.playground-result-modal__content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.playground-result__blocks-scroll.scrollbar {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@@ -1285,6 +1316,10 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playground-chat__service-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playground-shell__sidebar {
|
||||
display: none;
|
||||
}
|
||||
@@ -1490,10 +1525,26 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.users-table-region .ant-table-body {
|
||||
height: auto !important;
|
||||
/* users table: flex-fill approach so overlay x-track aligns with table bottom */
|
||||
.users-table-region .ant-table-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.users-table-region .ant-table-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.users-table-region .ant-table-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 0 !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
|
||||
.data-source-table-region .ant-table-wrapper,
|
||||
.data-source-table-region .ant-spin-nested-loading,
|
||||
.data-source-table-region .ant-spin-container {
|
||||
@@ -1583,14 +1634,14 @@ body {
|
||||
padding: 10px 12px !important;
|
||||
}
|
||||
|
||||
.data-source-table-region .ant-table-body,
|
||||
.data-source-table-region .ant-table-content {
|
||||
.table-scroll-region .ant-table-body,
|
||||
.table-scroll-region .ant-table-content {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.data-source-table-region .ant-table-body::-webkit-scrollbar,
|
||||
.data-source-table-region .ant-table-content::-webkit-scrollbar {
|
||||
.table-scroll-region .ant-table-body::-webkit-scrollbar,
|
||||
.table-scroll-region .ant-table-content::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
@@ -1757,7 +1808,10 @@ body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-right: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
@@ -1836,10 +1890,11 @@ body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-height: calc(100vh - 180px);
|
||||
overflow: auto;
|
||||
padding-right: 6px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
}
|
||||
|
||||
.bgp-page__brief-modal-body .scrollbar__viewport {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bgp-page__brief-evidence {
|
||||
@@ -1857,6 +1912,10 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.alerts-brief-drawer .scrollbar__viewport {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.alerts-brief-drawer__loading {
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
@@ -1887,8 +1946,25 @@ body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alerts-tab-panel > .ant-space-item,
|
||||
.system-alerts-page__stack > .ant-space-item,
|
||||
.bgp-alerts-page__stack > .ant-space-item,
|
||||
.situational-alerts-page__stack > .ant-space-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alerts-tab-panel > .ant-space-item:last-child,
|
||||
.system-alerts-page__stack > .ant-space-item:last-child,
|
||||
.bgp-alerts-page__stack > .ant-space-item:last-child,
|
||||
.situational-alerts-page__stack > .ant-space-item:last-child {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.system-alerts-page__table-card,
|
||||
@@ -1915,8 +1991,22 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bgp-alerts-page__tabs,
|
||||
.bgp-alerts-page__tabs .ant-tabs-content-holder,
|
||||
.bgp-alerts-page__tabs {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bgp-alerts-page__tabs .ant-tabs-content-holder {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bgp-alerts-page__tabs .ant-tabs-content,
|
||||
.bgp-alerts-page__tabs .ant-tabs-tabpane {
|
||||
min-width: 0;
|
||||
@@ -2128,29 +2218,71 @@ body {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact {
|
||||
flex-wrap: nowrap !important;
|
||||
.bgp-page__summary-scroll.scrollbar,
|
||||
.alerts-summary-scroll.scrollbar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bgp-page__summary-scroll .scrollbar__viewport,
|
||||
.alerts-summary-scroll .scrollbar__viewport {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
.bgp-page__summary-grid,
|
||||
.alerts-summary-grid {
|
||||
display: flex;
|
||||
flex-wrap: nowrap !important;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.82);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
.bgp-page__summary-cell,
|
||||
.alerts-summary-cell {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.situational-alerts-page__summary-grid {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.situational-alerts-page__panels-grid {
|
||||
display: flex;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.situational-alerts-page__panels-scroll .scrollbar__viewport {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.situational-alerts-page__summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.situational-alerts-page__summary-grid .alerts-summary-cell {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.situational-alerts-page__summary-grid {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
width: auto;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.situational-alerts-page__summary-grid .alerts-summary-cell {
|
||||
width: 220px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.bgp-page__table-card,
|
||||
@@ -2408,25 +2540,21 @@ body {
|
||||
.settings-panel-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.settings-panel-scroll.scrollbar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-panel-scroll .scrollbar__viewport {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 6px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.settings-panel-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.settings-panel-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.8);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.settings-panel-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.settings-panel-scroll .scrollbar__viewport > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-pane .data-source-table-region .ant-table-container {
|
||||
@@ -2447,34 +2575,25 @@ body {
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar {
|
||||
|
||||
.settings-tv-edit-modal .ant-modal-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-tv-edit-modal__body {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
height: min(80vh, 640px);
|
||||
min-height: 0;
|
||||
padding: 16px 0 0 24px;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
.settings-tv-edit-modal__scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-tv-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.data-list-workspace {
|
||||
min-height: 0;
|
||||
@@ -3273,7 +3392,6 @@ body {
|
||||
|
||||
.dashboard-restart-log {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #0f172a;
|
||||
@@ -3281,16 +3399,11 @@ body {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.dashboard-restart-log::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.dashboard-restart-log::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.55);
|
||||
.dashboard-restart-log .scrollbar__viewport {
|
||||
overflow-y: auto;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -21,6 +19,8 @@ import {
|
||||
} from 'antd'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
|
||||
import { getSituationalAwarenessGateway } from '../../services/situational-awareness'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
@@ -171,20 +171,22 @@ export function BGPAlertsPanel() {
|
||||
|
||||
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll bgp-alerts-page__summary-scroll">
|
||||
<div className="alerts-summary-grid" style={{ gap: '12px' }}>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
|
||||
<Card className="bgp-alerts-page__table-card">
|
||||
<Tabs
|
||||
@@ -194,7 +196,7 @@ export function BGPAlertsPanel() {
|
||||
key: 'incidents',
|
||||
label: 'BGP 事件',
|
||||
children: (
|
||||
<div className="table-scroll-region bgp-alerts-page__table-region">
|
||||
<TableScrollRegion className="bgp-alerts-page__table-region">
|
||||
<Table<BGPIncident>
|
||||
columns={incidentColumns}
|
||||
dataSource={incidents}
|
||||
@@ -204,14 +206,14 @@ export function BGPAlertsPanel() {
|
||||
scroll={{ x: 1200, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'anomalies',
|
||||
label: 'BGP 异常',
|
||||
children: (
|
||||
<div className="table-scroll-region bgp-alerts-page__table-region">
|
||||
<TableScrollRegion className="bgp-alerts-page__table-region">
|
||||
<Table<BGPAnomaly>
|
||||
columns={anomalyColumns}
|
||||
dataSource={anomalies}
|
||||
@@ -221,7 +223,7 @@ export function BGPAlertsPanel() {
|
||||
scroll={{ x: 1100, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -244,14 +246,14 @@ export function BGPAlertsPanel() {
|
||||
<Spin tip="正在生成 BGP AI 简报..." />
|
||||
</div>
|
||||
) : brief ? (
|
||||
<div className="bgp-page__brief-modal-body">
|
||||
<Scrollbar className="bgp-page__brief-modal-body">
|
||||
<Descriptions size="small" column={3} className="bgp-page__brief-meta">
|
||||
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
) : (
|
||||
<Text type="secondary">当前没有可查看的 BGP 简报。</Text>
|
||||
)}
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -18,6 +16,7 @@ import {
|
||||
import axios from 'axios'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
|
||||
import {
|
||||
getSituationalAwarenessGateway,
|
||||
@@ -119,42 +118,46 @@ export function SituationalAlertsPanel() {
|
||||
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
|
||||
/>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll situational-alerts-page__summary-scroll">
|
||||
<div className="alerts-summary-grid situational-alerts-page__summary-grid" style={{ gap: '12px' }}>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="系统告警侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="BGP 风险侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll situational-alerts-page__panels-scroll">
|
||||
<div className="alerts-summary-grid situational-alerts-page__panels-grid" style={{ gap: '12px' }}>
|
||||
<div className="alerts-summary-cell" style={{ width: '360px' }}>
|
||||
<Card title="系统告警侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '360px' }}>
|
||||
<Card title="BGP 风险侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Space>
|
||||
|
||||
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -22,6 +20,8 @@ import {
|
||||
import axios from 'axios'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
@@ -216,20 +216,22 @@ export function SystemAlertsPanel() {
|
||||
|
||||
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll system-alerts-page__summary-scroll">
|
||||
<div className="alerts-summary-grid" style={{ gap: '12px' }}>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
|
||||
<Card className="system-alerts-page__table-card" title="系统告警列表">
|
||||
<div className="table-scroll-region system-alerts-page__table-region">
|
||||
<TableScrollRegion className="system-alerts-page__table-region">
|
||||
<Table<AlertRecord>
|
||||
columns={columns}
|
||||
dataSource={alerts}
|
||||
@@ -239,7 +241,7 @@ export function SystemAlertsPanel() {
|
||||
scroll={{ x: 1100, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
@@ -260,7 +262,7 @@ export function SystemAlertsPanel() {
|
||||
</Modal>
|
||||
|
||||
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||
<div className="alerts-brief-drawer">
|
||||
<Scrollbar className="alerts-brief-drawer">
|
||||
{briefLoading ? (
|
||||
<div className="alerts-brief-drawer__loading">
|
||||
<Spin tip="正在汇总系统告警事实并生成简报..." />
|
||||
@@ -293,7 +295,7 @@ export function SystemAlertsPanel() {
|
||||
</Card>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Drawer>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
@@ -22,6 +20,8 @@ import {
|
||||
} from 'antd'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
import {
|
||||
type BGPAnomaly,
|
||||
@@ -708,37 +708,36 @@ function BGP() {
|
||||
/>
|
||||
|
||||
<Card className="bgp-page__summary-card">
|
||||
<Row
|
||||
gutter={[compactViewport ? 8 : 12, compactViewport ? 8 : 12]}
|
||||
className={`bgp-page__summary-grid${compactViewport ? ' bgp-page__summary-grid--compact' : ''}`}
|
||||
wrap={!compactViewport}
|
||||
>
|
||||
{summaryItems.map((item) => (
|
||||
<Col
|
||||
key={item.label}
|
||||
xs={24}
|
||||
sm={12}
|
||||
md={8}
|
||||
flex={compactViewport ? '180px' : undefined}
|
||||
>
|
||||
<div className="bgp-page__summary-item">
|
||||
<div className="bgp-page__summary-label">{item.label}</div>
|
||||
<Statistic className="bgp-page__summary-stat" value={item.value} />
|
||||
<Scrollbar className="bgp-page__summary-scroll">
|
||||
<div
|
||||
className="bgp-page__summary-grid"
|
||||
style={{ gap: `${compactViewport ? 8 : 12}px` }}
|
||||
>
|
||||
{summaryItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="bgp-page__summary-cell"
|
||||
style={{ width: compactViewport ? '180px' : '220px' }}
|
||||
>
|
||||
<div className="bgp-page__summary-item">
|
||||
<div className="bgp-page__summary-label">{item.label}</div>
|
||||
<Statistic className="bgp-page__summary-stat" value={item.value} />
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Card>
|
||||
|
||||
<Card className="bgp-page__table-card">
|
||||
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region">
|
||||
<TableScrollRegion ref={tableRegionRef} className="bgp-page__table-region">
|
||||
<Tabs
|
||||
className="bgp-page__tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -755,7 +754,7 @@ function BGP() {
|
||||
destroyOnHidden
|
||||
>
|
||||
{brief ? (
|
||||
<div className="bgp-page__brief-modal-body">
|
||||
<Scrollbar className="bgp-page__brief-modal-body">
|
||||
{(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? (
|
||||
<div className="bgp-page__brief-evidence">
|
||||
{brief.facts.length > 0 ? (
|
||||
@@ -785,7 +784,7 @@ function BGP() {
|
||||
</div>
|
||||
) : null}
|
||||
<MarkdownRenderer markdown={brief.content_markdown} />
|
||||
</div>
|
||||
</Scrollbar>
|
||||
) : (
|
||||
<Text type="secondary">当前没有可查看的简报内容。</Text>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
@@ -532,11 +533,11 @@ function Dashboard() {
|
||||
|
||||
<div className="dashboard-restart-section">
|
||||
<Text className="dashboard-restart-section__label">终端输出</Text>
|
||||
<div className="dashboard-restart-log">
|
||||
<Scrollbar className="dashboard-restart-log">
|
||||
{restartLogs.length > 0 ? restartLogs.map((line, index) => (
|
||||
<div key={`${line}-${index}`}>{line}</div>
|
||||
)) : <div>等待操作</div>}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -939,7 +940,7 @@ function DataList() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll-region data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
|
||||
<TableScrollRegion className="data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
@@ -960,7 +961,7 @@ function DataList() {
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import {
|
||||
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal,
|
||||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
|
||||
@@ -116,18 +118,39 @@ function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgres
|
||||
if (!batch || batch.sourceIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
const hasRunningItem = batch.sourceIds.some((sourceId) => batch.items[sourceId]?.is_running)
|
||||
if (hasRunningItem) {
|
||||
return batch
|
||||
function resolveTerminalBatchItem(
|
||||
sourceId: number,
|
||||
batch: BulkProgressBatch,
|
||||
builtInSources: BuiltInDataSource[],
|
||||
taskProgress: Record<number, TaskTrackerState>,
|
||||
): BulkProgressItem | null {
|
||||
const currentItem = batch.items[sourceId]
|
||||
const source = builtInSources.find((item) => item.id === sourceId)
|
||||
const trackedTask = taskProgress[sourceId]
|
||||
|
||||
const isRunning = trackedTask?.is_running ?? source?.is_running ?? currentItem?.is_running ?? false
|
||||
if (isRunning) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allFinished = batch.sourceIds.every((sourceId) => {
|
||||
const status = batch.items[sourceId]?.status
|
||||
return Boolean(status && status !== 'running')
|
||||
})
|
||||
const status = trackedTask?.status ?? source?.last_status ?? currentItem?.status ?? null
|
||||
if (!status || status === 'running') {
|
||||
return null
|
||||
}
|
||||
|
||||
return allFinished ? null : batch
|
||||
return {
|
||||
task_id: trackedTask?.task_id ?? currentItem?.task_id ?? source?.task_id ?? null,
|
||||
progress:
|
||||
status === 'success'
|
||||
? 100
|
||||
: trackedTask?.progress ?? source?.progress ?? currentItem?.progress ?? 0,
|
||||
is_running: false,
|
||||
phase: trackedTask?.phase ?? source?.phase ?? currentItem?.phase ?? null,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
interface WebSocketTaskMessage {
|
||||
@@ -192,6 +215,8 @@ function DataSources() {
|
||||
const customTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
|
||||
const [customTableHeight, setCustomTableHeight] = useState(360)
|
||||
const [builtinActionsCollapsed, builtinContainerRef] = useCollapsedActions()
|
||||
const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -436,6 +461,41 @@ function DataSources() {
|
||||
return () => clearInterval(interval)
|
||||
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bulkProgressBatch) return
|
||||
|
||||
let changed = false
|
||||
const nextItems = { ...bulkProgressBatch.items }
|
||||
|
||||
for (const sourceId of bulkProgressBatch.sourceIds) {
|
||||
const nextItem = resolveTerminalBatchItem(sourceId, bulkProgressBatch, builtInSources, taskProgress)
|
||||
if (!nextItem) continue
|
||||
|
||||
const previousItem = bulkProgressBatch.items[sourceId]
|
||||
if (
|
||||
previousItem?.status === nextItem.status &&
|
||||
previousItem?.is_running === nextItem.is_running &&
|
||||
previousItem?.progress === nextItem.progress &&
|
||||
previousItem?.phase === nextItem.phase
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
nextItems[sourceId] = nextItem
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!changed) return
|
||||
|
||||
setBulkProgressBatch((prev) => {
|
||||
if (!prev) return prev
|
||||
return finalizeBulkProgressBatch({
|
||||
...prev,
|
||||
items: nextItems,
|
||||
})
|
||||
})
|
||||
}, [bulkProgressBatch, builtInSources, taskProgress])
|
||||
|
||||
const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
|
||||
const force = options?.force ?? false
|
||||
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
|
||||
@@ -884,10 +944,29 @@ function DataSources() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
fixed: 'right' as const,
|
||||
width: builtinActionsCollapsed ? 40 : 164,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: BuiltInDataSource) => (
|
||||
<Space size="small">
|
||||
<TableActions
|
||||
collapsed={builtinActionsCollapsed}
|
||||
items={[
|
||||
{
|
||||
key: 'trigger',
|
||||
label: '触发',
|
||||
icon: <SyncOutlined />,
|
||||
disabled: !record.is_active,
|
||||
onClick: () => handleTrigger(record.id),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
label: record.is_active ? '禁用' : '启用',
|
||||
icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
|
||||
danger: record.is_active,
|
||||
onClick: () => handleToggle(record.id, record.is_active),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -907,7 +986,7 @@ function DataSources() {
|
||||
>
|
||||
{record.is_active ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Space>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -945,30 +1024,56 @@ function DataSources() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right' as const,
|
||||
width: customActionsCollapsed ? 40 : 228,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: CustomDataSource) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="编辑">
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={record.is_active ? '禁用' : '启用'}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => handleToggleCustom(record.id, record.is_active)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除此配置?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
<TableActions
|
||||
collapsed={customActionsCollapsed}
|
||||
items={[
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => openDrawer(record),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
label: record.is_active ? '禁用' : '启用',
|
||||
icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
|
||||
danger: record.is_active,
|
||||
onClick: () => handleToggleCustom(record.id, record.is_active),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: '确定删除此配置?',
|
||||
onOk: () => handleDelete(record.id),
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)}>编辑</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
danger={record.is_active}
|
||||
style={record.is_active ? undefined : { color: '#52c41a' }}
|
||||
onClick={() => handleToggleCustom(record.id, record.is_active)}
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
{record.is_active ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Popconfirm title="确定删除此配置?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -978,7 +1083,7 @@ function DataSources() {
|
||||
key: 'builtin',
|
||||
label: '内置数据源',
|
||||
children: (
|
||||
<div className="page-shell__body data-source-builtin-tab">
|
||||
<div className="page-shell__body data-source-builtin-tab" ref={builtinContainerRef}>
|
||||
<div className="data-source-bulk-toolbar">
|
||||
<div className="data-source-bulk-toolbar__meta">
|
||||
<div className="data-source-bulk-toolbar__title">采集实时进度</div>
|
||||
@@ -1064,7 +1169,7 @@ function DataSources() {
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="page-shell__body data-source-custom-tab">
|
||||
<div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
|
||||
<div className="data-source-custom-toolbar">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
添加数据源
|
||||
|
||||
@@ -22,6 +22,8 @@ import axios from 'axios'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
@@ -237,8 +239,10 @@ function Playground() {
|
||||
const [editingContent, setEditingContent] = useState('')
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const [composerFocused, setComposerFocused] = useState(false)
|
||||
const pollTimerRef = useRef<number | null>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const messagesShellRef = useRef<HTMLDivElement | null>(null)
|
||||
const forceScrollToBottomRef = useRef(true)
|
||||
|
||||
const selectedPreset = useMemo(
|
||||
@@ -575,7 +579,7 @@ function Playground() {
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="playground-card__scroll">
|
||||
<Scrollbar className="playground-card__scroll">
|
||||
<Spin spinning={statusLoading}>
|
||||
{providerStatus ? (
|
||||
<div className="playground-provider-panel">
|
||||
@@ -617,7 +621,7 @@ function Playground() {
|
||||
<Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" />
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -678,7 +682,7 @@ function Playground() {
|
||||
title="AI Chatbox"
|
||||
extra={(
|
||||
<Space size={4}>
|
||||
<Tooltip title="服务状态">
|
||||
<Tooltip title="服务状态" className="playground-chat__service-btn">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
@@ -699,7 +703,7 @@ function Playground() {
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<div className="playground-chat__messages-shell">
|
||||
<div ref={messagesShellRef} className="playground-chat__messages-shell">
|
||||
<div className="playground-chat__messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}>
|
||||
{messages.map((entry) => (
|
||||
<div key={entry.id} className={`playground-message playground-message--${entry.role}`}>
|
||||
@@ -760,8 +764,9 @@ function Playground() {
|
||||
<Button size="small" type="primary" loading={editSaving} onClick={() => void handleSaveEditMessage(entry)}>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollbarOverlay containerRef={messagesShellRef} targetSelector=".playground-chat__messages" />
|
||||
</div>
|
||||
</div>
|
||||
) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? (
|
||||
<div className="playground-message__markdown">
|
||||
@@ -845,31 +850,50 @@ function Playground() {
|
||||
</div>
|
||||
|
||||
<div className="playground-chat__composer">
|
||||
<div className="playground-chat__input-wrap">
|
||||
<div className="playground-preset-strip__actions">
|
||||
{PLAYGROUND_PRESETS.map((preset) => (
|
||||
<Tag.CheckableTag
|
||||
key={preset.key}
|
||||
checked={preset.key === selectedPreset.key}
|
||||
onChange={() => handleApplyPreset(preset)}
|
||||
>
|
||||
{preset.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
<div className={`playground-chat__input-wrap${composerFocused || !!inputValue ? ' playground-chat__input-wrap--expanded' : ''}`}>
|
||||
{(composerFocused || !!inputValue) && (
|
||||
<div className="playground-preset-strip__actions">
|
||||
{PLAYGROUND_PRESETS.map((preset) => (
|
||||
<Tag.CheckableTag
|
||||
key={preset.key}
|
||||
checked={preset.key === selectedPreset.key}
|
||||
onChange={() => handleApplyPreset(preset)}
|
||||
>
|
||||
{preset.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="playground-chat__input-row">
|
||||
<Input.TextArea
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
autoSize={composerFocused || !!inputValue ? { minRows: 4, maxRows: 10 } : { minRows: 1, maxRows: 1 }}
|
||||
placeholder="在这里输入本次分析请求..."
|
||||
className="playground-chat__input"
|
||||
onFocus={() => setComposerFocused(true)}
|
||||
onBlur={() => setComposerFocused(false)}
|
||||
onPressEnter={(event) => {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!(composerFocused || !!inputValue) && (
|
||||
<Tooltip title={sendButtonTooltip}>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
className={`playground-chat__send-button${requestPending || streaming ? ' playground-chat__send-button--stop' : ''}`}
|
||||
icon={requestPending || streaming ? <BorderOutlined /> : <ArrowUpOutlined />}
|
||||
onClick={requestPending || streaming ? handleStop : () => void handleSend()}
|
||||
aria-label={sendButtonTooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
autoSize={{ minRows: 4, maxRows: 10 }}
|
||||
placeholder="在这里输入本次分析请求。你可以写观察、问题、目标,或者直接贴一段待分析事实。"
|
||||
className="playground-chat__input"
|
||||
onPressEnter={(event) => {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(composerFocused || !!inputValue) && (
|
||||
<div className="playground-chat__actions">
|
||||
<div className="playground-chat__hints">
|
||||
<Tag>{title}</Tag>
|
||||
@@ -886,8 +910,9 @@ function Playground() {
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1000,42 +1025,42 @@ function Playground() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="playground-result-modal__content">
|
||||
<Scrollbar className="playground-result-modal__content">
|
||||
<Space direction="vertical" size={16} className="playground-result__stack" style={{ width: '100%' }}>
|
||||
{analysis.text_blocks.length ? (
|
||||
<div className="playground-result__blocks">
|
||||
<Text strong>文本块</Text>
|
||||
<div className="playground-result__blocks-scroll">
|
||||
<Scrollbar className="playground-result__blocks-scroll">
|
||||
{analysis.text_blocks.map((block, index) => (
|
||||
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
||||
<pre>{block}</pre>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
) : null}
|
||||
{analysis.thinking_blocks.length ? (
|
||||
<div className="playground-result__blocks">
|
||||
<Text strong>Thinking Blocks</Text>
|
||||
<div className="playground-result__blocks-scroll">
|
||||
<Scrollbar className="playground-result__blocks-scroll">
|
||||
{analysis.thinking_blocks.map((block, index) => (
|
||||
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
||||
<pre>{block}</pre>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="playground-result__blocks">
|
||||
<Text strong>Raw Response</Text>
|
||||
<div className="playground-result__blocks-scroll playground-result__blocks-scroll--raw">
|
||||
<Scrollbar className="playground-result__blocks-scroll playground-result__blocks-scroll--raw">
|
||||
<Card size="small">
|
||||
<pre>{JSON.stringify(analysis.raw_response, null, 2)}</pre>
|
||||
</Card>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import { CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -6,15 +9,19 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -91,7 +98,7 @@ function SettingsPanel({
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
<Card className="settings-panel-card" loading={loading}>
|
||||
<div className="settings-panel-scroll">{children}</div>
|
||||
<Scrollbar className="settings-panel-scroll">{children}</Scrollbar>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
@@ -106,11 +113,14 @@ function Settings() {
|
||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
|
||||
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
|
||||
const [savingTvSettings, setSavingTvSettings] = useState(false)
|
||||
const [editingSource, setEditingSource] = useState<TVStreamSource | null>(null)
|
||||
const [tvActionsCollapsed, tvTableRef] = useCollapsedActions(780)
|
||||
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
|
||||
const [systemForm] = Form.useForm<SystemSettings>()
|
||||
const [notificationForm] = Form.useForm<NotificationSettings>()
|
||||
const [securityForm] = Form.useForm<SecuritySettings>()
|
||||
const [tvEditForm] = Form.useForm<TVStreamSource>()
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
@@ -204,90 +214,95 @@ function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateTvSetting = <K extends keyof TVSettings>(field: K, value: TVSettings[K]) => {
|
||||
setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev))
|
||||
}
|
||||
|
||||
const updateTvSourceField = <K extends keyof TVStreamSource>(
|
||||
sourceId: string,
|
||||
field: K,
|
||||
value: TVStreamSource[K]
|
||||
) => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextSources = prev.sources.map((source) => {
|
||||
if (field === 'is_fallback' && value === true) {
|
||||
return { ...source, is_fallback: source.id === sourceId }
|
||||
}
|
||||
if (source.id === sourceId) {
|
||||
return { ...source, [field]: value }
|
||||
}
|
||||
return source
|
||||
})
|
||||
|
||||
const nextDefaultSourceId =
|
||||
field === 'is_enabled' && value === false && prev.default_source_id === sourceId
|
||||
? nextSources.find((source) => source.id !== sourceId && source.is_enabled)?.id || ''
|
||||
: prev.default_source_id
|
||||
|
||||
return {
|
||||
...prev,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
}
|
||||
})
|
||||
const setDefaultSource = (sourceId: string) => {
|
||||
if (!tvSettings) return
|
||||
const next = { ...tvSettings, default_source_id: sourceId }
|
||||
setTvSettings(next)
|
||||
saveTvSettings(next)
|
||||
}
|
||||
|
||||
const addTvSource = () => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextIndex = prev.sources.length + 1
|
||||
const newSource: TVStreamSource = {
|
||||
id: `manual-tv-${Date.now()}`,
|
||||
name: `新闻直播源 ${nextIndex}`,
|
||||
provider: 'Manual',
|
||||
region: 'Global',
|
||||
language: 'und',
|
||||
source_type: 'iframe',
|
||||
embed_url: '',
|
||||
stream_url: '',
|
||||
homepage_url: '',
|
||||
poster_url: '',
|
||||
youtube_video_id: '',
|
||||
youtube_channel: '',
|
||||
is_enabled: true,
|
||||
is_fallback: false,
|
||||
sort_order: nextIndex * 10,
|
||||
collector_source: null,
|
||||
notes: '',
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sources: [...prev.sources, newSource],
|
||||
}
|
||||
})
|
||||
const nextIndex = (tvSettings?.sources.length || 0) + 1
|
||||
const newSource: TVStreamSource = {
|
||||
id: `manual-tv-${Date.now()}`,
|
||||
name: `新闻直播源 ${nextIndex}`,
|
||||
provider: 'Manual',
|
||||
region: 'Global',
|
||||
language: 'und',
|
||||
source_type: 'iframe',
|
||||
embed_url: '',
|
||||
stream_url: '',
|
||||
homepage_url: '',
|
||||
poster_url: '',
|
||||
youtube_video_id: '',
|
||||
youtube_channel: '',
|
||||
is_enabled: true,
|
||||
is_fallback: false,
|
||||
sort_order: nextIndex * 10,
|
||||
collector_source: null,
|
||||
notes: '',
|
||||
}
|
||||
setEditingSource(newSource)
|
||||
tvEditForm.setFieldsValue(newSource)
|
||||
}
|
||||
|
||||
const removeTvSource = (sourceId: string) => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextSources = prev.sources.filter((source) => source.id !== sourceId)
|
||||
const nextDefaultSourceId =
|
||||
prev.default_source_id === sourceId ? nextSources[0]?.id || '' : prev.default_source_id
|
||||
return {
|
||||
...prev,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
const confirmEditSource = async () => {
|
||||
if (!editingSource || !tvSettings) return
|
||||
const values = tvEditForm.getFieldsValue()
|
||||
const nextSources = tvSettings.sources
|
||||
.map((source) => {
|
||||
if (source.id === editingSource.id) return { ...source, ...values }
|
||||
if (values.is_fallback) return { ...source, is_fallback: false }
|
||||
return source
|
||||
})
|
||||
|
||||
if (!tvSettings.sources.some((source) => source.id === editingSource.id)) {
|
||||
nextSources.push({
|
||||
...editingSource,
|
||||
...values,
|
||||
})
|
||||
if (values.is_fallback) {
|
||||
for (let index = 0; index < nextSources.length - 1; index += 1) {
|
||||
nextSources[index] = { ...nextSources[index], is_fallback: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const nextDefaultSourceId =
|
||||
values.is_enabled === false && tvSettings.default_source_id === editingSource.id
|
||||
? nextSources.find((s) => s.id !== editingSource.id && s.is_enabled)?.id || ''
|
||||
: tvSettings.default_source_id
|
||||
const next = { ...tvSettings, default_source_id: nextDefaultSourceId, sources: nextSources }
|
||||
setTvSettings(next)
|
||||
setEditingSource(null)
|
||||
await saveTvSettings(next)
|
||||
}
|
||||
|
||||
const saveTvSettings = async () => {
|
||||
const removeTvSource = async (sourceId: string) => {
|
||||
if (!tvSettings) return
|
||||
|
||||
const nextSources = tvSettings.sources.filter((source) => source.id !== sourceId)
|
||||
const nextDefaultSourceId =
|
||||
tvSettings.default_source_id === sourceId ? nextSources[0]?.id || '' : tvSettings.default_source_id
|
||||
const next = {
|
||||
...tvSettings,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
}
|
||||
|
||||
setTvSettings(next)
|
||||
if (editingSource?.id === sourceId) {
|
||||
setEditingSource(null)
|
||||
}
|
||||
await saveTvSettings(next)
|
||||
}
|
||||
|
||||
const saveTvSettings = async (next?: TVSettings) => {
|
||||
const toSave = next ?? tvSettings
|
||||
if (!toSave) return
|
||||
try {
|
||||
setSavingTvSettings(true)
|
||||
await axios.put('/api/v1/settings/tv', tvSettings)
|
||||
await axios.put('/api/v1/settings/tv', toSave)
|
||||
message.success('电视直播配置已保存')
|
||||
await fetchSettings()
|
||||
} catch (error) {
|
||||
@@ -400,105 +415,39 @@ function Settings() {
|
||||
const tvSourceColumns = [
|
||||
{
|
||||
title: '频道',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 220,
|
||||
render: (_: string, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input value={record.name} onChange={(event) => updateTvSourceField(record.id, 'name', event.target.value)} />
|
||||
<Input
|
||||
value={record.provider}
|
||||
placeholder="提供方"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'provider', event.target.value)}
|
||||
/>
|
||||
width: 180,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{record.name}</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{record.provider}</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '区域 / 语言',
|
||||
key: 'locale',
|
||||
width: 160,
|
||||
width: 130,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input
|
||||
value={record.region}
|
||||
placeholder="区域"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'region', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.language}
|
||||
placeholder="语言"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'language', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary">{record.region} · {record.language}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'source_type',
|
||||
key: 'source_type',
|
||||
width: 120,
|
||||
render: (value: TVStreamSource['source_type'], record: TVStreamSource) => (
|
||||
<Select
|
||||
value={value}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(nextValue) => updateTvSourceField(record.id, 'source_type', nextValue)}
|
||||
options={[
|
||||
{ value: 'iframe', label: 'iframe' },
|
||||
{ value: 'hls', label: 'hls' },
|
||||
{ value: 'video', label: 'video' },
|
||||
{ value: 'youtube', label: 'youtube' },
|
||||
{ value: 'external', label: 'external' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '播放地址',
|
||||
key: 'urls',
|
||||
width: 320,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input
|
||||
value={record.embed_url}
|
||||
placeholder="嵌入地址 / iframe 地址"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'embed_url', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.stream_url}
|
||||
placeholder="流地址 / HLS 地址"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'stream_url', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.youtube_video_id}
|
||||
placeholder="YouTube 视频 ID(可选)"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'youtube_video_id', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.youtube_channel}
|
||||
placeholder="YouTube 频道 Handle / URL(可选)"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'youtube_channel', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '官网',
|
||||
dataIndex: 'homepage_url',
|
||||
key: 'homepage_url',
|
||||
width: 220,
|
||||
render: (value: string, record: TVStreamSource) => (
|
||||
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'homepage_url', event.target.value)} />
|
||||
),
|
||||
width: 90,
|
||||
render: (value: string) => <Tag>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
width: 130,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Switch checked={record.is_enabled} onChange={(checked) => updateTvSourceField(record.id, 'is_enabled', checked)} />
|
||||
<Switch checked={record.is_fallback} onChange={(checked) => updateTvSourceField(record.id, 'is_fallback', checked)} />
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' as const }}>
|
||||
<Tag color={record.is_enabled ? 'success' : 'default'}>{record.is_enabled ? '启用' : '禁用'}</Tag>
|
||||
{record.id === tvSettings?.default_source_id && <Tag color="gold">默认</Tag>}
|
||||
{record.is_fallback && <Tag color="blue">备用</Tag>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -506,20 +455,82 @@ function Settings() {
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
key: 'notes',
|
||||
width: 220,
|
||||
render: (value: string, record: TVStreamSource) => (
|
||||
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'notes', event.target.value)} />
|
||||
),
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (value: string) => <Text type="secondary">{value || '—'}</Text>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
fixed: 'right' as const,
|
||||
width: tvActionsCollapsed ? 40 : 258,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<Button danger onClick={() => removeTvSource(record.id)} disabled={record.id === tvSettings?.default_source_id}>
|
||||
删除
|
||||
</Button>
|
||||
<TableActions
|
||||
collapsed={tvActionsCollapsed}
|
||||
items={[
|
||||
{
|
||||
key: 'default',
|
||||
label: '设为默认',
|
||||
icon: <CheckCircleOutlined />,
|
||||
disabled: record.id === tvSettings?.default_source_id,
|
||||
onClick: () => setDefaultSource(record.id),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setEditingSource(record)
|
||||
tvEditForm.setFieldsValue(record)
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
disabled: record.id === tvSettings?.default_source_id,
|
||||
onClick: () => {
|
||||
void removeTvSource(record.id)
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CheckCircleOutlined />}
|
||||
disabled={record.id === tvSettings?.default_source_id}
|
||||
onClick={() => setDefaultSource(record.id)}
|
||||
>
|
||||
设为默认
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditingSource(record)
|
||||
tvEditForm.setFieldsValue(record)
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={record.id === tvSettings?.default_source_id}
|
||||
onClick={() => {
|
||||
void removeTvSource(record.id)
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -610,51 +621,111 @@ function Settings() {
|
||||
key: 'tv',
|
||||
label: '电视直播',
|
||||
children: (
|
||||
<div className="settings-pane">
|
||||
<Card className="settings-panel-card settings-panel-card--table" loading={loading}>
|
||||
<div className="settings-panel-scroll" style={{ display: 'grid', gap: 16 }}>
|
||||
<div className="settings-tv-toolbar">
|
||||
<div className="settings-tv-toolbar__controls">
|
||||
<div className="settings-tv-field">
|
||||
<Text type="secondary">默认直播源</Text>
|
||||
<Select
|
||||
value={tvSettings?.default_source_id}
|
||||
style={{ minWidth: 260 }}
|
||||
options={(tvSettings?.sources || []).map((source) => ({
|
||||
value: source.id,
|
||||
label: source.name,
|
||||
}))}
|
||||
onChange={(value) => updateTvSetting('default_source_id', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-tv-field">
|
||||
<Text type="secondary">自动回退</Text>
|
||||
<Switch
|
||||
checked={tvSettings?.auto_fallback || false}
|
||||
onChange={(checked) => updateTvSetting('auto_fallback', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-tv-toolbar__actions">
|
||||
<Button onClick={addTvSource}>新增直播源</Button>
|
||||
<Button type="primary" loading={savingTvSettings} onClick={saveTvSettings}>
|
||||
保存电视直播配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll-region data-source-table-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={tvSourceColumns}
|
||||
dataSource={tvSettings?.sources || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 1500, y: 420 }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-pane" ref={tvTableRef}>
|
||||
<Card
|
||||
className="settings-panel-card settings-panel-card--table"
|
||||
loading={loading}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<TableScrollRegion
|
||||
className="data-source-table-region"
|
||||
style={{ flex: '1 1 auto', minHeight: 0 }}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={tvSourceColumns}
|
||||
dataSource={tvSettings?.sources || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content', y: 420 }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</TableScrollRegion>
|
||||
<Tooltip title="新增直播源">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addTvSource}
|
||||
style={{ width: '100%', borderRadius: 0, borderTop: '1px solid rgba(0,0,0,0.06)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
<Modal
|
||||
title={editingSource?.id.startsWith('manual-tv-') ? '新增直播源' : '编辑直播源'}
|
||||
open={editingSource !== null}
|
||||
onOk={confirmEditSource}
|
||||
onCancel={() => {
|
||||
setEditingSource(null)
|
||||
tvEditForm.resetFields()
|
||||
}}
|
||||
okText="保存"
|
||||
okButtonProps={{ loading: savingTvSettings }}
|
||||
cancelText="取消"
|
||||
width={560}
|
||||
centered
|
||||
destroyOnHidden
|
||||
className="settings-tv-edit-modal"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div className="settings-tv-edit-modal__body">
|
||||
<Scrollbar className="settings-tv-edit-modal__scroll">
|
||||
<Form form={tvEditForm} layout="vertical" style={{ paddingBottom: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item name="name" label="频道名称" rules={[{ required: true, message: '请输入频道名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供方">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="region" label="区域">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="language" label="语言">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="source_type" label="类型">
|
||||
<Select options={[
|
||||
{ value: 'iframe', label: 'iframe' },
|
||||
{ value: 'hls', label: 'HLS' },
|
||||
{ value: 'video', label: 'video' },
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'external', label: 'external(仅外部打开)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="embed_url" label="嵌入地址 / iframe 地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="stream_url" label="流地址 / HLS 地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="youtube_video_id" label="YouTube 视频 ID">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="youtube_channel" label="YouTube 频道 Handle / URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="homepage_url" label="官网地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_fallback" label="设为备用源" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -668,7 +739,7 @@ function Settings() {
|
||||
loading={loading}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div ref={collectorTableRegionRef} className="table-scroll-region data-source-table-region">
|
||||
<TableScrollRegion ref={collectorTableRegionRef} className="data-source-table-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={collectorColumns}
|
||||
@@ -678,7 +749,7 @@ function Settings() {
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
|
||||
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
interface Task {
|
||||
@@ -146,9 +147,9 @@ function Tasks() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="table-scroll-region">
|
||||
<TableScrollRegion>
|
||||
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Button, Tag, message, Modal, Form, Input, Select } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
@@ -18,9 +21,8 @@ function Users() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [tableHeight, setTableHeight] = useState(360)
|
||||
const [form] = Form.useForm()
|
||||
const [actionsCollapsed, containerRef] = useCollapsedActions()
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
@@ -36,24 +38,6 @@ function Users() {
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateTableHeight = () => {
|
||||
const regionHeight = tableRegionRef.current?.offsetHeight || 0
|
||||
setTableHeight(Math.max(220, regionHeight - 56))
|
||||
}
|
||||
|
||||
updateTableHeight()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateTableHeight)
|
||||
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [users.length])
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingUser(null)
|
||||
form.resetFields()
|
||||
@@ -126,12 +110,21 @@ function Users() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right' as const,
|
||||
width: actionsCollapsed ? 56 : 172,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: User) => (
|
||||
<Space>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
<TableActions
|
||||
collapsed={actionsCollapsed}
|
||||
items={[
|
||||
{ key: 'edit', label: '编辑', icon: <EditOutlined />, onClick: () => handleEdit(record) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, onClick: () => handleDelete(record.id) },
|
||||
]}
|
||||
>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -143,17 +136,19 @@ function Users() {
|
||||
<h2 style={{ margin: 0 }}>用户管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||
</div>
|
||||
<div className="page-shell__body">
|
||||
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region users-table-region" style={{ height: '100%' }}>
|
||||
<div className="page-shell__body" ref={containerRef}>
|
||||
<TableScrollRegion className="data-source-table-region users-table-region">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content', y: tableHeight }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={false}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user