Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b8f7138c0 | ||
|
|
d5f3784ffb | ||
|
|
195a8bf71c | ||
|
|
987c378f99 | ||
|
|
67f82dc41c | ||
|
|
abe04030fb | ||
|
|
6a5f9f7ad4 | ||
|
|
439a512148 | ||
|
|
f73fa1ea6d | ||
|
|
5b623a6385 |
91
.claude/commands/goal-driven.md
Normal file
91
.claude/commands/goal-driven.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
description: 用 goal-driven 方法推动一个复杂任务持续执行,直到明确成功标准被满足
|
||||||
|
argument-hint: 建议填写任务目标;若同时给出成功标准更好
|
||||||
|
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# /goal-driven — 目标驱动执行模式
|
||||||
|
|
||||||
|
使用 `lidangzzz/goal-driven` 的核心思想来推进复杂任务:先固定目标与成功标准,再持续执行和反复验收,直到标准真正满足。
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- 长周期实现任务
|
||||||
|
- 高复杂度工程任务
|
||||||
|
- 可被明确验收的研究、实现、迁移、验证类工作
|
||||||
|
|
||||||
|
不适用场景:
|
||||||
|
|
||||||
|
- 纯脑暴
|
||||||
|
- 无法定义成功标准的模糊任务
|
||||||
|
- 很小的一次性修改
|
||||||
|
|
||||||
|
## 输入要求
|
||||||
|
|
||||||
|
若 `$ARGUMENTS` 只包含目标,没有成功标准,先补全一版可执行的成功标准再开始。
|
||||||
|
|
||||||
|
启动时先输出:
|
||||||
|
|
||||||
|
```md
|
||||||
|
Goal
|
||||||
|
- ...
|
||||||
|
|
||||||
|
Criteria for success
|
||||||
|
- ...
|
||||||
|
|
||||||
|
Plan
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
3. ...
|
||||||
|
|
||||||
|
Verification
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 执行规则
|
||||||
|
|
||||||
|
1. 先把任务固化为两个核心块:
|
||||||
|
- `Goal`
|
||||||
|
- `Criteria for success`
|
||||||
|
|
||||||
|
2. 成功标准必须尽量客观,可验证,可落地。
|
||||||
|
优先写成:
|
||||||
|
- 需要交付什么
|
||||||
|
- 需要通过哪些测试或验证
|
||||||
|
- 如何判断结果真的完成
|
||||||
|
|
||||||
|
3. 进入持续执行循环:
|
||||||
|
- 完成一个阶段
|
||||||
|
- 检查当前结果是否满足成功标准
|
||||||
|
- 若未满足,明确剩余差距并继续推进
|
||||||
|
|
||||||
|
4. 任何“完成了”“差不多了”“已实现”之类的结论,都必须经过验证,不能直接接受。
|
||||||
|
|
||||||
|
5. 如果验证失败:
|
||||||
|
- 明确指出哪条成功标准没满足
|
||||||
|
- 继续工作,不要把阶段性进展误判为完成
|
||||||
|
|
||||||
|
6. 只有在以下情况之一才能停止:
|
||||||
|
- 成功标准已满足
|
||||||
|
- 用户明确要求停止
|
||||||
|
|
||||||
|
## 执行风格
|
||||||
|
|
||||||
|
- 重证据,轻口头判断
|
||||||
|
- 重验收,轻自我感觉
|
||||||
|
- 优先用测试、日志、产物、对比结果来证明完成
|
||||||
|
- 对长期任务保持“未达标就继续”的节奏
|
||||||
|
|
||||||
|
## 简版模板
|
||||||
|
|
||||||
|
```md
|
||||||
|
Goal: [[[[[在此填写最终目标]]]]]
|
||||||
|
|
||||||
|
Criteria for success: [[[[[在此填写成功标准]]]]]
|
||||||
|
|
||||||
|
循环执行:
|
||||||
|
1. 推进任务
|
||||||
|
2. 检查是否满足成功标准
|
||||||
|
3. 若未满足,继续工作
|
||||||
|
4. 直到满足标准或用户明确停止
|
||||||
|
```
|
||||||
3
.codex/config.toml
Normal file
3
.codex/config.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
approval_policy = "never"
|
||||||
|
|
||||||
|
sandbox_mode = "danger-full-access"
|
||||||
101
.codex/skills/goal-driven/SKILL.md
Executable file
101
.codex/skills/goal-driven/SKILL.md
Executable file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: goal-driven
|
||||||
|
description: Run a goal-driven execution loop for very large, long-horizon, rigorously verifiable tasks. Use when the user explicitly wants the lidangzzz/goal-driven method, a master-agent plus worker-agent style workflow, or a persistent loop that keeps working until concrete success criteria are satisfied.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Goal-Driven
|
||||||
|
|
||||||
|
Use this skill when the user wants a strict goal-driven workflow for a hard task with:
|
||||||
|
|
||||||
|
- one clear end goal
|
||||||
|
- explicit success criteria
|
||||||
|
- repeated verification against those criteria
|
||||||
|
- continued execution until the criteria are actually met
|
||||||
|
|
||||||
|
This skill is adapted from `lidangzzz/goal-driven`, but trimmed for local skill use to avoid bloating context.
|
||||||
|
|
||||||
|
## When To Use
|
||||||
|
|
||||||
|
Use it for tasks like:
|
||||||
|
|
||||||
|
- compilers, interpreters, theorem-like proof work, deep refactors
|
||||||
|
- long-running system design or implementation work
|
||||||
|
- problems that are expensive and complex, but still objectively testable
|
||||||
|
|
||||||
|
Do not use it for:
|
||||||
|
|
||||||
|
- vague brainstorming without a success condition
|
||||||
|
- short one-shot edits
|
||||||
|
- tasks where "done" cannot be evaluated in a meaningful way
|
||||||
|
|
||||||
|
## Core Model
|
||||||
|
|
||||||
|
The workflow has two roles:
|
||||||
|
|
||||||
|
1. Master role
|
||||||
|
Defines the goal, defines the success criteria, audits progress, and decides whether the work is actually complete.
|
||||||
|
|
||||||
|
2. Worker role
|
||||||
|
Keeps advancing the task toward the goal. If a result is partial, stalled, or unverifiable, the worker continues.
|
||||||
|
|
||||||
|
In Codex, only use actual subagents when the user explicitly asks for delegation or subagent work and the platform supports it. Otherwise emulate the same loop locally: keep working, checkpointing, and re-verifying until the criteria are satisfied.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Normalize the task into two blocks:
|
||||||
|
- `Goal`
|
||||||
|
- `Criteria for success`
|
||||||
|
|
||||||
|
2. Make the criteria concrete and testable.
|
||||||
|
Good criteria usually include:
|
||||||
|
- required outputs
|
||||||
|
- required validations or tests
|
||||||
|
- edge cases or coverage thresholds
|
||||||
|
- what evidence proves completion
|
||||||
|
|
||||||
|
3. Break the work into milestones that can each produce evidence.
|
||||||
|
|
||||||
|
4. Execute the next milestone.
|
||||||
|
If subagents are explicitly allowed, the master may delegate bounded worker tasks.
|
||||||
|
If not, do the work locally but keep the master/worker mindset.
|
||||||
|
|
||||||
|
5. Whenever work pauses, stalls, or appears complete, audit against the criteria directly.
|
||||||
|
Check artifacts, tests, logs, diffs, metrics, or other real evidence.
|
||||||
|
|
||||||
|
6. If the criteria are not met, continue with a specific delta:
|
||||||
|
- what is still missing
|
||||||
|
- what evidence failed
|
||||||
|
- what the next worker pass must improve
|
||||||
|
|
||||||
|
7. Stop only when the criteria are met, or when the user explicitly stops the process.
|
||||||
|
|
||||||
|
## Operating Rules
|
||||||
|
|
||||||
|
- Prefer objective checks over self-reported completion.
|
||||||
|
- Do not confuse progress with completion.
|
||||||
|
- If the worker says "done", verify it.
|
||||||
|
- If verification fails, continue from the gap instead of restarting blindly.
|
||||||
|
- Keep the goal stable unless the user changes it.
|
||||||
|
- Tighten fuzzy criteria before sinking large amounts of effort.
|
||||||
|
|
||||||
|
## Recommended Response Shape
|
||||||
|
|
||||||
|
When starting a goal-driven task, structure the kickoff like this:
|
||||||
|
|
||||||
|
```md
|
||||||
|
Goal
|
||||||
|
- ...
|
||||||
|
|
||||||
|
Criteria for success
|
||||||
|
- ...
|
||||||
|
|
||||||
|
Current plan
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
3. ...
|
||||||
|
|
||||||
|
Verification
|
||||||
|
- What evidence will prove completion
|
||||||
|
```
|
||||||
|
|
||||||
|
For a reusable prompt template, read [references/prompt-template.md](references/prompt-template.md).
|
||||||
7
.codex/skills/goal-driven/agents/openai.yaml
Normal file
7
.codex/skills/goal-driven/agents/openai.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Goal-Driven"
|
||||||
|
short_description: "Drive complex work until explicit success criteria are met."
|
||||||
|
default_prompt: "Use $goal-driven to turn this task into a concrete goal, explicit success criteria, and a verification-driven execution loop."
|
||||||
|
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
38
.codex/skills/goal-driven/references/prompt-template.md
Executable file
38
.codex/skills/goal-driven/references/prompt-template.md
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
# Goal-Driven Prompt Template
|
||||||
|
|
||||||
|
Use this when you want a reusable kickoff prompt for a master/worker execution loop.
|
||||||
|
|
||||||
|
```md
|
||||||
|
# Goal-Driven System
|
||||||
|
|
||||||
|
Goal: [[[[[DEFINE THE FINAL GOAL HERE]]]]]
|
||||||
|
|
||||||
|
Criteria for success: [[[[[DEFINE THE SUCCESS CRITERIA HERE]]]]]
|
||||||
|
|
||||||
|
You are the master agent.
|
||||||
|
|
||||||
|
Your job is to:
|
||||||
|
1. Keep the goal and criteria fixed.
|
||||||
|
2. Start worker execution toward the goal.
|
||||||
|
3. Audit any claimed progress against the criteria.
|
||||||
|
4. If the criteria are not met, continue the work with a precise next delta.
|
||||||
|
5. Stop only when the criteria are satisfied or the user explicitly stops the process.
|
||||||
|
|
||||||
|
Worker requirements:
|
||||||
|
1. Break the task into subproblems.
|
||||||
|
2. Keep producing concrete progress toward the goal.
|
||||||
|
3. Report evidence, not just claims.
|
||||||
|
4. Continue until the criteria are satisfied.
|
||||||
|
|
||||||
|
Master audit loop:
|
||||||
|
1. Check whether the worker is still making progress.
|
||||||
|
2. If the worker stalls or claims completion, verify against the criteria.
|
||||||
|
3. If verification fails, resume work from the remaining gap.
|
||||||
|
4. Repeat until the criteria are met.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Stronger criteria produce better results than stronger rhetoric.
|
||||||
|
- Prefer measurable checks such as tests, parity checks, generated artifacts, benchmarks, or reviewable outputs.
|
||||||
|
- If the environment does not support subagents, emulate the same loop locally.
|
||||||
114
README.md
114
README.md
@@ -227,6 +227,120 @@ bun run build
|
|||||||
|
|
||||||
启动服务后访问: `http://localhost:8000/docs`
|
启动服务后访问: `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
## WSL / Windows 局域网访问
|
||||||
|
|
||||||
|
如果服务运行在 WSL 中,而你希望:
|
||||||
|
|
||||||
|
- Windows 本机浏览器访问开发服务
|
||||||
|
- 同一局域网内的手机或其他电脑访问开发服务
|
||||||
|
|
||||||
|
推荐按下面顺序排查和配置。
|
||||||
|
|
||||||
|
### 1. 在 WSL 中启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./planet.sh start --allow-lan
|
||||||
|
```
|
||||||
|
|
||||||
|
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`。
|
||||||
|
|
||||||
|
### 2. 先确认 WSL 内部服务正常
|
||||||
|
|
||||||
|
在 WSL 中执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
ss -ltnp | grep -E ':3000|:8000'
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:
|
||||||
|
|
||||||
|
- `3000` 返回前端 HTML
|
||||||
|
- `8000/health` 返回健康检查 JSON
|
||||||
|
- `ss` 中能看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`
|
||||||
|
|
||||||
|
如果这一步不通,先不要继续做 Windows 转发。
|
||||||
|
|
||||||
|
### 3. 在 Windows 本机验证 localhost 直通
|
||||||
|
|
||||||
|
在 Windows PowerShell 中执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl http://localhost:3000
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
在常见的 WSL2 开发环境下,Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。
|
||||||
|
|
||||||
|
### 4. 如果需要让局域网设备访问,再做 Windows 端口转发
|
||||||
|
|
||||||
|
注意:下面的命令必须在“以管理员身份运行”的 PowerShell 中执行。
|
||||||
|
|
||||||
|
先把 Windows 对外网卡上的 `3000` / `8000` 转发到 Windows 本机 `127.0.0.1`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||||
|
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||||
|
|
||||||
|
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||||
|
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||||
|
```
|
||||||
|
|
||||||
|
再放行 Windows 防火墙:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||||
|
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
检查转发规则是否生效:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
netsh interface portproxy show all
|
||||||
|
```
|
||||||
|
|
||||||
|
预期能看到:
|
||||||
|
|
||||||
|
- `0.0.0.0:3000 -> 127.0.0.1:3000`
|
||||||
|
- `0.0.0.0:8000 -> 127.0.0.1:8000`
|
||||||
|
|
||||||
|
### 5. 查 Windows 局域网 IP,并让其他设备访问
|
||||||
|
|
||||||
|
在 Windows PowerShell 中执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ipconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
找到当前联网网卡的 IPv4 地址,例如 `192.168.8.228`。
|
||||||
|
|
||||||
|
局域网其他设备可访问:
|
||||||
|
|
||||||
|
- `http://<Windows局域网IP>:3000/earth`
|
||||||
|
- `http://<Windows局域网IP>:3000/admin`
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- `http://192.168.8.228:3000/earth`
|
||||||
|
|
||||||
|
### 6. 常见现象与判断
|
||||||
|
|
||||||
|
- WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000`
|
||||||
|
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常缺少 `portproxy` 或防火墙放行
|
||||||
|
- `whoami /groups` 中 `S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口
|
||||||
|
|
||||||
|
### 7. 本项目一次性验证顺序
|
||||||
|
|
||||||
|
建议固定按这个顺序验证:
|
||||||
|
|
||||||
|
1. WSL 中执行 `curl http://localhost:3000`
|
||||||
|
2. WSL 中执行 `curl http://localhost:8000/health`
|
||||||
|
3. Windows 中执行 `curl http://localhost:3000`
|
||||||
|
4. Windows 中执行 `curl http://localhost:8000/health`
|
||||||
|
5. 管理员 PowerShell 配置 `portproxy` 和防火墙
|
||||||
|
6. 用手机或其他电脑访问 `http://<Windows局域网IP>:3000/earth`
|
||||||
|
|
||||||
## 启动容错参数
|
## 启动容错参数
|
||||||
|
|
||||||
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。
|
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。
|
||||||
|
|||||||
11
TODO.md
11
TODO.md
@@ -22,5 +22,16 @@
|
|||||||
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
|
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
|
||||||
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
|
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
|
||||||
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
|
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
|
||||||
|
- [ ] 为 Planet / Earth 补一个可用的日志查看系统:先明确前后端/AI Provider/采集任务的日志入口、最近日志聚合、筛选与 tail 能力,再决定是先做脚本级统一入口还是控制台内置日志面板
|
||||||
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
|
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
|
||||||
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
|
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
|
||||||
|
- [ ] 为 Earth 地球表面增加一层与基础纹理对齐的材质/纹理 overlay,并在同层叠加国界轮廓参考线;要求国界线与底图稳定对齐,且 hover 到国家轮廓时能高亮当前国家,便于校准地表和增强交互
|
||||||
|
- [ ] 把 Earth 新闻接入通用巡航队列:按新闻发生地和时间排序生成巡航目标,巡航聚焦到新闻事件时显示对应新闻卡片,并保持实现边界为“通用巡航层 + 新闻业务适配层”,不要再把新闻逻辑直接耦合回 `main.js` 状态机
|
||||||
|
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
|
||||||
|
- [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py`
|
||||||
|
- [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标,也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索
|
||||||
|
- [ ] 为未知位置算力中心增加外部富化策略评估:可选接入公开知识源或搜索兜底,只抓“站点名/园区名/城市名”级别线索,不直接抓经纬度结论,并把结果作为候选证据而不是真值
|
||||||
|
- [ ] 为算力中心建立 `operator / cluster name / facility alias` 归一化层,先解决 `xAI / Colossus / Memphis`、`OpenAI / Stargate`、`CoreWeave`、`Lambda`、`Crusoe` 这类同一对象多种写法导致的地点匹配失败
|
||||||
|
- [ ] 为估算位置增加更细的视觉和产品表达:除了问号角标,还要支持 tooltip/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置”
|
||||||
|
- [ ] 为国家级估算点设计更合理的落点策略:优先落在“该国主要算力/数据中心城市候选集”而不是几何质心,必要时同国多节点做稳定散列分配,避免大量节点堆在荒漠或海上
|
||||||
|
- [ ] 为未知位置算力中心建立人工校验工作流:支持导出待核验清单、记录人工确认结果,并把人工确认反哺到位置注册表,逐步减少问号点比例
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.config import ROOT_DIR
|
from app.core.config import ROOT_DIR
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services.persistent_logs import record_audit_log, record_system_log
|
||||||
from app.services.system_control import (
|
from app.services.system_control import (
|
||||||
build_task_id,
|
build_task_id,
|
||||||
clear_active_task_id,
|
clear_active_task_id,
|
||||||
@@ -23,6 +26,15 @@ from app.services.system_control import (
|
|||||||
set_active_task_id,
|
set_active_task_id,
|
||||||
upsert_task_state,
|
upsert_task_state,
|
||||||
)
|
)
|
||||||
|
from app.services.system_logs import (
|
||||||
|
DEFAULT_LOG_LINE_LIMIT,
|
||||||
|
MAX_LOG_LINE_LIMIT,
|
||||||
|
SUPPORTED_LOG_LEVELS,
|
||||||
|
append_buffer_log,
|
||||||
|
list_log_sources,
|
||||||
|
normalize_log_level,
|
||||||
|
read_log_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -47,6 +59,59 @@ class RestartTaskLogsResponse(BaseModel):
|
|||||||
lines: list[str]
|
lines: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SystemLogSourceSummary(BaseModel):
|
||||||
|
source_id: str
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
location: str
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class SystemLogSourcesResponse(BaseModel):
|
||||||
|
items: list[SystemLogSourceSummary]
|
||||||
|
|
||||||
|
|
||||||
|
class SystemLogDailyMarker(BaseModel):
|
||||||
|
date_token: str
|
||||||
|
total: int
|
||||||
|
dominant_level: str
|
||||||
|
|
||||||
|
|
||||||
|
class SystemLogSnapshotResponse(BaseModel):
|
||||||
|
source_id: str
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
location: str
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
status: str
|
||||||
|
level: str
|
||||||
|
selected_levels: list[str] = []
|
||||||
|
search_query: str = ""
|
||||||
|
available_levels: list[str]
|
||||||
|
daily_markers: list[SystemLogDailyMarker] = []
|
||||||
|
line_limit: int
|
||||||
|
line_count: int
|
||||||
|
lines: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class EarthClientLogEventCreate(BaseModel):
|
||||||
|
level: str = "error"
|
||||||
|
message: str
|
||||||
|
category: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
module: str | None = None
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EarthClientLogEventResponse(BaseModel):
|
||||||
|
accepted: bool
|
||||||
|
source_id: str
|
||||||
|
level: str
|
||||||
|
|
||||||
|
|
||||||
def ensure_super_admin(current_user: User) -> None:
|
def ensure_super_admin(current_user: User) -> None:
|
||||||
if not require_super_admin(current_user.role):
|
if not require_super_admin(current_user.role):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -55,9 +120,22 @@ def ensure_super_admin(current_user: User) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_log_date(raw_value: str | None, field_name: str) -> str | None:
|
||||||
|
if raw_value in {None, ""}:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(raw_value, "%Y-%m-%d").date().isoformat()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"{field_name} must be in YYYY-MM-DD format",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/restart-tasks", response_model=RestartTaskResponse)
|
@router.post("/restart-tasks", response_model=RestartTaskResponse)
|
||||||
async def create_restart_task(
|
async def create_restart_task(
|
||||||
payload: RestartTaskCreate,
|
payload: RestartTaskCreate,
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
ensure_super_admin(current_user)
|
ensure_super_admin(current_user)
|
||||||
@@ -133,11 +211,31 @@ async def create_restart_task(
|
|||||||
requested_by=requested_by,
|
requested_by=requested_by,
|
||||||
)
|
)
|
||||||
clear_active_task_id(task_id)
|
clear_active_task_id(task_id)
|
||||||
|
await record_audit_log(
|
||||||
|
action="system.restart_task.requested",
|
||||||
|
actor_id=current_user.id,
|
||||||
|
actor_name=current_user.username,
|
||||||
|
target_type="restart_task",
|
||||||
|
target_id=task_id,
|
||||||
|
result="failed",
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
details={"action": payload.action, "message": task_state["message"]},
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=task_state["message"],
|
detail=task_state["message"],
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
await record_audit_log(
|
||||||
|
action="system.restart_task.requested",
|
||||||
|
actor_id=current_user.id,
|
||||||
|
actor_name=current_user.username,
|
||||||
|
target_type="restart_task",
|
||||||
|
target_id=task_id,
|
||||||
|
result="accepted",
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
details={"action": payload.action},
|
||||||
|
)
|
||||||
return task_state
|
return task_state
|
||||||
|
|
||||||
|
|
||||||
@@ -165,3 +263,92 @@ async def get_restart_task_logs(
|
|||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found")
|
||||||
return {"task_id": task_id, "lines": get_task_logs(task_id)}
|
return {"task_id": task_id, "lines": get_task_logs(task_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/sources", response_model=SystemLogSourcesResponse)
|
||||||
|
async def get_system_log_sources(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
ensure_super_admin(current_user)
|
||||||
|
return {"items": list_log_sources()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/{source_id}", response_model=SystemLogSnapshotResponse)
|
||||||
|
async def get_system_log_snapshot(
|
||||||
|
source_id: str,
|
||||||
|
limit: int = DEFAULT_LOG_LINE_LIMIT,
|
||||||
|
level: str = "all",
|
||||||
|
levels: str | None = Query(None, description="Comma-separated log levels"),
|
||||||
|
start_date: str | None = Query(None, description="Filter logs from this date (YYYY-MM-DD)"),
|
||||||
|
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
|
||||||
|
search: str | None = Query(None, description="Case-insensitive substring search"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
ensure_super_admin(current_user)
|
||||||
|
|
||||||
|
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}",
|
||||||
|
)
|
||||||
|
if str(level).strip().lower() not in SUPPORTED_LOG_LEVELS and normalize_log_level(level) == "all" and str(level).strip().lower() not in {"", "all"}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported log level")
|
||||||
|
if levels:
|
||||||
|
for raw_level in str(levels).split(","):
|
||||||
|
normalized_level = str(raw_level).strip().lower()
|
||||||
|
if not normalized_level:
|
||||||
|
continue
|
||||||
|
if normalized_level not in SUPPORTED_LOG_LEVELS and normalize_log_level(normalized_level) == "all":
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported log level")
|
||||||
|
normalized_start_date = validate_log_date(start_date, "start_date")
|
||||||
|
normalized_end_date = validate_log_date(end_date, "end_date")
|
||||||
|
if normalized_start_date and normalized_end_date and normalized_start_date > normalized_end_date:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date must be earlier than or equal to end_date")
|
||||||
|
|
||||||
|
snapshot = read_log_snapshot(
|
||||||
|
source_id,
|
||||||
|
limit,
|
||||||
|
level=level,
|
||||||
|
levels=levels,
|
||||||
|
start_date=normalized_start_date,
|
||||||
|
end_date=normalized_end_date,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
if snapshot is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Log source not found")
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logs/earth-client", response_model=EarthClientLogEventResponse)
|
||||||
|
async def ingest_earth_client_log(
|
||||||
|
payload: EarthClientLogEventCreate,
|
||||||
|
request: Request,
|
||||||
|
):
|
||||||
|
normalized_level = normalize_log_level(payload.level)
|
||||||
|
append_buffer_log(
|
||||||
|
"earth-client",
|
||||||
|
level=normalized_level,
|
||||||
|
message=payload.message,
|
||||||
|
context={
|
||||||
|
"category": payload.category or "",
|
||||||
|
"url": payload.url or "",
|
||||||
|
"module": payload.module or "",
|
||||||
|
"detail": payload.detail or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await record_system_log(
|
||||||
|
source="earth-client",
|
||||||
|
service="earth",
|
||||||
|
module=payload.module or "earth-client",
|
||||||
|
event="earth.client.runtime_log",
|
||||||
|
level=normalized_level,
|
||||||
|
message=payload.message,
|
||||||
|
category=payload.category or "client-runtime",
|
||||||
|
context={
|
||||||
|
"url": payload.url or "",
|
||||||
|
"detail": payload.detail or "",
|
||||||
|
"module": payload.module or "",
|
||||||
|
"client_ip": request.client.host if request.client else "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"accepted": True, "source_id": "earth-client", "level": normalized_level}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy import select, func
|
|||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from app.core.collected_data_fields import get_record_field
|
from app.core.collected_data_fields import get_record_field
|
||||||
|
from app.core.countries import get_country_centroid
|
||||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
@@ -22,8 +23,11 @@ from app.models.collected_data import CollectedData
|
|||||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||||
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
|
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
|
||||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||||
|
from app.services.persistent_logs import record_system_log
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = get_logger(__name__, service="api")
|
||||||
TERRAIN_TILE_URL_TEMPLATE = (
|
TERRAIN_TILE_URL_TEMPLATE = (
|
||||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||||
)
|
)
|
||||||
@@ -363,6 +367,215 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An
|
|||||||
return {"type": "FeatureCollection", "features": features}
|
return {"type": "FeatureCollection", "features": features}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_float(value: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
COMPUTE_CENTER_COORDINATE_HINTS = (
|
||||||
|
("el capitan", 37.6819, -121.7681),
|
||||||
|
("livermore", 37.6819, -121.7681),
|
||||||
|
("llnl", 37.6819, -121.7681),
|
||||||
|
("lawrence livermore", 37.6819, -121.7681),
|
||||||
|
("frontier", 35.9319, -84.3107),
|
||||||
|
("oak ridge", 35.9319, -84.3107),
|
||||||
|
("ornl", 35.9319, -84.3107),
|
||||||
|
("aurora", 41.7130, -87.9820),
|
||||||
|
("argonne", 41.7130, -87.9820),
|
||||||
|
("anl", 41.7130, -87.9820),
|
||||||
|
("fugaku", 34.6953, 135.1974),
|
||||||
|
("kobe", 34.6953, 135.1974),
|
||||||
|
("riken", 34.6953, 135.1974),
|
||||||
|
("summit", 35.9319, -84.3107),
|
||||||
|
("leonardo", 44.4949, 11.3426),
|
||||||
|
("bologna", 44.4949, 11.3426),
|
||||||
|
("alps", 46.0037, 8.9511),
|
||||||
|
("lugano", 46.0037, 8.9511),
|
||||||
|
("sunway taihulight", 31.4912, 120.3119),
|
||||||
|
("wuxi", 31.4912, 120.3119),
|
||||||
|
("tianhe-2", 23.1291, 113.2644),
|
||||||
|
("tianhe-2a", 23.1291, 113.2644),
|
||||||
|
("guangzhou", 23.1291, 113.2644),
|
||||||
|
("colossus", 35.1495, -90.0490),
|
||||||
|
("memphis", 35.1495, -90.0490),
|
||||||
|
("xai", 35.1495, -90.0490),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_hint_text(*parts: Any) -> str:
|
||||||
|
return " ".join(
|
||||||
|
str(part).strip().lower()
|
||||||
|
for part in parts
|
||||||
|
if part not in (None, "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_compute_center_coordinates(
|
||||||
|
record: CollectedData,
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
latitude = _parse_float(get_record_field(record, "latitude"))
|
||||||
|
longitude = _parse_float(get_record_field(record, "longitude"))
|
||||||
|
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||||
|
return {
|
||||||
|
"latitude": latitude,
|
||||||
|
"longitude": longitude,
|
||||||
|
"location_precision": "precise",
|
||||||
|
"geography_mode": "source_coordinates",
|
||||||
|
"is_estimated": False,
|
||||||
|
"estimated_reason": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
hint_text = _normalize_hint_text(
|
||||||
|
record.name,
|
||||||
|
get_record_field(record, "city"),
|
||||||
|
get_record_field(record, "country"),
|
||||||
|
metadata.get("site"),
|
||||||
|
metadata.get("organization"),
|
||||||
|
metadata.get("operator"),
|
||||||
|
)
|
||||||
|
for needle, resolved_latitude, resolved_longitude in COMPUTE_CENTER_COORDINATE_HINTS:
|
||||||
|
if needle in hint_text:
|
||||||
|
return {
|
||||||
|
"latitude": resolved_latitude,
|
||||||
|
"longitude": resolved_longitude,
|
||||||
|
"location_precision": "estimated_site",
|
||||||
|
"geography_mode": "site_hint",
|
||||||
|
"is_estimated": True,
|
||||||
|
"estimated_reason": f"Matched known site hint: {needle}",
|
||||||
|
}
|
||||||
|
|
||||||
|
centroid = get_country_centroid(get_record_field(record, "country"))
|
||||||
|
if centroid:
|
||||||
|
return {
|
||||||
|
"latitude": centroid.get("latitude"),
|
||||||
|
"longitude": centroid.get("longitude"),
|
||||||
|
"location_precision": "estimated_country",
|
||||||
|
"geography_mode": "country_centroid",
|
||||||
|
"is_estimated": True,
|
||||||
|
"estimated_reason": "Estimated from country centroid",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"latitude": latitude,
|
||||||
|
"longitude": longitude,
|
||||||
|
"location_precision": "unknown",
|
||||||
|
"geography_mode": "unknown",
|
||||||
|
"is_estimated": True,
|
||||||
|
"estimated_reason": "No resolvable location hints",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str) -> str:
|
||||||
|
if capacity_value is None:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
unit = str(capacity_unit or "").strip().lower()
|
||||||
|
if unit in {"pflop/s", "pflops", "pflop"}:
|
||||||
|
normalized_tflops = capacity_value * 1000
|
||||||
|
elif unit in {"gflop/s", "gflops", "gflop"}:
|
||||||
|
normalized_tflops = capacity_value / 1000
|
||||||
|
else:
|
||||||
|
normalized_tflops = capacity_value
|
||||||
|
|
||||||
|
if normalized_tflops >= 1_000_000:
|
||||||
|
return "exascale"
|
||||||
|
if normalized_tflops >= 100_000:
|
||||||
|
return "ultra"
|
||||||
|
if normalized_tflops >= 10_000:
|
||||||
|
return "large"
|
||||||
|
if normalized_tflops > 0:
|
||||||
|
return "regional"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||||
|
"""Convert compute infrastructure records into a unified GeoJSON layer."""
|
||||||
|
features = []
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
metadata = record.extra_data or {}
|
||||||
|
coordinate_info = _resolve_compute_center_coordinates(record, metadata)
|
||||||
|
latitude = coordinate_info.get("latitude")
|
||||||
|
longitude = coordinate_info.get("longitude")
|
||||||
|
site_type = (
|
||||||
|
"supercomputer"
|
||||||
|
if record.source == "top500" or record.data_type == "supercomputer"
|
||||||
|
else "gpu_cluster"
|
||||||
|
)
|
||||||
|
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if site_type == "supercomputer":
|
||||||
|
capacity_value = _parse_float(get_record_field(record, "rmax"))
|
||||||
|
capacity_unit = "GFlops"
|
||||||
|
else:
|
||||||
|
capacity_value = _parse_float(get_record_field(record, "value"))
|
||||||
|
capacity_unit = str(get_record_field(record, "unit") or "TFlop/s")
|
||||||
|
|
||||||
|
vendor = (
|
||||||
|
metadata.get("manufacturer")
|
||||||
|
or metadata.get("vendor")
|
||||||
|
or metadata.get("gpu_type")
|
||||||
|
)
|
||||||
|
operator = (
|
||||||
|
metadata.get("organization")
|
||||||
|
or metadata.get("operator")
|
||||||
|
or metadata.get("owner")
|
||||||
|
)
|
||||||
|
rank = metadata.get("rank")
|
||||||
|
if rank in (None, "") and site_type == "supercomputer":
|
||||||
|
rank = get_record_field(record, "rank")
|
||||||
|
|
||||||
|
updated_at = to_iso8601_utc(record.reference_date or record.collected_at)
|
||||||
|
|
||||||
|
features.append(
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": record.id,
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [longitude or 0, latitude or 0],
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": record.id,
|
||||||
|
"source_id": record.source_id,
|
||||||
|
"name": record.name,
|
||||||
|
"site_type": site_type,
|
||||||
|
"country": get_record_field(record, "country"),
|
||||||
|
"city": get_record_field(record, "city"),
|
||||||
|
"latitude": latitude,
|
||||||
|
"longitude": longitude,
|
||||||
|
"operator": operator,
|
||||||
|
"vendor": vendor,
|
||||||
|
"capacity_value": capacity_value,
|
||||||
|
"capacity_unit": capacity_unit,
|
||||||
|
"capacity_band": _normalize_capacity_band(capacity_value, capacity_unit),
|
||||||
|
"rank": rank,
|
||||||
|
"gpu_count": metadata.get("gpu_count"),
|
||||||
|
"gpu_type": metadata.get("gpu_type"),
|
||||||
|
"cores": get_record_field(record, "cores"),
|
||||||
|
"power": get_record_field(record, "power"),
|
||||||
|
"source": record.source,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"status": "observed",
|
||||||
|
"location_precision": coordinate_info.get("location_precision"),
|
||||||
|
"geography_mode": coordinate_info.get("geography_mode"),
|
||||||
|
"is_estimated": coordinate_info.get("is_estimated", False),
|
||||||
|
"estimated_reason": coordinate_info.get("estimated_reason"),
|
||||||
|
"data_type": "compute_center",
|
||||||
|
"metadata": metadata,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"type": "FeatureCollection", "features": features}
|
||||||
|
|
||||||
|
|
||||||
def convert_bgp_anomalies_to_geojson(
|
def convert_bgp_anomalies_to_geojson(
|
||||||
records: List[BGPAnomaly],
|
records: List[BGPAnomaly],
|
||||||
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||||
@@ -780,6 +993,21 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.exception_event(
|
||||||
|
"Failed to build cables GeoJSON response",
|
||||||
|
event="visualization.cables.load_failed",
|
||||||
|
context={"error": str(e)},
|
||||||
|
)
|
||||||
|
await record_system_log(
|
||||||
|
source="backend",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
event="visualization.cables.load_failed",
|
||||||
|
level="error",
|
||||||
|
message="Failed to build cables GeoJSON response",
|
||||||
|
category="visualization",
|
||||||
|
context={"error": str(e)},
|
||||||
|
)
|
||||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@@ -816,6 +1044,21 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.exception_event(
|
||||||
|
"Failed to build landing points GeoJSON response",
|
||||||
|
event="visualization.landing_points.load_failed",
|
||||||
|
context={"error": str(e)},
|
||||||
|
)
|
||||||
|
await record_system_log(
|
||||||
|
source="backend",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
event="visualization.landing_points.load_failed",
|
||||||
|
level="error",
|
||||||
|
message="Failed to build landing points GeoJSON response",
|
||||||
|
category="visualization",
|
||||||
|
context={"error": str(e)},
|
||||||
|
)
|
||||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@@ -975,6 +1218,53 @@ async def get_gpu_clusters_geojson(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/geo/compute-centers")
|
||||||
|
async def get_compute_centers_geojson(
|
||||||
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取统一算力中心 GeoJSON 数据"""
|
||||||
|
records_by_source = await _load_current_collected_data_by_sources(
|
||||||
|
db,
|
||||||
|
["top500", "epoch_ai_gpu"],
|
||||||
|
)
|
||||||
|
records = _filter_known_records(
|
||||||
|
records_by_source.get("top500", []) + records_by_source.get("epoch_ai_gpu", []),
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
records = records[:limit]
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
return {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [],
|
||||||
|
"count": 0,
|
||||||
|
"stats": {
|
||||||
|
"total": 0,
|
||||||
|
"supercomputers": 0,
|
||||||
|
"gpu_clusters": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
geojson = convert_compute_centers_to_geojson(records)
|
||||||
|
features = geojson.get("features", [])
|
||||||
|
return {
|
||||||
|
**geojson,
|
||||||
|
"count": len(features),
|
||||||
|
"stats": {
|
||||||
|
"total": len(features),
|
||||||
|
"supercomputers": sum(
|
||||||
|
1 for feature in features
|
||||||
|
if feature.get("properties", {}).get("site_type") == "supercomputer"
|
||||||
|
),
|
||||||
|
"gpu_clusters": sum(
|
||||||
|
1 for feature in features
|
||||||
|
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/geo/bgp-anomalies")
|
@router.get("/geo/bgp-anomalies")
|
||||||
async def get_bgp_anomalies_geojson(
|
async def get_bgp_anomalies_geojson(
|
||||||
severity: Optional[str] = Query(None),
|
severity: Optional[str] = Query(None),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -10,10 +9,11 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
|||||||
from jose import jwt, JWTError
|
from jose import jwt, JWTError
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.core.websocket.manager import manager
|
from app.core.websocket.manager import manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = get_logger(__name__, service="api")
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -22,11 +22,18 @@ async def authenticate_token(token: str) -> Optional[dict]:
|
|||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
logger.warning(f"WebSocket auth failed: wrong token type")
|
logger.warning_event(
|
||||||
|
"WebSocket auth failed: wrong token type",
|
||||||
|
event="auth.websocket.invalid_token_type",
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
return payload
|
return payload
|
||||||
except JWTError as e:
|
except JWTError as e:
|
||||||
logger.warning(f"WebSocket auth failed: {e}")
|
logger.warning_event(
|
||||||
|
"WebSocket auth failed",
|
||||||
|
event="auth.websocket.decode_failed",
|
||||||
|
context={"error": str(e)},
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -36,10 +43,17 @@ async def websocket_endpoint(
|
|||||||
token: str = Query(...),
|
token: str = Query(...),
|
||||||
):
|
):
|
||||||
"""WebSocket endpoint for real-time data"""
|
"""WebSocket endpoint for real-time data"""
|
||||||
logger.info(f"WebSocket connection attempt with token: {token[:20]}...")
|
logger.info_event(
|
||||||
|
"WebSocket connection attempt",
|
||||||
|
event="auth.websocket.connection_attempt",
|
||||||
|
context={"token_preview": f"{token[:8]}..."},
|
||||||
|
)
|
||||||
payload = await authenticate_token(token)
|
payload = await authenticate_token(token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
logger.warning("WebSocket authentication failed, closing connection")
|
logger.warning_event(
|
||||||
|
"WebSocket authentication failed, closing connection",
|
||||||
|
event="auth.websocket.connection_rejected",
|
||||||
|
)
|
||||||
await websocket.close(code=4001)
|
await websocket.close(code=4001)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"""Redis caching service"""
|
"""Redis caching service"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Lazy Redis client initialization
|
# Lazy Redis client initialization
|
||||||
@@ -47,7 +47,7 @@ class CacheService:
|
|||||||
return json.loads(value)
|
return json.loads(value)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Cache get error: {e}")
|
logger.warning_event("Cache get error", event="cache.get.failed", context={"error": str(e)})
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set(
|
def set(
|
||||||
@@ -61,7 +61,7 @@ class CacheService:
|
|||||||
serialized = json.dumps(value, default=str)
|
serialized = json.dumps(value, default=str)
|
||||||
return self.client.setex(key, expire_seconds, serialized)
|
return self.client.setex(key, expire_seconds, serialized)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Cache set error: {e}")
|
logger.warning_event("Cache set error", event="cache.set.failed", context={"error": str(e)})
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete(self, key: str) -> bool:
|
def delete(self, key: str) -> bool:
|
||||||
@@ -69,7 +69,7 @@ class CacheService:
|
|||||||
try:
|
try:
|
||||||
return self.client.delete(key) > 0
|
return self.client.delete(key) > 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Cache delete error: {e}")
|
logger.warning_event("Cache delete error", event="cache.delete.failed", context={"error": str(e)})
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_pattern(self, pattern: str) -> int:
|
def delete_pattern(self, pattern: str) -> int:
|
||||||
@@ -80,7 +80,7 @@ class CacheService:
|
|||||||
return self.client.delete(*keys)
|
return self.client.delete(*keys)
|
||||||
return 0
|
return 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Cache delete_pattern error: {e}")
|
logger.warning_event("Cache delete_pattern error", event="cache.delete_pattern.failed", context={"error": str(e)})
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def get_or_set(
|
def get_or_set(
|
||||||
|
|||||||
161
backend/app/core/logging.py
Normal file
161
backend/app/core/logging.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.request_context import get_request_id
|
||||||
|
|
||||||
|
DEFAULT_SERVICE = "backend"
|
||||||
|
DEFAULT_EVENT = "app.log"
|
||||||
|
DEFAULT_LOG_LEVEL = os.getenv("PLANET_LOG_LEVEL", "INFO").upper()
|
||||||
|
REDACTED = "[REDACTED]"
|
||||||
|
SENSITIVE_FIELD_NAMES = {
|
||||||
|
"access_token",
|
||||||
|
"api_key",
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"password",
|
||||||
|
"refresh_token",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
}
|
||||||
|
SENSITIVE_TEXT_PATTERNS = (
|
||||||
|
re.compile(r"(?i)(authorization\s*[:=]\s*)(.+)"),
|
||||||
|
re.compile(r"(?i)(bearer\s+)([A-Za-z0-9._\-]+)"),
|
||||||
|
re.compile(r"(?i)(token\s*[:=]\s*)(.+)"),
|
||||||
|
re.compile(r"(?i)(password\s*[:=]\s*)(.+)"),
|
||||||
|
re.compile(r"(?i)(cookie\s*[:=]\s*)(.+)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_log_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {
|
||||||
|
str(key): (REDACTED if str(key).lower() in SENSITIVE_FIELD_NAMES else sanitize_log_value(item))
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||||
|
return [sanitize_log_value(item) for item in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
sanitized = value
|
||||||
|
for pattern in SENSITIVE_TEXT_PATTERNS:
|
||||||
|
sanitized = pattern.sub(lambda match: f"{match.group(1)}{REDACTED}", sanitized)
|
||||||
|
return sanitized
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_context(context: Any) -> dict[str, Any]:
|
||||||
|
if context is None:
|
||||||
|
return {}
|
||||||
|
if isinstance(context, Mapping):
|
||||||
|
sanitized = sanitize_log_value(context)
|
||||||
|
return {str(key): value for key, value in sanitized.items()}
|
||||||
|
return {"value": sanitize_log_value(context)}
|
||||||
|
|
||||||
|
|
||||||
|
class PlanetContextFilter(logging.Filter):
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
record.request_id = getattr(record, "request_id", None) or get_request_id() or "-"
|
||||||
|
record.service = getattr(record, "service", None) or DEFAULT_SERVICE
|
||||||
|
record.event = getattr(record, "event", None) or DEFAULT_EVENT
|
||||||
|
record.context = _normalize_context(getattr(record, "context", None))
|
||||||
|
record.message = sanitize_log_value(record.getMessage())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class PlanetFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
timestamp = self.formatTime(record, self.datefmt)
|
||||||
|
level = record.levelname
|
||||||
|
service = getattr(record, "service", DEFAULT_SERVICE)
|
||||||
|
module_name = record.name
|
||||||
|
event = getattr(record, "event", DEFAULT_EVENT)
|
||||||
|
request_id = getattr(record, "request_id", "-")
|
||||||
|
message = sanitize_log_value(record.getMessage())
|
||||||
|
context = _normalize_context(getattr(record, "context", None))
|
||||||
|
context_suffix = ""
|
||||||
|
if context:
|
||||||
|
context_suffix = f" context={json.dumps(context, ensure_ascii=False, sort_keys=True)}"
|
||||||
|
rendered = (
|
||||||
|
f"{timestamp} {level} service={service} module={module_name} "
|
||||||
|
f"event={event} request_id={request_id} message={message}{context_suffix}"
|
||||||
|
)
|
||||||
|
if record.exc_info:
|
||||||
|
rendered = f"{rendered}\n{self.formatException(record.exc_info)}"
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
class PlanetLoggerAdapter(logging.LoggerAdapter):
|
||||||
|
def process(self, msg: Any, kwargs: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
|
||||||
|
extra = dict(self.extra)
|
||||||
|
extra.update(kwargs.get("extra", {}))
|
||||||
|
if "context" in extra:
|
||||||
|
extra["context"] = _normalize_context(extra.get("context"))
|
||||||
|
kwargs["extra"] = extra
|
||||||
|
return sanitize_log_value(msg), kwargs
|
||||||
|
|
||||||
|
def log_event(
|
||||||
|
self,
|
||||||
|
level: int,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
event: str,
|
||||||
|
context: Mapping[str, Any] | None = None,
|
||||||
|
**extra: Any,
|
||||||
|
) -> None:
|
||||||
|
self.log(level, message, extra={"event": event, "context": context or {}, **extra})
|
||||||
|
|
||||||
|
def debug_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||||
|
self.log_event(logging.DEBUG, message, event=event, context=context, **extra)
|
||||||
|
|
||||||
|
def info_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||||
|
self.log_event(logging.INFO, message, event=event, context=context, **extra)
|
||||||
|
|
||||||
|
def warning_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||||
|
self.log_event(logging.WARNING, message, event=event, context=context, **extra)
|
||||||
|
|
||||||
|
def error_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||||
|
self.log_event(logging.ERROR, message, event=event, context=context, **extra)
|
||||||
|
|
||||||
|
def exception_event(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
event: str,
|
||||||
|
context: Mapping[str, Any] | None = None,
|
||||||
|
**extra: Any,
|
||||||
|
) -> None:
|
||||||
|
self.error(message, exc_info=True, extra={"event": event, "context": context or {}, **extra})
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str, *, service: str = DEFAULT_SERVICE) -> PlanetLoggerAdapter:
|
||||||
|
return PlanetLoggerAdapter(logging.getLogger(name), {"service": service})
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str | None = None) -> None:
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
if getattr(configure_logging, "_configured", False):
|
||||||
|
if level:
|
||||||
|
root_logger.setLevel(level.upper())
|
||||||
|
return
|
||||||
|
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(PlanetFormatter(datefmt="%Y-%m-%d %H:%M:%S"))
|
||||||
|
handler.addFilter(PlanetContextFilter())
|
||||||
|
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
root_logger.setLevel((level or DEFAULT_LOG_LEVEL).upper())
|
||||||
|
|
||||||
|
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||||
|
target_logger = logging.getLogger(logger_name)
|
||||||
|
target_logger.handlers.clear()
|
||||||
|
target_logger.propagate = True
|
||||||
|
|
||||||
|
logging.captureWarnings(True)
|
||||||
|
configure_logging._configured = True
|
||||||
14
backend/app/core/request_context.py
Normal file
14
backend/app/core/request_context.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
|
||||||
|
request_id_context: ContextVar[str | None] = ContextVar("request_id", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def set_request_id(request_id: str | None) -> None:
|
||||||
|
request_id_context.set(request_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_id() -> str | None:
|
||||||
|
return request_id_context.get()
|
||||||
@@ -5,10 +5,22 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sess
|
|||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DB_POOL_CONFIG = {
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
"pool_recycle": 1800,
|
||||||
|
"pool_size": 10,
|
||||||
|
"max_overflow": 20,
|
||||||
|
"pool_timeout": 30,
|
||||||
|
}
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
settings.DATABASE_URL,
|
settings.DATABASE_URL,
|
||||||
echo=settings.DEBUG if hasattr(settings, "DEBUG") else False,
|
echo=settings.DEBUG if hasattr(settings, "DEBUG") else False,
|
||||||
|
**DB_POOL_CONFIG,
|
||||||
)
|
)
|
||||||
|
|
||||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
@@ -97,6 +109,19 @@ async def init_db():
|
|||||||
import app.models.system_setting # noqa: F401
|
import app.models.system_setting # noqa: F401
|
||||||
import app.models.playground_session # noqa: F401
|
import app.models.playground_session # noqa: F401
|
||||||
import app.models.playground_message # noqa: F401
|
import app.models.playground_message # noqa: F401
|
||||||
|
import app.models.system_log # noqa: F401
|
||||||
|
|
||||||
|
logger.warning_event(
|
||||||
|
"Database pool settings active",
|
||||||
|
event="database.pool.initialized",
|
||||||
|
context={
|
||||||
|
"pool_pre_ping": DB_POOL_CONFIG["pool_pre_ping"],
|
||||||
|
"pool_recycle": DB_POOL_CONFIG["pool_recycle"],
|
||||||
|
"pool_size": DB_POOL_CONFIG["pool_size"],
|
||||||
|
"max_overflow": DB_POOL_CONFIG["max_overflow"],
|
||||||
|
"pool_timeout": DB_POOL_CONFIG["pool_timeout"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -7,6 +8,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from app.api.main import api_router
|
from app.api.main import api_router
|
||||||
from app.api.v1 import websocket
|
from app.api.v1 import websocket
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.logging import configure_logging
|
||||||
|
from app.core.request_context import set_request_id
|
||||||
from app.core.websocket.broadcaster import broadcaster
|
from app.core.websocket.broadcaster import broadcaster
|
||||||
from app.db.session import init_db
|
from app.db.session import init_db
|
||||||
from app.services.scheduler import (
|
from app.services.scheduler import (
|
||||||
@@ -17,6 +20,9 @@ from app.services.scheduler import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
|
||||||
|
|
||||||
class WebSocketCORSMiddleware(BaseHTTPMiddleware):
|
class WebSocketCORSMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request, call_next):
|
async def dispatch(self, request, call_next):
|
||||||
if request.url.path.startswith("/ws") and request.method == "GET":
|
if request.url.path.startswith("/ws") and request.method == "GET":
|
||||||
@@ -28,6 +34,18 @@ class WebSocketCORSMiddleware(BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
request_id = request.headers.get("X-Request-ID") or uuid4().hex
|
||||||
|
set_request_id(request_id)
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
finally:
|
||||||
|
set_request_id(None)
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await init_db()
|
await init_db()
|
||||||
@@ -58,6 +76,7 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.add_middleware(RequestContextMiddleware)
|
||||||
app.add_middleware(WebSocketCORSMiddleware)
|
app.add_middleware(WebSocketCORSMiddleware)
|
||||||
|
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.models.bgp_observation import BGPObservation
|
|||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.playground_session import PlaygroundSession
|
from app.models.playground_session import PlaygroundSession
|
||||||
from app.models.playground_message import PlaygroundMessage
|
from app.models.playground_message import PlaygroundMessage
|
||||||
|
from app.models.system_log import SystemLog, AuditLog
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -26,4 +27,6 @@ __all__ = [
|
|||||||
"BGPAnomaly",
|
"BGPAnomaly",
|
||||||
"BGPIncident",
|
"BGPIncident",
|
||||||
"BGPObservation",
|
"BGPObservation",
|
||||||
|
"SystemLog",
|
||||||
|
"AuditLog",
|
||||||
]
|
]
|
||||||
|
|||||||
40
backend/app/models/system_log.py
Normal file
40
backend/app/models/system_log.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from sqlalchemy import JSON, Column, DateTime, Integer, String, Text
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SystemLog(Base):
|
||||||
|
__tablename__ = "system_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
occurred_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
source = Column(String(50), nullable=False, index=True)
|
||||||
|
service = Column(String(50), nullable=True)
|
||||||
|
module = Column(String(120), nullable=True)
|
||||||
|
event = Column(String(160), nullable=True, index=True)
|
||||||
|
level = Column(String(20), nullable=False, index=True)
|
||||||
|
message = Column(Text, nullable=False)
|
||||||
|
request_id = Column(String(64), nullable=True, index=True)
|
||||||
|
trace_id = Column(String(64), nullable=True)
|
||||||
|
user_id = Column(Integer, nullable=True, index=True)
|
||||||
|
category = Column(String(80), nullable=True, index=True)
|
||||||
|
context = Column(JSON, nullable=False, default=dict)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
occurred_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
actor_id = Column(Integer, nullable=True, index=True)
|
||||||
|
actor_name = Column(String(255), nullable=True)
|
||||||
|
action = Column(String(120), nullable=False, index=True)
|
||||||
|
target_type = Column(String(80), nullable=True)
|
||||||
|
target_id = Column(String(120), nullable=True)
|
||||||
|
result = Column(String(40), nullable=True, index=True)
|
||||||
|
request_id = Column(String(64), nullable=True, index=True)
|
||||||
|
ip = Column(String(64), nullable=True)
|
||||||
|
details = Column(JSON, nullable=False, default=dict)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -30,6 +30,14 @@ class RegionProfile:
|
|||||||
accent: str
|
accent: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RegionAnchor:
|
||||||
|
region: str
|
||||||
|
label: str
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class NewsFeedSource:
|
class NewsFeedSource:
|
||||||
id: str
|
id: str
|
||||||
@@ -95,6 +103,39 @@ REGION_PROFILES: dict[str, RegionProfile] = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
REGION_ANCHORS: dict[str, RegionAnchor] = {
|
||||||
|
"americas": RegionAnchor(
|
||||||
|
region="americas",
|
||||||
|
label="美洲",
|
||||||
|
latitude=37.0902,
|
||||||
|
longitude=-95.7129,
|
||||||
|
),
|
||||||
|
"europe": RegionAnchor(
|
||||||
|
region="europe",
|
||||||
|
label="欧洲",
|
||||||
|
latitude=50.1109,
|
||||||
|
longitude=8.6821,
|
||||||
|
),
|
||||||
|
"middle-east-africa": RegionAnchor(
|
||||||
|
region="middle-east-africa",
|
||||||
|
label="中东与非洲",
|
||||||
|
latitude=25.2048,
|
||||||
|
longitude=55.2708,
|
||||||
|
),
|
||||||
|
"asia-pacific": RegionAnchor(
|
||||||
|
region="asia-pacific",
|
||||||
|
label="亚太",
|
||||||
|
latitude=1.3521,
|
||||||
|
longitude=103.8198,
|
||||||
|
),
|
||||||
|
"global": RegionAnchor(
|
||||||
|
region="global",
|
||||||
|
label="全球",
|
||||||
|
latitude=20.0,
|
||||||
|
longitude=0.0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
|
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
|
||||||
return (
|
return (
|
||||||
@@ -213,6 +254,10 @@ def get_region_profile(region: str) -> RegionProfile:
|
|||||||
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
|
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_region_anchor(region: str) -> RegionAnchor:
|
||||||
|
return REGION_ANCHORS.get(region, REGION_ANCHORS["global"])
|
||||||
|
|
||||||
|
|
||||||
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
|
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
|
||||||
return sorted(
|
return sorted(
|
||||||
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
|
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
|
||||||
@@ -342,6 +387,7 @@ def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||||
published_at = item.published_at
|
published_at = item.published_at
|
||||||
|
anchor = get_region_anchor(item.feed_region)
|
||||||
return {
|
return {
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
"title": item.title,
|
"title": item.title,
|
||||||
@@ -352,6 +398,10 @@ def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, An
|
|||||||
"region": item.feed_region,
|
"region": item.feed_region,
|
||||||
"homepage_url": item.homepage_url,
|
"homepage_url": item.homepage_url,
|
||||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||||
|
"latitude": anchor.latitude,
|
||||||
|
"longitude": anchor.longitude,
|
||||||
|
"location_label": anchor.label,
|
||||||
|
"location_inferred": True,
|
||||||
"is_focus_match": item.feed_region == active_region,
|
"is_focus_match": item.feed_region == active_region,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
86
backend/app/services/persistent_logs.py
Normal file
86
backend/app/services/persistent_logs.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.logging import get_logger, sanitize_log_value
|
||||||
|
from app.core.request_context import get_request_id
|
||||||
|
from app.db.session import async_session_factory
|
||||||
|
from app.models.system_log import AuditLog, SystemLog
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def record_system_log(
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
level: str,
|
||||||
|
message: str,
|
||||||
|
service: str | None = None,
|
||||||
|
module: str | None = None,
|
||||||
|
event: str | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
trace_id: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
session.add(
|
||||||
|
SystemLog(
|
||||||
|
source=source,
|
||||||
|
service=service,
|
||||||
|
module=module,
|
||||||
|
event=event,
|
||||||
|
level=level.lower(),
|
||||||
|
message=str(sanitize_log_value(message)),
|
||||||
|
request_id=request_id or get_request_id(),
|
||||||
|
trace_id=trace_id,
|
||||||
|
user_id=user_id,
|
||||||
|
category=category,
|
||||||
|
context=sanitize_log_value(context or {}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception_event(
|
||||||
|
"Failed to persist system log",
|
||||||
|
event="system_log.persist.failed",
|
||||||
|
context={"event_name": event, "source": source},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def record_audit_log(
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
actor_id: int | None = None,
|
||||||
|
actor_name: str | None = None,
|
||||||
|
target_type: str | None = None,
|
||||||
|
target_id: str | None = None,
|
||||||
|
result: str | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
ip: str | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
session.add(
|
||||||
|
AuditLog(
|
||||||
|
actor_id=actor_id,
|
||||||
|
actor_name=actor_name,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
result=result,
|
||||||
|
request_id=request_id or get_request_id(),
|
||||||
|
ip=ip,
|
||||||
|
details=sanitize_log_value(details or {}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception_event(
|
||||||
|
"Failed to persist audit log",
|
||||||
|
event="audit_log.persist.failed",
|
||||||
|
context={"action": action},
|
||||||
|
)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Task Scheduler for running collection jobs."""
|
"""Task Scheduler for running collection jobs."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
@@ -9,13 +8,14 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.db.session import async_session_factory
|
from app.db.session import async_session_factory
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.models.datasource import DataSource
|
from app.models.datasource import DataSource
|
||||||
from app.models.task import CollectionTask
|
from app.models.task import CollectionTask
|
||||||
from app.services.collectors.registry import collector_registry
|
from app.services.collectors.registry import collector_registry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90
|
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90
|
||||||
@@ -54,7 +54,11 @@ async def _update_next_run_at(datasource: DataSource, session) -> None:
|
|||||||
async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
|
async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
|
||||||
collector = collector_registry.get(datasource.source)
|
collector = collector_registry.get(datasource.source)
|
||||||
if not collector:
|
if not collector:
|
||||||
logger.warning("Collector not found for datasource %s", datasource.source)
|
logger.warning_event(
|
||||||
|
"Collector not found for datasource",
|
||||||
|
event="collector.schedule.collector_missing",
|
||||||
|
context={"collector_name": datasource.source},
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
collector_registry.set_active(datasource.source, datasource.is_active)
|
collector_registry.set_active(datasource.source, datasource.is_active)
|
||||||
@@ -72,13 +76,17 @@ async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
kwargs={"collector_name": datasource.source},
|
kwargs={"collector_name": datasource.source},
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info_event(
|
||||||
"Scheduled collector: %s (every %sm)",
|
"Scheduled collector",
|
||||||
datasource.source,
|
event="collector.schedule.updated",
|
||||||
datasource.frequency_minutes,
|
context={"collector_name": datasource.source, "frequency_minutes": datasource.frequency_minutes},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Collector disabled: %s", datasource.source)
|
logger.info_event(
|
||||||
|
"Collector disabled",
|
||||||
|
event="collector.schedule.disabled",
|
||||||
|
context={"collector_name": datasource.source},
|
||||||
|
)
|
||||||
|
|
||||||
await _update_next_run_at(datasource, session)
|
await _update_next_run_at(datasource, session)
|
||||||
|
|
||||||
@@ -87,18 +95,30 @@ async def run_collector_task(collector_name: str):
|
|||||||
"""Run a single collector task."""
|
"""Run a single collector task."""
|
||||||
collector = collector_registry.get(collector_name)
|
collector = collector_registry.get(collector_name)
|
||||||
if not collector:
|
if not collector:
|
||||||
logger.error("Collector not found: %s", collector_name)
|
logger.error_event(
|
||||||
|
"Collector not found",
|
||||||
|
event="collector.run.collector_missing",
|
||||||
|
context={"collector_name": collector_name},
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
result = await db.execute(select(DataSource).where(DataSource.source == collector_name))
|
result = await db.execute(select(DataSource).where(DataSource.source == collector_name))
|
||||||
datasource = result.scalar_one_or_none()
|
datasource = result.scalar_one_or_none()
|
||||||
if not datasource:
|
if not datasource:
|
||||||
logger.error("Datasource not found for collector: %s", collector_name)
|
logger.error_event(
|
||||||
|
"Datasource not found for collector",
|
||||||
|
event="collector.run.datasource_missing",
|
||||||
|
context={"collector_name": collector_name},
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not datasource.is_active:
|
if not datasource.is_active:
|
||||||
logger.info("Skipping disabled collector: %s", collector_name)
|
logger.info_event(
|
||||||
|
"Skipping disabled collector",
|
||||||
|
event="collector.run.skipped_disabled",
|
||||||
|
context={"collector_name": collector_name},
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
running_result = await db.execute(
|
running_result = await db.execute(
|
||||||
@@ -122,10 +142,10 @@ async def run_collector_task(collector_name: str):
|
|||||||
and (now - started_at) > timedelta(minutes=RUNNING_TASK_GUARD_TIMEOUT_MINUTES)
|
and (now - started_at) > timedelta(minutes=RUNNING_TASK_GUARD_TIMEOUT_MINUTES)
|
||||||
)
|
)
|
||||||
if not is_stale:
|
if not is_stale:
|
||||||
logger.warning(
|
logger.warning_event(
|
||||||
"Skipping collector %s trigger because task %s is already running",
|
"Skipping collector trigger because task is already running",
|
||||||
collector_name,
|
event="collector.run.skipped_already_running",
|
||||||
existing_running.id,
|
context={"collector_name": collector_name, "task_id": existing_running.id},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -143,31 +163,47 @@ async def run_collector_task(collector_name: str):
|
|||||||
else stale_reason
|
else stale_reason
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.warning(
|
logger.warning_event(
|
||||||
"Marked stale running task %s as failed before rerun of %s",
|
"Marked stale running task as failed before rerun",
|
||||||
existing_running.id,
|
event="collector.run.stale_task_failed",
|
||||||
collector_name,
|
context={"collector_name": collector_name, "task_id": existing_running.id},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
collector._datasource_id = datasource.id
|
collector._datasource_id = datasource.id
|
||||||
logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id)
|
logger.info_event(
|
||||||
|
"Running collector",
|
||||||
|
event="collector.run.started",
|
||||||
|
context={"collector_name": collector_name, "datasource_id": datasource.id},
|
||||||
|
)
|
||||||
task_result = await collector.run(db)
|
task_result = await collector.run(db)
|
||||||
datasource.last_run_at = datetime.now(UTC)
|
datasource.last_run_at = datetime.now(UTC)
|
||||||
datasource.last_status = task_result.get("status")
|
datasource.last_status = task_result.get("status")
|
||||||
await _update_next_run_at(datasource, db)
|
await _update_next_run_at(datasource, db)
|
||||||
logger.info("Collector %s completed: %s", collector_name, task_result)
|
logger.info_event(
|
||||||
|
"Collector completed",
|
||||||
|
event="collector.run.completed",
|
||||||
|
context={"collector_name": collector_name, "datasource_id": datasource.id, "result": task_result},
|
||||||
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
datasource.last_run_at = datetime.now(UTC)
|
datasource.last_run_at = datetime.now(UTC)
|
||||||
datasource.last_status = "cancelled"
|
datasource.last_status = "cancelled"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.warning("Collector %s cancelled by operator", collector_name)
|
logger.warning_event(
|
||||||
|
"Collector cancelled by operator",
|
||||||
|
event="collector.run.cancelled",
|
||||||
|
context={"collector_name": collector_name, "datasource_id": datasource.id},
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
datasource.last_run_at = datetime.now(UTC)
|
datasource.last_run_at = datetime.now(UTC)
|
||||||
datasource.last_status = "failed"
|
datasource.last_status = "failed"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.exception("Collector %s failed: %s", collector_name, exc)
|
logger.exception_event(
|
||||||
|
"Collector failed",
|
||||||
|
event="collector.run.failed",
|
||||||
|
context={"collector_name": collector_name, "datasource_id": datasource.id, "error": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
|
async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
|
||||||
@@ -194,7 +230,11 @@ async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
|
|||||||
|
|
||||||
if stale_tasks:
|
if stale_tasks:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.warning("Cleaned up %s stale running collection task(s)", len(stale_tasks))
|
logger.warning_event(
|
||||||
|
"Cleaned up stale running collection tasks",
|
||||||
|
event="collector.cleanup.stale_tasks_cleaned",
|
||||||
|
context={"count": len(stale_tasks)},
|
||||||
|
)
|
||||||
|
|
||||||
return len(stale_tasks)
|
return len(stale_tasks)
|
||||||
|
|
||||||
@@ -203,14 +243,14 @@ def start_scheduler() -> None:
|
|||||||
"""Start the scheduler."""
|
"""Start the scheduler."""
|
||||||
if not scheduler.running:
|
if not scheduler.running:
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Scheduler started")
|
logger.info_event("Scheduler started", event="scheduler.started")
|
||||||
|
|
||||||
|
|
||||||
def stop_scheduler() -> None:
|
def stop_scheduler() -> None:
|
||||||
"""Stop the scheduler."""
|
"""Stop the scheduler."""
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
logger.info("Scheduler stopped")
|
logger.info_event("Scheduler stopped", event="scheduler.stopped")
|
||||||
|
|
||||||
|
|
||||||
async def sync_scheduler_with_datasources() -> None:
|
async def sync_scheduler_with_datasources() -> None:
|
||||||
@@ -271,12 +311,20 @@ def run_collector_now(collector_name: str) -> bool:
|
|||||||
"""Run a collector immediately (not scheduled)."""
|
"""Run a collector immediately (not scheduled)."""
|
||||||
collector = collector_registry.get(collector_name)
|
collector = collector_registry.get(collector_name)
|
||||||
if not collector:
|
if not collector:
|
||||||
logger.error("Collector not found: %s", collector_name)
|
logger.error_event(
|
||||||
|
"Collector not found",
|
||||||
|
event="collector.trigger.collector_missing",
|
||||||
|
context={"collector_name": collector_name},
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
existing_task = get_running_collector_task(collector_name)
|
existing_task = get_running_collector_task(collector_name)
|
||||||
if existing_task is not None and not existing_task.done():
|
if existing_task is not None and not existing_task.done():
|
||||||
logger.warning("Collector %s is already running in-memory; skipping duplicate trigger", collector_name)
|
logger.warning_event(
|
||||||
|
"Collector is already running in-memory; skipping duplicate trigger",
|
||||||
|
event="collector.trigger.skipped_already_running",
|
||||||
|
context={"collector_name": collector_name},
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -289,10 +337,18 @@ def run_collector_now(collector_name: str) -> bool:
|
|||||||
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
|
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
|
||||||
|
|
||||||
task.add_done_callback(_cleanup_task)
|
task.add_done_callback(_cleanup_task)
|
||||||
logger.info("Triggered collector: %s", collector_name)
|
logger.info_event(
|
||||||
|
"Triggered collector",
|
||||||
|
event="collector.trigger.started",
|
||||||
|
context={"collector_name": collector_name},
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to trigger collector %s: %s", collector_name, exc)
|
logger.error_event(
|
||||||
|
"Failed to trigger collector",
|
||||||
|
event="collector.trigger.failed",
|
||||||
|
context={"collector_name": collector_name, "error": str(exc)},
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
532
backend/app/services/system_logs.py
Normal file
532
backend/app/services/system_logs.py
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from collections import Counter, deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.security import redis_client
|
||||||
|
|
||||||
|
DEFAULT_LOG_LINE_LIMIT = 200
|
||||||
|
MAX_LOG_LINE_LIMIT = 1000
|
||||||
|
BUFFER_LOG_LIMIT = 1000
|
||||||
|
BUFFER_LOG_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||||
|
LOG_BUFFER_KEY_PREFIX = "planet:system_logs"
|
||||||
|
|
||||||
|
LOG_LEVEL_ERROR = "error"
|
||||||
|
LOG_LEVEL_WARNING = "warning"
|
||||||
|
LOG_LEVEL_INFO = "info"
|
||||||
|
LOG_LEVEL_DEBUG = "debug"
|
||||||
|
LOG_LEVEL_ALL = "all"
|
||||||
|
|
||||||
|
SUPPORTED_LOG_LEVELS = {
|
||||||
|
LOG_LEVEL_ALL,
|
||||||
|
LOG_LEVEL_ERROR,
|
||||||
|
LOG_LEVEL_WARNING,
|
||||||
|
LOG_LEVEL_INFO,
|
||||||
|
LOG_LEVEL_DEBUG,
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_LEVEL_ALIASES = {
|
||||||
|
"warn": LOG_LEVEL_WARNING,
|
||||||
|
"warning": LOG_LEVEL_WARNING,
|
||||||
|
"err": LOG_LEVEL_ERROR,
|
||||||
|
"error": LOG_LEVEL_ERROR,
|
||||||
|
"info": LOG_LEVEL_INFO,
|
||||||
|
"information": LOG_LEVEL_INFO,
|
||||||
|
"debug": LOG_LEVEL_DEBUG,
|
||||||
|
"trace": LOG_LEVEL_DEBUG,
|
||||||
|
"critical": LOG_LEVEL_ERROR,
|
||||||
|
"fatal": LOG_LEVEL_ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
TIMESTAMP_FORMATS = (
|
||||||
|
"%Y-%m-%d %H:%M:%S.%f",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
"%Y-%m-%dT%H:%M:%S.%f",
|
||||||
|
"%Y-%m-%dT%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
LEVEL_PATTERNS = (
|
||||||
|
("CRITICAL", LOG_LEVEL_ERROR),
|
||||||
|
("FATAL", LOG_LEVEL_ERROR),
|
||||||
|
("ERROR", LOG_LEVEL_ERROR),
|
||||||
|
("WARNING", LOG_LEVEL_WARNING),
|
||||||
|
("WARN", LOG_LEVEL_WARNING),
|
||||||
|
("INFO", LOG_LEVEL_INFO),
|
||||||
|
("DEBUG", LOG_LEVEL_DEBUG),
|
||||||
|
("TRACE", LOG_LEVEL_DEBUG),
|
||||||
|
)
|
||||||
|
|
||||||
|
LEADING_LEVEL_PATTERN = re.compile(
|
||||||
|
r"^\s*(?:\[[^\]]+\]\s*)?(CRITICAL|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\b[:\s-]*",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
EMBEDDED_LEVEL_PATTERN = re.compile(
|
||||||
|
r"\b(CRITICAL|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LogSource:
|
||||||
|
source_id: str
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
location: str
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
status: str = "ok"
|
||||||
|
buffer_key: str | None = None
|
||||||
|
container_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StructuredLogEntry:
|
||||||
|
timestamp: datetime | None
|
||||||
|
level: str | None
|
||||||
|
display_line: str
|
||||||
|
raw_line: str
|
||||||
|
search_text: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DailyLogMarker:
|
||||||
|
date_token: str
|
||||||
|
total: int
|
||||||
|
dominant_level: str
|
||||||
|
|
||||||
|
|
||||||
|
LOG_SOURCES: dict[str, LogSource] = {
|
||||||
|
"backend": LogSource(
|
||||||
|
source_id="backend",
|
||||||
|
name="后端服务",
|
||||||
|
kind="file",
|
||||||
|
location="/tmp/planet_backend.log",
|
||||||
|
description="FastAPI 后端、调度器和采集任务共享日志。",
|
||||||
|
category="service",
|
||||||
|
),
|
||||||
|
"frontend": LogSource(
|
||||||
|
source_id="frontend",
|
||||||
|
name="前端开发服务",
|
||||||
|
kind="file",
|
||||||
|
location="/tmp/planet_frontend.log",
|
||||||
|
description="控制台与 Earth 前端开发服务输出。",
|
||||||
|
category="service",
|
||||||
|
),
|
||||||
|
"ai-provider": LogSource(
|
||||||
|
source_id="ai-provider",
|
||||||
|
name="AI Provider",
|
||||||
|
kind="docker",
|
||||||
|
location="docker://planet_aiprovider",
|
||||||
|
description="AI Provider 容器实时输出日志。",
|
||||||
|
category="service",
|
||||||
|
container_name="planet_aiprovider",
|
||||||
|
),
|
||||||
|
"earth-client": LogSource(
|
||||||
|
source_id="earth-client",
|
||||||
|
name="Earth 浏览器端",
|
||||||
|
kind="buffer",
|
||||||
|
location="redis://planet:system_logs:earth-client",
|
||||||
|
description="Earth 浏览器端上报的运行时错误与关键业务日志。",
|
||||||
|
category="client",
|
||||||
|
buffer_key=f"{LOG_BUFFER_KEY_PREFIX}:earth-client",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_log_level(level: str | None) -> str:
|
||||||
|
if level is None:
|
||||||
|
return LOG_LEVEL_ALL
|
||||||
|
normalized = str(level).strip().lower()
|
||||||
|
if normalized in {"", LOG_LEVEL_ALL}:
|
||||||
|
return LOG_LEVEL_ALL
|
||||||
|
return LOG_LEVEL_ALIASES.get(normalized, LOG_LEVEL_ALL)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_log_levels(level: str | None = None, levels: str | None = None) -> tuple[str, ...]:
|
||||||
|
normalized_levels: list[str] = []
|
||||||
|
if levels:
|
||||||
|
for item in str(levels).split(","):
|
||||||
|
normalized = normalize_log_level(item)
|
||||||
|
if normalized != LOG_LEVEL_ALL and normalized not in normalized_levels:
|
||||||
|
normalized_levels.append(normalized)
|
||||||
|
normalized_level = normalize_log_level(level)
|
||||||
|
if normalized_level != LOG_LEVEL_ALL and normalized_level not in normalized_levels:
|
||||||
|
normalized_levels.append(normalized_level)
|
||||||
|
return tuple(normalized_levels)
|
||||||
|
|
||||||
|
|
||||||
|
def get_source_status(source: LogSource) -> str:
|
||||||
|
if source.kind == "file":
|
||||||
|
path = Path(source.location)
|
||||||
|
if not path.exists():
|
||||||
|
return "missing"
|
||||||
|
return "ok" if path.stat().st_size > 0 else "empty"
|
||||||
|
if source.kind == "docker":
|
||||||
|
return "ok" if shutil.which("docker") else "docker_unavailable"
|
||||||
|
if source.kind == "buffer":
|
||||||
|
if not source.buffer_key:
|
||||||
|
return "source_unavailable"
|
||||||
|
try:
|
||||||
|
return "ok" if redis_client.llen(source.buffer_key) > 0 else "empty"
|
||||||
|
except Exception:
|
||||||
|
return "source_unavailable"
|
||||||
|
return "source_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def list_log_sources() -> list[dict[str, str]]:
|
||||||
|
items: list[dict[str, str]] = []
|
||||||
|
for source in LOG_SOURCES.values():
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"source_id": source.source_id,
|
||||||
|
"name": source.name,
|
||||||
|
"kind": source.kind,
|
||||||
|
"location": source.location,
|
||||||
|
"description": source.description,
|
||||||
|
"category": source.category,
|
||||||
|
"status": get_source_status(source),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def get_buffer_log_key(source_id: str) -> str:
|
||||||
|
return f"{LOG_BUFFER_KEY_PREFIX}:{source_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def append_buffer_log(
|
||||||
|
source_id: str,
|
||||||
|
*,
|
||||||
|
level: str,
|
||||||
|
message: str,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload = {
|
||||||
|
"timestamp": datetime.now(tz=UTC).isoformat(),
|
||||||
|
"level": normalize_log_level(level),
|
||||||
|
"message": message,
|
||||||
|
"context": context or {},
|
||||||
|
}
|
||||||
|
buffer_key = get_buffer_log_key(source_id)
|
||||||
|
redis_client.rpush(buffer_key, json.dumps(payload, ensure_ascii=False))
|
||||||
|
redis_client.ltrim(buffer_key, -BUFFER_LOG_LIMIT, -1)
|
||||||
|
redis_client.expire(buffer_key, BUFFER_LOG_TTL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timestamp(raw_value: str | None) -> datetime | None:
|
||||||
|
if not raw_value:
|
||||||
|
return None
|
||||||
|
candidate = str(raw_value).strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
candidate = candidate.replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(candidate)
|
||||||
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
for fmt in TIMESTAMP_FORMATS:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(candidate, fmt).replace(tzinfo=UTC)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_prefixed_timestamp(line: str) -> tuple[datetime | None, str]:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None, ""
|
||||||
|
for prefix_length in (35, 32, 29, 26, 23, 19):
|
||||||
|
if len(stripped) < prefix_length:
|
||||||
|
continue
|
||||||
|
prefix = stripped[:prefix_length]
|
||||||
|
timestamp = parse_timestamp(prefix)
|
||||||
|
if timestamp is not None:
|
||||||
|
return timestamp, stripped[prefix_length:].lstrip()
|
||||||
|
first_token = stripped.split(maxsplit=1)[0]
|
||||||
|
timestamp = parse_timestamp(first_token)
|
||||||
|
if timestamp is not None:
|
||||||
|
remainder = stripped[len(first_token):].lstrip()
|
||||||
|
return timestamp, remainder
|
||||||
|
return None, stripped
|
||||||
|
|
||||||
|
|
||||||
|
def infer_log_level_from_text(text: str, *, allow_embedded: bool = True) -> str | None:
|
||||||
|
leading_match = LEADING_LEVEL_PATTERN.match(text)
|
||||||
|
if leading_match:
|
||||||
|
return normalize_log_level(leading_match.group(1))
|
||||||
|
|
||||||
|
if allow_embedded:
|
||||||
|
embedded_match = EMBEDDED_LEVEL_PATTERN.search(text)
|
||||||
|
if embedded_match:
|
||||||
|
return normalize_log_level(embedded_match.group(1))
|
||||||
|
upper_text = text.upper()
|
||||||
|
for pattern, normalized in LEVEL_PATTERNS:
|
||||||
|
if f"{pattern}:" in upper_text or f"{pattern} " in upper_text:
|
||||||
|
return normalized
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_display_line(timestamp: datetime | None, level: str | None, message: str) -> str:
|
||||||
|
message_part = message.strip() if message else ""
|
||||||
|
parts = []
|
||||||
|
if timestamp is not None:
|
||||||
|
parts.append(timestamp.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
if level:
|
||||||
|
parts.append(level.upper())
|
||||||
|
if message_part:
|
||||||
|
parts.append(message_part)
|
||||||
|
return " ".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_text_log_line(line: str) -> str:
|
||||||
|
return CONTROL_CHAR_PATTERN.sub("", line)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_text_log_entry(line: str) -> StructuredLogEntry:
|
||||||
|
sanitized_line = sanitize_text_log_line(line).rstrip("\n")
|
||||||
|
timestamp, remainder = parse_prefixed_timestamp(sanitized_line)
|
||||||
|
level = infer_log_level_from_text(remainder or sanitized_line, allow_embedded=False)
|
||||||
|
display_line = sanitized_line
|
||||||
|
return StructuredLogEntry(
|
||||||
|
timestamp=timestamp,
|
||||||
|
level=level,
|
||||||
|
display_line=display_line,
|
||||||
|
raw_line=display_line,
|
||||||
|
search_text=display_line.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_buffer_entry(payload: dict[str, Any]) -> StructuredLogEntry:
|
||||||
|
timestamp = parse_timestamp(str(payload.get("timestamp", "")).strip())
|
||||||
|
level = normalize_log_level(payload.get("level"))
|
||||||
|
if level == LOG_LEVEL_ALL:
|
||||||
|
level = None
|
||||||
|
message = str(payload.get("message", "")).strip()
|
||||||
|
context = payload.get("context")
|
||||||
|
context_map = context if isinstance(context, dict) else {}
|
||||||
|
context_fragments = []
|
||||||
|
for key in ("category", "module", "url", "detail"):
|
||||||
|
value = str(context_map.get(key, "")).strip()
|
||||||
|
if value:
|
||||||
|
context_fragments.append(f"{key}={value}")
|
||||||
|
message_with_context = " | ".join([message, *context_fragments]) if context_fragments else message
|
||||||
|
display_line = build_display_line(timestamp, level, message_with_context)
|
||||||
|
search_text = " ".join(
|
||||||
|
[
|
||||||
|
message,
|
||||||
|
json.dumps(context_map, ensure_ascii=False, sort_keys=True),
|
||||||
|
display_line,
|
||||||
|
]
|
||||||
|
).lower()
|
||||||
|
return StructuredLogEntry(
|
||||||
|
timestamp=timestamp,
|
||||||
|
level=level,
|
||||||
|
display_line=display_line,
|
||||||
|
raw_line=json.dumps(payload, ensure_ascii=False, sort_keys=True),
|
||||||
|
search_text=search_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_file_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||||
|
path = Path(source.location)
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||||
|
recent_lines = deque(handle, maxlen=scan_limit)
|
||||||
|
return [
|
||||||
|
parse_text_log_entry(line)
|
||||||
|
for line in recent_lines
|
||||||
|
if sanitize_text_log_line(line).strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def read_docker_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||||
|
if not shutil.which("docker") or not source.container_name:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"logs",
|
||||||
|
"--timestamps",
|
||||||
|
"--tail",
|
||||||
|
str(scan_limit),
|
||||||
|
source.container_name,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
parse_text_log_entry(line)
|
||||||
|
for line in completed.stdout.splitlines()
|
||||||
|
if line.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def read_buffer_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||||
|
if not source.buffer_key:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
raw_items = redis_client.lrange(source.buffer_key, -scan_limit, -1)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
entries: list[StructuredLogEntry] = []
|
||||||
|
for raw_item in raw_items:
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_item)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
entries.append(parse_text_log_entry(str(raw_item)))
|
||||||
|
continue
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
entries.append(build_buffer_entry(payload))
|
||||||
|
else:
|
||||||
|
entries.append(parse_text_log_entry(str(raw_item)))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def read_source_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||||
|
if source.kind == "file":
|
||||||
|
return read_file_entries(source, scan_limit)
|
||||||
|
if source.kind == "docker":
|
||||||
|
return read_docker_entries(source, scan_limit)
|
||||||
|
if source.kind == "buffer":
|
||||||
|
return read_buffer_entries(source, scan_limit)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def matches_levels(entry: StructuredLogEntry, selected_levels: tuple[str, ...]) -> bool:
|
||||||
|
if not selected_levels:
|
||||||
|
return True
|
||||||
|
return entry.level in selected_levels
|
||||||
|
|
||||||
|
|
||||||
|
def matches_date_range(
|
||||||
|
entry: StructuredLogEntry,
|
||||||
|
start_date: str | None,
|
||||||
|
end_date: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if not start_date and not end_date:
|
||||||
|
return True
|
||||||
|
if entry.timestamp is None:
|
||||||
|
return False
|
||||||
|
date_token = entry.timestamp.astimezone(UTC).date().isoformat()
|
||||||
|
if start_date and date_token < start_date:
|
||||||
|
return False
|
||||||
|
if end_date and date_token > end_date:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def matches_search(entry: StructuredLogEntry, search: str | None) -> bool:
|
||||||
|
if search is None:
|
||||||
|
return True
|
||||||
|
query = search.strip().lower()
|
||||||
|
if not query:
|
||||||
|
return True
|
||||||
|
return query in entry.search_text
|
||||||
|
|
||||||
|
|
||||||
|
def build_daily_log_markers(entries: list[StructuredLogEntry]) -> list[dict[str, Any]]:
|
||||||
|
grouped: dict[str, list[StructuredLogEntry]] = {}
|
||||||
|
for entry in entries:
|
||||||
|
if entry.timestamp is None:
|
||||||
|
continue
|
||||||
|
date_token = entry.timestamp.astimezone(UTC).date().isoformat()
|
||||||
|
grouped.setdefault(date_token, []).append(entry)
|
||||||
|
|
||||||
|
markers: list[DailyLogMarker] = []
|
||||||
|
for date_token, group in sorted(grouped.items()):
|
||||||
|
level_counts = Counter(
|
||||||
|
entry.level
|
||||||
|
for entry in group
|
||||||
|
if entry.level in SUPPORTED_LOG_LEVELS and entry.level != LOG_LEVEL_ALL
|
||||||
|
)
|
||||||
|
dominant_level = LOG_LEVEL_INFO
|
||||||
|
if level_counts:
|
||||||
|
dominant_level = sorted(
|
||||||
|
level_counts.items(),
|
||||||
|
key=lambda item: (
|
||||||
|
-item[1],
|
||||||
|
("error", "warning", "info", "debug").index(item[0]),
|
||||||
|
),
|
||||||
|
)[0][0]
|
||||||
|
markers.append(
|
||||||
|
DailyLogMarker(
|
||||||
|
date_token=date_token,
|
||||||
|
total=len(group),
|
||||||
|
dominant_level=dominant_level,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [marker.__dict__ for marker in markers]
|
||||||
|
|
||||||
|
|
||||||
|
def read_log_snapshot(
|
||||||
|
source_id: str,
|
||||||
|
limit: int,
|
||||||
|
*,
|
||||||
|
level: str = LOG_LEVEL_ALL,
|
||||||
|
levels: str | None = None,
|
||||||
|
start_date: str | None = None,
|
||||||
|
end_date: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
source = LOG_SOURCES.get(source_id)
|
||||||
|
if source is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
selected_levels = normalize_log_levels(level, levels)
|
||||||
|
search_query = (search or "").strip()
|
||||||
|
scan_limit = max(min(MAX_LOG_LINE_LIMIT * 5, 5000), limit * 5, BUFFER_LOG_LIMIT if source.kind == "buffer" else 1000)
|
||||||
|
all_entries = read_source_entries(source, scan_limit)
|
||||||
|
marker_entries = [
|
||||||
|
entry
|
||||||
|
for entry in all_entries
|
||||||
|
if matches_levels(entry, selected_levels) and matches_search(entry, search_query)
|
||||||
|
]
|
||||||
|
filtered_entries = [
|
||||||
|
entry
|
||||||
|
for entry in marker_entries
|
||||||
|
if matches_date_range(entry, start_date, end_date)
|
||||||
|
]
|
||||||
|
visible_entries = filtered_entries[-limit:]
|
||||||
|
|
||||||
|
compatibility_level = selected_levels[0] if len(selected_levels) == 1 else LOG_LEVEL_ALL
|
||||||
|
return {
|
||||||
|
"source_id": source.source_id,
|
||||||
|
"name": source.name,
|
||||||
|
"kind": source.kind,
|
||||||
|
"location": source.location,
|
||||||
|
"description": source.description,
|
||||||
|
"category": source.category,
|
||||||
|
"status": get_source_status(source),
|
||||||
|
"level": compatibility_level,
|
||||||
|
"selected_levels": list(selected_levels),
|
||||||
|
"search_query": search_query,
|
||||||
|
"available_levels": [
|
||||||
|
LOG_LEVEL_ALL,
|
||||||
|
LOG_LEVEL_ERROR,
|
||||||
|
LOG_LEVEL_WARNING,
|
||||||
|
LOG_LEVEL_INFO,
|
||||||
|
LOG_LEVEL_DEBUG,
|
||||||
|
],
|
||||||
|
"daily_markers": build_daily_log_markers(marker_entries),
|
||||||
|
"line_limit": limit,
|
||||||
|
"line_count": len(visible_entries),
|
||||||
|
"lines": [entry.display_line for entry in visible_entries],
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ async def test_health_check():
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["status"] == "healthy"
|
assert data["status"] == "healthy"
|
||||||
assert "version" in data
|
assert "version" in data
|
||||||
|
assert response.headers["x-request-id"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -161,6 +162,345 @@ async def test_alerts_endpoint_with_auth(auth_headers):
|
|||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_log_sources_requires_super_admin(auth_headers):
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides = {
|
||||||
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/system/logs/sources", headers=auth_headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_log_sources_with_super_admin(auth_headers):
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="root",
|
||||||
|
email="root@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="super_admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides = {
|
||||||
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.system_control.list_log_sources",
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"source_id": "backend",
|
||||||
|
"name": "后端服务",
|
||||||
|
"kind": "file",
|
||||||
|
"location": "/tmp/planet_backend.log",
|
||||||
|
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||||
|
"category": "service",
|
||||||
|
"status": "ok",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
):
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/system/logs/sources", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["items"][0]["source_id"] == "backend"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_log_snapshot_with_super_admin(auth_headers):
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="root",
|
||||||
|
email="root@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="super_admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides = {
|
||||||
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.system_control.read_log_snapshot",
|
||||||
|
return_value={
|
||||||
|
"source_id": "backend",
|
||||||
|
"name": "后端服务",
|
||||||
|
"kind": "file",
|
||||||
|
"location": "/tmp/planet_backend.log",
|
||||||
|
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||||
|
"category": "service",
|
||||||
|
"status": "ok",
|
||||||
|
"level": "all",
|
||||||
|
"selected_levels": [],
|
||||||
|
"search_query": "",
|
||||||
|
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||||
|
"daily_markers": [],
|
||||||
|
"line_limit": 50,
|
||||||
|
"line_count": 2,
|
||||||
|
"lines": ["line 1", "line 2"],
|
||||||
|
},
|
||||||
|
):
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/system/logs/backend?limit=50", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["source_id"] == "backend"
|
||||||
|
assert data["line_count"] == 2
|
||||||
|
assert data["lines"] == ["line 1", "line 2"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_log_snapshot_supports_level_filter(auth_headers):
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="root",
|
||||||
|
email="root@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="super_admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides = {
|
||||||
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.system_control.read_log_snapshot",
|
||||||
|
return_value={
|
||||||
|
"source_id": "backend",
|
||||||
|
"name": "后端服务",
|
||||||
|
"kind": "file",
|
||||||
|
"location": "/tmp/planet_backend.log",
|
||||||
|
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||||
|
"category": "service",
|
||||||
|
"status": "ok",
|
||||||
|
"level": "error",
|
||||||
|
"selected_levels": ["error"],
|
||||||
|
"search_query": "",
|
||||||
|
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||||
|
"daily_markers": [],
|
||||||
|
"line_limit": 50,
|
||||||
|
"line_count": 1,
|
||||||
|
"lines": ["ERROR: failed"],
|
||||||
|
},
|
||||||
|
):
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/system/logs/backend?limit=50&level=error", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["level"] == "error"
|
||||||
|
assert data["lines"] == ["ERROR: failed"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_log_snapshot_supports_date_range_filter(auth_headers):
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="root",
|
||||||
|
email="root@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="super_admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides = {
|
||||||
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.system_control.read_log_snapshot",
|
||||||
|
return_value={
|
||||||
|
"source_id": "backend",
|
||||||
|
"name": "后端服务",
|
||||||
|
"kind": "file",
|
||||||
|
"location": "/tmp/planet_backend.log",
|
||||||
|
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||||
|
"category": "service",
|
||||||
|
"status": "ok",
|
||||||
|
"level": "all",
|
||||||
|
"selected_levels": [],
|
||||||
|
"search_query": "",
|
||||||
|
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||||
|
"daily_markers": [],
|
||||||
|
"line_limit": 50,
|
||||||
|
"line_count": 1,
|
||||||
|
"lines": ["2026-04-23 INFO: service started"],
|
||||||
|
},
|
||||||
|
) as mock_read_log_snapshot:
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/system/logs/backend?limit=50&start_date=2026-04-20&end_date=2026-04-23",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_read_log_snapshot.assert_called_once_with(
|
||||||
|
"backend",
|
||||||
|
50,
|
||||||
|
level="all",
|
||||||
|
levels=None,
|
||||||
|
start_date="2026-04-20",
|
||||||
|
end_date="2026-04-23",
|
||||||
|
search=None,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_log_snapshot_supports_levels_and_search_filter(auth_headers):
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="root",
|
||||||
|
email="root@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="super_admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides = {
|
||||||
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.system_control.read_log_snapshot",
|
||||||
|
return_value={
|
||||||
|
"source_id": "backend",
|
||||||
|
"name": "后端服务",
|
||||||
|
"kind": "file",
|
||||||
|
"location": "/tmp/planet_backend.log",
|
||||||
|
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||||
|
"category": "service",
|
||||||
|
"status": "ok",
|
||||||
|
"level": "all",
|
||||||
|
"selected_levels": ["error", "warning"],
|
||||||
|
"search_query": "timeout",
|
||||||
|
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||||
|
"daily_markers": [],
|
||||||
|
"line_limit": 50,
|
||||||
|
"line_count": 1,
|
||||||
|
"lines": ["2026-04-23 10:00:00 ERROR timeout"],
|
||||||
|
},
|
||||||
|
) as mock_read_log_snapshot:
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/system/logs/backend?limit=50&levels=error,warning&search=timeout",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_read_log_snapshot.assert_called_once_with(
|
||||||
|
"backend",
|
||||||
|
50,
|
||||||
|
level="all",
|
||||||
|
levels="error,warning",
|
||||||
|
start_date=None,
|
||||||
|
end_date=None,
|
||||||
|
search="timeout",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_log_snapshot_rejects_invalid_date_range(auth_headers):
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="root",
|
||||||
|
email="root@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="super_admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides = {
|
||||||
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/system/logs/backend?start_date=2026-04-31",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "start_date must be in YYYY-MM-DD format" in response.json()["detail"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ingest_earth_client_log_accepts_public_events():
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.system_control.append_buffer_log") as mock_append_buffer_log:
|
||||||
|
with patch("app.api.v1.system_control.record_system_log", new_callable=AsyncMock) as mock_record_system_log:
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/system/logs/earth-client",
|
||||||
|
json={
|
||||||
|
"level": "error",
|
||||||
|
"message": "登陆点加载失败: 登陆点接口返回 HTTP 500",
|
||||||
|
"category": "startup-load",
|
||||||
|
"module": "layer-startup",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["accepted"] is True
|
||||||
|
assert data["source_id"] == "earth-client"
|
||||||
|
mock_append_buffer_log.assert_called_once()
|
||||||
|
mock_record_system_log.assert_awaited_once()
|
||||||
|
persisted_kwargs = mock_record_system_log.await_args.kwargs
|
||||||
|
assert persisted_kwargs["source"] == "earth-client"
|
||||||
|
assert persisted_kwargs["event"] == "earth.client.runtime_log"
|
||||||
|
assert persisted_kwargs["category"] == "startup-load"
|
||||||
|
assert persisted_kwargs["level"] == "error"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_id_header_is_echoed_when_provided():
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/health", headers={"X-Request-ID": "planet-test-request"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["x-request-id"] == "planet-test-request"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_token():
|
async def test_invalid_token():
|
||||||
"""Test that invalid token is rejected"""
|
"""Test that invalid token is rejected"""
|
||||||
@@ -263,6 +603,8 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers):
|
|||||||
assert "content_blocks" in data
|
assert "content_blocks" in data
|
||||||
assert "text_blocks" in data
|
assert "text_blocks" in data
|
||||||
assert "thinking_blocks" in data
|
assert "thinking_blocks" in data
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -382,8 +724,6 @@ async def test_save_playground_session_with_auth(auth_headers):
|
|||||||
assert data["state"]["objective"] == "测试目标"
|
assert data["state"]["objective"] == "测试目标"
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
49
backend/tests/test_earth_news.py
Normal file
49
backend/tests/test_earth_news.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.services.earth_news import ParsedNewsItem, _serialize_item
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_item_includes_region_anchor_for_cruise():
|
||||||
|
item = ParsedNewsItem(
|
||||||
|
id="google-apac:test",
|
||||||
|
title="Example APAC story",
|
||||||
|
summary="Example summary",
|
||||||
|
url="https://example.com/story",
|
||||||
|
source="Example Source",
|
||||||
|
feed_name="Global Monitor / APAC",
|
||||||
|
feed_region="asia-pacific",
|
||||||
|
homepage_url="https://example.com",
|
||||||
|
published_at=datetime(2026, 4, 23, 2, 30, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = _serialize_item(item, active_region="asia-pacific")
|
||||||
|
|
||||||
|
assert payload["latitude"] == 1.3521
|
||||||
|
assert payload["longitude"] == 103.8198
|
||||||
|
assert payload["location_label"] == "亚太"
|
||||||
|
assert payload["location_inferred"] is True
|
||||||
|
assert payload["is_focus_match"] is True
|
||||||
|
assert payload["published_at"] == "2026-04-23T02:30:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_item_falls_back_to_global_anchor():
|
||||||
|
item = ParsedNewsItem(
|
||||||
|
id="custom:test",
|
||||||
|
title="Fallback story",
|
||||||
|
summary="Fallback summary",
|
||||||
|
url="https://example.com/fallback",
|
||||||
|
source="Fallback Source",
|
||||||
|
feed_name="Fallback Feed",
|
||||||
|
feed_region="unknown-region",
|
||||||
|
homepage_url="https://example.com",
|
||||||
|
published_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = _serialize_item(item, active_region="americas")
|
||||||
|
|
||||||
|
assert payload["latitude"] == 20.0
|
||||||
|
assert payload["longitude"] == 0.0
|
||||||
|
assert payload["location_label"] == "全球"
|
||||||
|
assert payload["location_inferred"] is True
|
||||||
|
assert payload["is_focus_match"] is False
|
||||||
|
assert payload["published_at"] is None
|
||||||
78
backend/tests/test_logging.py
Normal file
78
backend/tests/test_logging.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
from app.core.logging import PlanetContextFilter, PlanetFormatter, get_logger
|
||||||
|
from app.core.request_context import set_request_id
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_output(callback):
|
||||||
|
stream = StringIO()
|
||||||
|
handler = logging.StreamHandler(stream)
|
||||||
|
handler.setFormatter(PlanetFormatter(datefmt="%Y-%m-%d %H:%M:%S"))
|
||||||
|
handler.addFilter(PlanetContextFilter())
|
||||||
|
|
||||||
|
adapter = get_logger("tests.logging")
|
||||||
|
target_logger = adapter.logger
|
||||||
|
original_handlers = list(target_logger.handlers)
|
||||||
|
original_level = target_logger.level
|
||||||
|
original_propagate = target_logger.propagate
|
||||||
|
|
||||||
|
target_logger.handlers = [handler]
|
||||||
|
target_logger.setLevel(logging.INFO)
|
||||||
|
target_logger.propagate = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
callback(adapter)
|
||||||
|
finally:
|
||||||
|
handler.flush()
|
||||||
|
target_logger.handlers = original_handlers
|
||||||
|
target_logger.setLevel(original_level)
|
||||||
|
target_logger.propagate = original_propagate
|
||||||
|
|
||||||
|
return stream.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_logger_injects_request_id_and_event():
|
||||||
|
set_request_id("req-test-123")
|
||||||
|
try:
|
||||||
|
output = _capture_output(
|
||||||
|
lambda logger: logger.info_event(
|
||||||
|
"collector started",
|
||||||
|
event="collector.run.started",
|
||||||
|
context={"collector_name": "bgp_news"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
set_request_id(None)
|
||||||
|
|
||||||
|
assert "request_id=req-test-123" in output
|
||||||
|
assert "event=collector.run.started" in output
|
||||||
|
assert "service=backend" in output
|
||||||
|
assert '"collector_name": "bgp_news"' in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_logger_redacts_sensitive_text_and_context():
|
||||||
|
set_request_id("req-test-redact")
|
||||||
|
try:
|
||||||
|
output = _capture_output(
|
||||||
|
lambda logger: logger.error_event(
|
||||||
|
"Authorization: Bearer super-secret-token",
|
||||||
|
event="auth.token.failed",
|
||||||
|
context={
|
||||||
|
"token": "plain-secret",
|
||||||
|
"nested": {"password": "hunter2"},
|
||||||
|
"safe": "visible",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
set_request_id(None)
|
||||||
|
|
||||||
|
assert "super-secret-token" not in output
|
||||||
|
assert "plain-secret" not in output
|
||||||
|
assert "hunter2" not in output
|
||||||
|
assert "[REDACTED]" in output
|
||||||
|
assert '"safe": "visible"' in output
|
||||||
217
backend/tests/test_system_logs.py
Normal file
217
backend/tests/test_system_logs.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.services import system_logs
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.store: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
def rpush(self, key: str, value: str) -> None:
|
||||||
|
self.store.setdefault(key, []).append(value)
|
||||||
|
|
||||||
|
def ltrim(self, key: str, start: int, end: int) -> None:
|
||||||
|
items = self.store.get(key, [])
|
||||||
|
normalized_end = None if end == -1 else end + 1
|
||||||
|
self.store[key] = items[start:normalized_end]
|
||||||
|
|
||||||
|
def expire(self, key: str, seconds: int) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def lrange(self, key: str, start: int, end: int) -> list[str]:
|
||||||
|
items = self.store.get(key, [])
|
||||||
|
normalized_end = None if end == -1 else end + 1
|
||||||
|
return items[start:normalized_end]
|
||||||
|
|
||||||
|
def llen(self, key: str) -> int:
|
||||||
|
return len(self.store.get(key, []))
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_log_snapshot_uses_structured_buffer_timestamp_level_and_search(monkeypatch):
|
||||||
|
fake_redis = FakeRedis()
|
||||||
|
monkeypatch.setattr(system_logs, "redis_client", fake_redis)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
system_logs,
|
||||||
|
"LOG_SOURCES",
|
||||||
|
{
|
||||||
|
"earth-client": system_logs.LogSource(
|
||||||
|
source_id="earth-client",
|
||||||
|
name="Earth 浏览器端",
|
||||||
|
kind="buffer",
|
||||||
|
location="redis://planet:system_logs:earth-client",
|
||||||
|
description="Earth 浏览器端上报日志",
|
||||||
|
category="client",
|
||||||
|
buffer_key=system_logs.get_buffer_log_key("earth-client"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_redis.rpush(
|
||||||
|
system_logs.get_buffer_log_key("earth-client"),
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-22T10:15:30Z",
|
||||||
|
"level": "warning",
|
||||||
|
"message": "news feed degraded",
|
||||||
|
"context": {"module": "news", "detail": "timeout"},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fake_redis.rpush(
|
||||||
|
system_logs.get_buffer_log_key("earth-client"),
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-23T06:01:00Z",
|
||||||
|
"level": "error",
|
||||||
|
"message": "landing points failed",
|
||||||
|
"context": {"module": "layer-startup", "detail": "http 500"},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = system_logs.read_log_snapshot(
|
||||||
|
"earth-client",
|
||||||
|
50,
|
||||||
|
levels="error,warning",
|
||||||
|
start_date="2026-04-23",
|
||||||
|
end_date="2026-04-23",
|
||||||
|
search="landing",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot is not None
|
||||||
|
assert snapshot["selected_levels"] == ["error", "warning"]
|
||||||
|
assert snapshot["search_query"] == "landing"
|
||||||
|
assert snapshot["line_count"] == 1
|
||||||
|
assert snapshot["lines"][0].startswith("2026-04-23 06:01:00 ERROR landing points failed")
|
||||||
|
assert snapshot["daily_markers"] == [
|
||||||
|
{"date_token": "2026-04-23", "total": 1, "dominant_level": "error"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_log_snapshot_parses_file_timestamp_and_builds_markers(tmp_path: Path, monkeypatch):
|
||||||
|
log_path = tmp_path / "backend.log"
|
||||||
|
log_path.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"2026-04-22 08:00:00 INFO service booted",
|
||||||
|
"2026-04-23 09:15:00 WARNING disk pressure detected",
|
||||||
|
"2026-04-23 09:16:00 ERROR sync failed",
|
||||||
|
"2026-04-24 10:00:00 DEBUG collector trace",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
system_logs,
|
||||||
|
"LOG_SOURCES",
|
||||||
|
{
|
||||||
|
"backend": system_logs.LogSource(
|
||||||
|
source_id="backend",
|
||||||
|
name="后端服务",
|
||||||
|
kind="file",
|
||||||
|
location=str(log_path),
|
||||||
|
description="测试文件日志",
|
||||||
|
category="service",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = system_logs.read_log_snapshot(
|
||||||
|
"backend",
|
||||||
|
50,
|
||||||
|
levels="warning,error",
|
||||||
|
search="failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot is not None
|
||||||
|
assert snapshot["line_count"] == 1
|
||||||
|
assert snapshot["lines"] == ["2026-04-23 09:16:00 ERROR sync failed"]
|
||||||
|
assert snapshot["daily_markers"] == [
|
||||||
|
{"date_token": "2026-04-23", "total": 1, "dominant_level": "error"}
|
||||||
|
]
|
||||||
|
assert snapshot["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_buffer_log_persists_normalized_level(monkeypatch):
|
||||||
|
fake_redis = FakeRedis()
|
||||||
|
monkeypatch.setattr(system_logs, "redis_client", fake_redis)
|
||||||
|
|
||||||
|
system_logs.append_buffer_log(
|
||||||
|
"earth-client",
|
||||||
|
level="warn",
|
||||||
|
message="feed delayed",
|
||||||
|
context={"module": "news"},
|
||||||
|
)
|
||||||
|
|
||||||
|
stored_items = fake_redis.lrange(system_logs.get_buffer_log_key("earth-client"), 0, -1)
|
||||||
|
payload = json.loads(stored_items[0])
|
||||||
|
assert payload["level"] == "warning"
|
||||||
|
assert payload["message"] == "feed delayed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_log_level_prefers_leading_prefix_over_query_string():
|
||||||
|
line = 'INFO: 127.0.0.1 - "GET /api/v1/system/logs/backend?limit=200&level=error&levels=error HTTP/1.1" 200 OK'
|
||||||
|
|
||||||
|
entry = system_logs.parse_text_log_entry(line)
|
||||||
|
|
||||||
|
assert entry.level == "info"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_text_log_entry_does_not_promote_exception_context_to_error():
|
||||||
|
line = "websockets.exceptions.ConnectionClosedError: sent 1011 (internal error) keepalive ping timeout"
|
||||||
|
|
||||||
|
entry = system_logs.parse_text_log_entry(line)
|
||||||
|
|
||||||
|
assert entry.level is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_text_log_entry_still_detects_explicit_error_prefix():
|
||||||
|
line = "ERROR: [Errno 98] Address already in use"
|
||||||
|
|
||||||
|
entry = system_logs.parse_text_log_entry(line)
|
||||||
|
|
||||||
|
assert entry.level == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_log_snapshot_strips_nul_bytes_from_file_lines(tmp_path: Path, monkeypatch):
|
||||||
|
log_path = tmp_path / "backend.log"
|
||||||
|
log_path.write_bytes(
|
||||||
|
(
|
||||||
|
b"INFO: service booted\n"
|
||||||
|
b"ERROR: bind failed\n"
|
||||||
|
+ b"\x00" * 32
|
||||||
|
+ b"2026-04-23 23:41:32 INFO service=backend message=request served\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
system_logs,
|
||||||
|
"LOG_SOURCES",
|
||||||
|
{
|
||||||
|
"backend": system_logs.LogSource(
|
||||||
|
source_id="backend",
|
||||||
|
name="后端服务",
|
||||||
|
kind="file",
|
||||||
|
location=str(log_path),
|
||||||
|
description="测试文件日志",
|
||||||
|
category="service",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = system_logs.read_log_snapshot("backend", 50)
|
||||||
|
|
||||||
|
assert snapshot is not None
|
||||||
|
assert snapshot["line_count"] == 3
|
||||||
|
assert snapshot["lines"] == [
|
||||||
|
"INFO: service booted",
|
||||||
|
"ERROR: bind failed",
|
||||||
|
"2026-04-23 23:41:32 INFO service=backend message=request served",
|
||||||
|
]
|
||||||
217
backend/tests/test_visualization_compute_centers.py
Normal file
217
backend/tests/test_visualization_compute_centers.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.api.v1.visualization import convert_compute_centers_to_geojson
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.collected_data import CollectedData
|
||||||
|
|
||||||
|
|
||||||
|
def _build_record(
|
||||||
|
*,
|
||||||
|
record_id: int,
|
||||||
|
source: str,
|
||||||
|
data_type: str,
|
||||||
|
name: str,
|
||||||
|
country: str,
|
||||||
|
city: str,
|
||||||
|
latitude: float,
|
||||||
|
longitude: float,
|
||||||
|
metadata: dict,
|
||||||
|
):
|
||||||
|
return CollectedData(
|
||||||
|
id=record_id,
|
||||||
|
source=source,
|
||||||
|
data_type=data_type,
|
||||||
|
source_id=f"{source}-{record_id}",
|
||||||
|
name=name,
|
||||||
|
extra_data={
|
||||||
|
"country": country,
|
||||||
|
"city": city,
|
||||||
|
"latitude": latitude,
|
||||||
|
"longitude": longitude,
|
||||||
|
**metadata,
|
||||||
|
},
|
||||||
|
collected_at=datetime(2026, 4, 22, tzinfo=timezone.utc),
|
||||||
|
reference_date=datetime(2026, 4, 21, tzinfo=timezone.utc),
|
||||||
|
is_current=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_compute_centers_to_geojson_unifies_sources():
|
||||||
|
top500_record = _build_record(
|
||||||
|
record_id=1,
|
||||||
|
source="top500",
|
||||||
|
data_type="supercomputer",
|
||||||
|
name="Frontier",
|
||||||
|
country="United States",
|
||||||
|
city="Oak Ridge",
|
||||||
|
latitude=35.93,
|
||||||
|
longitude=-84.31,
|
||||||
|
metadata={
|
||||||
|
"rank": 1,
|
||||||
|
"manufacturer": "HPE",
|
||||||
|
"organization": "ORNL",
|
||||||
|
"rmax": 1102000.0,
|
||||||
|
"cores": 8730112,
|
||||||
|
"power": 21510.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
gpu_record = _build_record(
|
||||||
|
record_id=2,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Colossus",
|
||||||
|
country="United States",
|
||||||
|
city="Memphis",
|
||||||
|
latitude=35.15,
|
||||||
|
longitude=-90.05,
|
||||||
|
metadata={
|
||||||
|
"organization": "xAI",
|
||||||
|
"gpu_type": "H100",
|
||||||
|
"gpu_count": 100000,
|
||||||
|
"value": "20000",
|
||||||
|
"unit": "TFlop/s",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([top500_record, gpu_record])
|
||||||
|
|
||||||
|
assert payload["type"] == "FeatureCollection"
|
||||||
|
assert len(payload["features"]) == 2
|
||||||
|
|
||||||
|
supercomputer_feature = payload["features"][0]
|
||||||
|
assert supercomputer_feature["properties"]["site_type"] == "supercomputer"
|
||||||
|
assert supercomputer_feature["properties"]["capacity_unit"] == "GFlops"
|
||||||
|
assert supercomputer_feature["properties"]["capacity_band"] == "exascale"
|
||||||
|
assert supercomputer_feature["properties"]["operator"] == "ORNL"
|
||||||
|
assert supercomputer_feature["properties"]["location_precision"] == "precise"
|
||||||
|
assert supercomputer_feature["properties"]["is_estimated"] is False
|
||||||
|
|
||||||
|
gpu_feature = payload["features"][1]
|
||||||
|
assert gpu_feature["properties"]["site_type"] == "gpu_cluster"
|
||||||
|
assert gpu_feature["properties"]["vendor"] == "H100"
|
||||||
|
assert gpu_feature["properties"]["gpu_count"] == 100000
|
||||||
|
assert gpu_feature["properties"]["capacity_band"] == "large"
|
||||||
|
assert gpu_feature["properties"]["location_precision"] == "precise"
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_compute_centers_to_geojson_uses_coordinate_hints():
|
||||||
|
hinted_record = _build_record(
|
||||||
|
record_id=3,
|
||||||
|
source="top500",
|
||||||
|
data_type="supercomputer",
|
||||||
|
name="Frontier",
|
||||||
|
country="United States",
|
||||||
|
city="",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={
|
||||||
|
"organization": "Oak Ridge National Laboratory",
|
||||||
|
"rmax": 1102000.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([hinted_record])
|
||||||
|
|
||||||
|
assert len(payload["features"]) == 1
|
||||||
|
coords = payload["features"][0]["geometry"]["coordinates"]
|
||||||
|
assert coords[0] == pytest.approx(-84.3107)
|
||||||
|
assert coords[1] == pytest.approx(35.9319)
|
||||||
|
assert payload["features"][0]["properties"]["is_estimated"] is True
|
||||||
|
assert payload["features"][0]["properties"]["location_precision"] == "estimated_site"
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_compute_centers_to_geojson_falls_back_to_country_centroid():
|
||||||
|
centroid_record = _build_record(
|
||||||
|
record_id=4,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Unknown Cluster",
|
||||||
|
country="United States",
|
||||||
|
city="",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={
|
||||||
|
"organization": "Unknown Operator",
|
||||||
|
"value": "10000",
|
||||||
|
"unit": "TFlop/s",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([centroid_record])
|
||||||
|
|
||||||
|
assert len(payload["features"]) == 1
|
||||||
|
props = payload["features"][0]["properties"]
|
||||||
|
coords = payload["features"][0]["geometry"]["coordinates"]
|
||||||
|
assert coords[0] == pytest.approx(-98.5795)
|
||||||
|
assert coords[1] == pytest.approx(39.8283)
|
||||||
|
assert props["is_estimated"] is True
|
||||||
|
assert props["location_precision"] == "estimated_country"
|
||||||
|
assert props["geography_mode"] == "country_centroid"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compute_centers_geojson_endpoint_returns_stats():
|
||||||
|
records = [
|
||||||
|
_build_record(
|
||||||
|
record_id=1,
|
||||||
|
source="top500",
|
||||||
|
data_type="supercomputer",
|
||||||
|
name="Frontier",
|
||||||
|
country="United States",
|
||||||
|
city="Oak Ridge",
|
||||||
|
latitude=35.93,
|
||||||
|
longitude=-84.31,
|
||||||
|
metadata={"rank": 1, "rmax": 1102000.0},
|
||||||
|
),
|
||||||
|
_build_record(
|
||||||
|
record_id=2,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Colossus",
|
||||||
|
country="United States",
|
||||||
|
city="Memphis",
|
||||||
|
latitude=35.15,
|
||||||
|
longitude=-90.05,
|
||||||
|
metadata={"value": "20000", "unit": "TFlop/s"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
class _ScalarResult:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def scalars(self):
|
||||||
|
class _Scalars:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
return _Scalars(self._rows)
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
async def execute(self, _query):
|
||||||
|
return _ScalarResult(records)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
yield _FakeSession()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/visualization/geo/compute-centers")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["count"] == 2
|
||||||
|
assert data["stats"]["supercomputers"] == 1
|
||||||
|
assert data["stats"]["gpu_clusters"] == 1
|
||||||
|
assert data["features"][0]["properties"]["data_type"] == "compute_center"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
@@ -8,6 +8,153 @@ This project follows the repository versioning rule:
|
|||||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||||
- `bugfix` -> `+0.0.1`
|
- `bugfix` -> `+0.0.1`
|
||||||
|
|
||||||
|
## [0.39.0] — 2026-04-24
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- 后端正式落下统一结构化日志地基:请求上下文、事件名、脱敏与持久化链路开始收口为可扩展的企业级日志体系
|
||||||
|
- 系统日志页重构为真正的日志工作台:顶部筛选更紧凑,终端日志区成为主视觉,移动端 Earth 新闻/态势细节交互继续补稳
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 新增 `backend/app/core/logging.py`,统一 `request_id`、`service`、`event` 注入与敏感字段脱敏,并接入后端主入口、调度器、缓存、数据库和可视化链路
|
||||||
|
- 系统日志页筛选区重排为更紧凑的两层结构,信息摘要并入终端工具栏 tooltip,日志终端区留出更稳定的按钮避让空间
|
||||||
|
- Earth 移动端态势抽屉补齐宽度约束与图例换行规则,新闻详情抽屉在巡航切换时可同步更新标题和摘要
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复 `/tmp/planet_backend.log` 中混入空字节时,日志摘要条行数与实际可见日志不一致的问题
|
||||||
|
- 修复移动端“态势”tab 在内容渲染后被图例文本撑宽、超出一屏的问题
|
||||||
|
- 修复移动端新闻详情抽屉在巡航切换下一条新闻时标题更新但 summary 不同步的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.38.0] — 2026-04-23
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- Earth 新闻正式接入通用巡航层:新闻和 BGP 统一进入可配置巡航模块,桌面端与移动端都能在巡航聚焦时展示对应新闻卡片
|
||||||
|
- 系统日志页升级为结构化过滤链路:按真实时间戳、结构化级别和字符串检索统一筛选,不再依赖前端或后端从日志文本里猜结果
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 新闻巡航补齐业务适配层:按发生地与时间生成巡航目标,桌面端与移动端统一标题 + summary 卡片风格,并增加连线与打字机摘要展示
|
||||||
|
- 日志页筛选体验重排,统一服务源、级别、行数、时间和检索布局,日历标记改为由后端返回的结构化每日聚合结果驱动
|
||||||
|
- 后端补充 `system_logs` 结构化解析与多级别精确过滤能力,Earth 浏览器端日志缓冲与系统日志 API 现在走同一套筛选语义
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复新闻巡航模块开启后难以关闭、桌面/移动端设置状态互相污染的问题
|
||||||
|
- 修复新闻巡航卡片缺少摘要、移动端详情样式不统一、新闻巡航缺少连线的问题
|
||||||
|
- 修复日志级别筛选会被访问日志 query string 中的 `level=error` 等参数污染,从而把 `INFO` 行误判为 `ERROR` 的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.37.2] — 2026-04-23
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- Earth 图层系统新增经纬线开关,桌面图层面板与移动端抽屉都可直接控制
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 经纬线正式接入 Earth layer registry,复用现有图层切换、移动端图层卡片与设置持久化流
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复经纬线只能默认常驻、无法作为独立图层开关控制的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.37.1] — 2026-04-23
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- `planet.sh` 后端重启链路修复 `uvicorn --reload` 残留 worker 场景,`restart` 现在能真正替换旧实例
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 收口后端清理逻辑,统一按 `uvicorn` 进程、端口占用进程和进程组执行清理,减少 reload 场景漏杀分支
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复部分机器执行 `./planet.sh restart --allow-lan` 后后端仍停留旧实例,导致 `/api/v1/visualization/geo/compute-centers` 返回 `404` 的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.35.1] — 2026-04-22
|
||||||
|
## [0.37.0] — 2026-04-23
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- Earth 连线系统正式从巡航里解耦成通用 callout connector:桌面端和移动端统一支持对象级锚点、四边切换与临界区边缘滑动
|
||||||
|
- BGP 巡航展示继续收口为稳定的“先定位卡片、再连真实锚点、再展示卡片”链路,移动端 popup 与桌面 info panel 的路线规则统一
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- connector 配置从 `CRUISE_CONFIG` 拆到独立 `CONNECTOR_CONFIG`,默认类名、动画名和实例命名也全部去 cruise 语义
|
||||||
|
- 移动端 popup 增加更稳定的 dock/obstacle 处理,拖动卡片时连线起终点会持续按几何关系自适应刷新
|
||||||
|
- Earth 多个图层与控制逻辑继续收口,补充算力中心/BGP 风格对齐、layer panel 与相关交互细节调整
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复巡航模式下终点只像“视觉锚点”而不是真实绑定对象的问题,卡片拖动后终点现在会跟随
|
||||||
|
- 修复移动端与桌面端多类连线路线异常:压线、反向、临界区折返、起点遮挡事件点等问题
|
||||||
|
- 修复对象矩形临界区内连线仍强制中点到中点导致路线像“先钻进 source 内部”再出去的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.35.1] — 2026-04-22
|
||||||
|
## [0.36.0] — 2026-04-22
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- Earth 新增统一“算力中心”图层:接入超算与 GPU 集群,支持搜索、统计、图例、详情卡与独立图层开关
|
||||||
|
- 算力中心支持精确位置与估算位置两种状态,估算点会以问号角标区分,避免数据不全时整批节点在地图上消失
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- Earth 详情卡拖拽与地球拖拽交互继续收口,减少拖动卡片和旋转地球时的选中文本与 pointer 竞争
|
||||||
|
- `planet.sh` 改为通过独立脚本计算 AI Provider 依赖指纹,降低与根仓库依赖版本文件的无关耦合
|
||||||
|
- README 补充 WSL / Windows 局域网访问排查与转发配置说明,便于开发环境联调
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复 Earth 算力中心图层在无原始坐标时无法显示的问题,支持站点提示和国家级估算回退
|
||||||
|
- 修复信息卡拖拽事件可能被卡片级 stopPropagation 吞掉,导致拖拽流中断的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.35.1] — 2026-04-22
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- Earth 统计展示改为统一 `data-earth-stat` 绑定机制,桌面 HUD 和移动端抽屉复用同一套状态更新入口
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 收口海缆、登陆点、卫星、BGP 事件与 BGP 状态的统计写入逻辑,减少后续继续补桌面/移动双写分支的成本
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复移动端态势抽屉中的海缆、登陆点与 BGP 统计在图层切换后可能停留旧值的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.35.0] — 2026-04-22
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- Earth 移动端底部抽屉系统全面上线:响应式布局自动切换、Tab 导航、手势上拉/下滑开合、惯性速度判定
|
||||||
|
- 移动端点击可交互物件(海缆、登陆点、卫星、BGP)后弹出智能定位悬浮卡片,可拖动,点击跳转详情
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 抽屉把手区域缩小至 36px(collapsed 时仅露出把手,不遮挡地球操作区)
|
||||||
|
- 抽屉定期弹跳动画提示用户可上拉,5 秒间隔,打开后自动停止
|
||||||
|
- 通知胶囊位置调整,不再覆盖品牌 logo
|
||||||
|
- 移动端单指旋转、双指捏合缩放地球,触控事件冲突修复(pointer-events 级联)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复移动端抽屉 shell 因 layout 高度(240px+)遮挡地球触控区域,pointer-events 改为按层级精确控制
|
||||||
|
- 修复悬浮卡片因 setPointerCapture 在 iOS Safari 抑制合成 click 事件导致无法点击的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.34.0] — 2026-04-22
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- Earth 搜索面板正式接入,支持搜索海缆、登陆点、卫星、BGP 事件与观测站,并可直接聚焦到对应对象
|
||||||
|
- `planet.sh --allow-lan` 打通 Bun + Vite 的局域网开放链路,启动成功后自动打印推荐访问地址与后端健康检查地址
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 前端开发启动链统一改成 Bun 直接执行 Vite 入口,不再依赖 shell 中额外暴露的 Node 路径
|
||||||
|
- Earth 搜索结果接入登陆点详情卡片与对象聚焦,搜索后可直接进入对应详情流
|
||||||
|
- `planet.sh` 补充局域网 IPv4 自动识别与推荐地址输出,减少 WSL 局域网调试成本
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复 `./planet.sh restart --allow-lan` 全量重启时未把 `--allow-lan` 继续传给 `start()`,导致前端退回本机监听的问题
|
||||||
|
- 修复 WSL + Bun 环境下前端偶发因 Vite 启动链不稳定而无法正确监听 `0.0.0.0:3000` 的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.33.0] — 2026-04-22
|
## [0.33.0] — 2026-04-22
|
||||||
|
|
||||||
### ✨ Highlights
|
### ✨ Highlights
|
||||||
@@ -65,8 +212,6 @@ This project follows the repository versioning rule:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [0.31.0] — 2026-04-21
|
|
||||||
|
|
||||||
## [0.31.2] — 2026-04-21
|
## [0.31.2] — 2026-04-21
|
||||||
|
|
||||||
### ✨ Highlights
|
### ✨ Highlights
|
||||||
@@ -103,6 +248,8 @@ This project follows the repository versioning rule:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.31.0] — 2026-04-21
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件,逐帧追踪连接线位置,支持外部交互立即中断序列(cancel notifier 模式)
|
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件,逐帧追踪连接线位置,支持外部交互立即中断序列(cancel notifier 模式)
|
||||||
- 巡航目标事件点高亮显示:hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
|
- 巡航目标事件点高亮显示:hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
|
||||||
@@ -116,8 +263,6 @@ This project follows the repository versioning rule:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [0.29.1] — 2026-04-20
|
|
||||||
|
|
||||||
## [0.30.0] — 2026-04-21
|
## [0.30.0] — 2026-04-21
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
当前重点入口:
|
当前重点入口:
|
||||||
|
|
||||||
|
- [earth-mobile-drawer-ui-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
|
||||||
|
- [earth-compute-center-bgp-style-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
|
||||||
- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
|
- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
|
||||||
- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
|
- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
|
||||||
- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)
|
- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)
|
||||||
|
|||||||
372
docs/plans/earth-compute-center-bgp-style-plan.md
Normal file
372
docs/plans/earth-compute-center-bgp-style-plan.md
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
# Earth Compute Center BGP-Style Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
这份文档定义如何按照 BGP 模块的产品方式,把“算力中心”提升为 Earth 上的一级能力。
|
||||||
|
|
||||||
|
这里的“按 BGP 方式”指的是:
|
||||||
|
|
||||||
|
- 有独立的数据语义和接口入口
|
||||||
|
- 有独立的 Earth 图层与图例
|
||||||
|
- 有独立的 hover / click / 选中态 / 详情卡
|
||||||
|
- 有独立的统计口径与后续专题页扩展空间
|
||||||
|
|
||||||
|
这里的“按 BGP 方式”不指:
|
||||||
|
|
||||||
|
- 机械复制 BGP 的 anomaly / incident / collector 三层事件模型
|
||||||
|
- 为静态算力设施强行引入不必要的复杂告警语义
|
||||||
|
|
||||||
|
算力中心本质上更接近“长期基础设施分布层”,不是“高频动态异常层”。
|
||||||
|
因此应该复用 BGP 的模块化方法,而不是照搬 BGP 的事件结构。
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
当前仓库里已经有算力相关基础:
|
||||||
|
|
||||||
|
- 后端已有 `top500` 和 `epoch_ai_gpu` 数据采集
|
||||||
|
- 可视化接口已有 `/api/v1/visualization/geo/supercomputers` 和 `/api/v1/visualization/geo/gpu-clusters`
|
||||||
|
- Earth 信息卡已对 `supercomputer` 和 `gpu_cluster` 做了基础类型兼容
|
||||||
|
|
||||||
|
但当前能力还停留在“数据可取到”的阶段,没有形成像 BGP 那样完整的可视化模块:
|
||||||
|
|
||||||
|
- Earth 缺少独立的算力图层加载模块
|
||||||
|
- 缺少算力 marker 体系和视觉层级
|
||||||
|
- 缺少算力图例、统计、开关和搜索接入
|
||||||
|
- 缺少与海缆、BGP、卫星的关系表达
|
||||||
|
- 缺少算力专题页和后续告警/研判扩展入口
|
||||||
|
|
||||||
|
所以当前真正的缺口不是“有没有数据”,而是“有没有产品级模块”。
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
算力中心应当采用和 BGP 一致的模块化分层:
|
||||||
|
|
||||||
|
1. 数据层:稳定的数据契约和 GeoJSON 输出
|
||||||
|
2. 渲染层:独立的 Earth 图层、marker 和视觉状态管理
|
||||||
|
3. 交互层:hover、click、锁定态、详情卡、图例和统计
|
||||||
|
4. 扩展层:后续专题页、关系分析、告警和 AI 研判
|
||||||
|
|
||||||
|
但语义上必须保持算力中心自身的特点:
|
||||||
|
|
||||||
|
- `site / center` 是主对象,不是事件
|
||||||
|
- `capacity / rank / vendor / operator / status` 是主信息,不是异常严重度
|
||||||
|
- `distribution / concentration / dependency` 是后续分析方向,不是第一阶段必须项
|
||||||
|
|
||||||
|
## Recommended Scope
|
||||||
|
|
||||||
|
第一版“算力中心”建议统一承载两类对象:
|
||||||
|
|
||||||
|
- `supercomputer`
|
||||||
|
- `gpu_cluster`
|
||||||
|
|
||||||
|
并在 Earth 上收口为一个主题层:`compute_centers`
|
||||||
|
|
||||||
|
这样做有几个好处:
|
||||||
|
|
||||||
|
- 用户看到的是统一的“算力基础设施”语义,而不是零散数据源
|
||||||
|
- 后端仍可保留 `top500` 和 `epoch_ai_gpu` 的来源差异
|
||||||
|
- 前端可以在一个图层里再细分两种 marker 语言
|
||||||
|
|
||||||
|
## Current Gap
|
||||||
|
|
||||||
|
和 BGP 对比,当前差距主要在下面几层。
|
||||||
|
|
||||||
|
### 1. Data Contract Gap
|
||||||
|
|
||||||
|
现在的算力 GeoJSON 还是通用 `collected_data` 输出思路,字段较轻:
|
||||||
|
|
||||||
|
- `gpu_cluster` 只有基础名称和地点
|
||||||
|
- `supercomputer` 只暴露一部分性能字段
|
||||||
|
- 缺少统一的 `site_type / operator / capacity_band / source / updated_at / confidence`
|
||||||
|
- 缺少统一的算力层聚合出口
|
||||||
|
|
||||||
|
### 2. Earth Rendering Gap
|
||||||
|
|
||||||
|
当前 Earth 里没有类似 `bgp.js` 的算力模块:
|
||||||
|
|
||||||
|
- `constants.js` 没有算力 API 路径和视觉配置
|
||||||
|
- `main.js` 没有算力加载、拾取、状态同步和 HUD 更新
|
||||||
|
- `controls.js` 没有算力图层开关和启动加载优先级
|
||||||
|
- `layer-startup-tasks.js` 没有算力启动任务
|
||||||
|
- `legend.js` / `ui.js` 没有算力统计与图例模式
|
||||||
|
|
||||||
|
### 3. Interaction Gap
|
||||||
|
|
||||||
|
虽然 `info-card.js` 支持基础字段,但还没有形成 BGP 那种完整交互链路:
|
||||||
|
|
||||||
|
- 没有 hover / selected / dimmed 的视觉状态
|
||||||
|
- 没有算力对象专属 tooltip 与摘要文案
|
||||||
|
- 没有锁定后与其他基础设施的联动高亮
|
||||||
|
- 没有搜索、统计卡和详情组织方式
|
||||||
|
|
||||||
|
### 4. Product Expansion Gap
|
||||||
|
|
||||||
|
当前还没有“算力中心”专题页与分析语义:
|
||||||
|
|
||||||
|
- 没有全球分布/国家聚合/厂商聚合视图
|
||||||
|
- 没有算力与海缆/BGP/区域的关系表达
|
||||||
|
- 没有 AI brief / assessment 的后续落点
|
||||||
|
|
||||||
|
## Architecture Direction
|
||||||
|
|
||||||
|
推荐把算力中心做成“BGP 同级能力”,但采用更适合静态基础设施的结构。
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
建议新增统一聚合接口,例如:
|
||||||
|
|
||||||
|
- `/api/v1/visualization/geo/compute-centers`
|
||||||
|
|
||||||
|
它的职责是把:
|
||||||
|
|
||||||
|
- `top500`
|
||||||
|
- `epoch_ai_gpu`
|
||||||
|
|
||||||
|
统一转换成一个主题层输出,同时保留对象细分类型:
|
||||||
|
|
||||||
|
- `site_type: supercomputer | gpu_cluster`
|
||||||
|
|
||||||
|
建议统一字段至少包括:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `site_type`
|
||||||
|
- `country`
|
||||||
|
- `city`
|
||||||
|
- `latitude`
|
||||||
|
- `longitude`
|
||||||
|
- `operator`
|
||||||
|
- `vendor`
|
||||||
|
- `capacity_value`
|
||||||
|
- `capacity_unit`
|
||||||
|
- `capacity_band`
|
||||||
|
- `rank`
|
||||||
|
- `source`
|
||||||
|
- `updated_at`
|
||||||
|
- `location_precision`
|
||||||
|
- `geography_mode`
|
||||||
|
- `is_estimated`
|
||||||
|
- `estimated_reason`
|
||||||
|
- `metadata`
|
||||||
|
|
||||||
|
这里建议优先做“统一聚合出口”,而不是一开始就新增独立数据库表。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 当前源数据更新频率低,先复用 `collected_data` 成本更低
|
||||||
|
- 可以先把 Earth 产品体验做完整
|
||||||
|
- 如果后续要做历史趋势、关系推断、告警,再评估是否拆成独立模型
|
||||||
|
|
||||||
|
### Frontend Earth
|
||||||
|
|
||||||
|
建议新增独立模块,例如:
|
||||||
|
|
||||||
|
- `frontend/public/earth/js/compute-centers.js`
|
||||||
|
|
||||||
|
职责参照 `bgp.js`:
|
||||||
|
|
||||||
|
- 拉取算力中心 GeoJSON
|
||||||
|
- 创建 marker
|
||||||
|
- 管理 hover / selected / dimmed 状态
|
||||||
|
- 输出图例项
|
||||||
|
- 输出统计摘要
|
||||||
|
- 提供 overlay 和详情格式化辅助函数
|
||||||
|
|
||||||
|
推荐视觉分层:
|
||||||
|
|
||||||
|
1. `supercomputer` 用更稳定、更规整的设施型符号
|
||||||
|
2. `gpu_cluster` 用更活跃、更现代的密度型符号
|
||||||
|
3. 选中态通过 halo / ring / related infrastructure highlight 表达
|
||||||
|
|
||||||
|
视觉上应避免把算力中心做成“BGP 事件点”那种高频脉冲风格。
|
||||||
|
它应该更像长期存在的高价值设施。
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
## Phase 1: Unified Earth Layer
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 先把算力中心做成 Earth 上可用、可点、可解释的一级图层
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 新增统一算力 GeoJSON 接口
|
||||||
|
- 新增 `compute-centers.js`
|
||||||
|
- 在 `constants.js` 增加 API 路径和视觉配置
|
||||||
|
- 在 `controls.js` 增加算力图层开关与启动元数据
|
||||||
|
- 在 `layer-startup-tasks.js` 增加算力启动加载任务
|
||||||
|
- 在 `main.js` 接入算力拾取、hover、click、锁定态和 HUD 统计
|
||||||
|
- 在 `ui.js` / `legend.js` / `index.html` 增加算力统计与图例入口
|
||||||
|
- 在 `info-card.js` 提升算力详情字段组织
|
||||||
|
- 对无法精确定位、但可按国家或弱线索推测的大概位置,仍然生成地图点位
|
||||||
|
- 这类对象必须带显式“估算位置”状态,例如图标问号角标与详情说明
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- Earth 上能独立显示/隐藏算力中心
|
||||||
|
- 两类对象有可区分的视觉表达
|
||||||
|
- hover / click / 详情卡 / 图例 / 统计全部打通
|
||||||
|
- 精确位置与估算位置在图标或文案上可区分,不会误导为同一精度
|
||||||
|
- 不干扰现有海缆、卫星、BGP 的交互链路
|
||||||
|
|
||||||
|
## Phase 2: Relationship Layer
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 让算力中心不只是“点”,而是和其他基础设施产生上下文关系
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 建立算力中心与国家/区域聚合摘要
|
||||||
|
- 增加与附近海缆登陆点的关系提示
|
||||||
|
- 增加与 BGP 事件/观测范围的空间邻近提示
|
||||||
|
- 增加与卫星覆盖或区域连通性的实验性提示
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 点击算力中心时,用户能看到“它和哪些基础设施相关”
|
||||||
|
- 信息表达以辅助判断为主,不做夸张推断
|
||||||
|
|
||||||
|
## Phase 3: Compute Center Observatory
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 把算力中心从 Earth 图层扩展成独立专题观测能力
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 新增算力中心专题页
|
||||||
|
- 提供国家/厂商/类型/容量分布统计
|
||||||
|
- 支持列表、筛选、详情和历史快照
|
||||||
|
- 预留 AI brief / assessment 入口
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 算力中心不再只是 Earth 上的视觉点位
|
||||||
|
- 能作为独立业务上下文进入日常观察与研判
|
||||||
|
|
||||||
|
## Phase 4: Alerts And Assessment
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 在不滥造“假动态告警”的前提下,引入真正有价值的变化感知
|
||||||
|
|
||||||
|
候选方向:
|
||||||
|
|
||||||
|
- 新增大规模算力中心
|
||||||
|
- 既有中心容量显著变化
|
||||||
|
- 国家/区域集中度显著变化
|
||||||
|
- 高价值中心与关键网络基础设施关系变化
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 告警来自可解释的结构变化
|
||||||
|
- 不把静态数据硬做成噪声式实时事件流
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
建议按下面顺序推进:
|
||||||
|
|
||||||
|
1. 先统一 GeoJSON 契约
|
||||||
|
2. 再做 Earth 独立模块和图层开关
|
||||||
|
3. 再补详情卡、图例和统计
|
||||||
|
4. 最后才做关系层和专题页
|
||||||
|
|
||||||
|
这样可以避免一开始把范围摊得过大。
|
||||||
|
|
||||||
|
## Unknown Location Strategy
|
||||||
|
|
||||||
|
由于部分算力数据源不会直接提供经纬度,未知位置补全不能只依赖“继续找 API 字段”。
|
||||||
|
更稳妥的方式是做成一条分层富化链路,而不是单一猜测规则。
|
||||||
|
|
||||||
|
推荐按下面优先级推进:
|
||||||
|
|
||||||
|
1. 直接源信息
|
||||||
|
|
||||||
|
- 源记录显式给出 `latitude / longitude`
|
||||||
|
- 源记录给出 `city / region / facility / campus / operator`
|
||||||
|
- 源页面详情、内嵌 JSON、结构化元数据、新闻稿链接里能抽出地点线索
|
||||||
|
|
||||||
|
2. 名称与机构归一化
|
||||||
|
|
||||||
|
- 建立 `canonical_name / aliases / operator / facility` 归一化表
|
||||||
|
- 把 `cluster name`、`operator`、`campus name` 归一到同一个实体
|
||||||
|
- 优先解决同一对象多写法导致的命中失败,而不是先扩大猜测范围
|
||||||
|
|
||||||
|
3. 本地位置注册表
|
||||||
|
|
||||||
|
- 用仓库内可维护的 registry 保存高价值对象的位置知识
|
||||||
|
- 每条记录至少包含:`canonical_name`、`aliases`、`operator`、`country`、`region`、`city`、`lat`、`lon`、`confidence`、`source_note`
|
||||||
|
- 转换层优先读取 registry,避免地点知识长期散落在转换代码里
|
||||||
|
|
||||||
|
4. 分层回退定位
|
||||||
|
|
||||||
|
- `precise`
|
||||||
|
- `estimated_site`
|
||||||
|
- `estimated_city`
|
||||||
|
- `estimated_region`
|
||||||
|
- `estimated_national_hub`
|
||||||
|
- `estimated_country`
|
||||||
|
|
||||||
|
这里建议把“国家内主要算力城市”作为国家质心之前的一层。
|
||||||
|
例如没有美国精确位置时,优先考虑已知的主要算力/数据中心城市候选,而不是直接落在几何质心。
|
||||||
|
|
||||||
|
5. 候选证据富化
|
||||||
|
|
||||||
|
- 如果源 API 无地点信息,可以允许采集链路读取公开辅助证据
|
||||||
|
- 例如机构官网、数据中心介绍页、新闻稿、百科型页面、公开 PDF
|
||||||
|
- 但只提取“地点线索”,不把外部页面上的经纬度当真值直接写回
|
||||||
|
|
||||||
|
6. 人工校验闭环
|
||||||
|
|
||||||
|
- 对高价值且仍然未知的对象输出待核验清单
|
||||||
|
- 把人工确认结果回写到位置注册表
|
||||||
|
- 后续采集继续优先复用这层人工确认结果
|
||||||
|
|
||||||
|
### Additional Solution Paths
|
||||||
|
|
||||||
|
除了静态映射表,还可以考虑下面这些办法:
|
||||||
|
|
||||||
|
- 基于国家和运营方建立“主要园区候选集”,用稳定散列把同国未知节点分散到若干可信城市,而不是全部压到一个点
|
||||||
|
- 基于数据中心/云厂商公开 region 列表建立 `operator -> city set` 候选映射,用于云 GPU 集群类对象
|
||||||
|
- 把“估算依据”结构化,例如 `matched_alias`、`matched_operator`、`matched_city_text`、`fallback_country_hub`
|
||||||
|
- 给位置补全增加 `last_verified_at`,便于后续按时间重新校验老旧映射
|
||||||
|
- 单独维护“不可可靠定位”状态;这类对象仍可在国家级聚合统计中出现,但可以允许用户在地图上过滤掉
|
||||||
|
- 后续如果你们愿意投入更多,可把这条链路做成小型 enrichment pipeline,而不是仅在 API 转换时临时判断
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
第一阶段不建议做这些内容:
|
||||||
|
|
||||||
|
- 不复制 BGP 巡航模式到算力中心
|
||||||
|
- 不先做复杂实时 websocket 推送
|
||||||
|
- 不先引入独立 `compute_center_incident` 一类模型
|
||||||
|
- 不先做全量 AI 分析面板
|
||||||
|
|
||||||
|
原因是算力中心的第一需求是“被看清楚”,不是“被实时播报”。
|
||||||
|
但“被看清楚”不等于“只显示精确坐标对象”。
|
||||||
|
对于没有精确经纬度、但能推测到国家或区域级位置的算力中心,应优先以上图并标注估算状态的方式处理,而不是直接在地图上消失。
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
|
||||||
|
- 后端存在统一的算力中心 GeoJSON 出口
|
||||||
|
- Earth 有独立算力图层模块,而不是散落在 `main.js`
|
||||||
|
- 页面上有清晰的算力开关、图例和统计
|
||||||
|
- `supercomputer` 和 `gpu_cluster` 在视觉和详情上都可区分
|
||||||
|
- 估算位置对象在地图和详情中都有明确状态提示
|
||||||
|
- 现有 BGP / 海缆 / 卫星功能无回归
|
||||||
|
- 代码结构上为后续专题页和关系分析留出了明确扩展点
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
这项工作的本质不是“再多画几个点”。
|
||||||
|
|
||||||
|
它应该把算力中心从已有数据源,升级成与 BGP 同级的 Earth 观测主题:
|
||||||
|
|
||||||
|
- 有独立语义
|
||||||
|
- 有独立图层
|
||||||
|
- 有独立交互
|
||||||
|
- 有后续分析扩展能力
|
||||||
|
|
||||||
|
推荐先完成 Phase 1,把算力中心做成真正可用的 Earth 一级模块,再继续推进关系层和专题页。
|
||||||
400
docs/plans/earth-mobile-drawer-ui-plan.md
Normal file
400
docs/plans/earth-mobile-drawer-ui-plan.md
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
# Earth Mobile Drawer UI Plan
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前 Earth 移动端已经补上了基础触控能力,例如:
|
||||||
|
|
||||||
|
- 单指拖拽旋转地球
|
||||||
|
- 双指缩放
|
||||||
|
- 点击阈值和基础事件隔离
|
||||||
|
|
||||||
|
但移动端 UI 仍然存在一个根本问题:
|
||||||
|
|
||||||
|
它还在沿用桌面 HUD 的内容切分方式,只是把原来的 panel、modal、toolbar 改位置、改层级、改容器。这样虽然能快速复用旧代码,但手机端体验仍然是生硬的,因为:
|
||||||
|
|
||||||
|
- 信息密度和结构是按桌面设计的
|
||||||
|
- 面板标题、关闭、折叠、开关项是桌面心智,不是手机心智
|
||||||
|
- 很多内容只是“被塞进抽屉”,而不是为抽屉重新设计
|
||||||
|
- 设置里仍然带有“显示/隐藏某些 panel”的思路,但移动端本来就不应该存在那些独立 panel
|
||||||
|
|
||||||
|
因此本计划进一步收紧:
|
||||||
|
|
||||||
|
移动端不只是“底部抽屉化”,而是**重新设计一套 fit 抽屉体系的 mobile-first UI**。
|
||||||
|
|
||||||
|
## 新目标
|
||||||
|
|
||||||
|
1. 手机端不再使用现有 `toolbar` 作为主入口。
|
||||||
|
2. 手机端不再使用现有独立 `panel / modal / sheet` 作为直接 UI 单元。
|
||||||
|
3. 手机端统一采用“底部抽屉 + 顶部标题 + tab 切换 + 卡片内容”的单前景模式。
|
||||||
|
4. 抽屉内部每个 tab 页面都按移动端重新设计内容结构,而不是直接复用旧 panel 结构。
|
||||||
|
5. 设置页移除“显示/隐藏 panel”的桌面遗留配置。
|
||||||
|
6. 媒体页拆成两个移动端页面:`新闻` 与 `TV`,都归入抽屉体系。
|
||||||
|
7. 桌面端保持现有 HUD 体系,不回退。
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
### 1. 只复用数据和状态,不复用桌面 UI 结构
|
||||||
|
|
||||||
|
可复用:
|
||||||
|
|
||||||
|
- 图层注册表
|
||||||
|
- 搜索结果数据
|
||||||
|
- BGP / 海缆 / 卫星详情数据
|
||||||
|
- 媒体数据
|
||||||
|
- 旋转、缩放、选择、高亮等运行时状态
|
||||||
|
|
||||||
|
不直接复用:
|
||||||
|
|
||||||
|
- 桌面 panel DOM 结构
|
||||||
|
- 桌面 panel header / close / collapse 交互
|
||||||
|
- 桌面 settings 项里的“显示某 panel”逻辑
|
||||||
|
- 桌面媒体面板布局
|
||||||
|
|
||||||
|
### 2. 抽屉是唯一主前景层
|
||||||
|
|
||||||
|
移动端同一时刻只有一个主前景层:底部抽屉。
|
||||||
|
|
||||||
|
抽屉内部切换内容页,而不是多个悬浮层互相覆盖。
|
||||||
|
|
||||||
|
### 3. 每个 tab 都是移动端页面,而不是 panel 容器
|
||||||
|
|
||||||
|
抽屉中的每一项都应视为一个移动端子页面:
|
||||||
|
|
||||||
|
- 有自己的标题
|
||||||
|
- 有自己的内容层次
|
||||||
|
- 有自己的滚动区域
|
||||||
|
- 有自己的主操作
|
||||||
|
|
||||||
|
而不是简单挂一个旧面板进去。
|
||||||
|
|
||||||
|
### 4. 移动端状态提示不占据屏幕正中
|
||||||
|
|
||||||
|
桌面端当前很多通知、状态提示、胶囊消息更适合在屏幕上方居中出现,但移动端不应继续沿用这套布局。
|
||||||
|
|
||||||
|
移动端统一改为:
|
||||||
|
|
||||||
|
- 通知栏放在右上角安全区
|
||||||
|
- 胶囊提示放在右上角堆叠
|
||||||
|
- 不遮挡地球中心视野
|
||||||
|
- 不与底部抽屉主交互区冲突
|
||||||
|
|
||||||
|
## 交互模型
|
||||||
|
|
||||||
|
### 默认态
|
||||||
|
|
||||||
|
移动端默认只显示:
|
||||||
|
|
||||||
|
- 地球主画布
|
||||||
|
- 底部半露出的抽屉头部
|
||||||
|
|
||||||
|
不再单独显示上箭头按钮。
|
||||||
|
|
||||||
|
### 展开态
|
||||||
|
|
||||||
|
用户从底边直接上拉抽屉,或点击抽屉头部展开。
|
||||||
|
|
||||||
|
展开后显示:
|
||||||
|
|
||||||
|
- 当前页面标题
|
||||||
|
- tab 导航
|
||||||
|
- 当前页面内容
|
||||||
|
|
||||||
|
### 收起态
|
||||||
|
|
||||||
|
用户下拉抽屉头部收起,或点击背景收起。
|
||||||
|
|
||||||
|
## 信息架构
|
||||||
|
|
||||||
|
移动端抽屉内的一级页面重定为:
|
||||||
|
|
||||||
|
1. 图层
|
||||||
|
2. 搜索
|
||||||
|
3. 态势
|
||||||
|
4. 新闻
|
||||||
|
5. TV
|
||||||
|
6. 设置
|
||||||
|
7. 详情(按需出现,不固定常驻 tab)
|
||||||
|
|
||||||
|
其中 `新闻` 和 `TV` 不再共享同一个移动端媒体面板。
|
||||||
|
|
||||||
|
## 页面重设计要求
|
||||||
|
|
||||||
|
### 图层页
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 成为移动端最核心的控制页
|
||||||
|
- 强调快速开关,不强调桌面 panel 感
|
||||||
|
|
||||||
|
内容建议:
|
||||||
|
|
||||||
|
- 顶部摘要:当前已启用图层数量
|
||||||
|
- 图层列表卡片
|
||||||
|
- 每个图层项只保留:
|
||||||
|
- 图标
|
||||||
|
- 中文名
|
||||||
|
- 英文副标题
|
||||||
|
- 开关
|
||||||
|
- 去掉桌面式 header / collapse / close 结构
|
||||||
|
|
||||||
|
### 搜索页
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 成为抽屉中的完整搜索页
|
||||||
|
- 避免看起来像桌面 modal 被塞进抽屉
|
||||||
|
|
||||||
|
内容建议:
|
||||||
|
|
||||||
|
- 顶部搜索输入框
|
||||||
|
- 搜索提示文案
|
||||||
|
- 结果列表
|
||||||
|
- 结果项更适合手指点击
|
||||||
|
- 结果点击后:
|
||||||
|
- 聚焦地球对象
|
||||||
|
- 自动切换到详情页
|
||||||
|
|
||||||
|
### 态势页
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 合并原来的 `stats + legend` 思路
|
||||||
|
- 成为移动端全局态势页
|
||||||
|
|
||||||
|
内容建议:
|
||||||
|
|
||||||
|
- 顶部核心统计卡
|
||||||
|
- 海缆数量
|
||||||
|
- 登陆点数量
|
||||||
|
- 卫星数量
|
||||||
|
- BGP 事件数量
|
||||||
|
- 当前关注层图例
|
||||||
|
- BGP 状态摘要
|
||||||
|
- 不再出现独立 legend 面板和独立 stats 面板
|
||||||
|
|
||||||
|
### 新闻页
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 从原媒体面板中拆出单独的移动端新闻页
|
||||||
|
|
||||||
|
内容建议:
|
||||||
|
|
||||||
|
- 当前区域焦点
|
||||||
|
- 新闻源数量
|
||||||
|
- 新闻卡片列表
|
||||||
|
- 卡片内显示标题、来源、时间、区域
|
||||||
|
- 外链操作更清晰
|
||||||
|
|
||||||
|
### TV 页
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 从原媒体面板中拆出单独的移动端 TV 页
|
||||||
|
|
||||||
|
内容建议:
|
||||||
|
|
||||||
|
- 顶部频道选择
|
||||||
|
- 直播状态
|
||||||
|
- 当前频道说明
|
||||||
|
- 视频播放器区域
|
||||||
|
- 刷新和外链按钮
|
||||||
|
|
||||||
|
不再保留桌面式“新闻/TV tab 共处一个 panel”的结构。
|
||||||
|
|
||||||
|
### 设置页
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 只保留对移动端仍有意义的系统配置
|
||||||
|
|
||||||
|
必须移除:
|
||||||
|
|
||||||
|
- 图层控制 panel 显示/隐藏
|
||||||
|
- 图例 panel 显示/隐藏
|
||||||
|
- 全球态势 panel 显示/隐藏
|
||||||
|
- 媒体 panel 显示/隐藏
|
||||||
|
|
||||||
|
保留项建议:
|
||||||
|
|
||||||
|
- 旋转模式
|
||||||
|
- 日夜模式
|
||||||
|
- 地球默认大小
|
||||||
|
- 地形透明度
|
||||||
|
- 系统入口
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
移动端已经没有这些独立 panel 了,所以继续保留这些开关会制造错误心智。
|
||||||
|
|
||||||
|
### 详情页
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 成为海缆 / BGP / 卫星对象的统一移动端详情页
|
||||||
|
|
||||||
|
内容建议:
|
||||||
|
|
||||||
|
- 标题区
|
||||||
|
- 类型标签
|
||||||
|
- 关键属性列表
|
||||||
|
- 相关对象摘要
|
||||||
|
- 相关图层或态势提示
|
||||||
|
|
||||||
|
行为建议:
|
||||||
|
|
||||||
|
- 点击对象后自动切入详情页
|
||||||
|
- 搜索结果点击后也切入详情页
|
||||||
|
|
||||||
|
## 阶段重定义
|
||||||
|
|
||||||
|
### 阶段 2:抽屉壳层
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
1. 实现底部抽屉基本壳层。
|
||||||
|
2. 支持上拉展开、下拉收起、背景点击关闭。
|
||||||
|
3. `mobile` 模式下隐藏旧 toolbar。
|
||||||
|
4. `mobile` 模式下不再直接显示旧 panel。
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
1. 手机端只有地球主视图和抽屉。
|
||||||
|
2. 抽屉开合稳定。
|
||||||
|
|
||||||
|
### 阶段 3:基础页面重做
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
1. 重新设计并实现图层页。
|
||||||
|
2. 重新设计并实现搜索页。
|
||||||
|
3. 重新设计并实现设置页。
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
1. 这三个页面不再是旧 panel 原样移植。
|
||||||
|
2. 设置页已移除 panel 可见性开关。
|
||||||
|
|
||||||
|
### 阶段 4:态势与详情重做
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
1. 将 stats 和 legend 合并为新的态势页。
|
||||||
|
2. 实现统一详情页。
|
||||||
|
3. 对象点击与搜索结果点击都可切入详情页。
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
1. 不再存在移动端独立 legend / stats 面板。
|
||||||
|
2. 详情页成为统一对象信息入口。
|
||||||
|
|
||||||
|
### 阶段 5:媒体拆分重做
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
1. 将原媒体面板拆成两个移动端页面:新闻页、TV 页。
|
||||||
|
2. 分别重做这两个页面的布局。
|
||||||
|
3. 保留各自必要操作,但不继续共享桌面 panel 结构。
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
1. 新闻与 TV 各自成为独立移动端页面。
|
||||||
|
2. 不再使用桌面媒体 panel 的 tab 结构作为移动端主体。
|
||||||
|
|
||||||
|
### 阶段 6:手感与真机修正
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
1. 调整抽屉高度、节奏、手势阈值。
|
||||||
|
2. 调整 tab 密度与文字层级。
|
||||||
|
3. 优化 iPhone / Android 安全区。
|
||||||
|
4. 优化抽屉滚动与地球拖拽边界。
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
1. 抽屉和地球不会抢手势。
|
||||||
|
2. 手机端各页面信息层次清晰。
|
||||||
|
3. 真机下无遮挡、无死层、无错误交互心智。
|
||||||
|
|
||||||
|
## 技术落点调整
|
||||||
|
|
||||||
|
### [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只保留移动端抽屉壳层
|
||||||
|
- 为各页面提供新的页面容器
|
||||||
|
|
||||||
|
不再把旧 panel 作为最终结构直接塞进抽屉。
|
||||||
|
|
||||||
|
### [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 管理抽屉开合
|
||||||
|
- 管理 tab 切换
|
||||||
|
- 管理详情页切入
|
||||||
|
- 管理 mobile / desktop 分流
|
||||||
|
|
||||||
|
### [frontend/public/earth/js/search.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/search.js)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 保留搜索能力和结果逻辑
|
||||||
|
- 输出给新的移动端搜索页
|
||||||
|
|
||||||
|
### [frontend/public/earth/js/info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 从桌面 info-card 逻辑中提取可复用的数据层
|
||||||
|
- 服务新的移动端详情页
|
||||||
|
|
||||||
|
### [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 为新的 TV 页面提供数据和状态
|
||||||
|
- 不再直接主导移动端媒体 panel 壳层
|
||||||
|
|
||||||
|
### [frontend/public/earth/js/news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 为新的新闻页面提供列表和区域焦点数据
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
需要新增真正的移动端页面样式,而不是继续在旧 panel class 上堆条件分支:
|
||||||
|
|
||||||
|
- 图层页样式
|
||||||
|
- 搜索页样式
|
||||||
|
- 态势页样式
|
||||||
|
- 新闻页样式
|
||||||
|
- TV 页样式
|
||||||
|
- 设置页样式
|
||||||
|
- 详情页样式
|
||||||
|
- 移动端右上角通知 / 胶囊提示样式
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
1. `mobile` 模式下不再显示旧 toolbar。
|
||||||
|
2. `mobile` 模式下不再把旧 panel 直接作为最终 UI。
|
||||||
|
3. 图层、搜索、态势、新闻、TV、设置都是重新设计的移动端页面。
|
||||||
|
4. 设置页不再包含移动端无意义的 panel 显示/隐藏项。
|
||||||
|
5. 新闻与 TV 已拆分为两个移动端页面。
|
||||||
|
6. legend / stats 已整合为态势页。
|
||||||
|
7. 详情页成为统一对象详情入口。
|
||||||
|
8. 移动端通知栏和胶囊提示已统一放到右上角安全区,而不是屏幕正中。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
本计划进一步明确:
|
||||||
|
|
||||||
|
移动端目标不是“把桌面 HUD 放进抽屉”,而是“以抽屉为载体,重做一套适合手机端的信息页面”。
|
||||||
|
|
||||||
|
后续开发必须以此为准:
|
||||||
|
|
||||||
|
- 复用数据
|
||||||
|
- 重做界面
|
||||||
|
- 清除桌面遗留心智
|
||||||
793
docs/plans/enterprise-logging-system-plan.md
Normal file
793
docs/plans/enterprise-logging-system-plan.md
Normal file
@@ -0,0 +1,793 @@
|
|||||||
|
# Planet 企业级日志系统实施计划
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
把 Planet 当前“能看一点运行输出”的日志能力,升级为一套真正可用、可定位、可纠错、可追责、可演进的企业级日志系统。
|
||||||
|
|
||||||
|
这里的“企业级”不是指一上来就接入很重的外部平台,而是指这套系统需要同时满足下面五件事:
|
||||||
|
|
||||||
|
1. 排障可用
|
||||||
|
2. 历史可查
|
||||||
|
3. 业务可解释
|
||||||
|
4. 权限操作可追责
|
||||||
|
5. 出错后能够反向定位到请求、任务、模块和操作者
|
||||||
|
|
||||||
|
最终目标不是“把更多 stdout 放到日志页里”,而是建立一套统一的日志契约与落地链路:
|
||||||
|
|
||||||
|
- 统一日志字段
|
||||||
|
- 统一事件命名
|
||||||
|
- 统一采集入口
|
||||||
|
- 统一查询视图
|
||||||
|
- 清晰的实时日志、持久化事件、审计日志分层
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
当前仓库已经有一些日志基础,但离真正可用的日志系统还有明显距离。
|
||||||
|
|
||||||
|
已有基础:
|
||||||
|
|
||||||
|
- 后端运行日志可通过 `/tmp/planet_backend.log` 查看
|
||||||
|
- 前端开发服务日志可通过 `/tmp/planet_frontend.log` 查看
|
||||||
|
- AI Provider 可从 Docker 容器读取日志
|
||||||
|
- Earth 浏览器端关键日志可上报到后端并进入 Redis 缓冲
|
||||||
|
- 已有 `system_logs` / `audit_logs` 持久化能力
|
||||||
|
- 管理台已有“系统日志”页面,支持来源、级别、日期、搜索
|
||||||
|
|
||||||
|
当前缺口:
|
||||||
|
|
||||||
|
- 后端日志仍以 `uvicorn` / 文本输出为主,不是统一结构化事件流
|
||||||
|
- 不同模块的日志格式不一致,很多地方只有 message,没有 event 语义
|
||||||
|
- 还没有统一的后端 logger 封装与字段注入机制
|
||||||
|
- 前端虽然能上报错误,但还没有统一 logger API 和统一事件词汇
|
||||||
|
- Earth 与管理台之间的错误事件还没有形成可串联的事件链路
|
||||||
|
- 历史持久化还偏点状,很多高价值失败并没有系统性落库
|
||||||
|
- 系统日志页当前更像“运行输出查看器”,不是“多层日志查询台”
|
||||||
|
- 审计日志与运行日志尚未形成明确的产品级联动
|
||||||
|
|
||||||
|
所以当前真正的问题不是“有没有日志页”,而是:
|
||||||
|
|
||||||
|
**当前系统能看见输出,但还不能稳定回答“发生了什么、影响了谁、在哪条链路上坏了、是否已修复、是谁触发的”。**
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
截至 2026-04-23,当前代码中的日志相关能力大致如下。
|
||||||
|
|
||||||
|
### 1. 日志来源
|
||||||
|
|
||||||
|
当前系统日志页主要读取以下来源:
|
||||||
|
|
||||||
|
- `backend`
|
||||||
|
读取 `/tmp/planet_backend.log`
|
||||||
|
- `frontend`
|
||||||
|
读取 `/tmp/planet_frontend.log`
|
||||||
|
- `ai-provider`
|
||||||
|
读取 Docker 容器日志
|
||||||
|
- `earth-client`
|
||||||
|
读取 Redis 缓冲的浏览器端日志
|
||||||
|
|
||||||
|
这些来源定义在:
|
||||||
|
|
||||||
|
- [backend/app/services/system_logs.py](/home/ray/dev/linkong/planet/backend/app/services/system_logs.py)
|
||||||
|
|
||||||
|
### 2. 当前日志读取模型
|
||||||
|
|
||||||
|
当前 `read_log_snapshot()` 的职责是:
|
||||||
|
|
||||||
|
- 读取某个来源的最近若干行
|
||||||
|
- 解析基础级别与时间
|
||||||
|
- 按级别、日期、搜索进行过滤
|
||||||
|
- 返回用于日志页展示的快照
|
||||||
|
|
||||||
|
这个模型适合“运维查看器”,但不适合企业级日志系统,原因是:
|
||||||
|
|
||||||
|
- 读取基于文本尾部扫描,不是基于事件模型
|
||||||
|
- 不同来源的结构粒度完全不同
|
||||||
|
- 过滤依赖文本解析,准确率有限
|
||||||
|
- 没有请求、任务、用户、资源、动作等核心关联字段
|
||||||
|
|
||||||
|
### 3. 已有持久化能力
|
||||||
|
|
||||||
|
当前已经存在两个持久化入口:
|
||||||
|
|
||||||
|
- `record_system_log(...)`
|
||||||
|
- `record_audit_log(...)`
|
||||||
|
|
||||||
|
位置:
|
||||||
|
|
||||||
|
- [backend/app/services/persistent_logs.py](/home/ray/dev/linkong/planet/backend/app/services/persistent_logs.py)
|
||||||
|
|
||||||
|
这说明系统并不是从 0 开始,但也说明当前最大的问题是:
|
||||||
|
|
||||||
|
**持久化能力存在,但没有成为统一默认路径。**
|
||||||
|
|
||||||
|
### 4. 已有 request_id 基础
|
||||||
|
|
||||||
|
当前系统已具备 `request_id` 相关基础,部分持久化能力也会尝试写入 `request_id`。
|
||||||
|
|
||||||
|
这为后续做:
|
||||||
|
|
||||||
|
- 请求链路排障
|
||||||
|
- 前后端关联查询
|
||||||
|
- 任务执行追踪
|
||||||
|
|
||||||
|
提供了很好的基础。
|
||||||
|
|
||||||
|
### 5. 当前日志页定位
|
||||||
|
|
||||||
|
当前日志页已经具备:
|
||||||
|
|
||||||
|
- 来源切换
|
||||||
|
- 级别筛选
|
||||||
|
- 日期筛选
|
||||||
|
- 搜索
|
||||||
|
- 文本控制台视图
|
||||||
|
|
||||||
|
但它仍然是“单层视图”:
|
||||||
|
|
||||||
|
- 上面是筛选器
|
||||||
|
- 下面是一块文本控制台
|
||||||
|
|
||||||
|
它还不是:
|
||||||
|
|
||||||
|
- 运行日志 + 事件日志 + 审计日志 的统一入口
|
||||||
|
- 也没有事件详情、关联跳转、纠错建议、链路追踪能力
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
这套日志系统后续必须遵循下面几个原则。
|
||||||
|
|
||||||
|
### 1. 分层,而不是混存
|
||||||
|
|
||||||
|
日志必须拆成三层:
|
||||||
|
|
||||||
|
1. 运行日志
|
||||||
|
2. 持久化事件日志
|
||||||
|
3. 审计日志
|
||||||
|
|
||||||
|
它们的用途不同,绝不能继续混成一个概念。
|
||||||
|
|
||||||
|
#### 运行日志
|
||||||
|
|
||||||
|
用于:
|
||||||
|
|
||||||
|
- 实时排障
|
||||||
|
- 观察服务运行状态
|
||||||
|
- 看 stdout / stderr / exception / collector 输出
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 数据量大
|
||||||
|
- 时效性强
|
||||||
|
- 保留周期短
|
||||||
|
- 不要求每条都落库
|
||||||
|
|
||||||
|
#### 持久化事件日志
|
||||||
|
|
||||||
|
用于:
|
||||||
|
|
||||||
|
- 记录高价值错误
|
||||||
|
- 记录关键业务失败
|
||||||
|
- 支撑历史追溯
|
||||||
|
- 支撑趋势分析
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 只持久化有价值事件
|
||||||
|
- 必须结构化
|
||||||
|
- 必须有统一 event 命名
|
||||||
|
|
||||||
|
#### 审计日志
|
||||||
|
|
||||||
|
用于:
|
||||||
|
|
||||||
|
- 留痕
|
||||||
|
- 追责
|
||||||
|
- 还原高权限操作
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 必须单独建模
|
||||||
|
- 不与普通运行日志混用
|
||||||
|
|
||||||
|
### 2. 结构化优先
|
||||||
|
|
||||||
|
正式日志必须可拆字段,不能长期依赖自由文本。
|
||||||
|
|
||||||
|
最低要求至少能拿到:
|
||||||
|
|
||||||
|
- `timestamp`
|
||||||
|
- `level`
|
||||||
|
- `service`
|
||||||
|
- `module`
|
||||||
|
- `event`
|
||||||
|
- `message`
|
||||||
|
- `request_id`
|
||||||
|
- `trace_id`
|
||||||
|
- `user_id` / `actor`
|
||||||
|
- `context`
|
||||||
|
|
||||||
|
### 3. 事件命名优先于 message 命名
|
||||||
|
|
||||||
|
人看的 message 可以变化,但机器查询和跨模块关联必须依赖稳定事件名。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- `collector.run.started`
|
||||||
|
- `collector.run.completed`
|
||||||
|
- `collector.run.failed`
|
||||||
|
- `earth.layer.load_failed`
|
||||||
|
- `earth.cruise.route_build_failed`
|
||||||
|
- `system.restart_task.failed`
|
||||||
|
- `auth.websocket.invalid_token`
|
||||||
|
|
||||||
|
### 4. 查询链路必须可串联
|
||||||
|
|
||||||
|
企业级日志系统的核心不是“有很多日志”,而是“能串起来”。
|
||||||
|
|
||||||
|
最终一条高价值事件,至少要能回链到下面任意几类对象:
|
||||||
|
|
||||||
|
- 某个请求
|
||||||
|
- 某个任务
|
||||||
|
- 某个用户
|
||||||
|
- 某个数据源
|
||||||
|
- 某个 Earth 模块
|
||||||
|
- 某个管理动作
|
||||||
|
|
||||||
|
### 5. 默认脱敏
|
||||||
|
|
||||||
|
日志体系必须明确禁止记录:
|
||||||
|
|
||||||
|
- token
|
||||||
|
- password
|
||||||
|
- Authorization header
|
||||||
|
- cookie
|
||||||
|
- session
|
||||||
|
- 明文敏感个人信息
|
||||||
|
|
||||||
|
并且需要有统一脱敏器,而不是靠调用者自觉。
|
||||||
|
|
||||||
|
### 6. “可纠错”不是一句口号
|
||||||
|
|
||||||
|
这里的“可纠错”至少包含三层:
|
||||||
|
|
||||||
|
1. 日志字段足够解释错误,方便人排查
|
||||||
|
2. 系统能识别常见错误模式并给出纠偏建议
|
||||||
|
3. 关键错误支持闭环动作,例如重试、重建索引、重新触发采集、跳转到对应对象
|
||||||
|
|
||||||
|
也就是说,这套日志系统最终不只是“告诉你出错了”,而要尽量接近“告诉你为什么出错、怎么修、去哪修”。
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
第一阶段不追求:
|
||||||
|
|
||||||
|
- 全量接入 ELK / Loki / Datadog / OpenTelemetry 全家桶
|
||||||
|
- 做分布式 trace 全链路可视化大屏
|
||||||
|
- 把所有历史日志都迁进数据库
|
||||||
|
- 先做特别复杂的规则引擎
|
||||||
|
|
||||||
|
第一阶段追求的是:
|
||||||
|
|
||||||
|
- 在当前仓库和当前部署方式下,先把基础日志体系做正确
|
||||||
|
- 再为后续平台化接入预留好接口
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
推荐目标架构如下。
|
||||||
|
|
||||||
|
### Layer 1: Runtime Logs
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 承载后端、前端开发服务、容器输出、浏览器端缓冲事件
|
||||||
|
- 提供最近窗口内的实时查看能力
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- 文件
|
||||||
|
- Docker
|
||||||
|
- Redis 缓冲
|
||||||
|
- 后续可扩展到 stdout collector
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
|
- `GET /api/v1/system/logs/sources`
|
||||||
|
- `GET /api/v1/system/logs/{source_id}`
|
||||||
|
|
||||||
|
这层继续保留,但需要做结构化增强和来源补强。
|
||||||
|
|
||||||
|
### Layer 2: Persistent System Events
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只存高价值事件
|
||||||
|
- 供历史追溯、事件列表、趋势和纠错使用
|
||||||
|
|
||||||
|
数据来源:
|
||||||
|
|
||||||
|
- 后端关键异常
|
||||||
|
- 浏览器端关键失败
|
||||||
|
- 采集器/调度器关键失败
|
||||||
|
- 业务关键告警与降级事件
|
||||||
|
|
||||||
|
接口建议:
|
||||||
|
|
||||||
|
- `GET /api/v1/system/events`
|
||||||
|
- `GET /api/v1/system/events/{id}`
|
||||||
|
- `POST /api/v1/system/events/{id}/actions/...`(后续)
|
||||||
|
|
||||||
|
### Layer 3: Audit Logs
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 留痕高权限操作
|
||||||
|
- 记录操作者、对象、结果、请求号
|
||||||
|
|
||||||
|
接口建议:
|
||||||
|
|
||||||
|
- `GET /api/v1/system/audit-logs`
|
||||||
|
|
||||||
|
### Layer 4: Error Intelligence / Triage
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 对高频错误做归类
|
||||||
|
- 对已知错误给出解释与建议动作
|
||||||
|
- 对相同错误进行 fingerprint 聚合
|
||||||
|
|
||||||
|
这是“可纠错”能力的关键层。
|
||||||
|
|
||||||
|
建议字段:
|
||||||
|
|
||||||
|
- `fingerprint`
|
||||||
|
- `root_cause_type`
|
||||||
|
- `known_fix_hint`
|
||||||
|
- `runbook_url`
|
||||||
|
- `related_resource_type`
|
||||||
|
- `related_resource_id`
|
||||||
|
|
||||||
|
## Canonical Event Model
|
||||||
|
|
||||||
|
推荐统一事件字段模型如下。
|
||||||
|
|
||||||
|
### Runtime Log Record
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-23T10:15:30Z",
|
||||||
|
"level": "error",
|
||||||
|
"service": "backend",
|
||||||
|
"module": "app.services.scheduler",
|
||||||
|
"event": "collector.run.failed",
|
||||||
|
"message": "Collector bgp_news failed",
|
||||||
|
"request_id": "req_xxx",
|
||||||
|
"trace_id": "trace_xxx",
|
||||||
|
"user_id": null,
|
||||||
|
"actor": null,
|
||||||
|
"resource_type": "collector",
|
||||||
|
"resource_id": "bgp_news",
|
||||||
|
"context": {
|
||||||
|
"datasource_id": 12,
|
||||||
|
"exception_type": "TimeoutError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistent System Event
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1024,
|
||||||
|
"event": "earth.layer.load_failed",
|
||||||
|
"level": "error",
|
||||||
|
"source": "earth-client",
|
||||||
|
"service": "earth",
|
||||||
|
"module": "cables",
|
||||||
|
"message": "Failed to load cable layer",
|
||||||
|
"fingerprint": "earth.layer.load_failed:cables:network_timeout",
|
||||||
|
"request_id": "req_xxx",
|
||||||
|
"trace_id": null,
|
||||||
|
"user_id": 1,
|
||||||
|
"resource_type": "earth_layer",
|
||||||
|
"resource_id": "cables",
|
||||||
|
"category": "visualization",
|
||||||
|
"status": "open",
|
||||||
|
"context": {
|
||||||
|
"url": "/api/v1/visualization/geo/cables"
|
||||||
|
},
|
||||||
|
"created_at": "2026-04-23T10:15:30Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit Log
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 88,
|
||||||
|
"action": "system.restart_task.requested",
|
||||||
|
"actor_id": 1,
|
||||||
|
"actor_name": "root",
|
||||||
|
"target_type": "restart_task",
|
||||||
|
"target_id": "restart_20260423_xxx",
|
||||||
|
"result": "success",
|
||||||
|
"request_id": "req_xxx",
|
||||||
|
"ip": "127.0.0.1",
|
||||||
|
"details": {
|
||||||
|
"action": "restart_backend"
|
||||||
|
},
|
||||||
|
"created_at": "2026-04-23T10:15:30Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
## Phase 0: Logging Inventory And Naming Freeze
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 先统一“记录什么”和“怎么命名”,避免后面越做越乱
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 盘点当前所有 `logging.getLogger` 使用点
|
||||||
|
- 盘点裸 `print`
|
||||||
|
- 盘点 `record_system_log` / `record_audit_log` 已落点位
|
||||||
|
- 建立统一事件命名表
|
||||||
|
- 定义 service / module / category / resource 字段枚举
|
||||||
|
- 输出日志字段白名单和脱敏规范
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 有一份稳定的事件命名清单
|
||||||
|
- 有一份字段规范清单
|
||||||
|
- 后续新增日志不再“临时起名”
|
||||||
|
|
||||||
|
## Phase 1: Backend Structured Logging Foundation
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 把后端从“散落 logging + 文本输出”升级成“统一结构化 logger”
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 新增统一后端 logger helper,例如 `app/core/logging.py`
|
||||||
|
- 自动注入:
|
||||||
|
- `service`
|
||||||
|
- `module`
|
||||||
|
- `request_id`
|
||||||
|
- `trace_id`
|
||||||
|
- 增加统一脱敏 filter
|
||||||
|
- 把关键模块先切到统一 logger:
|
||||||
|
- API 层
|
||||||
|
- scheduler
|
||||||
|
- collectors
|
||||||
|
- websocket
|
||||||
|
- visualization
|
||||||
|
- system control
|
||||||
|
- 约束:
|
||||||
|
- 正式路径禁止裸 `print`
|
||||||
|
- 正式异常优先 `logger.exception(..., extra={...})`
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 后端关键模块都有稳定 `event`
|
||||||
|
- request 日志和异常日志能挂上 `request_id`
|
||||||
|
- 不再依赖只看 `uvicorn` 原生文本输出来定位问题
|
||||||
|
|
||||||
|
## Phase 2: Persistent Event Layer
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 把“值得长期保留的错误和关键事件”系统性落库
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 重新定义 `record_system_log()` 的使用边界
|
||||||
|
- 明确哪些事件必须持久化:
|
||||||
|
- API 关键失败
|
||||||
|
- 调度器失败
|
||||||
|
- 采集器失败
|
||||||
|
- Earth 客户端关键错误
|
||||||
|
- 数据源不可用
|
||||||
|
- 业务降级与恢复
|
||||||
|
- 补齐字段:
|
||||||
|
- `event`
|
||||||
|
- `resource_type`
|
||||||
|
- `resource_id`
|
||||||
|
- `category`
|
||||||
|
- `fingerprint`
|
||||||
|
- `status`
|
||||||
|
- 增加高频错误去重/聚合策略
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 高价值错误不再只存在于运行日志里
|
||||||
|
- 能查询最近一周/一月的关键失败事件
|
||||||
|
- 相同错误具备聚合基础
|
||||||
|
|
||||||
|
## Phase 3: Frontend And Earth Unified Logger
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 把前端从“点状 error 上报”升级成统一前端事件流
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 在前端新增统一 logger API
|
||||||
|
- 统一方法:
|
||||||
|
- `debug`
|
||||||
|
- `info`
|
||||||
|
- `warn`
|
||||||
|
- `error`
|
||||||
|
- 统一字段:
|
||||||
|
- `page`
|
||||||
|
- `module`
|
||||||
|
- `event`
|
||||||
|
- `message`
|
||||||
|
- `url`
|
||||||
|
- `user_agent`
|
||||||
|
- `context`
|
||||||
|
- Earth 模块优先接入:
|
||||||
|
- layer load failed
|
||||||
|
- cruise build failed
|
||||||
|
- popup render failed
|
||||||
|
- connector render failed
|
||||||
|
- websocket dropped
|
||||||
|
- 管理台优先接入:
|
||||||
|
- settings save failed
|
||||||
|
- datasource toggle failed
|
||||||
|
- restart task submit failed
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 前端日志事件名与后端可对齐
|
||||||
|
- Earth 和管理台关键失败不再只停留在 console
|
||||||
|
- 浏览器端关键问题能进入统一系统日志/事件层
|
||||||
|
|
||||||
|
## Phase 4: Audit Logging Completion
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 把管理员与高权限操作真正做成企业级审计
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 扩大审计覆盖面:
|
||||||
|
- 系统重启
|
||||||
|
- 数据源启停
|
||||||
|
- 调度规则变更
|
||||||
|
- 配置变更
|
||||||
|
- 人工触发采集
|
||||||
|
- 删除/修改关键配置
|
||||||
|
- 增加字段:
|
||||||
|
- actor
|
||||||
|
- target
|
||||||
|
- before / after
|
||||||
|
- request_id
|
||||||
|
- IP
|
||||||
|
- 审计页支持:
|
||||||
|
- 动作筛选
|
||||||
|
- 操作者筛选
|
||||||
|
- 时间筛选
|
||||||
|
- 目标对象筛选
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 所有高权限操作都能追到人、时间、对象、结果
|
||||||
|
|
||||||
|
## Phase 5: Log Console To Enterprise Observability UI
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 把当前“系统日志”页升级为真正的多层日志工作台
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 将页面拆为三个主视图:
|
||||||
|
1. 运行日志
|
||||||
|
2. 关键事件
|
||||||
|
3. 审计日志
|
||||||
|
- 运行日志视图:
|
||||||
|
- 保留大控制台
|
||||||
|
- 支持来源、级别、日期、搜索
|
||||||
|
- 关键事件视图:
|
||||||
|
- 列表化展示高价值事件
|
||||||
|
- 支持聚合、状态、指纹、对象筛选
|
||||||
|
- 审计视图:
|
||||||
|
- 列表化展示管理员动作
|
||||||
|
- 增加详情抽屉:
|
||||||
|
- 原始 message
|
||||||
|
- context
|
||||||
|
- request_id
|
||||||
|
- related resource
|
||||||
|
- recommended action
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 日志页不再只是“终端文本窗口”
|
||||||
|
- 运维排障、历史追溯、审计留痕三者分层清晰
|
||||||
|
|
||||||
|
## Phase 6: Corrective Intelligence
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 让系统从“能看日志”进化到“能辅助修错”
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 引入错误 fingerprint
|
||||||
|
- 对已知错误配置:
|
||||||
|
- 根因类型
|
||||||
|
- 修复建议
|
||||||
|
- runbook 链接
|
||||||
|
- 推荐动作
|
||||||
|
- 支持常见纠错动作:
|
||||||
|
- 重试采集任务
|
||||||
|
- 重载配置
|
||||||
|
- 跳转到对应模块/资源
|
||||||
|
- 打开相关日志过滤视图
|
||||||
|
- 高频错误支持聚合与静默窗口
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 已知错误能给出明确建议
|
||||||
|
- 运维不需要每次都从零猜
|
||||||
|
|
||||||
|
## Recommended Module Changes
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
建议新增/增强的模块:
|
||||||
|
|
||||||
|
- `backend/app/core/logging.py`
|
||||||
|
- 统一 logger 封装
|
||||||
|
- formatter
|
||||||
|
- filter
|
||||||
|
- request/trace 注入
|
||||||
|
- `backend/app/services/persistent_logs.py`
|
||||||
|
- 扩展字段
|
||||||
|
- 统一持久化策略
|
||||||
|
- `backend/app/services/system_logs.py`
|
||||||
|
- 逐步从“文本尾部查看器”升级为“运行日志聚合器”
|
||||||
|
- `backend/app/services/log_classification.py`
|
||||||
|
- 指纹
|
||||||
|
- 根因分类
|
||||||
|
- 纠错建议
|
||||||
|
- `backend/app/api/v1/system_control.py`
|
||||||
|
- 补充事件 / 审计 / 日志多视图接口
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
建议新增/增强:
|
||||||
|
|
||||||
|
- `frontend/src/lib/logger.ts`
|
||||||
|
- 统一前端 logger API
|
||||||
|
- `frontend/src/pages/Logs/Logs.tsx`
|
||||||
|
- 升级为多层工作台
|
||||||
|
- `frontend/public/earth/js/...`
|
||||||
|
- 各 Earth 模块接入统一事件 logger
|
||||||
|
|
||||||
|
## Event Naming Convention
|
||||||
|
|
||||||
|
建议采用:
|
||||||
|
|
||||||
|
`<domain>.<resource>.<action>.<result>`
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- `collector.datasource.run.started`
|
||||||
|
- `collector.datasource.run.failed`
|
||||||
|
- `earth.layer.cables.load.failed`
|
||||||
|
- `earth.cruise.route.build.failed`
|
||||||
|
- `system.restart_task.requested`
|
||||||
|
- `system.restart_task.completed`
|
||||||
|
- `auth.websocket.connect.failed`
|
||||||
|
- `settings.datasource.priority.updated`
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 不用自然语言句子
|
||||||
|
- 不把 ID 塞进 event 名里
|
||||||
|
- 资源对象通过字段承载,不通过 event 名承载
|
||||||
|
|
||||||
|
## Query Model
|
||||||
|
|
||||||
|
最终推荐支持的查询维度:
|
||||||
|
|
||||||
|
- 时间范围
|
||||||
|
- level
|
||||||
|
- source
|
||||||
|
- service
|
||||||
|
- module
|
||||||
|
- event
|
||||||
|
- request_id
|
||||||
|
- trace_id
|
||||||
|
- user_id / actor
|
||||||
|
- resource_type / resource_id
|
||||||
|
- category
|
||||||
|
- fingerprint
|
||||||
|
- status
|
||||||
|
- full-text search
|
||||||
|
|
||||||
|
## Retention Strategy
|
||||||
|
|
||||||
|
推荐保留策略:
|
||||||
|
|
||||||
|
- 运行日志:
|
||||||
|
- 文件 / 容器 / Redis 缓冲保留短周期
|
||||||
|
- 持久化事件:
|
||||||
|
- 保留中长期
|
||||||
|
- 审计日志:
|
||||||
|
- 长期保留
|
||||||
|
|
||||||
|
初版可以先这样:
|
||||||
|
|
||||||
|
- 运行日志:7 到 14 天
|
||||||
|
- 关键事件:90 到 180 天
|
||||||
|
- 审计日志:180 天以上
|
||||||
|
|
||||||
|
后续再根据存储与合规要求调整。
|
||||||
|
|
||||||
|
## Security And Compliance
|
||||||
|
|
||||||
|
必须落实:
|
||||||
|
|
||||||
|
- 敏感字段脱敏
|
||||||
|
- 前端上报白名单
|
||||||
|
- 防止日志注入
|
||||||
|
- 审计日志不可被普通管理员随意篡改
|
||||||
|
- 高敏感纠错动作必须再次鉴权
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
当下面这些条件成立时,才算这套日志系统真的“成了”:
|
||||||
|
|
||||||
|
1. 一个后端请求失败时,能通过 `request_id` 在运行日志、持久化事件、审计日志之间串联查询
|
||||||
|
2. 一个 Earth 前端错误能定位到页面、模块、事件名和上下文
|
||||||
|
3. 一个采集器失败能同时看到运行日志、持久化事件和可执行纠错动作
|
||||||
|
4. 一个管理员操作能查到操作者、目标对象、结果和 request_id
|
||||||
|
5. 日志页不再只是文本控制台,而是完整的“运行日志 / 关键事件 / 审计日志”工作台
|
||||||
|
6. 高频已知错误能聚合并给出修复建议
|
||||||
|
|
||||||
|
## Delivery Order
|
||||||
|
|
||||||
|
推荐严格按下面顺序做,不要乱跳:
|
||||||
|
|
||||||
|
1. Phase 0 命名与字段规范冻结
|
||||||
|
2. Phase 1 后端结构化 logging 基础
|
||||||
|
3. Phase 2 高价值事件持久化
|
||||||
|
4. Phase 3 前端 / Earth 统一 logger
|
||||||
|
5. Phase 4 审计覆盖补齐
|
||||||
|
6. Phase 5 日志工作台 UI 重构
|
||||||
|
7. Phase 6 指纹 / 纠错 / runbook
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 如果不先统一字段和命名,后面 UI 和持久化会越来越乱
|
||||||
|
- 如果不先做后端结构化基础,前端上报再多也串不起来
|
||||||
|
- 如果不先补持久化层,就只有“实时可看”,没有“历史可查”
|
||||||
|
|
||||||
|
## First Actionable Milestone
|
||||||
|
|
||||||
|
如果要从明天就开始做,最合理的第一个里程碑是:
|
||||||
|
|
||||||
|
### M1: 让后端关键路径全部拥有统一结构化事件
|
||||||
|
|
||||||
|
范围:
|
||||||
|
|
||||||
|
- API 请求入口/出口
|
||||||
|
- scheduler
|
||||||
|
- collectors
|
||||||
|
- websocket
|
||||||
|
- visualization
|
||||||
|
- system control
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- 统一 logger helper
|
||||||
|
- 统一 event naming 表
|
||||||
|
- 统一 request_id 注入
|
||||||
|
- 统一脱敏策略
|
||||||
|
- 关键模块替换完成
|
||||||
|
|
||||||
|
完成这个里程碑后,Planet 才算真正拥有了“企业级日志系统的地基”。
|
||||||
|
|
||||||
@@ -16,12 +16,21 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.33.0`
|
- `dev` 当前开发分支历史推导到:`0.39.0`
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
| Version | Type | Branch | Commit | Summary |
|
| Version | Type | Branch | Commit | Summary |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `0.39.0` | feature | `dev` | `pending` | 后端统一结构化日志地基落地,系统日志页重构为紧凑日志工作台,并修复 Earth 移动端态势抽屉与新闻详情同步问题 |
|
||||||
|
| `0.38.0` | feature | `dev` | `pending` | Earth 新闻接入通用巡航与专用卡片链路,系统日志页升级为结构化时间/级别过滤与真正字符串检索 |
|
||||||
|
| `0.37.2` | bugfix | `dev` | `pending` | Earth 图层系统新增经纬线开关,并将经纬线接入统一 layer registry、移动端抽屉与设置持久化流 |
|
||||||
|
| `0.37.1` | bugfix | `dev` | `pending` | 修复 `planet.sh` 在 `uvicorn --reload` 场景下未清理旧 worker 的问题,避免后端重启后仍停留旧实例并导致算力中心聚合接口 404 |
|
||||||
|
| `0.37.0` | feature | `dev` | `pending` | Earth 连线系统从巡航语义中完全解耦为通用 callout connector,统一桌面/移动端对象级锚点、临界区锚点滑动与稳定巡航展示链路 |
|
||||||
|
| `0.36.0` | feature | `dev` | `pending` | Earth 新增统一算力中心图层与估算位置展示,继续收口拖拽交互,并补充 AI Provider 指纹与 WSL 局域网访问支撑 |
|
||||||
|
| `0.35.1` | bugfix | `dev` | `pending` | 收口 Earth 桌面 HUD 与移动端抽屉的统一统计绑定机制,修复态势统计在图层切换后的同步遗漏 |
|
||||||
|
| `0.35.0` | feature | `dev` | `pending` | Earth 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 |
|
||||||
|
| `0.34.0` | feature | `dev` | `pending` | Earth 搜索面板正式接入,`planet.sh --allow-lan` 打通 Bun + Vite 局域网开放链路,并自动输出推荐访问地址与健康检查地址 |
|
||||||
| `0.33.0` | feature | `dev` | `pending` | `news_live_streams` 默认接入 iptv-org 频道目录,内置数据源支持直接编辑 override,并修复 TV 合并采集源后默认频道消失的问题 |
|
| `0.33.0` | feature | `dev` | `pending` | `news_live_streams` 默认接入 iptv-org 频道目录,内置数据源支持直接编辑 override,并修复 TV 合并采集源后默认频道消失的问题 |
|
||||||
| `0.32.0` | feature | `dev` | `pending` | Earth 设置新增默认地球大小真源,并继续收口卫星焦点层次、toolbar/scrollbar 性能与 HUD 设置面板细节 |
|
| `0.32.0` | feature | `dev` | `pending` | Earth 设置新增默认地球大小真源,并继续收口卫星焦点层次、toolbar/scrollbar 性能与 HUD 设置面板细节 |
|
||||||
| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |
|
| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.33.0",
|
"version": "0.39.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
"vite": "^5.0.10"
|
"vite": "^5.0.10"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "bun ./node_modules/vite/bin/vite.js",
|
||||||
"build": "tsc && vite build",
|
"build": "bun x tsc && bun ./node_modules/vite/bin/vite.js build",
|
||||||
"preview": "vite preview"
|
"preview": "bun ./node_modules/vite/bin/vite.js preview"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--hud-scale: 1;
|
--hud-scale: 1;
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-right: env(safe-area-inset-right, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--safe-left: env(safe-area-inset-left, 0px);
|
||||||
--hud-offset: calc(20px * var(--hud-scale));
|
--hud-offset: calc(20px * var(--hud-scale));
|
||||||
--hud-radius: calc(22px * var(--hud-scale));
|
--hud-radius: calc(22px * var(--hud-scale));
|
||||||
--hud-panel-padding: calc(18px * var(--hud-scale));
|
--hud-panel-padding: calc(18px * var(--hud-scale));
|
||||||
@@ -66,12 +70,23 @@ body.earth-page {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.is-globe-dragging,
|
||||||
|
body.earth-page.is-globe-dragging,
|
||||||
|
body.earth-page.is-globe-dragging * {
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.earth-app {
|
.earth-app {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.earth-app canvas {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
.earth-app.dragging {
|
.earth-app.dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,3 +133,20 @@
|
|||||||
right: var(--hud-offset);
|
right: var(--hud-offset);
|
||||||
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
|
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .hud-panel-stats {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(8px + var(--safe-top));
|
||||||
|
right: 8px;
|
||||||
|
width: min(180px, calc(100vw - 16px));
|
||||||
|
z-index: 205;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile.earth-search-open .hud-panel-stats,
|
||||||
|
.layout-mode-mobile.earth-settings-open .hud-panel-stats,
|
||||||
|
.layout-mode-mobile.earth-media-open .hud-panel-stats,
|
||||||
|
.layout-mode-mobile.earth-info-open .hud-panel-stats {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-12px);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -153,15 +153,28 @@
|
|||||||
transition:
|
transition:
|
||||||
opacity 0.22s ease,
|
opacity 0.22s ease,
|
||||||
transform 0.22s ease;
|
transform 0.22s ease;
|
||||||
|
visibility: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hud-panel-info.is-visible {
|
.hud-panel-info.is-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1) translateY(0);
|
transform: scale(1) translateY(0);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card-cruise-link {
|
.hud-panel-info.hud-panel-info--anchor-stable {
|
||||||
|
transform: none;
|
||||||
|
transition: opacity 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-panel-info.hud-panel-info--anchor-stable.is-visible {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout-connector {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -173,7 +186,7 @@
|
|||||||
z-index: 49;
|
z-index: 49;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card-cruise-link polyline {
|
.callout-connector polyline {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: rgba(255, 255, 255, 0.98);
|
stroke: rgba(255, 255, 255, 0.98);
|
||||||
stroke-width: 2.15;
|
stroke-width: 2.15;
|
||||||
@@ -185,7 +198,7 @@
|
|||||||
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
|
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card-cruise-link circle {
|
.callout-connector circle {
|
||||||
fill: rgba(255, 255, 255, 0.98);
|
fill: rgba(255, 255, 255, 0.98);
|
||||||
stroke: rgba(7, 16, 32, 0.72);
|
stroke: rgba(7, 16, 32, 0.72);
|
||||||
stroke-width: 1.0;
|
stroke-width: 1.0;
|
||||||
@@ -197,29 +210,29 @@
|
|||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card-cruise-link.is-visible {
|
.callout-connector.is-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card-cruise-link.is-animating polyline {
|
.callout-connector.is-animating polyline {
|
||||||
animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
animation: calloutConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card-cruise-link.is-animating circle {
|
.callout-connector.is-animating circle {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card-cruise-link.is-animating circle:first-of-type {
|
.callout-connector.is-animating circle:first-of-type {
|
||||||
animation: cruiseConnectorNodeIn 0.14s ease forwards;
|
animation: calloutConnectorNodeIn 0.14s ease forwards;
|
||||||
animation-delay: 0.02s;
|
animation-delay: 0.02s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card-cruise-link.is-animating circle:last-of-type {
|
.callout-connector.is-animating circle:last-of-type {
|
||||||
animation: cruiseConnectorNodeIn 0.16s ease forwards;
|
animation: calloutConnectorNodeIn 0.16s ease forwards;
|
||||||
animation-delay: 0.34s;
|
animation-delay: 0.34s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cruiseConnectorDraw {
|
@keyframes calloutConnectorDraw {
|
||||||
from {
|
from {
|
||||||
stroke-dashoffset: var(--connector-length, 0px);
|
stroke-dashoffset: var(--connector-length, 0px);
|
||||||
}
|
}
|
||||||
@@ -228,7 +241,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cruiseConnectorNodeIn {
|
@keyframes calloutConnectorNodeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.72);
|
transform: scale(0.72);
|
||||||
@@ -290,6 +303,8 @@
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card-content::-webkit-scrollbar {
|
.info-card-content::-webkit-scrollbar {
|
||||||
@@ -327,6 +342,8 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: color 0.18s ease;
|
transition: color 0.18s ease;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card-label:hover {
|
.info-card-label:hover {
|
||||||
@@ -341,6 +358,8 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
max-width: calc(180px * var(--hud-scale));
|
max-width: calc(180px * var(--hud-scale));
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Type-specific header accent colors */
|
/* Type-specific header accent colors */
|
||||||
@@ -362,8 +381,151 @@
|
|||||||
}
|
}
|
||||||
.info-card.bgp .info-card-header h3 { color: var(--hud-accent-strong); }
|
.info-card.bgp .info-card-header h3 { color: var(--hud-accent-strong); }
|
||||||
|
|
||||||
|
.info-card.news .info-card-header {
|
||||||
|
background: rgba(255, 196, 92, 0.12);
|
||||||
|
border-bottom-color: rgba(255, 196, 92, 0.16);
|
||||||
|
}
|
||||||
|
.info-card.news .info-card-header h3 { color: #ffd77a; }
|
||||||
|
|
||||||
|
.info-card.news .info-card-content {
|
||||||
|
padding-top: calc(10px * var(--hud-scale));
|
||||||
|
padding-bottom: calc(12px * var(--hud-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-news-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: calc(10px * var(--hud-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-news-kicker {
|
||||||
|
color: rgba(255, 215, 122, 0.82);
|
||||||
|
font-size: calc(0.62rem * var(--hud-scale));
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-news-title {
|
||||||
|
color: var(--hud-title);
|
||||||
|
font-size: calc(0.96rem * var(--hud-scale));
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 700;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-news-summary-shell {
|
||||||
|
position: relative;
|
||||||
|
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||||
|
border: 1px solid rgba(255, 215, 122, 0.12);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 215, 122, 0.05), rgba(255, 255, 255, 0.02)),
|
||||||
|
radial-gradient(circle at top left, rgba(120, 180, 255, 0.08), transparent 56%),
|
||||||
|
rgba(7, 15, 29, 0.42);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||||
|
0 10px 28px rgba(0, 0, 0, 0.16);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-news-summary-shell::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 0%, rgba(122, 180, 255, 0.12) 50%, transparent 100%);
|
||||||
|
opacity: 0.42;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: infoCardNewsScan 3.2s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-news-summary-label {
|
||||||
|
color: rgba(188, 212, 238, 0.72);
|
||||||
|
font-size: calc(0.6rem * var(--hud-scale));
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: calc(6px * var(--hud-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-news-summary {
|
||||||
|
position: relative;
|
||||||
|
color: #d7e6f7;
|
||||||
|
font-size: calc(0.8rem * var(--hud-scale));
|
||||||
|
line-height: 1.65;
|
||||||
|
min-height: calc(4.8em * var(--hud-scale));
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-news-summary.is-typing::after {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.58em;
|
||||||
|
height: 1.05em;
|
||||||
|
margin-left: 0.16em;
|
||||||
|
vertical-align: -0.14em;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 215, 122, 0.96), rgba(122, 180, 255, 0.78));
|
||||||
|
box-shadow: 0 0 10px rgba(255, 215, 122, 0.28);
|
||||||
|
animation: infoCardNewsCaret 0.9s steps(1, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes infoCardNewsScan {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes infoCardNewsCaret {
|
||||||
|
0%, 49% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%, 100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Layout-expanded: slide left column off-screen ────────────── */
|
/* ── Layout-expanded: slide left column off-screen ────────────── */
|
||||||
|
|
||||||
.earth-app.layout-expanded .earth-left-column {
|
.earth-app.layout-expanded .earth-left-column {
|
||||||
transform: translate(calc(-100% + var(--hud-offset)), 0);
|
transform: translate(calc(-100% + var(--hud-offset)), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .earth-left-column {
|
||||||
|
top: calc(8px + var(--safe-top));
|
||||||
|
left: 8px;
|
||||||
|
max-width: min(300px, calc(100vw - 16px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .hud-panel-info {
|
||||||
|
position: fixed;
|
||||||
|
left: 8px !important;
|
||||||
|
right: 8px !important;
|
||||||
|
top: auto !important;
|
||||||
|
bottom: calc(84px + var(--safe-bottom)) !important;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
max-height: min(58vh, 520px);
|
||||||
|
z-index: 240;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .info-card-header {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .info-card-content {
|
||||||
|
max-height: min(46vh, 420px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .info-card-property {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .info-card-value {
|
||||||
|
max-width: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|||||||
@@ -160,6 +160,23 @@
|
|||||||
.layer-panel-list {
|
.layer-panel-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
max-height: calc(5 * (56px * var(--hud-scale)));
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-panel-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-panel-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-panel-list::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
|
||||||
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-row {
|
.layer-row {
|
||||||
@@ -169,6 +186,7 @@
|
|||||||
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||||
border-bottom: 1px solid var(--hud-line);
|
border-bottom: 1px solid var(--hud-line);
|
||||||
transition: background 0.14s ease;
|
transition: background 0.14s ease;
|
||||||
|
min-height: calc(56px * var(--hud-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-row:last-child {
|
.layer-row:last-child {
|
||||||
@@ -317,3 +335,34 @@
|
|||||||
|
|
||||||
/* Layout-expanded: layer panel slides off with .earth-left-column — no
|
/* Layout-expanded: layer panel slides off with .earth-left-column — no
|
||||||
individual rule needed since the whole column translates together. */
|
individual rule needed since the whole column translates together. */
|
||||||
|
|
||||||
|
.layout-mode-mobile .hud-panel-layers {
|
||||||
|
position: fixed;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: calc(88px + var(--safe-bottom));
|
||||||
|
width: auto;
|
||||||
|
max-height: min(60vh, 520px);
|
||||||
|
margin-top: 0;
|
||||||
|
z-index: 220;
|
||||||
|
transform: translateY(calc(100% + 28px));
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: transform 0.24s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .hud-panel-layers.is-mobile-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .layer-panel-body {
|
||||||
|
max-height: min(52vh, 460px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .layer-panel-list {
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
|||||||
@@ -138,3 +138,24 @@
|
|||||||
bottom: var(--hud-offset);
|
bottom: var(--hud-offset);
|
||||||
transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset)));
|
transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .hud-panel-legend {
|
||||||
|
position: fixed;
|
||||||
|
left: 8px;
|
||||||
|
bottom: calc(84px + var(--safe-bottom));
|
||||||
|
width: min(172px, calc(100vw - 16px));
|
||||||
|
z-index: 205;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .legend-list {
|
||||||
|
max-height: min(20vh, 180px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile.earth-search-open .hud-panel-legend,
|
||||||
|
.layout-mode-mobile.earth-settings-open .hud-panel-legend,
|
||||||
|
.layout-mode-mobile.earth-media-open .hud-panel-legend,
|
||||||
|
.layout-mode-mobile.earth-info-open .hud-panel-legend {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -135,6 +135,15 @@
|
|||||||
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
|
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.news-story-card--cruise {
|
||||||
|
border-color: rgba(255, 213, 128, 0.42);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 248, 220, 0.1), rgba(255, 184, 77, 0.08));
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255, 213, 128, 0.18) inset,
|
||||||
|
0 0 18px rgba(255, 184, 77, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.news-story-meta,
|
.news-story-meta,
|
||||||
.news-story-tags {
|
.news-story-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -105,6 +105,14 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.earth-toolbar-orb:has(#layer-action) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .earth-toolbar-group {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * {
|
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,6 +285,27 @@
|
|||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .hud-panel-media {
|
||||||
|
position: fixed;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
top: calc(8px + var(--safe-top));
|
||||||
|
bottom: calc(84px + var(--safe-bottom));
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
min-width: 0;
|
||||||
|
z-index: 230;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .tv-panel-player {
|
||||||
|
min-height: min(42vh, 360px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mode-mobile .tv-panel-edge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 右下角视觉标记 */
|
/* 右下角视觉标记 */
|
||||||
.tv-panel-edge[data-edge="br"]::before {
|
.tv-panel-edge[data-edge="br"]::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
|||||||
@@ -16,6 +16,22 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
|
(function applyInitialEarthViewportMode() {
|
||||||
|
var width = window.innerWidth;
|
||||||
|
var height = window.innerHeight;
|
||||||
|
var mode = "desktop";
|
||||||
|
|
||||||
|
if (width <= 820) {
|
||||||
|
mode = "mobile";
|
||||||
|
} else if (width <= 1080 || height <= 760) {
|
||||||
|
mode = "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.classList.toggle("layout-mode-mobile", mode === "mobile");
|
||||||
|
document.documentElement.classList.toggle("layout-mode-compact", mode === "compact");
|
||||||
|
document.documentElement.dataset.earthLayoutMode = mode;
|
||||||
|
})();
|
||||||
|
|
||||||
(function applyInitialHudScale() {
|
(function applyInitialHudScale() {
|
||||||
var referenceWidth = 1920;
|
var referenceWidth = 1920;
|
||||||
var referenceHeight = 1080;
|
var referenceHeight = 1080;
|
||||||
@@ -102,6 +118,16 @@
|
|||||||
<span class="layer-row-toggle-track"></span>
|
<span class="layer-row-toggle-track"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
|
||||||
|
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
|
||||||
|
<div class="layer-row-copy">
|
||||||
|
<span class="layer-row-label">经纬线</span>
|
||||||
|
<span class="layer-row-meta">Graticule</span>
|
||||||
|
</div>
|
||||||
|
<button id="toggle-grid-lines" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换经纬线显示">
|
||||||
|
<span class="layer-row-toggle-track"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="layer-row" data-layer-name="卫星 satellites">
|
<div class="layer-row" data-layer-name="卫星 satellites">
|
||||||
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
|
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
|
||||||
<div class="layer-row-copy">
|
<div class="layer-row-copy">
|
||||||
@@ -132,6 +158,16 @@
|
|||||||
<span class="layer-row-toggle-track"></span>
|
<span class="layer-row-toggle-track"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="layer-row" data-layer-name="算力中心 compute centers">
|
||||||
|
<span class="material-symbols-rounded layer-row-icon">memory</span>
|
||||||
|
<div class="layer-row-copy">
|
||||||
|
<span class="layer-row-label">算力中心</span>
|
||||||
|
<span class="layer-row-meta">Compute Centers</span>
|
||||||
|
</div>
|
||||||
|
<button id="toggle-compute-centers" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换算力中心显示">
|
||||||
|
<span class="layer-row-toggle-track"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="layer-row" data-layer-name="bgp观测 routing signals">
|
<div class="layer-row" data-layer-name="bgp观测 routing signals">
|
||||||
<span class="material-symbols-rounded layer-row-icon">hub</span>
|
<span class="material-symbols-rounded layer-row-icon">hub</span>
|
||||||
<div class="layer-row-copy">
|
<div class="layer-row-copy">
|
||||||
@@ -156,14 +192,22 @@
|
|||||||
<div id="control-toolbar" class="earth-toolbar">
|
<div id="control-toolbar" class="earth-toolbar">
|
||||||
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
|
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
|
||||||
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
|
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
|
||||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
|
<button id="layer-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="图层">
|
||||||
|
<span class="icon" aria-hidden="true">
|
||||||
|
<span class="material-symbols-rounded">layers</span>
|
||||||
|
</span>
|
||||||
|
<span class="tooltip earth-toolbar-tooltip">图层</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.12s;">
|
||||||
|
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">search</span>
|
<span class="material-symbols-rounded">search</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
|
<span class="tooltip earth-toolbar-tooltip">搜索</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
|
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.24s;">
|
||||||
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
||||||
<span class="icon rotate-icon icon-pause" aria-hidden="true">
|
<span class="icon rotate-icon icon-pause" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">pause</span>
|
<span class="material-symbols-rounded">pause</span>
|
||||||
@@ -174,7 +218,7 @@
|
|||||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.36s;">
|
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.36s;">
|
||||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">live_tv</span>
|
<span class="material-symbols-rounded">live_tv</span>
|
||||||
@@ -182,7 +226,7 @@
|
|||||||
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
|
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.54s;">
|
<div class="earth-toolbar-orb" data-orb-index="4" style="--orb-delay: 0.54s;">
|
||||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">refresh</span>
|
<span class="material-symbols-rounded">refresh</span>
|
||||||
@@ -190,7 +234,7 @@
|
|||||||
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="4" style="--orb-delay: 0.72s;">
|
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="5" style="--orb-delay: 0.72s;">
|
||||||
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
|
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">zoom_in</span>
|
<span class="material-symbols-rounded">zoom_in</span>
|
||||||
@@ -203,7 +247,7 @@
|
|||||||
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true">−</span></button>
|
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true">−</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="5" style="--orb-delay: 0.9s;">
|
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 0.9s;">
|
||||||
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">settings</span>
|
<span class="material-symbols-rounded">settings</span>
|
||||||
@@ -211,7 +255,7 @@
|
|||||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 1.08s;">
|
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.08s;">
|
||||||
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">my_location</span>
|
<span class="material-symbols-rounded">my_location</span>
|
||||||
@@ -219,7 +263,7 @@
|
|||||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.26s;">
|
<div class="earth-toolbar-orb" data-orb-index="8" style="--orb-delay: 1.26s;">
|
||||||
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
||||||
<span class="icon layout-icon layout-expand" aria-hidden="true">
|
<span class="icon layout-icon layout-expand" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">open_in_full</span>
|
<span class="material-symbols-rounded">open_in_full</span>
|
||||||
@@ -274,23 +318,27 @@
|
|||||||
<!-- 2-col KPI grid -->
|
<!-- 2-col KPI grid -->
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<span class="stat-num" id="cable-count">—</span>
|
<span class="stat-num" id="cable-count" data-earth-stat="cable-count">—</span>
|
||||||
<span class="stat-label">海缆系统</span>
|
<span class="stat-label">海缆系统</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<span class="stat-num" id="landing-point-count">—</span>
|
<span class="stat-num" id="landing-point-count" data-earth-stat="landing-point-count">—</span>
|
||||||
<span class="stat-label">登陆点</span>
|
<span class="stat-label">登陆点</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<span class="stat-num" id="satellite-count">—</span>
|
<span class="stat-num" id="satellite-count" data-earth-stat="satellite-count">—</span>
|
||||||
<span class="stat-label">在轨卫星</span>
|
<span class="stat-label">在轨卫星</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<span class="stat-num" id="bgp-anomaly-count">—</span>
|
<span class="stat-num" id="compute-center-count" data-earth-stat="compute-center-count">—</span>
|
||||||
|
<span class="stat-label">算力中心</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell">
|
||||||
|
<span class="stat-num" id="bgp-anomaly-count" data-earth-stat="bgp-anomaly-count">—</span>
|
||||||
<span class="stat-label">BGP 事件</span>
|
<span class="stat-label">BGP 事件</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<span class="stat-num" id="bgp-collector-count">—</span>
|
<span class="stat-num" id="bgp-collector-count" data-earth-stat="bgp-collector-count">—</span>
|
||||||
<span class="stat-label">BGP 观测站</span>
|
<span class="stat-label">BGP 观测站</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
@@ -302,12 +350,12 @@
|
|||||||
<!-- BGP status footer -->
|
<!-- BGP status footer -->
|
||||||
<div class="stats-footer">
|
<div class="stats-footer">
|
||||||
<span class="stats-footer-dot"></span>
|
<span class="stats-footer-dot"></span>
|
||||||
<span id="bgp-status-summary" class="stats-footer-text">暂无观测数据</span>
|
<span id="bgp-status-summary" class="stats-footer-text" data-earth-stat="bgp-status-summary">暂无观测数据</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- hidden elements kept for JS compatibility -->
|
<!-- hidden elements kept for JS compatibility -->
|
||||||
<span id="terrain-status" hidden></span>
|
<span id="terrain-status" data-earth-stat="terrain-status" hidden></span>
|
||||||
<span id="texture-quality" hidden></span>
|
<span id="texture-quality" data-earth-stat="texture-quality" hidden></span>
|
||||||
<span id="camera-distance" hidden></span>
|
<span id="camera-distance" hidden></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -412,6 +460,354 @@
|
|||||||
|
|
||||||
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
|
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
|
||||||
<div id="tooltip" class="earth-tooltip"></div>
|
<div id="tooltip" class="earth-tooltip"></div>
|
||||||
|
<div id="earth-mobile-popup" class="earth-mobile-popup" hidden aria-live="polite">
|
||||||
|
<span id="earth-mobile-popup-dock" class="earth-mobile-popup-dock" aria-hidden="true"></span>
|
||||||
|
<span class="earth-mobile-popup-icon" id="earth-mobile-popup-icon"></span>
|
||||||
|
<div class="earth-mobile-popup-body">
|
||||||
|
<div class="earth-mobile-popup-title" id="earth-mobile-popup-title"></div>
|
||||||
|
<div class="earth-mobile-popup-sub" id="earth-mobile-popup-sub"></div>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-rounded earth-mobile-popup-chevron">chevron_right</span>
|
||||||
|
</div>
|
||||||
|
<div id="mobile-drawer-overlay" class="earth-mobile-drawer-overlay" hidden></div>
|
||||||
|
<div id="mobile-drawer-shell" class="earth-mobile-drawer-shell" aria-hidden="true">
|
||||||
|
<div class="earth-mobile-drawer-sheet">
|
||||||
|
<div id="mobile-drawer-handle" class="earth-mobile-drawer-header">
|
||||||
|
<div class="earth-mobile-drawer-grabber" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-drawer-nav">
|
||||||
|
<div class="earth-mobile-drawer-nav-copy">
|
||||||
|
<span class="earth-mobile-drawer-nav-kicker">Earth Menu</span>
|
||||||
|
<span class="earth-mobile-drawer-nav-title">模块切换</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-drawer-tabs-shell">
|
||||||
|
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--left" aria-hidden="true"></div>
|
||||||
|
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--right" aria-hidden="true"></div>
|
||||||
|
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
|
||||||
|
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">
|
||||||
|
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">layers</span>
|
||||||
|
<span class="earth-mobile-drawer-tab-label">图层</span>
|
||||||
|
</button>
|
||||||
|
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">
|
||||||
|
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">search</span>
|
||||||
|
<span class="earth-mobile-drawer-tab-label">搜索</span>
|
||||||
|
</button>
|
||||||
|
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">
|
||||||
|
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">travel_explore</span>
|
||||||
|
<span class="earth-mobile-drawer-tab-label">态势</span>
|
||||||
|
</button>
|
||||||
|
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">
|
||||||
|
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">article</span>
|
||||||
|
<span class="earth-mobile-drawer-tab-label">新闻</span>
|
||||||
|
</button>
|
||||||
|
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">
|
||||||
|
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">live_tv</span>
|
||||||
|
<span class="earth-mobile-drawer-tab-label">TV</span>
|
||||||
|
</button>
|
||||||
|
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">
|
||||||
|
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">tune</span>
|
||||||
|
<span class="earth-mobile-drawer-tab-label">设置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-drawer-content">
|
||||||
|
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
|
||||||
|
<div class="earth-mobile-page earth-mobile-page--layers">
|
||||||
|
<div class="earth-mobile-page-intro">
|
||||||
|
<span class="earth-mobile-page-kicker">Layer Control</span>
|
||||||
|
<span id="mobile-layer-summary" class="earth-mobile-page-summary">已启用 0 个图层</span>
|
||||||
|
</div>
|
||||||
|
<div id="mobile-layer-list" class="earth-mobile-layer-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="earth-mobile-drawer-slot" data-drawer-slot="search">
|
||||||
|
<div class="earth-mobile-page earth-mobile-page--search">
|
||||||
|
<div class="earth-mobile-page-intro">
|
||||||
|
<span class="earth-mobile-page-kicker">Object Search</span>
|
||||||
|
<span class="earth-mobile-page-summary">搜索海缆、登陆点、卫星、算力中心和 BGP 事件</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-search-shell">
|
||||||
|
<span class="material-symbols-rounded earth-mobile-search-icon" aria-hidden="true">search</span>
|
||||||
|
<input
|
||||||
|
id="mobile-earth-search-input"
|
||||||
|
class="earth-mobile-search-input"
|
||||||
|
type="text"
|
||||||
|
inputmode="search"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="输入名称、地点、NORAD、ASN..."
|
||||||
|
>
|
||||||
|
<button id="mobile-earth-search-clear" class="earth-mobile-search-clear" type="button" aria-label="清除搜索" hidden>
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="mobile-earth-search-meta" class="earth-mobile-search-meta">输入关键词以搜索当前地球对象</div>
|
||||||
|
<div id="mobile-earth-search-results" class="earth-mobile-search-results" role="listbox" aria-label="移动端搜索结果"></div>
|
||||||
|
<div id="mobile-earth-search-empty" class="earth-mobile-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="earth-mobile-drawer-slot earth-mobile-drawer-slot--situation" data-drawer-slot="situation">
|
||||||
|
<div class="earth-mobile-page earth-mobile-page--situation">
|
||||||
|
<div class="earth-mobile-page-intro">
|
||||||
|
<span class="earth-mobile-page-kicker">Situation</span>
|
||||||
|
<span class="earth-mobile-page-summary">面向移动端整合的全球态势概览</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-stats-grid">
|
||||||
|
<div class="earth-mobile-stat-card">
|
||||||
|
<span id="mobile-cable-count" class="earth-mobile-stat-num" data-earth-stat="cable-count">—</span>
|
||||||
|
<span class="earth-mobile-stat-label">海缆系统</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-stat-card">
|
||||||
|
<span id="mobile-landing-point-count" class="earth-mobile-stat-num" data-earth-stat="landing-point-count">—</span>
|
||||||
|
<span class="earth-mobile-stat-label">登陆点</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-stat-card">
|
||||||
|
<span id="mobile-satellite-count" class="earth-mobile-stat-num" data-earth-stat="satellite-count">—</span>
|
||||||
|
<span class="earth-mobile-stat-label">在轨卫星</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-stat-card">
|
||||||
|
<span id="mobile-compute-center-count" class="earth-mobile-stat-num" data-earth-stat="compute-center-count">—</span>
|
||||||
|
<span class="earth-mobile-stat-label">算力中心</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-stat-card">
|
||||||
|
<span id="mobile-bgp-anomaly-count" class="earth-mobile-stat-num" data-earth-stat="bgp-anomaly-count">—</span>
|
||||||
|
<span class="earth-mobile-stat-label">BGP 事件</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-situation-card">
|
||||||
|
<div class="earth-mobile-situation-card-title">图例</div>
|
||||||
|
<div id="mobile-situation-legend-mode" class="earth-mobile-situation-card-subtitle">海缆</div>
|
||||||
|
<div id="mobile-situation-legend-list" class="earth-mobile-situation-legend-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-situation-card">
|
||||||
|
<div class="earth-mobile-situation-card-title">BGP 状态</div>
|
||||||
|
<div id="mobile-bgp-status-summary" class="earth-mobile-situation-status" data-earth-stat="bgp-status-summary">暂无观测数据</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="earth-mobile-drawer-slot" data-drawer-slot="news">
|
||||||
|
<div class="earth-mobile-page earth-mobile-page--news">
|
||||||
|
<div class="earth-mobile-page-intro">
|
||||||
|
<span class="earth-mobile-page-kicker">News</span>
|
||||||
|
<span class="earth-mobile-page-summary">跟随当前视角聚焦全球区域新闻</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-news-focus">
|
||||||
|
<div>
|
||||||
|
<div class="earth-mobile-news-focus-kicker">当前关注区域</div>
|
||||||
|
<div id="mobile-news-focus-label" class="earth-mobile-news-focus-label">全球焦点</div>
|
||||||
|
<div id="mobile-news-focus-coords" class="earth-mobile-news-focus-coords">跟随当前视角自动聚焦</div>
|
||||||
|
</div>
|
||||||
|
<div id="mobile-news-source-count" class="earth-mobile-news-source-count">0 路聚合源</div>
|
||||||
|
</div>
|
||||||
|
<div id="mobile-news-board-status" class="earth-mobile-news-board-status">正在准备全球态势新闻...</div>
|
||||||
|
<div id="mobile-news-board-list" class="earth-mobile-news-board-list"></div>
|
||||||
|
<div id="mobile-news-board-empty" class="earth-mobile-news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
|
||||||
|
<div class="earth-mobile-news-actions">
|
||||||
|
<button id="mobile-news-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
|
||||||
|
<button id="mobile-news-open-external" class="earth-mobile-action-btn" type="button">打开源站</button>
|
||||||
|
</div>
|
||||||
|
<a id="mobile-news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="earth-mobile-drawer-slot" data-drawer-slot="tv">
|
||||||
|
<div class="earth-mobile-page earth-mobile-page--tv">
|
||||||
|
<div class="earth-mobile-page-intro">
|
||||||
|
<span class="earth-mobile-page-kicker">TV</span>
|
||||||
|
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
|
||||||
|
</div>
|
||||||
|
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
|
||||||
|
<div class="earth-mobile-tv-player">
|
||||||
|
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||||
|
<iframe
|
||||||
|
id="mobile-tv-iframe"
|
||||||
|
class="earth-mobile-tv-iframe"
|
||||||
|
hidden
|
||||||
|
title="移动端新闻直播"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
allow="autoplay; fullscreen; picture-in-picture"
|
||||||
|
></iframe>
|
||||||
|
<video id="mobile-tv-video" class="earth-mobile-tv-video" hidden controls autoplay muted playsinline></video>
|
||||||
|
</div>
|
||||||
|
<div id="mobile-tv-overview" class="earth-mobile-tv-overview">
|
||||||
|
<div id="mobile-tv-overview-bar" class="earth-mobile-tv-overview-bar" role="button" tabindex="0" aria-expanded="false" aria-controls="mobile-tv-meta-wrap">
|
||||||
|
<div class="earth-mobile-tv-overview-copy">
|
||||||
|
<span class="earth-mobile-tv-overview-kicker">频道信息</span>
|
||||||
|
<span id="mobile-tv-overview-headline" class="earth-mobile-tv-overview-headline">暂无可用频道</span>
|
||||||
|
<span id="mobile-tv-overview-summary" class="earth-mobile-tv-overview-summary">展开查看当前频道来源、目录和补充说明</span>
|
||||||
|
<div id="mobile-tv-overview-tags" class="earth-mobile-tv-overview-tags" aria-label="频道摘要标签"></div>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-tv-overview-actions">
|
||||||
|
<button id="mobile-tv-refresh" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="刷新频道列表" title="刷新频道列表">
|
||||||
|
<span class="material-symbols-rounded" aria-hidden="true">refresh</span>
|
||||||
|
</button>
|
||||||
|
<button id="mobile-tv-open-external" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="访问频道官网" title="访问频道官网">
|
||||||
|
<span class="material-symbols-rounded" aria-hidden="true">open_in_new</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mobile-tv-meta-wrap" class="earth-mobile-tv-meta-wrap">
|
||||||
|
<div class="earth-mobile-tv-meta">
|
||||||
|
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
|
||||||
|
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
|
||||||
|
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
|
||||||
|
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
|
||||||
|
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="earth-mobile-drawer-slot" data-drawer-slot="settings">
|
||||||
|
<div class="earth-mobile-page earth-mobile-page--settings">
|
||||||
|
<div class="earth-mobile-page-intro">
|
||||||
|
<span class="earth-mobile-page-kicker">Settings</span>
|
||||||
|
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-settings-group">
|
||||||
|
<div class="earth-mobile-settings-title">旋转</div>
|
||||||
|
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||||
|
<div class="earth-mobile-settings-copy">
|
||||||
|
<span class="earth-mobile-settings-label">旋转模式</span>
|
||||||
|
<span class="earth-mobile-settings-subtitle">巡航模式会按已启用模块的目标队列轮播聚焦</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
|
||||||
|
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
|
||||||
|
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||||
|
<div class="earth-mobile-settings-copy">
|
||||||
|
<span class="earth-mobile-settings-label">巡航模块</span>
|
||||||
|
<span class="earth-mobile-settings-subtitle">选择哪些业务模块参与巡航队列。默认 BGP,新闻可按需加入。</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-settings-chip-group" role="group" aria-label="移动端选择巡航模块">
|
||||||
|
<button type="button" class="earth-mobile-settings-chip is-active" data-cruise-module-toggle="bgp" aria-pressed="true">BGP</button>
|
||||||
|
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="news" aria-pressed="false">新闻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-settings-group">
|
||||||
|
<div class="earth-mobile-settings-title">视图</div>
|
||||||
|
<label class="earth-mobile-settings-card">
|
||||||
|
<div class="earth-mobile-settings-copy">
|
||||||
|
<span class="earth-mobile-settings-label">日夜模式</span>
|
||||||
|
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
|
||||||
|
</div>
|
||||||
|
<span class="earth-mobile-settings-switch">
|
||||||
|
<input type="checkbox" data-daynight-toggle checked>
|
||||||
|
<span class="earth-mobile-settings-switch-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||||
|
<div class="earth-mobile-settings-copy">
|
||||||
|
<span class="earth-mobile-settings-label">地球默认大小</span>
|
||||||
|
<span class="earth-mobile-settings-subtitle">用于重置视角、缩放重置和巡航视图</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-settings-slider-row">
|
||||||
|
<input
|
||||||
|
class="earth-mobile-settings-slider"
|
||||||
|
type="range"
|
||||||
|
min="0.5"
|
||||||
|
max="5"
|
||||||
|
step="0.01"
|
||||||
|
value="1"
|
||||||
|
data-default-earth-size-slider
|
||||||
|
aria-label="移动端调整地球默认大小"
|
||||||
|
>
|
||||||
|
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-settings-group">
|
||||||
|
<div class="earth-mobile-settings-title">地形</div>
|
||||||
|
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||||
|
<div class="earth-mobile-settings-copy">
|
||||||
|
<span class="earth-mobile-settings-label">地形透明度</span>
|
||||||
|
<span class="earth-mobile-settings-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-settings-slider-row">
|
||||||
|
<input
|
||||||
|
class="earth-mobile-settings-slider"
|
||||||
|
type="range"
|
||||||
|
min="0.05"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value="0.62"
|
||||||
|
data-terrain-opacity-slider
|
||||||
|
aria-label="移动端调整地形透明度"
|
||||||
|
>
|
||||||
|
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-settings-group">
|
||||||
|
<div class="earth-mobile-settings-title">系统</div>
|
||||||
|
<div class="earth-mobile-settings-actions">
|
||||||
|
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
|
||||||
|
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开 Admin</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="earth-mobile-drawer-slot" data-drawer-slot="details">
|
||||||
|
<div class="earth-mobile-page earth-mobile-page--details">
|
||||||
|
<div class="earth-mobile-page-intro">
|
||||||
|
<span class="earth-mobile-page-kicker">Details</span>
|
||||||
|
<span class="earth-mobile-page-summary">点击地球对象后查看统一详情</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-mobile-detail-card">
|
||||||
|
<div class="earth-mobile-detail-header">
|
||||||
|
<span id="mobile-info-card-icon" class="earth-mobile-detail-icon">🛰️</span>
|
||||||
|
<div class="earth-mobile-detail-heading">
|
||||||
|
<div id="mobile-info-card-title" class="earth-mobile-detail-title">对象详情</div>
|
||||||
|
<div id="mobile-info-card-type" class="earth-mobile-detail-type">等待选择对象</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mobile-info-card-content" class="earth-mobile-detail-content">
|
||||||
|
<div class="earth-mobile-detail-empty">点击海缆、算力中心、BGP 事件或卫星后在这里查看详情。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="search-modal" class="earth-search-modal" aria-hidden="true">
|
||||||
|
<div id="search-backdrop" class="earth-search-backdrop"></div>
|
||||||
|
<div class="earth-search-sheet hud-panel" role="dialog" aria-modal="true" aria-label="搜索">
|
||||||
|
<div class="earth-search-header hud-panel__header">
|
||||||
|
<div class="hud-panel__title-group">
|
||||||
|
<div class="earth-search-kicker">搜索</div>
|
||||||
|
</div>
|
||||||
|
<div class="hud-panel__actions">
|
||||||
|
<button id="search-close" class="earth-search-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭搜索">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="earth-search-content hud-panel__body">
|
||||||
|
<div class="earth-search-input-shell">
|
||||||
|
<span class="material-symbols-rounded earth-search-input-icon" aria-hidden="true">search</span>
|
||||||
|
<input
|
||||||
|
id="earth-search-input"
|
||||||
|
class="earth-search-input"
|
||||||
|
type="text"
|
||||||
|
inputmode="search"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="搜索海缆、登陆点、卫星、算力中心、BGP 事件..."
|
||||||
|
>
|
||||||
|
<button id="earth-search-clear" class="earth-search-clear hud-panel__action" type="button" aria-label="清除搜索" hidden>
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="earth-search-meta" class="earth-search-meta">输入关键词以搜索当前地球对象</div>
|
||||||
|
<div id="earth-search-results" class="earth-search-results" role="listbox" aria-label="搜索结果"></div>
|
||||||
|
<div id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
|
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
|
||||||
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
||||||
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
|
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
|
||||||
@@ -434,7 +830,7 @@
|
|||||||
<div class="earth-settings-item earth-settings-item--stacked">
|
<div class="earth-settings-item earth-settings-item--stacked">
|
||||||
<div class="earth-settings-copy">
|
<div class="earth-settings-copy">
|
||||||
<span class="earth-settings-item-title">旋转模式</span>
|
<span class="earth-settings-item-title">旋转模式</span>
|
||||||
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
|
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按已启用模块的目标队列轮播聚焦</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
|
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
|
||||||
<button
|
<button
|
||||||
@@ -455,6 +851,30 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="earth-settings-item earth-settings-item--stacked">
|
||||||
|
<div class="earth-settings-copy">
|
||||||
|
<span class="earth-settings-item-title">巡航模块</span>
|
||||||
|
<span class="earth-settings-item-subtitle">选择哪些业务模块参与巡航队列。默认 BGP,新闻会按发生地与时间加入巡航目标并显示新闻卡片。</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-settings-chip-group" role="group" aria-label="选择巡航模块">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="earth-settings-chip is-active"
|
||||||
|
data-cruise-module-toggle="bgp"
|
||||||
|
aria-pressed="true"
|
||||||
|
>
|
||||||
|
BGP
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="earth-settings-chip"
|
||||||
|
data-cruise-module-toggle="news"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
新闻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="earth-settings-section">
|
<section class="earth-settings-section">
|
||||||
|
|||||||
@@ -1,17 +1,73 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
import { CRUISE_CONFIG, PATHS } from "./constants.js";
|
import { CONNECTOR_CONFIG, CRUISE_CONFIG, PATHS } from "./constants.js";
|
||||||
import { createElbowConnectorPoints } from "./callout-connector.js";
|
import {
|
||||||
|
computeNearestPerimeterAnchor,
|
||||||
|
createConnectorPath,
|
||||||
|
resolveConnectorAnchor,
|
||||||
|
} from "./callout-connector.js";
|
||||||
|
|
||||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||||
const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
|
const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
|
||||||
const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
|
const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
|
||||||
const CRUISE_CARD_VIEWPORT_PADDING_PX = 32;
|
const CRUISE_CARD_VIEWPORT_PADDING_PX = 32;
|
||||||
const CRUISE_CARD_SCREEN_MARGIN_PX = 12;
|
const CRUISE_CARD_SCREEN_MARGIN_PX = 12;
|
||||||
const CRUISE_CARD_ANCHOR_OFFSET_PX = 18;
|
const CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX = 220;
|
||||||
|
const CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX = 68;
|
||||||
|
const CRUISE_MOBILE_POPUP_TOP_RATIO = 0.17;
|
||||||
|
const CRUISE_MOBILE_POPUP_MARGIN_PX = 14;
|
||||||
|
const CRUISE_MOBILE_DRAWER_CLEARANCE_PX = 52;
|
||||||
|
const CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT = 3;
|
||||||
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||||
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
||||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||||
|
const MOBILE_POPUP_OBSTACLE_PADDING_PX = 16;
|
||||||
|
const DESKTOP_PANEL_OBSTACLE_PADDING_PX = 12;
|
||||||
|
const CRUISE_MARKER_SCREEN_PADDING_PX = 4;
|
||||||
|
|
||||||
|
function getDockAxisOffsets(dockSide, gapPx) {
|
||||||
|
return {
|
||||||
|
offsetX:
|
||||||
|
dockSide === "right" ? gapPx : dockSide === "left" ? -gapPx : 0,
|
||||||
|
offsetY:
|
||||||
|
dockSide === "bottom" ? gapPx : dockSide === "top" ? -gapPx : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObstaclePaddingBySide(side, paddingPx) {
|
||||||
|
if (side === "left") {
|
||||||
|
return { left: 0, top: paddingPx, right: paddingPx, bottom: paddingPx };
|
||||||
|
}
|
||||||
|
if (side === "right") {
|
||||||
|
return { left: paddingPx, top: paddingPx, right: 0, bottom: paddingPx };
|
||||||
|
}
|
||||||
|
if (side === "top") {
|
||||||
|
return { left: paddingPx, top: 0, right: paddingPx, bottom: paddingPx };
|
||||||
|
}
|
||||||
|
return { left: paddingPx, top: paddingPx, right: paddingPx, bottom: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scratchMarkerWorldScale = new THREE.Vector3();
|
||||||
|
const scratchCameraQuaternion = new THREE.Quaternion();
|
||||||
|
const scratchCameraRight = new THREE.Vector3();
|
||||||
|
const scratchCameraUp = new THREE.Vector3();
|
||||||
|
const scratchMarkerRightPoint = new THREE.Vector3();
|
||||||
|
const scratchMarkerLeftPoint = new THREE.Vector3();
|
||||||
|
const scratchMarkerTopPoint = new THREE.Vector3();
|
||||||
|
const scratchMarkerBottomPoint = new THREE.Vector3();
|
||||||
|
|
||||||
|
function projectWorldToScreen(point, camera) {
|
||||||
|
if (!point || !camera) return null;
|
||||||
|
const projected = point.clone().project(camera);
|
||||||
|
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||||
|
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getMarkerTimestamp(marker) {
|
function getMarkerTimestamp(marker) {
|
||||||
const rawValue = marker?.userData?.created_at_raw;
|
const rawValue = marker?.userData?.created_at_raw;
|
||||||
@@ -53,14 +109,78 @@ export function createBGPCruiseAdapter({
|
|||||||
if (!marker || !camera) return null;
|
if (!marker || !camera) return null;
|
||||||
scratchBGPWorldPosition.copy(marker.position);
|
scratchBGPWorldPosition.copy(marker.position);
|
||||||
marker.parent?.localToWorld(scratchBGPWorldPosition);
|
marker.parent?.localToWorld(scratchBGPWorldPosition);
|
||||||
const projected = scratchBGPWorldPosition.clone().project(camera);
|
return projectWorldToScreen(scratchBGPWorldPosition, camera);
|
||||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
}
|
||||||
|
|
||||||
|
function getVisibleMobilePopup() {
|
||||||
|
const mobilePopup = document.getElementById("earth-mobile-popup");
|
||||||
|
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
|
||||||
|
? mobilePopup
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleInfoPanel() {
|
||||||
|
const infoPanel = document.getElementById("info-panel");
|
||||||
|
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
|
||||||
|
? infoPanel
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarkerScreenRect(marker) {
|
||||||
|
const center = getMarkerScreenCoords(marker);
|
||||||
|
if (!center || !camera || !marker) return null;
|
||||||
|
|
||||||
|
marker.getWorldScale(scratchMarkerWorldScale);
|
||||||
|
const worldWidth = Math.max(
|
||||||
|
0.0001,
|
||||||
|
Number(marker.userData?.baseScale ?? scratchMarkerWorldScale.x ?? 0) || scratchMarkerWorldScale.x,
|
||||||
|
);
|
||||||
|
const worldHeight = Math.max(
|
||||||
|
0.0001,
|
||||||
|
Number(scratchMarkerWorldScale.y || worldWidth),
|
||||||
|
);
|
||||||
|
|
||||||
|
camera.getWorldQuaternion(scratchCameraQuaternion);
|
||||||
|
scratchCameraRight.set(1, 0, 0).applyQuaternion(scratchCameraQuaternion).normalize();
|
||||||
|
scratchCameraUp.set(0, 1, 0).applyQuaternion(scratchCameraQuaternion).normalize();
|
||||||
|
|
||||||
|
scratchMarkerRightPoint
|
||||||
|
.copy(scratchBGPWorldPosition)
|
||||||
|
.addScaledVector(scratchCameraRight, worldWidth * 0.5);
|
||||||
|
scratchMarkerLeftPoint
|
||||||
|
.copy(scratchBGPWorldPosition)
|
||||||
|
.addScaledVector(scratchCameraRight, -worldWidth * 0.5);
|
||||||
|
scratchMarkerTopPoint
|
||||||
|
.copy(scratchBGPWorldPosition)
|
||||||
|
.addScaledVector(scratchCameraUp, worldHeight * 0.5);
|
||||||
|
scratchMarkerBottomPoint
|
||||||
|
.copy(scratchBGPWorldPosition)
|
||||||
|
.addScaledVector(scratchCameraUp, -worldHeight * 0.5);
|
||||||
|
|
||||||
|
const rightPoint = projectWorldToScreen(scratchMarkerRightPoint, camera);
|
||||||
|
const leftPoint = projectWorldToScreen(scratchMarkerLeftPoint, camera);
|
||||||
|
const topPoint = projectWorldToScreen(scratchMarkerTopPoint, camera);
|
||||||
|
const bottomPoint = projectWorldToScreen(scratchMarkerBottomPoint, camera);
|
||||||
|
if (!rightPoint || !leftPoint || !topPoint || !bottomPoint) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const halfWidth = Math.max(
|
||||||
|
Math.abs(rightPoint.x - center.x),
|
||||||
|
Math.abs(leftPoint.x - center.x),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const halfHeight = Math.max(
|
||||||
|
Math.abs(topPoint.y - center.y),
|
||||||
|
Math.abs(bottomPoint.y - center.y),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
x: center.x - halfWidth - CRUISE_MARKER_SCREEN_PADDING_PX,
|
||||||
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
y: center.y - halfHeight - CRUISE_MARKER_SCREEN_PADDING_PX,
|
||||||
|
width: halfWidth * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
|
||||||
|
height: halfHeight * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +188,53 @@ export function createBGPCruiseAdapter({
|
|||||||
const markerCoords = getMarkerScreenCoords(marker);
|
const markerCoords = getMarkerScreenCoords(marker);
|
||||||
if (!markerCoords) return null;
|
if (!markerCoords) return null;
|
||||||
|
|
||||||
|
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||||
|
const safeBottom =
|
||||||
|
parseFloat(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
|
||||||
|
) || 0;
|
||||||
|
const estimatedCardWidth = Math.min(
|
||||||
|
CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX,
|
||||||
|
window.innerWidth - CRUISE_MOBILE_POPUP_MARGIN_PX * 2,
|
||||||
|
);
|
||||||
|
const estimatedCardHeight = CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX;
|
||||||
|
const topBound = Math.max(
|
||||||
|
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||||
|
Math.min(
|
||||||
|
window.innerHeight * CRUISE_MOBILE_POPUP_TOP_RATIO,
|
||||||
|
window.innerHeight -
|
||||||
|
CRUISE_MOBILE_DRAWER_CLEARANCE_PX -
|
||||||
|
safeBottom -
|
||||||
|
estimatedCardHeight -
|
||||||
|
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rightSlotLeft = Math.max(
|
||||||
|
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||||
|
window.innerWidth - estimatedCardWidth - CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||||
|
);
|
||||||
|
const leftSlotLeft = CRUISE_MOBILE_POPUP_MARGIN_PX;
|
||||||
|
const rightSlotCenterX = rightSlotLeft + estimatedCardWidth * 0.5;
|
||||||
|
const leftSlotCenterX = leftSlotLeft + estimatedCardWidth * 0.5;
|
||||||
|
const rightClearance = rightSlotLeft - markerCoords.x;
|
||||||
|
const leftClearance = markerCoords.x - (leftSlotLeft + estimatedCardWidth);
|
||||||
|
const rightCost =
|
||||||
|
Math.max(0, -rightClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
|
||||||
|
Math.abs(rightSlotCenterX - markerCoords.x);
|
||||||
|
const leftCost =
|
||||||
|
Math.max(0, -leftClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
|
||||||
|
Math.abs(markerCoords.x - leftSlotCenterX);
|
||||||
|
const placeOnRight = rightCost <= leftCost;
|
||||||
|
const left = placeOnRight ? rightSlotLeft : leftSlotLeft;
|
||||||
|
return {
|
||||||
|
x: left,
|
||||||
|
y: topBound,
|
||||||
|
width: estimatedCardWidth,
|
||||||
|
height: estimatedCardHeight,
|
||||||
|
dockSide: placeOnRight ? "left" : "right",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const hudScale =
|
const hudScale =
|
||||||
Number.parseFloat(
|
Number.parseFloat(
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||||
@@ -94,38 +261,148 @@ export function createBGPCruiseAdapter({
|
|||||||
Math.max(margin, y),
|
Math.max(margin, y),
|
||||||
Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
|
Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
|
||||||
);
|
);
|
||||||
const anchorY = clampedY + Math.max(
|
|
||||||
CRUISE_CARD_ANCHOR_OFFSET_PX * hudScale,
|
|
||||||
estimatedCardHeight * 0.18,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: clampedX,
|
x: clampedX,
|
||||||
y: clampedY,
|
y: clampedY,
|
||||||
width: estimatedCardWidth,
|
width: estimatedCardWidth,
|
||||||
height: estimatedCardHeight,
|
height: estimatedCardHeight,
|
||||||
anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx,
|
|
||||||
anchorY,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCardAnchorTarget() {
|
||||||
|
const mobilePopup = getVisibleMobilePopup();
|
||||||
|
if (
|
||||||
|
document.body.classList.contains("layout-mode-mobile") &&
|
||||||
|
mobilePopup
|
||||||
|
) {
|
||||||
|
const dockSide = mobilePopup.dataset.dockSide || "left";
|
||||||
|
const { offsetX, offsetY } = getDockAxisOffsets(
|
||||||
|
dockSide,
|
||||||
|
CONNECTOR_CONFIG.panelGapPx,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
element: mobilePopup,
|
||||||
|
side: dockSide,
|
||||||
|
alignRatio: 0.5,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardObstacleTarget(fallbackPlacement = null) {
|
||||||
|
const mobilePopup = getVisibleMobilePopup();
|
||||||
|
if (
|
||||||
|
document.body.classList.contains("layout-mode-mobile") &&
|
||||||
|
mobilePopup
|
||||||
|
) {
|
||||||
|
const side = mobilePopup.dataset.dockSide || "left";
|
||||||
|
return {
|
||||||
|
element: mobilePopup,
|
||||||
|
padding: getObstaclePaddingBySide(side, MOBILE_POPUP_OBSTACLE_PADDING_PX),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoPanel = getVisibleInfoPanel();
|
||||||
|
if (infoPanel) {
|
||||||
|
return {
|
||||||
|
element: infoPanel,
|
||||||
|
padding: getObstaclePaddingBySide("left", DESKTOP_PANEL_OBSTACLE_PADDING_PX),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackPlacement) {
|
||||||
|
return {
|
||||||
|
x: fallbackPlacement.x,
|
||||||
|
y: fallbackPlacement.y,
|
||||||
|
width: fallbackPlacement.width ?? 0,
|
||||||
|
height: fallbackPlacement.height ?? 0,
|
||||||
|
padding: DESKTOP_PANEL_OBSTACLE_PADDING_PX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAdaptiveDockSide(markerCoords, fallbackPlacement = null) {
|
||||||
|
const mobilePopup = getVisibleMobilePopup();
|
||||||
|
const popupRect =
|
||||||
|
mobilePopup
|
||||||
|
? mobilePopup.getBoundingClientRect()
|
||||||
|
: fallbackPlacement
|
||||||
|
? {
|
||||||
|
left: fallbackPlacement.x,
|
||||||
|
top: fallbackPlacement.y,
|
||||||
|
right: fallbackPlacement.x + (fallbackPlacement.width ?? 0),
|
||||||
|
bottom: fallbackPlacement.y + (fallbackPlacement.height ?? 0),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (!markerCoords || !popupRect) return fallbackPlacement?.dockSide === "right" ? "right" : "left";
|
||||||
|
|
||||||
|
return computeNearestPerimeterAnchor(markerCoords, popupRect, 0)?.side || "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMobileDockSide(markerCoords, fallbackPlacement = null) {
|
||||||
|
const mobilePopup = getVisibleMobilePopup();
|
||||||
|
const dockSide = resolveAdaptiveDockSide(markerCoords, fallbackPlacement);
|
||||||
|
if (mobilePopup) {
|
||||||
|
mobilePopup.dataset.dockSide = dockSide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getConnectorPath(marker) {
|
function getConnectorPath(marker) {
|
||||||
const markerCoords = getMarkerScreenCoords(marker);
|
const markerCoords = getMarkerScreenCoords(marker);
|
||||||
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
const markerRect = getMarkerScreenRect(marker);
|
||||||
if (!markerCoords || !targetCardCoords) return null;
|
if (!markerCoords) return null;
|
||||||
|
|
||||||
return createElbowConnectorPoints(
|
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||||
markerCoords,
|
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||||
{
|
syncMobileDockSide(markerCoords, targetCardCoords);
|
||||||
x: targetCardCoords.anchorX,
|
const cardAnchorTarget = getCardAnchorTarget();
|
||||||
y: targetCardCoords.anchorY,
|
const cardAnchorCoords = resolveConnectorAnchor(cardAnchorTarget);
|
||||||
},
|
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
|
||||||
{
|
if (!cardAnchorCoords) return null;
|
||||||
|
|
||||||
|
return createConnectorPath(markerCoords, cardAnchorTarget ?? cardAnchorCoords, {
|
||||||
|
routingMode: "adaptive",
|
||||||
|
sourceRect: markerRect,
|
||||||
|
targetAnchor: cardAnchorTarget ?? cardAnchorCoords,
|
||||||
|
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
|
||||||
startFrom: "source",
|
startFrom: "source",
|
||||||
sourceGapPx: CRUISE_CONFIG.linkMarkerGapPx,
|
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||||
targetGapPx: CRUISE_CONFIG.linkPanelGapPx,
|
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||||
elbowOffsetPx: CRUISE_CONFIG.linkElbowOffsetPx,
|
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||||
elbowDropPx: CRUISE_CONFIG.linkElbowDropPx,
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||||
|
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
|
||||||
|
if (!targetCardCoords) return null;
|
||||||
|
|
||||||
|
const infoPanel = getVisibleInfoPanel();
|
||||||
|
const desktopTarget =
|
||||||
|
infoPanel
|
||||||
|
? infoPanel
|
||||||
|
: {
|
||||||
|
x: targetCardCoords.x,
|
||||||
|
y: targetCardCoords.y,
|
||||||
|
width: targetCardCoords.width,
|
||||||
|
height: targetCardCoords.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createConnectorPath(
|
||||||
|
markerCoords,
|
||||||
|
desktopTarget,
|
||||||
|
{
|
||||||
|
routingMode: "adaptive",
|
||||||
|
sourceRect: markerRect,
|
||||||
|
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
|
||||||
|
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||||
|
startFrom: "source",
|
||||||
|
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||||
|
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,7 +443,6 @@ export function createBGPCruiseAdapter({
|
|||||||
async focusMarker(marker, { interrupt = false } = {}) {
|
async focusMarker(marker, { interrupt = false } = {}) {
|
||||||
if (!marker) return;
|
if (!marker) return;
|
||||||
currentMarkerId = marker.userData?.id || null;
|
currentMarkerId = marker.userData?.id || null;
|
||||||
cardPlacement = getCardScreenCoords(marker);
|
|
||||||
setMarkerLocked(marker);
|
setMarkerLocked(marker);
|
||||||
showMarkerOverlay(marker);
|
showMarkerOverlay(marker);
|
||||||
|
|
||||||
@@ -179,10 +455,31 @@ export function createBGPCruiseAdapter({
|
|||||||
: CRUISE_CONFIG.focusDurationMs,
|
: CRUISE_CONFIG.focusDurationMs,
|
||||||
suppressStatus: true,
|
suppressStatus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cardPlacement = getCardScreenCoords(marker);
|
||||||
},
|
},
|
||||||
async presentMarker(marker, { context }) {
|
async presentMarker(marker, { context }) {
|
||||||
if (!marker) return false;
|
if (!marker) return false;
|
||||||
|
|
||||||
|
const showCruiseMarkerInfo = ({ reveal = true } = {}) =>
|
||||||
|
showMarkerInfo(marker, {
|
||||||
|
x: cardPlacement?.x,
|
||||||
|
y: cardPlacement?.y,
|
||||||
|
absolute: true,
|
||||||
|
reveal,
|
||||||
|
anchorStable: true,
|
||||||
|
dockSide: cardPlacement?.dockSide,
|
||||||
|
});
|
||||||
|
|
||||||
|
showCruiseMarkerInfo({ reveal: false });
|
||||||
|
await context.nextFrame();
|
||||||
|
if (!context.isCurrent()) {
|
||||||
|
cardPlacement = null;
|
||||||
|
connector.hide();
|
||||||
|
hideInfo();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
let connectorReady = false;
|
let connectorReady = false;
|
||||||
while (context.isCurrent()) {
|
while (context.isCurrent()) {
|
||||||
@@ -211,18 +508,10 @@ export function createBGPCruiseAdapter({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
showMarkerInfo(marker, {
|
showCruiseMarkerInfo();
|
||||||
x: cardPlacement?.x,
|
|
||||||
y: cardPlacement?.y,
|
|
||||||
absolute: true,
|
|
||||||
});
|
|
||||||
await context.nextFrame();
|
await context.nextFrame();
|
||||||
if (!isInfoVisible()) {
|
if (!isInfoVisible()) {
|
||||||
showMarkerInfo(marker, {
|
showCruiseMarkerInfo();
|
||||||
x: cardPlacement?.x,
|
|
||||||
y: cardPlacement?.y,
|
|
||||||
absolute: true,
|
|
||||||
});
|
|
||||||
await context.nextFrame();
|
await context.nextFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
|
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||||
import { latLonToVector3 } from "./utils.js";
|
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||||
|
|
||||||
const bgpGroup = new THREE.Group();
|
const bgpGroup = new THREE.Group();
|
||||||
const bgpOverlayGroup = new THREE.Group();
|
const bgpOverlayGroup = new THREE.Group();
|
||||||
@@ -280,37 +280,23 @@ function blendHexColors(fromHex, toHex, ratio) {
|
|||||||
function getCollectorDistanceScale(marker, camera) {
|
function getCollectorDistanceScale(marker, camera) {
|
||||||
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
||||||
|
|
||||||
marker.getWorldPosition(collectorWorldPosition);
|
return getSurfaceMarkerCameraScale(camera, {
|
||||||
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
|
altitudeOffset: BGP_CONFIG.collectorAltitudeOffset,
|
||||||
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset;
|
referenceFov: 75,
|
||||||
const referenceFovRad = (75 * Math.PI) / 180;
|
min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6),
|
||||||
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
|
max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9),
|
||||||
const min = Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6);
|
});
|
||||||
const max = Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9);
|
|
||||||
const worldPerPixel =
|
|
||||||
distanceToCamera * Math.tan(cameraFovRad / 2);
|
|
||||||
const referenceWorldPerPixel =
|
|
||||||
referenceDistance * Math.tan(referenceFovRad / 2);
|
|
||||||
|
|
||||||
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventDistanceScale(marker, camera) {
|
function getEventDistanceScale(marker, camera) {
|
||||||
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
||||||
|
|
||||||
marker.getWorldPosition(collectorWorldPosition);
|
return getSurfaceMarkerCameraScale(camera, {
|
||||||
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
|
altitudeOffset: BGP_CONFIG.altitudeOffset,
|
||||||
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.altitudeOffset;
|
referenceFov: 75,
|
||||||
const referenceFovRad = (75 * Math.PI) / 180;
|
min: Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7),
|
||||||
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
|
max: Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9),
|
||||||
const min = Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7);
|
});
|
||||||
const max = Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9);
|
|
||||||
const worldPerPixel =
|
|
||||||
distanceToCamera * Math.tan(cameraFovRad / 2);
|
|
||||||
const referenceWorldPerPixel =
|
|
||||||
referenceDistance * Math.tan(referenceFovRad / 2);
|
|
||||||
|
|
||||||
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function orientCollectorMarkerToSurface(marker, position) {
|
function orientCollectorMarkerToSurface(marker, position) {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
CABLE_STATE,
|
CABLE_STATE,
|
||||||
CABLE_CONFIG,
|
CABLE_CONFIG,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import { latLonToVector3 } from "./utils.js";
|
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||||
import { updateEarthStats, showStatusMessage } from "./ui.js";
|
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
|
||||||
import { showInfoCard } from "./info-card.js";
|
import { showInfoCard } from "./info-card.js";
|
||||||
import { setLegendItems, setLegendMode } from "./legend.js";
|
import { setLegendItems, setLegendMode } from "./legend.js";
|
||||||
|
|
||||||
@@ -21,7 +21,6 @@ let cableIdMap = new Map();
|
|||||||
let cableStates = new Map();
|
let cableStates = new Map();
|
||||||
let cablesVisible = true;
|
let cablesVisible = true;
|
||||||
let landingPointGeometry = null;
|
let landingPointGeometry = null;
|
||||||
const landingPointWorldPosition = new THREE.Vector3();
|
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
function clamp(value, min, max) {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
@@ -33,24 +32,13 @@ function getLandingPointDistanceScale(point, camera) {
|
|||||||
!camera ||
|
!camera ||
|
||||||
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
|
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
|
||||||
) return 1;
|
) return 1;
|
||||||
point.getWorldPosition(landingPointWorldPosition);
|
|
||||||
const distanceToCamera = camera.position.distanceTo(landingPointWorldPosition);
|
return getSurfaceMarkerCameraScale(camera, {
|
||||||
const referenceDistance =
|
altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||||
CONFIG.defaultCameraZ -
|
referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75,
|
||||||
CONFIG.earthRadius +
|
min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
|
||||||
CABLE_CONFIG.landingPoint.altitudeOffset;
|
max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
|
||||||
const referenceFovDeg =
|
});
|
||||||
CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75;
|
|
||||||
const referenceFovRad = (referenceFovDeg * Math.PI) / 180;
|
|
||||||
const cameraFovRad =
|
|
||||||
(((camera.fov || referenceFovDeg)) * Math.PI) / 180;
|
|
||||||
const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2);
|
|
||||||
const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2);
|
|
||||||
return clamp(
|
|
||||||
worldPerPixel / referenceWorldPerPixel,
|
|
||||||
CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
|
|
||||||
CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function disposeMaterial(material) {
|
function disposeMaterial(material) {
|
||||||
@@ -336,9 +324,8 @@ export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
|||||||
feature.properties.status === "In Service"),
|
feature.properties.status === "In Service"),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const cableCountEl = document.getElementById("cable-count");
|
|
||||||
const statusEl = document.getElementById("cable-status-summary");
|
const statusEl = document.getElementById("cable-status-summary");
|
||||||
if (cableCountEl) cableCountEl.textContent = cableCount + "个";
|
setEarthStatValue("cable-count", `${cableCount}个`);
|
||||||
if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
|
if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
|
||||||
|
|
||||||
updateEarthStats({
|
updateEarthStats({
|
||||||
@@ -435,10 +422,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
|||||||
validCount++;
|
validCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const landingPointCountEl = document.getElementById("landing-point-count");
|
setEarthStatValue("landing-point-count", `${validCount}个`);
|
||||||
if (landingPointCountEl) {
|
|
||||||
landingPointCountEl.textContent = validCount + "个";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||||
|
|||||||
@@ -1,13 +1,316 @@
|
|||||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
const DEFAULT_CLASS_NAME = "info-card-cruise-link";
|
const DEFAULT_CLASS_NAME = "callout-connector";
|
||||||
const DEFAULT_DRAW_ANIMATION_NAME = "cruiseConnectorDraw";
|
const DEFAULT_DRAW_ANIMATION_NAME = "calloutConnectorDraw";
|
||||||
|
const DEFAULT_SOURCE_ANCHOR_GAP_PX = 6;
|
||||||
|
const MIN_SOURCE_ANCHOR_GAP_PX = 4;
|
||||||
|
|
||||||
function createSvgElement(tagName) {
|
function createSvgElement(tagName) {
|
||||||
return document.createElementNS(SVG_NS, tagName);
|
return document.createElementNS(SVG_NS, tagName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveElementAnchorSide(side) {
|
||||||
|
switch (side) {
|
||||||
|
case "right":
|
||||||
|
case "top":
|
||||||
|
case "bottom":
|
||||||
|
case "left":
|
||||||
|
return side;
|
||||||
|
default:
|
||||||
|
return "left";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveElementAnchorAlignRatio(ratio) {
|
||||||
|
if (!Number.isFinite(ratio)) return 0.5;
|
||||||
|
return Math.min(Math.max(ratio, 0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAnchorElement(target) {
|
||||||
|
if (target instanceof HTMLElement) return target;
|
||||||
|
if (target?.element instanceof HTMLElement) return target.element;
|
||||||
|
if (typeof target?.selector === "string") {
|
||||||
|
const matched = document.querySelector(target.selector);
|
||||||
|
return matched instanceof HTMLElement ? matched : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRectElement(target) {
|
||||||
|
if (target instanceof HTMLElement) return target;
|
||||||
|
if (target?.element instanceof HTMLElement) return target.element;
|
||||||
|
if (typeof target?.selector === "string") {
|
||||||
|
const matched = document.querySelector(target.selector);
|
||||||
|
return matched instanceof HTMLElement ? matched : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteRect(rect) {
|
||||||
|
return (
|
||||||
|
rect &&
|
||||||
|
Number.isFinite(rect.left) &&
|
||||||
|
Number.isFinite(rect.top) &&
|
||||||
|
Number.isFinite(rect.right) &&
|
||||||
|
Number.isFinite(rect.bottom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRect(rect) {
|
||||||
|
if (!rect) return null;
|
||||||
|
const left = Number(rect.left);
|
||||||
|
const top = Number(rect.top);
|
||||||
|
const right = Number(rect.right);
|
||||||
|
const bottom = Number(rect.bottom);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(left) ||
|
||||||
|
!Number.isFinite(top) ||
|
||||||
|
!Number.isFinite(right) ||
|
||||||
|
!Number.isFinite(bottom)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
left: Math.min(left, right),
|
||||||
|
top: Math.min(top, bottom),
|
||||||
|
right: Math.max(left, right),
|
||||||
|
bottom: Math.max(top, bottom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandRect(rect, padding = 0) {
|
||||||
|
const normalized = normalizeRect(rect);
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (typeof padding === "object" && padding !== null) {
|
||||||
|
const leftPadding = Number.isFinite(padding.left) ? Number(padding.left) : 0;
|
||||||
|
const topPadding = Number.isFinite(padding.top) ? Number(padding.top) : 0;
|
||||||
|
const rightPadding = Number.isFinite(padding.right) ? Number(padding.right) : 0;
|
||||||
|
const bottomPadding = Number.isFinite(padding.bottom) ? Number(padding.bottom) : 0;
|
||||||
|
return {
|
||||||
|
left: normalized.left - leftPadding,
|
||||||
|
top: normalized.top - topPadding,
|
||||||
|
right: normalized.right + rightPadding,
|
||||||
|
bottom: normalized.bottom + bottomPadding,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
left: normalized.left - padding,
|
||||||
|
top: normalized.top - padding,
|
||||||
|
right: normalized.right + padding,
|
||||||
|
bottom: normalized.bottom + padding,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeSequentialPoints(points) {
|
||||||
|
const nextPoints = [];
|
||||||
|
for (const point of points) {
|
||||||
|
const previous = nextPoints[nextPoints.length - 1];
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
Math.abs(previous.x - point.x) < 0.5 &&
|
||||||
|
Math.abs(previous.y - point.y) < 0.5
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextPoints.push(point);
|
||||||
|
}
|
||||||
|
return nextPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the anchor point on the nearest perimeter edge of rect to source.
|
||||||
|
// gap is applied outward from the edge, so the anchor is outside the rect.
|
||||||
|
export function computeNearestPerimeterAnchor(source, rect, gap = 0) {
|
||||||
|
const { left, top, right, bottom } = rect;
|
||||||
|
const midX = (left + right) * 0.5;
|
||||||
|
const midY = (top + bottom) * 0.5;
|
||||||
|
|
||||||
|
if (source.x <= left) return { x: left - gap, y: midY, side: "left" };
|
||||||
|
if (source.x >= right) return { x: right + gap, y: midY, side: "right" };
|
||||||
|
if (source.y <= top) return { x: midX, y: top - gap, side: "top" };
|
||||||
|
if (source.y >= bottom) return { x: midX, y: bottom + gap, side: "bottom" };
|
||||||
|
|
||||||
|
// Source inside rect: snap to nearest edge midpoint
|
||||||
|
const dLeft = source.x - left;
|
||||||
|
const dRight = right - source.x;
|
||||||
|
const dTop = source.y - top;
|
||||||
|
const dBottom = bottom - source.y;
|
||||||
|
const minD = Math.min(dLeft, dRight, dTop, dBottom);
|
||||||
|
|
||||||
|
if (minD === dLeft) return { x: left - gap, y: midY, side: "left" };
|
||||||
|
if (minD === dRight) return { x: right + gap, y: midY, side: "right" };
|
||||||
|
if (minD === dTop) return { x: midX, y: top - gap, side: "top" };
|
||||||
|
return { x: midX, y: bottom + gap, side: "bottom" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConnectorObstacleRect(target, options = {}) {
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
if (typeof target === "function") {
|
||||||
|
return resolveConnectorObstacleRect(target(), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPadding =
|
||||||
|
target && (Number.isFinite(target.padding) || (typeof target.padding === "object" && target.padding))
|
||||||
|
? target.padding
|
||||||
|
: options.padding;
|
||||||
|
const padding =
|
||||||
|
Number.isFinite(resolvedPadding) || (typeof resolvedPadding === "object" && resolvedPadding)
|
||||||
|
? resolvedPadding
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (isFiniteRect(target)) {
|
||||||
|
return expandRect(target, padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number.isFinite(target.x) &&
|
||||||
|
Number.isFinite(target.y) &&
|
||||||
|
Number.isFinite(target.width) &&
|
||||||
|
Number.isFinite(target.height)
|
||||||
|
) {
|
||||||
|
return expandRect(
|
||||||
|
{
|
||||||
|
left: Number(target.x),
|
||||||
|
top: Number(target.y),
|
||||||
|
right: Number(target.x) + Number(target.width),
|
||||||
|
bottom: Number(target.y) + Number(target.height),
|
||||||
|
},
|
||||||
|
padding,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = resolveRectElement(target);
|
||||||
|
if (!(element instanceof HTMLElement)) return null;
|
||||||
|
return expandRect(element.getBoundingClientRect(), padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConnectorAnchor(target) {
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
if (typeof target === "function") {
|
||||||
|
return resolveConnectorAnchor(target());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(target.x) && Number.isFinite(target.y)) {
|
||||||
|
return { x: Number(target.x), y: Number(target.y) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = resolveAnchorElement(target);
|
||||||
|
if (!(element instanceof HTMLElement)) return null;
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const side = resolveElementAnchorSide(target.side);
|
||||||
|
const alignRatio = resolveElementAnchorAlignRatio(
|
||||||
|
target.alignRatio ?? target.anchorRatio ?? target.ratio,
|
||||||
|
);
|
||||||
|
const offsetX = Number.isFinite(target.offsetX) ? Number(target.offsetX) : 0;
|
||||||
|
const offsetY = Number.isFinite(target.offsetY) ? Number(target.offsetY) : 0;
|
||||||
|
|
||||||
|
let x = rect.left + rect.width * 0.5;
|
||||||
|
let y = rect.top + rect.height * 0.5;
|
||||||
|
|
||||||
|
if (side === "left") {
|
||||||
|
x = rect.left;
|
||||||
|
y = rect.top + rect.height * alignRatio;
|
||||||
|
} else if (side === "right") {
|
||||||
|
x = rect.right;
|
||||||
|
y = rect.top + rect.height * alignRatio;
|
||||||
|
} else if (side === "top") {
|
||||||
|
x = rect.left + rect.width * alignRatio;
|
||||||
|
y = rect.top;
|
||||||
|
} else if (side === "bottom") {
|
||||||
|
x = rect.left + rect.width * alignRatio;
|
||||||
|
y = rect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: x + offsetX,
|
||||||
|
y: y + offsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnectorRect(target) {
|
||||||
|
return resolveConnectorObstacleRect(target, { padding: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRectSideMidpoint(rect, side, gap = 0) {
|
||||||
|
const normalizedRect = normalizeRect(rect);
|
||||||
|
if (!normalizedRect) return null;
|
||||||
|
|
||||||
|
const midpointX = (normalizedRect.left + normalizedRect.right) * 0.5;
|
||||||
|
const midpointY = (normalizedRect.top + normalizedRect.bottom) * 0.5;
|
||||||
|
|
||||||
|
if (side === "left") {
|
||||||
|
return { x: normalizedRect.left - gap, y: midpointY, side };
|
||||||
|
}
|
||||||
|
if (side === "right") {
|
||||||
|
return { x: normalizedRect.right + gap, y: midpointY, side };
|
||||||
|
}
|
||||||
|
if (side === "top") {
|
||||||
|
return { x: midpointX, y: normalizedRect.top - gap, side };
|
||||||
|
}
|
||||||
|
if (side === "bottom") {
|
||||||
|
return { x: midpointX, y: normalizedRect.bottom + gap, side };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRectEdgeAnchor(rect, side, position, gap = 0) {
|
||||||
|
const normalizedRect = normalizeRect(rect);
|
||||||
|
if (!normalizedRect) return null;
|
||||||
|
|
||||||
|
const x =
|
||||||
|
side === "left"
|
||||||
|
? normalizedRect.left - gap
|
||||||
|
: side === "right"
|
||||||
|
? normalizedRect.right + gap
|
||||||
|
: clamp(
|
||||||
|
Number.isFinite(position?.x) ? Number(position.x) : (normalizedRect.left + normalizedRect.right) * 0.5,
|
||||||
|
normalizedRect.left,
|
||||||
|
normalizedRect.right,
|
||||||
|
);
|
||||||
|
const y =
|
||||||
|
side === "top"
|
||||||
|
? normalizedRect.top - gap
|
||||||
|
: side === "bottom"
|
||||||
|
? normalizedRect.bottom + gap
|
||||||
|
: clamp(
|
||||||
|
Number.isFinite(position?.y) ? Number(position.y) : (normalizedRect.top + normalizedRect.bottom) * 0.5,
|
||||||
|
normalizedRect.top,
|
||||||
|
normalizedRect.bottom,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { x, y, side };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOrthogonalPointsFromDirections(startPoint, endPoint, directions = []) {
|
||||||
|
if (!startPoint || !endPoint) return null;
|
||||||
|
const normalizedDirections = directions.filter(Boolean);
|
||||||
|
if (!normalizedDirections.length) {
|
||||||
|
return dedupeSequentialPoints([startPoint, endPoint]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstDirection = normalizedDirections[0];
|
||||||
|
const corner =
|
||||||
|
firstDirection === "left" || firstDirection === "right"
|
||||||
|
? { x: endPoint.x, y: startPoint.y }
|
||||||
|
: { x: startPoint.x, y: endPoint.y };
|
||||||
|
|
||||||
|
return dedupeSequentialPoints([startPoint, corner, endPoint]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSourceAnchorGapPx(sourceGapPx) {
|
||||||
|
if (!Number.isFinite(sourceGapPx)) return DEFAULT_SOURCE_ANCHOR_GAP_PX;
|
||||||
|
return Math.max(MIN_SOURCE_ANCHOR_GAP_PX, Math.round(sourceGapPx * 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
export function createElbowConnectorPoints(source, target, options = {}) {
|
export function createElbowConnectorPoints(source, target, options = {}) {
|
||||||
if (!source || !target) return null;
|
const resolvedSource = resolveConnectorAnchor(source);
|
||||||
|
const resolvedTarget = resolveConnectorAnchor(target);
|
||||||
|
if (!resolvedSource || !resolvedTarget) return null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
startFrom = "source",
|
startFrom = "source",
|
||||||
@@ -17,8 +320,8 @@ export function createElbowConnectorPoints(source, target, options = {}) {
|
|||||||
elbowDropPx = 14,
|
elbowDropPx = 14,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const sourcePoint = { x: Number(source.x), y: Number(source.y) };
|
const sourcePoint = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
|
||||||
const targetPoint = { x: Number(target.x), y: Number(target.y) };
|
const targetPoint = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(sourcePoint.x) ||
|
!Number.isFinite(sourcePoint.x) ||
|
||||||
!Number.isFinite(sourcePoint.y) ||
|
!Number.isFinite(sourcePoint.y) ||
|
||||||
@@ -53,6 +356,163 @@ export function createElbowConnectorPoints(source, target, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAdaptiveConnectorPoints(source, target, options = {}) {
|
||||||
|
const resolvedSource = resolveConnectorAnchor(source);
|
||||||
|
if (!resolvedSource) return null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
startFrom = "source",
|
||||||
|
sourceGapPx = 12,
|
||||||
|
targetGapPx = 8,
|
||||||
|
obstacleClearancePx = 8,
|
||||||
|
obstacles = [],
|
||||||
|
targetAnchor = null,
|
||||||
|
sourceRect = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const sp = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
|
||||||
|
if (!Number.isFinite(sp.x) || !Number.isFinite(sp.y)) return null;
|
||||||
|
|
||||||
|
const normalizedObstacles = (Array.isArray(obstacles) ? obstacles : [obstacles])
|
||||||
|
.map((obstacle) => resolveConnectorObstacleRect(obstacle, { padding: obstacleClearancePx }))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const fallbackTargetRect = resolveConnectorObstacleRect(target, { padding: 0 });
|
||||||
|
const resolvedTarget =
|
||||||
|
resolveConnectorAnchor(targetAnchor ?? target) ||
|
||||||
|
(fallbackTargetRect
|
||||||
|
? computeNearestPerimeterAnchor(
|
||||||
|
sp,
|
||||||
|
fallbackTargetRect,
|
||||||
|
Math.max(targetGapPx, obstacleClearancePx + 1),
|
||||||
|
)
|
||||||
|
: null);
|
||||||
|
if (!resolvedTarget) return null;
|
||||||
|
|
||||||
|
const end = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
|
||||||
|
if (!Number.isFinite(end.x) || !Number.isFinite(end.y)) return null;
|
||||||
|
|
||||||
|
const primaryObstacle = normalizedObstacles[0] || null;
|
||||||
|
const relationRect = fallbackTargetRect || primaryObstacle;
|
||||||
|
if (!relationRect) return null;
|
||||||
|
|
||||||
|
const sourceRelationRect = resolveConnectorRect(sourceRect ?? source);
|
||||||
|
const targetCenterX = (relationRect.left + relationRect.right) * 0.5;
|
||||||
|
const targetCenterY = (relationRect.top + relationRect.bottom) * 0.5;
|
||||||
|
|
||||||
|
const leftMidpoint = createRectSideMidpoint(relationRect, "left", targetGapPx);
|
||||||
|
const rightMidpoint = createRectSideMidpoint(relationRect, "right", targetGapPx);
|
||||||
|
const isTargetAbove = relationRect.bottom < sp.y;
|
||||||
|
const isTargetBelow = relationRect.top > sp.y;
|
||||||
|
const isSourceWithinAnchorHorizontalRange =
|
||||||
|
sp.x >= leftMidpoint.x && sp.x <= rightMidpoint.x;
|
||||||
|
const isRightMidpointLeftOfSource = rightMidpoint.x < sp.x;
|
||||||
|
const isLeftMidpointRightOfSource = leftMidpoint.x > sp.x;
|
||||||
|
|
||||||
|
let directions = [];
|
||||||
|
let targetSide = null;
|
||||||
|
const isTargetCenterWithinSourceVerticalRange =
|
||||||
|
sourceRelationRect &&
|
||||||
|
targetCenterY >= sourceRelationRect.top &&
|
||||||
|
targetCenterY <= sourceRelationRect.bottom;
|
||||||
|
const isTargetCenterWithinSourceHorizontalRange =
|
||||||
|
sourceRelationRect &&
|
||||||
|
targetCenterX >= sourceRelationRect.left &&
|
||||||
|
targetCenterX <= sourceRelationRect.right;
|
||||||
|
|
||||||
|
if (isRightMidpointLeftOfSource) {
|
||||||
|
targetSide = "right";
|
||||||
|
if (isTargetCenterWithinSourceVerticalRange) {
|
||||||
|
directions = ["left"];
|
||||||
|
} else if (rightMidpoint.y < sp.y) {
|
||||||
|
directions = ["top", "left"];
|
||||||
|
} else if (rightMidpoint.y > sp.y) {
|
||||||
|
directions = ["bottom", "left"];
|
||||||
|
} else {
|
||||||
|
directions = ["left"];
|
||||||
|
}
|
||||||
|
} else if (isLeftMidpointRightOfSource) {
|
||||||
|
targetSide = "left";
|
||||||
|
if (isTargetCenterWithinSourceVerticalRange) {
|
||||||
|
directions = ["right"];
|
||||||
|
} else if (leftMidpoint.y < sp.y) {
|
||||||
|
directions = ["top", "right"];
|
||||||
|
} else if (leftMidpoint.y > sp.y) {
|
||||||
|
directions = ["bottom", "right"];
|
||||||
|
} else {
|
||||||
|
directions = ["right"];
|
||||||
|
}
|
||||||
|
} else if (isSourceWithinAnchorHorizontalRange) {
|
||||||
|
if (isTargetAbove) {
|
||||||
|
targetSide = "bottom";
|
||||||
|
directions = isTargetCenterWithinSourceHorizontalRange
|
||||||
|
? ["top"]
|
||||||
|
: targetCenterX >= sp.x
|
||||||
|
? ["right", "top"]
|
||||||
|
: ["left", "top"];
|
||||||
|
} else if (isTargetBelow) {
|
||||||
|
targetSide = "top";
|
||||||
|
directions = isTargetCenterWithinSourceHorizontalRange
|
||||||
|
? ["bottom"]
|
||||||
|
: targetCenterX >= sp.x
|
||||||
|
? ["right", "bottom"]
|
||||||
|
: ["left", "bottom"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetSide || !directions.length) {
|
||||||
|
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const derivedTargetAnchor = createRectSideMidpoint(relationRect, targetSide, targetGapPx);
|
||||||
|
const targetPoint =
|
||||||
|
resolvedTarget && targetAnchor
|
||||||
|
? end
|
||||||
|
: derivedTargetAnchor || end;
|
||||||
|
|
||||||
|
const sourceSide = directions[0] || null;
|
||||||
|
const sourceAnchorGapPx = resolveSourceAnchorGapPx(sourceGapPx);
|
||||||
|
const shouldSlideSourceAnchorAlongEdge =
|
||||||
|
(sourceSide === "left" || sourceSide === "right") &&
|
||||||
|
isTargetCenterWithinSourceVerticalRange ||
|
||||||
|
(sourceSide === "top" || sourceSide === "bottom") &&
|
||||||
|
isTargetCenterWithinSourceHorizontalRange;
|
||||||
|
const derivedSourceAnchor =
|
||||||
|
sourceRelationRect && sourceSide
|
||||||
|
? shouldSlideSourceAnchorAlongEdge
|
||||||
|
? createRectEdgeAnchor(sourceRelationRect, sourceSide, targetPoint, sourceAnchorGapPx)
|
||||||
|
: createRectSideMidpoint(sourceRelationRect, sourceSide, sourceAnchorGapPx)
|
||||||
|
: null;
|
||||||
|
const startPoint = derivedSourceAnchor || sp;
|
||||||
|
|
||||||
|
let pts = createOrthogonalPointsFromDirections(startPoint, targetPoint, directions);
|
||||||
|
if (!pts) {
|
||||||
|
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
|
||||||
|
}
|
||||||
|
pts = dedupeSequentialPoints(pts);
|
||||||
|
return {
|
||||||
|
points: startFrom === "target" ? pts.slice().reverse() : pts,
|
||||||
|
start: startFrom === "target" ? pts[pts.length - 1] : pts[0],
|
||||||
|
end: startFrom === "target" ? pts[0] : pts[pts.length - 1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectorPath(source, target, options = {}) {
|
||||||
|
const {
|
||||||
|
routingMode = "simple",
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (routingMode === "adaptive") {
|
||||||
|
return createAdaptiveConnectorPoints(source, target, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routingMode === "simple") {
|
||||||
|
return createElbowConnectorPoints(source, target, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createElbowConnectorPoints(source, target, options);
|
||||||
|
}
|
||||||
|
|
||||||
export class CalloutConnector {
|
export class CalloutConnector {
|
||||||
constructor({
|
constructor({
|
||||||
container = null,
|
container = null,
|
||||||
|
|||||||
96
frontend/public/earth/js/client-logs.js
Normal file
96
frontend/public/earth/js/client-logs.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { PATHS } from "./constants.js";
|
||||||
|
|
||||||
|
const RECENT_EVENT_TTL_MS = 15_000;
|
||||||
|
const recentEventMap = new Map();
|
||||||
|
|
||||||
|
function normalizeErrorDetail(detail) {
|
||||||
|
if (!detail) return "";
|
||||||
|
if (detail instanceof Error) {
|
||||||
|
return detail.stack || detail.message || String(detail);
|
||||||
|
}
|
||||||
|
if (typeof detail === "string") {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(detail);
|
||||||
|
} catch {
|
||||||
|
return String(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeKey(level, message, detail, category) {
|
||||||
|
return `${level}::${category || ""}::${message}::${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkip(level, message, detail, category) {
|
||||||
|
const key = dedupeKey(level, message, detail, category);
|
||||||
|
const now = Date.now();
|
||||||
|
const lastSeenAt = recentEventMap.get(key);
|
||||||
|
recentEventMap.set(key, now);
|
||||||
|
|
||||||
|
for (const [entryKey, entryTime] of recentEventMap.entries()) {
|
||||||
|
if (now - entryTime > RECENT_EVENT_TTL_MS) {
|
||||||
|
recentEventMap.delete(entryKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastSeenAt && now - lastSeenAt < RECENT_EVENT_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportEarthClientLog({
|
||||||
|
level = "error",
|
||||||
|
message,
|
||||||
|
category = "runtime",
|
||||||
|
module = "earth",
|
||||||
|
detail = "",
|
||||||
|
}) {
|
||||||
|
if (!message) return;
|
||||||
|
const normalizedDetail = normalizeErrorDetail(detail);
|
||||||
|
if (shouldSkip(level, message, normalizedDetail, category)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(PATHS.earthClientLogsApi, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
category,
|
||||||
|
module,
|
||||||
|
url: window.location.href,
|
||||||
|
detail: normalizedDetail.slice(0, 4000),
|
||||||
|
}),
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Swallow reporting failures to avoid recursive log noise.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerEarthClientErrorHandlers() {
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
console.error("全局错误:", event.error);
|
||||||
|
void reportEarthClientLog({
|
||||||
|
level: "error",
|
||||||
|
category: "window-error",
|
||||||
|
module: "main",
|
||||||
|
message: event.message || "Earth 页面发生未捕获错误",
|
||||||
|
detail: event.error || `${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
console.error("未处理的 Promise 错误:", event.reason);
|
||||||
|
void reportEarthClientLog({
|
||||||
|
level: "error",
|
||||||
|
category: "unhandledrejection",
|
||||||
|
module: "main",
|
||||||
|
message: "Earth 页面发生未处理 Promise 错误",
|
||||||
|
detail: event.reason,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
375
frontend/public/earth/js/compute-centers.js
Normal file
375
frontend/public/earth/js/compute-centers.js
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||||
|
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||||
|
|
||||||
|
const computeCenterGroup = new THREE.Group();
|
||||||
|
const computeCenterMarkers = [];
|
||||||
|
const textureCache = new Map();
|
||||||
|
let showComputeCenters = true;
|
||||||
|
let supercomputerCount = 0;
|
||||||
|
let gpuClusterCount = 0;
|
||||||
|
|
||||||
|
function buildComputeCenterMarkerData(feature) {
|
||||||
|
const props = feature?.properties || {};
|
||||||
|
const coordinates = feature?.geometry?.coordinates || [];
|
||||||
|
const longitude = Number(coordinates[0]);
|
||||||
|
const latitude = Number(coordinates[1]);
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
displayLatitude: latitude,
|
||||||
|
displayLongitude: longitude,
|
||||||
|
site_type: normalizeSiteType(props.site_type),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spreadComputeCenterPositions(markers) {
|
||||||
|
const groups = new Map();
|
||||||
|
const precision = COMPUTE_CENTER_CONFIG.overlapSpread.groupPrecision;
|
||||||
|
|
||||||
|
markers.forEach((marker) => {
|
||||||
|
const key = `${marker.latitude.toFixed(precision)}|${marker.longitude.toFixed(precision)}`;
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, []);
|
||||||
|
}
|
||||||
|
groups.get(key).push(marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
if (group.length <= 1) return;
|
||||||
|
|
||||||
|
const radius = COMPUTE_CENTER_CONFIG.overlapSpread.radius;
|
||||||
|
const offsetStep = COMPUTE_CENTER_CONFIG.overlapSpread.offsetStep;
|
||||||
|
group.forEach((marker, index) => {
|
||||||
|
const angle = (Math.PI * 2 * index) / group.length;
|
||||||
|
marker.displayLatitude =
|
||||||
|
marker.latitude + Math.sin(angle) * radius * offsetStep;
|
||||||
|
marker.displayLongitude =
|
||||||
|
marker.longitude + Math.cos(angle) * radius * offsetStep;
|
||||||
|
marker.isSpread = true;
|
||||||
|
marker.groupSize = group.length;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
markers.forEach((marker) => {
|
||||||
|
if (marker.isSpread) return;
|
||||||
|
marker.displayLatitude = marker.latitude;
|
||||||
|
marker.displayLongitude = marker.longitude;
|
||||||
|
marker.isSpread = false;
|
||||||
|
marker.groupSize = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMarkerTexture(siteType, isEstimated = false) {
|
||||||
|
const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`;
|
||||||
|
if (textureCache.has(textureKey)) {
|
||||||
|
return textureCache.get(textureKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const color =
|
||||||
|
COMPUTE_CENTER_CONFIG.colors[siteType] ||
|
||||||
|
COMPUTE_CENTER_CONFIG.colors.gpu_cluster;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 128;
|
||||||
|
canvas.height = 128;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
const centerX = 64;
|
||||||
|
const centerY = 64;
|
||||||
|
const baseFill = color;
|
||||||
|
|
||||||
|
function fillPath(draw, options = {}) {
|
||||||
|
const { fillStyle = color } = options;
|
||||||
|
context.save();
|
||||||
|
context.fillStyle = fillStyle;
|
||||||
|
context.beginPath();
|
||||||
|
draw();
|
||||||
|
context.fill();
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.clearRect(0, 0, 128, 128);
|
||||||
|
|
||||||
|
if (siteType === "supercomputer") {
|
||||||
|
fillPath(() => {
|
||||||
|
context.roundRect(40, 42, 48, 30, 7);
|
||||||
|
}, {
|
||||||
|
fillStyle: baseFill,
|
||||||
|
});
|
||||||
|
fillPath(() => {
|
||||||
|
context.roundRect(58, 74, 12, 8, 3);
|
||||||
|
context.roundRect(50, 84, 28, 5, 2.5);
|
||||||
|
}, {
|
||||||
|
fillStyle: baseFill,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fillPath(() => {
|
||||||
|
context.ellipse(centerX, 46, 18, 8, 0, 0, Math.PI * 2);
|
||||||
|
context.rect(46, 46, 36, 28);
|
||||||
|
context.ellipse(centerX, 74, 18, 8, 0, 0, Math.PI);
|
||||||
|
}, {
|
||||||
|
fillStyle: baseFill,
|
||||||
|
});
|
||||||
|
fillPath(() => {
|
||||||
|
context.ellipse(centerX, 58, 12, 4.5, 0, 0, Math.PI * 2);
|
||||||
|
context.rect(52, 58, 24, 6);
|
||||||
|
context.ellipse(centerX, 64, 12, 4.5, 0, 0, Math.PI);
|
||||||
|
}, {
|
||||||
|
fillStyle: baseFill,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEstimated) {
|
||||||
|
fillPath(() => {
|
||||||
|
context.arc(94, 36, 12, 0, Math.PI * 2);
|
||||||
|
}, {
|
||||||
|
fillStyle: "rgba(15,23,42,0.92)",
|
||||||
|
});
|
||||||
|
context.save();
|
||||||
|
context.fillStyle = "rgba(255,255,255,0.98)";
|
||||||
|
context.font = "bold 18px sans-serif";
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.fillText("?", 94, 36);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
textureCache.set(textureKey, texture);
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSiteType(siteType) {
|
||||||
|
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseScale(siteType) {
|
||||||
|
return siteType === "supercomputer"
|
||||||
|
? COMPUTE_CENTER_CONFIG.marker.supercomputerScale
|
||||||
|
: COMPUTE_CENTER_CONFIG.marker.gpuClusterScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDistanceScale(marker, camera) {
|
||||||
|
if (!marker || !camera || COMPUTE_CENTER_CONFIG.sizeStabilization.enabled === false) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSurfaceMarkerCameraScale(camera, {
|
||||||
|
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||||
|
referenceFov: 75,
|
||||||
|
min: COMPUTE_CENTER_CONFIG.sizeStabilization.min,
|
||||||
|
max: COMPUTE_CENTER_CONFIG.sizeStabilization.max,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGroup(group) {
|
||||||
|
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
||||||
|
const child = group.children[index];
|
||||||
|
child.material?.dispose?.();
|
||||||
|
group.remove(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createComputeCenterMarker(markerData) {
|
||||||
|
const siteType = markerData.site_type;
|
||||||
|
const material = new THREE.SpriteMaterial({
|
||||||
|
map: createMarkerTexture(siteType, Boolean(markerData.is_estimated)),
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
|
||||||
|
});
|
||||||
|
const marker = new THREE.Sprite(material);
|
||||||
|
const baseScale = getBaseScale(siteType);
|
||||||
|
marker.position.copy(
|
||||||
|
latLonToVector3(
|
||||||
|
markerData.displayLatitude,
|
||||||
|
markerData.displayLongitude,
|
||||||
|
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
marker.scale.setScalar(baseScale);
|
||||||
|
marker.renderOrder = 8;
|
||||||
|
marker.visible = showComputeCenters;
|
||||||
|
marker.userData = {
|
||||||
|
...markerData,
|
||||||
|
site_type: siteType,
|
||||||
|
type: "compute_center",
|
||||||
|
baseScale,
|
||||||
|
state: "normal",
|
||||||
|
pulseOffset: Math.random() * Math.PI * 2,
|
||||||
|
};
|
||||||
|
computeCenterGroup.add(marker);
|
||||||
|
computeCenterMarkers.push(marker);
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatComputeCenterTypeLabel(siteType) {
|
||||||
|
return siteType === "supercomputer" ? "超算中心" : "GPU 集群";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatComputeCenterCapacity(markerData) {
|
||||||
|
const value = markerData?.capacity_value;
|
||||||
|
const unit = markerData?.capacity_unit;
|
||||||
|
if (value === null || value === undefined || value === "") return "-";
|
||||||
|
return `${value}${unit ? ` ${unit}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatComputeCenterUpdatedAt(value) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
|
return date.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatComputeCenterLocationPrecision(markerData) {
|
||||||
|
const precision = markerData?.location_precision;
|
||||||
|
if (precision === "precise") return "精确坐标";
|
||||||
|
if (precision === "estimated_site") return "估算位置(站点级)";
|
||||||
|
if (precision === "estimated_country") return "估算位置(国家级)";
|
||||||
|
return "位置未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComputeCenterLegendItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "超算中心",
|
||||||
|
color: COMPUTE_CENTER_CONFIG.colors.supercomputer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "GPU 集群",
|
||||||
|
color: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComputeCenterMarkers() {
|
||||||
|
return computeCenterMarkers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComputeCenterCount() {
|
||||||
|
return computeCenterMarkers.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComputeCenterSupercomputerCount() {
|
||||||
|
return supercomputerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComputeCenterGPUClusterCount() {
|
||||||
|
return gpuClusterCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComputeCenterStatusSummary() {
|
||||||
|
if (computeCenterMarkers.length === 0) return "暂无算力中心数据";
|
||||||
|
return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setComputeCenterMarkerState(marker, state = "normal") {
|
||||||
|
if (!marker || marker.userData?.type !== "compute_center") return;
|
||||||
|
marker.userData.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearComputeCenterSelection() {
|
||||||
|
computeCenterMarkers.forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearComputeCenterData(earth) {
|
||||||
|
computeCenterMarkers.length = 0;
|
||||||
|
supercomputerCount = 0;
|
||||||
|
gpuClusterCount = 0;
|
||||||
|
clearGroup(computeCenterGroup);
|
||||||
|
if (earth && computeCenterGroup.parent === earth) {
|
||||||
|
earth.remove(computeCenterGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleComputeCenters(show) {
|
||||||
|
showComputeCenters = Boolean(show);
|
||||||
|
computeCenterGroup.visible = showComputeCenters;
|
||||||
|
computeCenterMarkers.forEach((marker) => {
|
||||||
|
marker.visible = showComputeCenters;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShowComputeCenters() {
|
||||||
|
return showComputeCenters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadComputeCenters(_scene, earth) {
|
||||||
|
const response = await fetch(PATHS.computeCentersApi);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Compute centers HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||||
|
|
||||||
|
clearComputeCenterData(earth);
|
||||||
|
|
||||||
|
spreadComputeCenterPositions(
|
||||||
|
features
|
||||||
|
.map((feature) => buildComputeCenterMarkerData(feature))
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers)
|
||||||
|
.forEach((markerData) => {
|
||||||
|
const marker = createComputeCenterMarker(markerData);
|
||||||
|
if (!marker) return;
|
||||||
|
if (marker.userData.site_type === "supercomputer") {
|
||||||
|
supercomputerCount += 1;
|
||||||
|
} else {
|
||||||
|
gpuClusterCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (earth && !computeCenterGroup.parent) {
|
||||||
|
earth.add(computeCenterGroup);
|
||||||
|
}
|
||||||
|
computeCenterGroup.visible = showComputeCenters;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCount: computeCenterMarkers.length,
|
||||||
|
supercomputerCount,
|
||||||
|
gpuClusterCount,
|
||||||
|
summary: getComputeCenterStatusSummary(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
|
||||||
|
const hasFocus = lockedObjectType === "compute_center" && lockedObject;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
computeCenterMarkers.forEach((marker) => {
|
||||||
|
const isLocked = lockedObjectType === "compute_center" && lockedObject === marker;
|
||||||
|
const state = marker.userData?.state || "normal";
|
||||||
|
const pulse =
|
||||||
|
1 +
|
||||||
|
COMPUTE_CENTER_CONFIG.marker.pulseAmplitude *
|
||||||
|
Math.sin(now * COMPUTE_CENTER_CONFIG.marker.pulseSpeed + marker.userData.pulseOffset);
|
||||||
|
|
||||||
|
let opacity = COMPUTE_CENTER_CONFIG.marker.baseOpacity;
|
||||||
|
let scaleMultiplier = 1;
|
||||||
|
|
||||||
|
if (isLocked) {
|
||||||
|
opacity = 1;
|
||||||
|
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.lockedScale * pulse;
|
||||||
|
} else if (state === "hover") {
|
||||||
|
opacity = 0.98;
|
||||||
|
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.hoverScale;
|
||||||
|
} else if (hasFocus) {
|
||||||
|
opacity = COMPUTE_CENTER_CONFIG.marker.dimmedOpacity;
|
||||||
|
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.dimmedScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceScale = getDistanceScale(marker, camera);
|
||||||
|
marker.material.opacity = showComputeCenters ? opacity : 0;
|
||||||
|
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
|
||||||
|
marker.visible = showComputeCenters;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,13 @@ export const ROTATION_MODE = {
|
|||||||
CRUISE: "cruise",
|
CRUISE: "cruise",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CRUISE_MODULES = {
|
||||||
|
BGP: "bgp",
|
||||||
|
NEWS: "news",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CRUISE_MODULES = [CRUISE_MODULES.BGP];
|
||||||
|
|
||||||
export const CRUISE_CONFIG = {
|
export const CRUISE_CONFIG = {
|
||||||
dwellMs: 7_000,
|
dwellMs: 7_000,
|
||||||
focusDurationMs: 1_400,
|
focusDurationMs: 1_400,
|
||||||
@@ -25,12 +32,12 @@ export const CRUISE_CONFIG = {
|
|||||||
maxPolledEvents: 200,
|
maxPolledEvents: 200,
|
||||||
cardAnchorXRatio: 0.68,
|
cardAnchorXRatio: 0.68,
|
||||||
cardAnchorYRatio: 0.24,
|
cardAnchorYRatio: 0.24,
|
||||||
linkMarkerGapPx: 18,
|
};
|
||||||
linkPanelGapPx: 12,
|
|
||||||
linkElbowOffsetPx: 72,
|
export const CONNECTOR_CONFIG = {
|
||||||
linkAnchorHeightRatio: 0.26,
|
markerGapPx: 18,
|
||||||
linkForcedBendPx: 34,
|
panelGapPx: 12,
|
||||||
linkElbowDropPx: 24,
|
obstacleClearancePx: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HUD_CONFIG = {
|
export const HUD_CONFIG = {
|
||||||
@@ -156,9 +163,42 @@ export const TERRAIN_CONFIG = {
|
|||||||
export const PATHS = {
|
export const PATHS = {
|
||||||
cablesApi: '/api/v1/visualization/geo/cables',
|
cablesApi: '/api/v1/visualization/geo/cables',
|
||||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||||
|
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
|
||||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||||
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
||||||
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
||||||
|
earthClientLogsApi: '/api/v1/system/logs/earth-client',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COMPUTE_CENTER_CONFIG = {
|
||||||
|
altitudeOffset: 0.48,
|
||||||
|
maxRenderedMarkers: 300,
|
||||||
|
overlapSpread: {
|
||||||
|
groupPrecision: 4,
|
||||||
|
radius: 1.4,
|
||||||
|
offsetStep: 0.28,
|
||||||
|
},
|
||||||
|
marker: {
|
||||||
|
baseOpacity: 0.88,
|
||||||
|
supercomputerScale: 12,
|
||||||
|
gpuClusterScale: 12,
|
||||||
|
hoverScale: 1.16,
|
||||||
|
lockedScale: 1.22,
|
||||||
|
dimmedScale: 0.82,
|
||||||
|
dimmedOpacity: 0.34,
|
||||||
|
pulseSpeed: 0.0038,
|
||||||
|
pulseAmplitude: 0.03,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
supercomputer: "#38bdf8",
|
||||||
|
gpu_cluster: "#2dd4bf",
|
||||||
|
linked: "#f8fafc",
|
||||||
|
},
|
||||||
|
sizeStabilization: {
|
||||||
|
enabled: true,
|
||||||
|
min: 0.12,
|
||||||
|
max: 3.0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cable colors mapping
|
// Cable colors mapping
|
||||||
|
|||||||
1129
frontend/public/earth/js/controls.js
vendored
1129
frontend/public/earth/js/controls.js
vendored
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { latLonToVector3 } from './utils.js';
|
|||||||
export let earth = null;
|
export let earth = null;
|
||||||
export let clouds = null;
|
export let clouds = null;
|
||||||
export let terrain = null;
|
export let terrain = null;
|
||||||
|
let showGridLines = true;
|
||||||
|
|
||||||
const textureLoader = new THREE.TextureLoader();
|
const textureLoader = new THREE.TextureLoader();
|
||||||
let _earthMaterial = null;
|
let _earthMaterial = null;
|
||||||
@@ -200,7 +201,6 @@ export function createClouds(scene, earthObj) {
|
|||||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64);
|
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64);
|
||||||
const material = new THREE.MeshPhongMaterial({
|
const material = new THREE.MeshPhongMaterial({
|
||||||
transparent: true,
|
transparent: true,
|
||||||
linewidth: 2,
|
|
||||||
opacity: 0.15,
|
opacity: 0.15,
|
||||||
depthTest: true,
|
depthTest: true,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
@@ -238,7 +238,6 @@ export function createTerrain(earthObj) {
|
|||||||
specular: TERRAIN_CONFIG.specular,
|
specular: TERRAIN_CONFIG.specular,
|
||||||
shininess: TERRAIN_CONFIG.shininess,
|
shininess: TERRAIN_CONFIG.shininess,
|
||||||
vertexColors: true,
|
vertexColors: true,
|
||||||
vertexAlphas: true,
|
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: TERRAIN_CONFIG.opacity,
|
opacity: TERRAIN_CONFIG.opacity,
|
||||||
flatShading: false,
|
flatShading: false,
|
||||||
@@ -321,6 +320,7 @@ export function createGridLines(scene, earthObj) {
|
|||||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||||
const line = new THREE.Line(geometry, gridMaterial);
|
const line = new THREE.Line(geometry, gridMaterial);
|
||||||
line.userData = { type: 'latitude', value: lat };
|
line.userData = { type: 'latitude', value: lat };
|
||||||
|
line.visible = showGridLines;
|
||||||
earthObj.add(line);
|
earthObj.add(line);
|
||||||
latitudeLines.push(line);
|
latitudeLines.push(line);
|
||||||
}
|
}
|
||||||
@@ -335,11 +335,26 @@ export function createGridLines(scene, earthObj) {
|
|||||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||||
const line = new THREE.Line(geometry, gridMaterial);
|
const line = new THREE.Line(geometry, gridMaterial);
|
||||||
line.userData = { type: 'longitude', value: lon };
|
line.userData = { type: 'longitude', value: lon };
|
||||||
|
line.visible = showGridLines;
|
||||||
earthObj.add(line);
|
earthObj.add(line);
|
||||||
longitudeLines.push(line);
|
longitudeLines.push(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggleGridLines(visible) {
|
||||||
|
showGridLines = visible;
|
||||||
|
latitudeLines.forEach((line) => {
|
||||||
|
line.visible = visible;
|
||||||
|
});
|
||||||
|
longitudeLines.forEach((line) => {
|
||||||
|
line.visible = visible;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShowGridLines() {
|
||||||
|
return showGridLines;
|
||||||
|
}
|
||||||
|
|
||||||
export function getEarth() {
|
export function getEarth() {
|
||||||
return earth;
|
return earth;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,406 @@ import { showStatusMessage } from './ui.js';
|
|||||||
|
|
||||||
let currentType = null;
|
let currentType = null;
|
||||||
let cardMounted = false;
|
let cardMounted = false;
|
||||||
|
let typewriterTimerId = null;
|
||||||
|
let typewriterToken = 0;
|
||||||
|
let pendingMobileDetailState = null;
|
||||||
|
let mobileDetailsListenerBound = false;
|
||||||
|
let renderedMobileDetailKey = null;
|
||||||
|
|
||||||
|
function getNewsSummaryText(data) {
|
||||||
|
return (data?.summary || data?.title || '').trim() || '暂无摘要';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNewsSummaryPreview(data, maxLength = 34) {
|
||||||
|
const text = getNewsSummaryText(data).replace(/\s+/g, ' ').trim();
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTypewriterAnimation() {
|
||||||
|
typewriterToken += 1;
|
||||||
|
if (typewriterTimerId) {
|
||||||
|
window.clearTimeout(typewriterTimerId);
|
||||||
|
typewriterTimerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTypewriterAnimation(target, text, options = {}) {
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
stopTypewriterAnimation();
|
||||||
|
|
||||||
|
const content = typeof text === 'string' ? text : '';
|
||||||
|
const token = typewriterToken;
|
||||||
|
const stepMs = Number.isFinite(options.stepMs) ? options.stepMs : 22;
|
||||||
|
const startDelayMs = Number.isFinite(options.startDelayMs) ? options.startDelayMs : 90;
|
||||||
|
|
||||||
|
target.textContent = '';
|
||||||
|
target.classList.add('is-typing');
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const tick = () => {
|
||||||
|
if (token !== typewriterToken) return;
|
||||||
|
index += 1;
|
||||||
|
target.textContent = content.slice(0, index);
|
||||||
|
if (index < content.length) {
|
||||||
|
typewriterTimerId = window.setTimeout(tick, stepMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.classList.remove('is-typing');
|
||||||
|
typewriterTimerId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
typewriterTimerId = window.setTimeout(() => {
|
||||||
|
if (token !== typewriterToken) return;
|
||||||
|
if (!content) {
|
||||||
|
target.classList.remove('is-typing');
|
||||||
|
typewriterTimerId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
}, startDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNewsCardContent(content, data) {
|
||||||
|
if (!(content instanceof HTMLElement)) return;
|
||||||
|
const summary = getNewsSummaryText(data);
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="info-card-news-layout">
|
||||||
|
<div class="info-card-news-kicker">NEWS SIGNAL</div>
|
||||||
|
<div class="info-card-news-title">${data?.title || '新闻事件'}</div>
|
||||||
|
<div class="info-card-news-summary-shell">
|
||||||
|
<div class="info-card-news-summary-label">SUMMARY</div>
|
||||||
|
<div class="info-card-news-summary" data-news-summary></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const summaryEl = content.querySelector('[data-news-summary]');
|
||||||
|
startTypewriterAnimation(summaryEl, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMobileNewsCardContent(content, data) {
|
||||||
|
if (!(content instanceof HTMLElement)) return;
|
||||||
|
const summary = getNewsSummaryText(data);
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="earth-mobile-news-detail">
|
||||||
|
<div class="earth-mobile-news-detail-kicker">NEWS SIGNAL</div>
|
||||||
|
<div class="earth-mobile-news-detail-title">${data?.title || '新闻事件'}</div>
|
||||||
|
<div class="earth-mobile-news-detail-summary-shell">
|
||||||
|
<div class="earth-mobile-news-detail-summary-label">SUMMARY</div>
|
||||||
|
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const summaryEl = content.querySelector('[data-news-summary]');
|
||||||
|
startTypewriterAnimation(summaryEl, summary, { stepMs: 20, startDelayMs: 70 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMobileDetailContent(type, config, data) {
|
||||||
|
const content = document.getElementById('mobile-info-card-content');
|
||||||
|
if (!(content instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
stopTypewriterAnimation();
|
||||||
|
if (type === 'news') {
|
||||||
|
renderMobileNewsCardContent(content, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const field of config.fields) {
|
||||||
|
let value = data[field.key];
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
value = '-';
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
value = value.toLocaleString();
|
||||||
|
}
|
||||||
|
if (field.unit && value !== '-') value = value + ' ' + field.unit;
|
||||||
|
html += `
|
||||||
|
<div class="earth-mobile-detail-row">
|
||||||
|
<span class="earth-mobile-detail-row-label">${field.label}</span>
|
||||||
|
<span class="earth-mobile-detail-row-value">${value}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
content.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMobileDetailRenderKey(type, data) {
|
||||||
|
if (type !== 'news') return null;
|
||||||
|
return [
|
||||||
|
type,
|
||||||
|
data?.id ?? '',
|
||||||
|
data?.url ?? '',
|
||||||
|
data?.published_at ?? '',
|
||||||
|
data?.title ?? '',
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMobileDetailsDrawerActive() {
|
||||||
|
const detailsSlot = document.querySelector('[data-drawer-slot="details"]');
|
||||||
|
return detailsSlot instanceof HTMLElement && detailsSlot.classList.contains('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMobileDetailsListener() {
|
||||||
|
if (mobileDetailsListenerBound) return;
|
||||||
|
mobileDetailsListenerBound = true;
|
||||||
|
|
||||||
|
window.addEventListener('earth:open-details-tab', () => {
|
||||||
|
if (!document.body.classList.contains('layout-mode-mobile')) return;
|
||||||
|
if (!pendingMobileDetailState) return;
|
||||||
|
const nextKey = getMobileDetailRenderKey(
|
||||||
|
pendingMobileDetailState.type,
|
||||||
|
pendingMobileDetailState.data,
|
||||||
|
);
|
||||||
|
if (nextKey && nextKey === renderedMobileDetailKey) return;
|
||||||
|
renderMobileDetailContent(
|
||||||
|
pendingMobileDetailState.type,
|
||||||
|
pendingMobileDetailState.config,
|
||||||
|
pendingMobileDetailState.data,
|
||||||
|
);
|
||||||
|
renderedMobileDetailKey = nextKey;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDefaultCardContent(content, config, data) {
|
||||||
|
let html = '';
|
||||||
|
for (const field of config.fields) {
|
||||||
|
let value = data[field.key];
|
||||||
|
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
value = '-';
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
value = value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.unit && value !== '-') {
|
||||||
|
value = value + ' ' + field.unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="info-card-property">
|
||||||
|
<span class="info-card-label">${field.label}</span>
|
||||||
|
<span class="info-card-value">${value}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mobile popup ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getMobilePopupTitle(type, data) {
|
||||||
|
switch (type) {
|
||||||
|
case 'cable': return data.name || '海缆';
|
||||||
|
case 'landing_point': return data.name || '登陆点';
|
||||||
|
case 'satellite': return data.name || '卫星';
|
||||||
|
case 'bgp': return data.anomaly_type || 'BGP事件';
|
||||||
|
case 'news': return data.title || '新闻事件';
|
||||||
|
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||||||
|
case 'supercomputer': return data.name || '超算';
|
||||||
|
case 'gpu_cluster': return data.name || 'GPU集群';
|
||||||
|
default: return '详情';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMobilePopupSubtitle(type, data) {
|
||||||
|
switch (type) {
|
||||||
|
case 'cable': return data.owner || data.status || '海缆';
|
||||||
|
case 'landing_point': return data.country || '登陆点';
|
||||||
|
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
|
||||||
|
case 'bgp': return data.severity || 'BGP路由异常';
|
||||||
|
case 'news': return getNewsSummaryPreview(data, 30) || '态势新闻';
|
||||||
|
case 'bgp_collector': return data.location || 'BGP观测站';
|
||||||
|
case 'supercomputer': return data.country || '超级计算机';
|
||||||
|
case 'gpu_cluster': return data.country || 'GPU集群';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionMobilePopup(popup, touchX, touchY, options = {}) {
|
||||||
|
const margin = 14;
|
||||||
|
const drawerClearance = 52;
|
||||||
|
const vpW = window.innerWidth;
|
||||||
|
const vpH = window.innerHeight;
|
||||||
|
const safeBottom = parseFloat(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--safe-bottom')
|
||||||
|
) || 0;
|
||||||
|
const bottomBound = vpH - drawerClearance - safeBottom;
|
||||||
|
|
||||||
|
// Measure actual popup size (it's rendered but invisible via opacity)
|
||||||
|
const popW = popup.offsetWidth || 200;
|
||||||
|
const popH = popup.offsetHeight || 68;
|
||||||
|
|
||||||
|
if (options.absolute === true) {
|
||||||
|
const left = Math.max(margin, Math.min(touchX, vpW - popW - margin));
|
||||||
|
const top = Math.max(margin, Math.min(touchY, bottomBound - popH - margin));
|
||||||
|
popup.style.left = `${left}px`;
|
||||||
|
popup.style.top = `${top}px`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = 22;
|
||||||
|
const spaceRight = vpW - touchX;
|
||||||
|
const spaceLeft = touchX;
|
||||||
|
const spaceBottom = bottomBound - touchY;
|
||||||
|
const spaceTop = touchY;
|
||||||
|
|
||||||
|
let left, top;
|
||||||
|
|
||||||
|
// Horizontal: side with more room
|
||||||
|
if (spaceRight >= popW + gap + margin) {
|
||||||
|
left = touchX + gap;
|
||||||
|
} else if (spaceLeft >= popW + gap + margin) {
|
||||||
|
left = touchX - gap - popW;
|
||||||
|
} else {
|
||||||
|
left = Math.max(margin, Math.min(touchX - popW / 2, vpW - popW - margin));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical: prefer above touch, then below
|
||||||
|
if (spaceTop >= popH + gap + margin) {
|
||||||
|
top = touchY - gap - popH;
|
||||||
|
} else if (spaceBottom >= popH + gap + margin) {
|
||||||
|
top = touchY + gap;
|
||||||
|
} else {
|
||||||
|
top = Math.max(margin, Math.min(touchY - popH / 2, bottomBound - popH - margin));
|
||||||
|
}
|
||||||
|
|
||||||
|
left = Math.max(margin, Math.min(left, vpW - popW - margin));
|
||||||
|
top = Math.max(margin, Math.min(top, bottomBound - popH - margin));
|
||||||
|
|
||||||
|
popup.style.left = `${left}px`;
|
||||||
|
popup.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let popupShowToken = 0;
|
||||||
|
|
||||||
|
function showMobilePopup(type, data, x, y, options = {}) {
|
||||||
|
// Require coordinates — skip if called without position (e.g. from handleCableClick)
|
||||||
|
if (x == null || y == null) return;
|
||||||
|
|
||||||
|
const popup = document.getElementById('earth-mobile-popup');
|
||||||
|
const iconEl = document.getElementById('earth-mobile-popup-icon');
|
||||||
|
const titleEl = document.getElementById('earth-mobile-popup-title');
|
||||||
|
const subEl = document.getElementById('earth-mobile-popup-sub');
|
||||||
|
if (!popup || !iconEl || !titleEl || !subEl) return;
|
||||||
|
|
||||||
|
const config = CARD_CONFIG[type];
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
iconEl.textContent = config.icon;
|
||||||
|
titleEl.textContent = getMobilePopupTitle(type, data);
|
||||||
|
subEl.textContent = getMobilePopupSubtitle(type, data);
|
||||||
|
|
||||||
|
// Invalidate any in-flight hide listener
|
||||||
|
popupShowToken += 1;
|
||||||
|
const token = popupShowToken;
|
||||||
|
|
||||||
|
popup.dataset.dockSide = options.dockSide === 'right' ? 'right' : 'left';
|
||||||
|
popup.classList.toggle('earth-mobile-popup--anchor-stable', options.anchorStable === true);
|
||||||
|
popup.removeAttribute('hidden');
|
||||||
|
popup.classList.remove('is-visible');
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
positionMobilePopup(popup, x, y, options);
|
||||||
|
if (options.reveal === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token !== popupShowToken) return; // superseded
|
||||||
|
void popup.getBoundingClientRect();
|
||||||
|
popup.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMobilePopup() {
|
||||||
|
const popup = document.getElementById('earth-mobile-popup');
|
||||||
|
if (!popup) return;
|
||||||
|
popupShowToken += 1; // invalidate any pending show
|
||||||
|
popup.classList.remove('is-visible');
|
||||||
|
popup.classList.remove('earth-mobile-popup--anchor-stable');
|
||||||
|
delete popup.dataset.dockSide;
|
||||||
|
popup.addEventListener('transitionend', () => {
|
||||||
|
if (!popup.classList.contains('is-visible')) {
|
||||||
|
popup.setAttribute('hidden', '');
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let popupClickBound = false;
|
||||||
|
function ensurePopupClickHandler() {
|
||||||
|
if (popupClickBound) return;
|
||||||
|
popupClickBound = true;
|
||||||
|
const popup = document.getElementById('earth-mobile-popup');
|
||||||
|
if (!popup) return;
|
||||||
|
|
||||||
|
let dragPointerId = null;
|
||||||
|
let startX = 0, startY = 0;
|
||||||
|
let startLeft = 0, startTop = 0;
|
||||||
|
let dragged = false;
|
||||||
|
const DRAG_THRESHOLD = 10;
|
||||||
|
|
||||||
|
const emitDragEvent = (dragging) => {
|
||||||
|
const rect = popup.getBoundingClientRect();
|
||||||
|
window.dispatchEvent(new CustomEvent('earth:info-card-drag', {
|
||||||
|
detail: {
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
dragging,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
popup.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.button > 0) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
dragPointerId = e.pointerId;
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
const rect = popup.getBoundingClientRect();
|
||||||
|
startLeft = rect.left;
|
||||||
|
startTop = rect.top;
|
||||||
|
dragged = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track drag at document level so pointer can leave popup bounds
|
||||||
|
document.addEventListener('pointermove', (e) => {
|
||||||
|
if (e.pointerId !== dragPointerId) return;
|
||||||
|
const dx = e.clientX - startX;
|
||||||
|
const dy = e.clientY - startY;
|
||||||
|
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||||
|
dragged = true;
|
||||||
|
e.stopPropagation();
|
||||||
|
const margin = 8;
|
||||||
|
const left = Math.max(margin, Math.min(startLeft + dx, window.innerWidth - popup.offsetWidth - margin));
|
||||||
|
const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
|
||||||
|
popup.style.left = `${left}px`;
|
||||||
|
popup.style.top = `${top}px`;
|
||||||
|
emitDragEvent(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('pointerup', (e) => {
|
||||||
|
if (e.pointerId !== dragPointerId) return;
|
||||||
|
const wasDragged = dragged;
|
||||||
|
dragPointerId = null;
|
||||||
|
dragged = false;
|
||||||
|
emitDragEvent(false);
|
||||||
|
if (!wasDragged) {
|
||||||
|
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('pointercancel', (e) => {
|
||||||
|
if (e.pointerId === dragPointerId) {
|
||||||
|
dragPointerId = null;
|
||||||
|
dragged = false;
|
||||||
|
emitDragEvent(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Block click from bubbling to document (which would close the drawer)
|
||||||
|
popup.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
}
|
||||||
|
|
||||||
const CARD_CONFIG = {
|
const CARD_CONFIG = {
|
||||||
cable: {
|
cable: {
|
||||||
@@ -18,6 +418,18 @@ const CARD_CONFIG = {
|
|||||||
{ key: 'rfs', label: '投入使用' }
|
{ key: 'rfs', label: '投入使用' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
landing_point: {
|
||||||
|
icon: '📍',
|
||||||
|
title: '登陆点详情',
|
||||||
|
className: 'cable',
|
||||||
|
fields: [
|
||||||
|
{ key: 'name', label: '名称' },
|
||||||
|
{ key: 'country', label: '国家' },
|
||||||
|
{ key: 'status', label: '状态' },
|
||||||
|
{ key: 'cable_count', label: '关联海缆数' },
|
||||||
|
{ key: 'cables', label: '关联海缆' }
|
||||||
|
]
|
||||||
|
},
|
||||||
satellite: {
|
satellite: {
|
||||||
icon: '🛰️',
|
icon: '🛰️',
|
||||||
title: '卫星详情',
|
title: '卫星详情',
|
||||||
@@ -55,6 +467,20 @@ const CARD_CONFIG = {
|
|||||||
{ key: 'summary', label: '摘要' }
|
{ key: 'summary', label: '摘要' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
news: {
|
||||||
|
icon: '📰',
|
||||||
|
title: '新闻事件详情',
|
||||||
|
className: 'news',
|
||||||
|
fields: [
|
||||||
|
{ key: 'source', label: '来源' },
|
||||||
|
{ key: 'published_at_display', label: '发布时间' },
|
||||||
|
{ key: 'location_label', label: '发生地' },
|
||||||
|
{ key: 'region_label', label: '区域' },
|
||||||
|
{ key: 'feed_name', label: '聚合源' },
|
||||||
|
{ key: 'summary', label: '摘要' },
|
||||||
|
{ key: 'url', label: '原文链接' }
|
||||||
|
]
|
||||||
|
},
|
||||||
bgp_collector: {
|
bgp_collector: {
|
||||||
icon: '📍',
|
icon: '📍',
|
||||||
title: 'BGP观测站详情',
|
title: 'BGP观测站详情',
|
||||||
@@ -79,15 +505,22 @@ const CARD_CONFIG = {
|
|||||||
},
|
},
|
||||||
supercomputer: {
|
supercomputer: {
|
||||||
icon: '🖥️',
|
icon: '🖥️',
|
||||||
title: '超算详情',
|
title: '超算中心详情',
|
||||||
className: 'supercomputer',
|
className: 'supercomputer',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'name', label: '名称' },
|
{ key: 'name', label: '名称' },
|
||||||
|
{ key: 'site_type_label', label: '类型' },
|
||||||
{ key: 'rank', label: '排名' },
|
{ key: 'rank', label: '排名' },
|
||||||
{ key: 'r_max', label: 'Rmax', unit: 'GFlops' },
|
{ key: 'capacity', label: '实测算力' },
|
||||||
{ key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
|
{ key: 'vendor', label: '厂商' },
|
||||||
|
{ key: 'operator', label: '运营方' },
|
||||||
|
{ key: 'cores', label: '核心数' },
|
||||||
|
{ key: 'power', label: '功耗', unit: 'kW' },
|
||||||
{ key: 'country', label: '国家' },
|
{ key: 'country', label: '国家' },
|
||||||
{ key: 'city', label: '城市' }
|
{ key: 'city', label: '城市' },
|
||||||
|
{ key: 'location_precision_label', label: '位置精度' },
|
||||||
|
{ key: 'source', label: '来源' },
|
||||||
|
{ key: 'updated_at', label: '更新时间' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
gpu_cluster: {
|
gpu_cluster: {
|
||||||
@@ -96,8 +529,17 @@ const CARD_CONFIG = {
|
|||||||
className: 'gpu_cluster',
|
className: 'gpu_cluster',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'name', label: '名称' },
|
{ key: 'name', label: '名称' },
|
||||||
|
{ key: 'site_type_label', label: '类型' },
|
||||||
|
{ key: 'capacity', label: '估算算力' },
|
||||||
|
{ key: 'gpu_count', label: 'GPU 数量' },
|
||||||
|
{ key: 'gpu_type', label: 'GPU 型号' },
|
||||||
|
{ key: 'vendor', label: '芯片/平台' },
|
||||||
|
{ key: 'operator', label: '运营方' },
|
||||||
{ key: 'country', label: '国家' },
|
{ key: 'country', label: '国家' },
|
||||||
{ key: 'city', label: '城市' }
|
{ key: 'city', label: '城市' },
|
||||||
|
{ key: 'location_precision_label', label: '位置精度' },
|
||||||
|
{ key: 'source', label: '来源' },
|
||||||
|
{ key: 'updated_at', label: '更新时间' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -114,19 +556,47 @@ function setupInfoCardDrag(panel) {
|
|||||||
if (!handle) return;
|
if (!handle) return;
|
||||||
|
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
|
let activePointerId = null;
|
||||||
let startPointerX = 0;
|
let startPointerX = 0;
|
||||||
let startPointerY = 0;
|
let startPointerY = 0;
|
||||||
let startLeft = 0;
|
let startLeft = 0;
|
||||||
let startTop = 0;
|
let startTop = 0;
|
||||||
|
|
||||||
const stopDragging = () => {
|
const emitDragEvent = () => {
|
||||||
|
const rect = panel.getBoundingClientRect();
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('earth:info-card-drag', {
|
||||||
|
detail: {
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
dragging: isDragging,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDragging = (event) => {
|
||||||
|
if (
|
||||||
|
event &&
|
||||||
|
activePointerId !== null &&
|
||||||
|
"pointerId" in event &&
|
||||||
|
event.pointerId !== activePointerId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
|
activePointerId = null;
|
||||||
panel.classList.remove('is-dragging');
|
panel.classList.remove('is-dragging');
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
|
emitDragEvent();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMove = (event) => {
|
const onMove = (event) => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
if (activePointerId !== null && event.pointerId !== activePointerId) return;
|
||||||
|
event.preventDefault();
|
||||||
const appRect = app.getBoundingClientRect();
|
const appRect = app.getBoundingClientRect();
|
||||||
const panelRect = panel.getBoundingClientRect();
|
const panelRect = panel.getBoundingClientRect();
|
||||||
const nextLeft = Math.min(
|
const nextLeft = Math.min(
|
||||||
@@ -139,11 +609,14 @@ function setupInfoCardDrag(panel) {
|
|||||||
);
|
);
|
||||||
panel.style.left = `${nextLeft}px`;
|
panel.style.left = `${nextLeft}px`;
|
||||||
panel.style.top = `${nextTop}px`;
|
panel.style.top = `${nextTop}px`;
|
||||||
|
emitDragEvent();
|
||||||
};
|
};
|
||||||
|
|
||||||
handle.addEventListener('pointerdown', (event) => {
|
handle.addEventListener('pointerdown', (event) => {
|
||||||
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
||||||
|
event.preventDefault();
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
|
activePointerId = event.pointerId;
|
||||||
startPointerX = event.clientX;
|
startPointerX = event.clientX;
|
||||||
startPointerY = event.clientY;
|
startPointerY = event.clientY;
|
||||||
const appRect = app.getBoundingClientRect();
|
const appRect = app.getBoundingClientRect();
|
||||||
@@ -157,11 +630,14 @@ function setupInfoCardDrag(panel) {
|
|||||||
panel.classList.add('is-dragging');
|
panel.classList.add('is-dragging');
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
handle.setPointerCapture?.(event.pointerId);
|
handle.setPointerCapture?.(event.pointerId);
|
||||||
|
emitDragEvent();
|
||||||
});
|
});
|
||||||
|
|
||||||
handle.addEventListener('pointermove', onMove);
|
// Listen in capture phase so card-level stopPropagation used to shield the
|
||||||
handle.addEventListener('pointerup', stopDragging);
|
// globe canvas does not swallow the drag stream before we can reposition.
|
||||||
handle.addEventListener('pointercancel', stopDragging);
|
window.addEventListener('pointermove', onMove, { passive: false, capture: true });
|
||||||
|
window.addEventListener('pointerup', stopDragging, { capture: true });
|
||||||
|
window.addEventListener('pointercancel', stopDragging, { capture: true });
|
||||||
handle.addEventListener('lostpointercapture', stopDragging);
|
handle.addEventListener('lostpointercapture', stopDragging);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +651,8 @@ function mountCard() {
|
|||||||
panel.id = 'info-panel';
|
panel.id = 'info-panel';
|
||||||
panel.className = 'hud-panel hud-panel-info';
|
panel.className = 'hud-panel hud-panel-info';
|
||||||
panel.setAttribute('aria-live', 'polite');
|
panel.setAttribute('aria-live', 'polite');
|
||||||
|
panel.setAttribute('aria-hidden', 'true');
|
||||||
|
panel.setAttribute('hidden', '');
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div id="info-card" class="info-card">
|
<div id="info-card" class="info-card">
|
||||||
<div class="info-card-header hud-panel-drag-handle">
|
<div class="info-card-header hud-panel-drag-handle">
|
||||||
@@ -240,6 +718,13 @@ function mountCard() {
|
|||||||
|
|
||||||
function positionPanel(panel, x, y, options = {}) {
|
function positionPanel(panel, x, y, options = {}) {
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||||
|
panel.style.left = '8px';
|
||||||
|
panel.style.right = '8px';
|
||||||
|
panel.style.top = 'auto';
|
||||||
|
panel.style.bottom = 'calc(84px + env(safe-area-inset-bottom, 0px))';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const margin = 12;
|
const margin = 12;
|
||||||
const offset = 14;
|
const offset = 14;
|
||||||
const vpW = window.innerWidth;
|
const vpW = window.innerWidth;
|
||||||
@@ -282,13 +767,35 @@ function positionPanel(panel, x, y, options = {}) {
|
|||||||
function showPanel(x, y, options = {}) {
|
function showPanel(x, y, options = {}) {
|
||||||
const panel = getPanel();
|
const panel = getPanel();
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
panel.classList.toggle('hud-panel-info--anchor-stable', options.anchorStable === true);
|
||||||
|
panel.removeAttribute('hidden');
|
||||||
|
panel.setAttribute('aria-hidden', 'false');
|
||||||
if (x != null && y != null) positionPanel(panel, x, y, options);
|
if (x != null && y != null) positionPanel(panel, x, y, options);
|
||||||
panel.classList.add('is-visible');
|
if (options.reveal === false) {
|
||||||
|
panel.classList.remove('is-visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
panel.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
document.body.classList.add('earth-info-open');
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hidePanel() {
|
function hidePanel() {
|
||||||
const panel = getPanel();
|
const panel = getPanel();
|
||||||
if (panel) panel.classList.remove('is-visible');
|
if (panel) {
|
||||||
|
panel.classList.remove('is-visible');
|
||||||
|
panel.classList.remove('hud-panel-info--anchor-stable');
|
||||||
|
panel.setAttribute('aria-hidden', 'true');
|
||||||
|
panel.setAttribute('hidden', '');
|
||||||
|
}
|
||||||
|
document.body.classList.remove('earth-info-open');
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No-op: event binding now happens lazily in mountCard()
|
// No-op: event binding now happens lazily in mountCard()
|
||||||
@@ -308,6 +815,49 @@ export function showInfoCard(type, data, options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||||
|
currentType = type;
|
||||||
|
pendingMobileDetailState = { type, config, data };
|
||||||
|
ensureMobileDetailsListener();
|
||||||
|
|
||||||
|
// Fill drawer details slot (accessible when user taps popup → opens details tab)
|
||||||
|
const icon = document.getElementById('mobile-info-card-icon');
|
||||||
|
const title = document.getElementById('mobile-info-card-title');
|
||||||
|
const typeLabel = document.getElementById('mobile-info-card-type');
|
||||||
|
const content = document.getElementById('mobile-info-card-content');
|
||||||
|
|
||||||
|
if (icon) icon.textContent = config.icon;
|
||||||
|
if (title) {
|
||||||
|
title.textContent = type === 'news'
|
||||||
|
? (data?.title || '新闻事件')
|
||||||
|
: config.title;
|
||||||
|
}
|
||||||
|
if (typeLabel) {
|
||||||
|
typeLabel.textContent = type === 'news'
|
||||||
|
? 'news signal'
|
||||||
|
: type.replaceAll('_', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content && type !== 'news') {
|
||||||
|
renderMobileDetailContent(type, config, data);
|
||||||
|
renderedMobileDetailKey = null;
|
||||||
|
} else if (content && type === 'news' && isMobileDetailsDrawerActive()) {
|
||||||
|
renderMobileNewsCardContent(content, data);
|
||||||
|
renderedMobileDetailKey = getMobileDetailRenderKey(type, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the floating mini popup near the touch point (requires coordinates)
|
||||||
|
if (options.x != null && options.y != null) {
|
||||||
|
ensurePopupClickHandler();
|
||||||
|
showMobilePopup(type, data, options.x, options.y, options);
|
||||||
|
document.body.classList.add('earth-info-open');
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
mountCard();
|
mountCard();
|
||||||
|
|
||||||
currentType = type;
|
currentType = type;
|
||||||
@@ -316,37 +866,34 @@ export function showInfoCard(type, data, options = {}) {
|
|||||||
const title = document.getElementById('info-card-title');
|
const title = document.getElementById('info-card-title');
|
||||||
const content = document.getElementById('info-card-content');
|
const content = document.getElementById('info-card-content');
|
||||||
|
|
||||||
|
stopTypewriterAnimation();
|
||||||
card.className = 'info-card ' + config.className;
|
card.className = 'info-card ' + config.className;
|
||||||
icon.textContent = config.icon;
|
icon.textContent = config.icon;
|
||||||
title.textContent = config.title;
|
title.textContent = type === 'news'
|
||||||
|
? (data?.title || '新闻事件')
|
||||||
|
: config.title;
|
||||||
|
|
||||||
let html = '';
|
if (type === 'news') {
|
||||||
for (const field of config.fields) {
|
renderNewsCardContent(content, data);
|
||||||
let value = data[field.key];
|
} else {
|
||||||
|
renderDefaultCardContent(content, config, data);
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
value = '-';
|
|
||||||
} else if (typeof value === 'number') {
|
|
||||||
value = value.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.unit && value !== '-') {
|
|
||||||
value = value + ' ' + field.unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="info-card-property">
|
|
||||||
<span class="info-card-label">${field.label}</span>
|
|
||||||
<span class="info-card-value">${value}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
content.innerHTML = html;
|
|
||||||
showPanel(options.x, options.y, options);
|
showPanel(options.x, options.y, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideInfoCard() {
|
export function hideInfoCard() {
|
||||||
|
stopTypewriterAnimation();
|
||||||
|
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||||
|
hideMobilePopup();
|
||||||
|
document.body.classList.remove('earth-info-open');
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
|
||||||
|
);
|
||||||
|
currentType = null;
|
||||||
|
pendingMobileDetailState = null;
|
||||||
|
renderedMobileDetailKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
hidePanel();
|
hidePanel();
|
||||||
currentType = null;
|
currentType = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
loadBGPAnomalies,
|
loadBGPAnomalies,
|
||||||
toggleBGP,
|
toggleBGP,
|
||||||
} from "./bgp.js";
|
} from "./bgp.js";
|
||||||
|
import {
|
||||||
|
loadComputeCenters,
|
||||||
|
toggleComputeCenters,
|
||||||
|
} from "./compute-centers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layer startup task registry.
|
* Layer startup task registry.
|
||||||
@@ -70,6 +74,7 @@ function registerBuiltinLayerStartupTasks() {
|
|||||||
startupTaskRegistry.clear();
|
startupTaskRegistry.clear();
|
||||||
registerCableStartupTask();
|
registerCableStartupTask();
|
||||||
registerSatelliteStartupTask();
|
registerSatelliteStartupTask();
|
||||||
|
registerComputeCenterStartupTask();
|
||||||
registerBGPStartupTask();
|
registerBGPStartupTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,4 +176,26 @@ function registerBGPStartupTask() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerComputeCenterStartupTask() {
|
||||||
|
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
|
||||||
|
context.setLoadingMessage(
|
||||||
|
resolveStartupMessage(layer, "load", "正在加载算力中心..."),
|
||||||
|
);
|
||||||
|
await context.yieldFrame(12);
|
||||||
|
try {
|
||||||
|
const computeCenterResult = await loadComputeCenters(context.scene, context.earth);
|
||||||
|
if (!context.isCancelled()) {
|
||||||
|
toggleComputeCenters(context.getShowComputeCenters());
|
||||||
|
context.updateComputeCenterHud(computeCenterResult);
|
||||||
|
context.setLegendItems("computeCenters", context.getComputeCenterLegendItems());
|
||||||
|
context.refreshLegend();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
context.reportError(layer?.startupLabel || layer?.label || "算力中心", error);
|
||||||
|
}
|
||||||
|
if (context.isCancelled()) return;
|
||||||
|
await context.yieldFrame(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
registerBuiltinLayerStartupTasks();
|
registerBuiltinLayerStartupTasks();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createHUDPanel } from "./hud-panels.js";
|
import { createHUDPanel } from "./hud-panels.js";
|
||||||
|
|
||||||
const LEGEND_MODES = {
|
const LEGEND_MODES = {
|
||||||
cables: { title: "海缆" },
|
cables: { title: "海缆" },
|
||||||
satellites: { title: "卫星" },
|
satellites: { title: "卫星" },
|
||||||
bgp: { title: "BGP" },
|
computeCenters: { title: "算力" },
|
||||||
|
bgp: { title: "BGP" },
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentLegendMode = "cables";
|
let currentLegendMode = "cables";
|
||||||
@@ -11,6 +12,7 @@ let legendPanel = null;
|
|||||||
let legendItemsByMode = {
|
let legendItemsByMode = {
|
||||||
cables: [],
|
cables: [],
|
||||||
satellites: [],
|
satellites: [],
|
||||||
|
computeCenters: [],
|
||||||
bgp: [],
|
bgp: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,17 +64,18 @@ export function setLegendItems(mode, items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncCurrentLabel(mode) {
|
function syncCurrentLabel(mode) {
|
||||||
const labelEl = document.getElementById("legend-current-label");
|
const nextLabel = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
||||||
if (!labelEl) return;
|
[document.getElementById("legend-current-label"), document.getElementById("mobile-situation-legend-mode")]
|
||||||
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
.forEach((labelEl) => {
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = nextLabel;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLegend(mode) {
|
function renderLegend(mode) {
|
||||||
const listEl = document.querySelector("#legend .legend-list");
|
|
||||||
if (!listEl) return;
|
|
||||||
|
|
||||||
const items = legendItemsByMode[mode] || [];
|
const items = legendItemsByMode[mode] || [];
|
||||||
listEl.innerHTML = items
|
const html = items
|
||||||
.map(
|
.map(
|
||||||
(item) => `
|
(item) => `
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
@@ -81,4 +84,14 @@ function renderLegend(mode) {
|
|||||||
</div>`,
|
</div>`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
|
const desktopList = document.querySelector("#legend .legend-list");
|
||||||
|
if (desktopList) {
|
||||||
|
desktopList.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileList = document.getElementById("mobile-situation-legend-list");
|
||||||
|
if (mobileList) {
|
||||||
|
mobileList.innerHTML = html;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
357
frontend/public/earth/js/news-cruise-adapter.js
Normal file
357
frontend/public/earth/js/news-cruise-adapter.js
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
import { CONFIG, CONNECTOR_CONFIG, CRUISE_CONFIG } from "./constants.js";
|
||||||
|
import { showInfoCard, hideInfoCard } from "./info-card.js";
|
||||||
|
import { latLonToVector3 } from "./utils.js";
|
||||||
|
import {
|
||||||
|
createConnectorPath,
|
||||||
|
resolveConnectorAnchor,
|
||||||
|
} from "./callout-connector.js";
|
||||||
|
import {
|
||||||
|
ensureNewsPanelReady,
|
||||||
|
getNewsPayload,
|
||||||
|
selectNewsItem,
|
||||||
|
clearSelectedNewsItem,
|
||||||
|
} from "./news.js";
|
||||||
|
|
||||||
|
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||||
|
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||||
|
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
||||||
|
const MOBILE_CARD_MARGIN_PX = 14;
|
||||||
|
const MOBILE_CARD_TOP_RATIO = 0.16;
|
||||||
|
const MOBILE_CARD_WIDTH_PX = 220;
|
||||||
|
const scratchNewsWorldPosition = new THREE.Vector3();
|
||||||
|
|
||||||
|
const REGION_LABELS = {
|
||||||
|
americas: "美洲",
|
||||||
|
europe: "欧洲",
|
||||||
|
"middle-east-africa": "中东与非洲",
|
||||||
|
"asia-pacific": "亚太",
|
||||||
|
global: "全球",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getItemTimestamp(item) {
|
||||||
|
const parsed = item?.published_at ? new Date(item.published_at).getTime() : 0;
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPublishedAt(rawValue) {
|
||||||
|
if (!rawValue) return "刚刚同步";
|
||||||
|
const parsed = new Date(rawValue);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return "刚刚同步";
|
||||||
|
return parsed.toLocaleString("zh-CN", {
|
||||||
|
hour12: false,
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardPlacement() {
|
||||||
|
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||||
|
const safeBottom =
|
||||||
|
parseFloat(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
|
||||||
|
) || 0;
|
||||||
|
const width = Math.min(MOBILE_CARD_WIDTH_PX, window.innerWidth - MOBILE_CARD_MARGIN_PX * 2);
|
||||||
|
return {
|
||||||
|
x: Math.max(MOBILE_CARD_MARGIN_PX, window.innerWidth - width - MOBILE_CARD_MARGIN_PX),
|
||||||
|
y: Math.max(
|
||||||
|
MOBILE_CARD_MARGIN_PX,
|
||||||
|
Math.min(
|
||||||
|
window.innerHeight * MOBILE_CARD_TOP_RATIO,
|
||||||
|
window.innerHeight - safeBottom - 120,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hudScale =
|
||||||
|
Number.parseFloat(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||||
|
) || 1;
|
||||||
|
const estimatedCardHeight = Math.min(420 * hudScale, window.innerHeight * 0.7);
|
||||||
|
const estimatedCardWidth = Math.min(300 * hudScale, window.innerWidth - 32);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5,
|
||||||
|
y: window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5,
|
||||||
|
width: estimatedCardWidth,
|
||||||
|
height: estimatedCardHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNewsItemToCruiseEvent(item) {
|
||||||
|
const latitude = Number(item?.latitude);
|
||||||
|
const longitude = Number(item?.longitude);
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `news:${item.id}`,
|
||||||
|
sourceId: item.id,
|
||||||
|
type: "news",
|
||||||
|
title: item.title || "新闻事件",
|
||||||
|
summary: item.summary || "",
|
||||||
|
source: item.source || "",
|
||||||
|
feedName: item.feed_name || "",
|
||||||
|
region: item.region || "global",
|
||||||
|
regionLabel: REGION_LABELS[item.region] || item.region || "全球",
|
||||||
|
url: item.url || "",
|
||||||
|
publishedAt: item.published_at || null,
|
||||||
|
publishedAtDisplay: formatPublishedAt(item.published_at),
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
locationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
|
||||||
|
sortTimestamp: getItemTimestamp(item),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNewsCruiseAdapter({ camera, earth, connector, focusView }) {
|
||||||
|
let currentItemId = null;
|
||||||
|
let knownEventIds = new Set();
|
||||||
|
let cardPlacement = null;
|
||||||
|
|
||||||
|
function getVisibleMobilePopup() {
|
||||||
|
const mobilePopup = document.getElementById("earth-mobile-popup");
|
||||||
|
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
|
||||||
|
? mobilePopup
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleInfoPanel() {
|
||||||
|
const infoPanel = document.getElementById("info-panel");
|
||||||
|
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
|
||||||
|
? infoPanel
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemWorldPosition(item) {
|
||||||
|
const earthObj = earth?.();
|
||||||
|
if (!earthObj || !item) return null;
|
||||||
|
scratchNewsWorldPosition.copy(
|
||||||
|
latLonToVector3(item.latitude, item.longitude, CONFIG.earthRadius + 0.3),
|
||||||
|
);
|
||||||
|
earthObj.localToWorld(scratchNewsWorldPosition);
|
||||||
|
return scratchNewsWorldPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemScreenCoords(item) {
|
||||||
|
if (!camera || !item) return null;
|
||||||
|
const worldPosition = getItemWorldPosition(item);
|
||||||
|
if (!worldPosition) return null;
|
||||||
|
const projected = worldPosition.clone().project(camera);
|
||||||
|
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||||
|
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardAnchorTarget() {
|
||||||
|
const mobilePopup = getVisibleMobilePopup();
|
||||||
|
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
|
||||||
|
return {
|
||||||
|
element: mobilePopup,
|
||||||
|
side: mobilePopup.dataset.dockSide || "left",
|
||||||
|
alignRatio: 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardObstacleTarget(fallbackPlacement = null) {
|
||||||
|
const mobilePopup = getVisibleMobilePopup();
|
||||||
|
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
|
||||||
|
return { element: mobilePopup };
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoPanel = getVisibleInfoPanel();
|
||||||
|
if (infoPanel) {
|
||||||
|
return { element: infoPanel };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackPlacement) {
|
||||||
|
return {
|
||||||
|
x: fallbackPlacement.x,
|
||||||
|
y: fallbackPlacement.y,
|
||||||
|
width: fallbackPlacement.width ?? 0,
|
||||||
|
height: fallbackPlacement.height ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectorPath(item) {
|
||||||
|
const itemCoords = getItemScreenCoords(item);
|
||||||
|
if (!itemCoords) return null;
|
||||||
|
|
||||||
|
const targetPlacement = cardPlacement || getCardPlacement();
|
||||||
|
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||||
|
const anchorTarget = getCardAnchorTarget();
|
||||||
|
const anchorCoords = resolveConnectorAnchor(anchorTarget);
|
||||||
|
if (!anchorCoords) return null;
|
||||||
|
return createConnectorPath(itemCoords, anchorTarget ?? anchorCoords, {
|
||||||
|
routingMode: "adaptive",
|
||||||
|
obstacles: getCardObstacleTarget(targetPlacement),
|
||||||
|
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||||
|
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||||
|
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoPanel = getVisibleInfoPanel();
|
||||||
|
const target =
|
||||||
|
infoPanel ||
|
||||||
|
{
|
||||||
|
x: targetPlacement.x,
|
||||||
|
y: targetPlacement.y,
|
||||||
|
width: targetPlacement.width,
|
||||||
|
height: targetPlacement.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createConnectorPath(itemCoords, target, {
|
||||||
|
routingMode: "adaptive",
|
||||||
|
obstacles: getCardObstacleTarget(targetPlacement),
|
||||||
|
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||||
|
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||||
|
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConnector(item, { animate = false } = {}) {
|
||||||
|
const path = getConnectorPath(item);
|
||||||
|
if (!path) return false;
|
||||||
|
return connector?.render(path, { animate }) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedItems() {
|
||||||
|
const items = Array.isArray(getNewsPayload()?.items) ? getNewsPayload().items : [];
|
||||||
|
return items
|
||||||
|
.map(mapNewsItemToCruiseEvent)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.sortTimestamp - a.sortTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async ensureItemsLoaded() {
|
||||||
|
await ensureNewsPanelReady();
|
||||||
|
},
|
||||||
|
getSortedItems,
|
||||||
|
clearCurrentHighlight() {
|
||||||
|
currentItemId = null;
|
||||||
|
clearSelectedNewsItem();
|
||||||
|
},
|
||||||
|
async focusItem(item, { interrupt = false } = {}) {
|
||||||
|
if (!item) return;
|
||||||
|
currentItemId = item.id;
|
||||||
|
await ensureNewsPanelReady();
|
||||||
|
await focusView({
|
||||||
|
lat: item.latitude,
|
||||||
|
lon: item.longitude,
|
||||||
|
rotLon: item.longitude - 270,
|
||||||
|
duration: interrupt
|
||||||
|
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
|
||||||
|
: CRUISE_CONFIG.focusDurationMs,
|
||||||
|
suppressStatus: true,
|
||||||
|
});
|
||||||
|
cardPlacement = getCardPlacement();
|
||||||
|
},
|
||||||
|
async presentItem(item, { context }) {
|
||||||
|
if (!item) return false;
|
||||||
|
|
||||||
|
selectNewsItem(item.sourceId);
|
||||||
|
const placement = cardPlacement || getCardPlacement();
|
||||||
|
cardPlacement = placement;
|
||||||
|
showInfoCard("news", {
|
||||||
|
title: item.title,
|
||||||
|
summary: item.summary || item.title || "",
|
||||||
|
}, {
|
||||||
|
x: placement.x,
|
||||||
|
y: placement.y,
|
||||||
|
absolute: true,
|
||||||
|
anchorStable: true,
|
||||||
|
reveal: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.nextFrame();
|
||||||
|
if (!context.isCurrent()) {
|
||||||
|
cardPlacement = null;
|
||||||
|
connector?.hide();
|
||||||
|
hideInfoCard();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let connectorReady = false;
|
||||||
|
while (context.isCurrent()) {
|
||||||
|
connectorReady = renderConnector(item, { animate: !connectorReady });
|
||||||
|
if (connectorReady) break;
|
||||||
|
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await context.nextFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectorReady || !context.isCurrent()) {
|
||||||
|
cardPlacement = null;
|
||||||
|
connector?.hide();
|
||||||
|
hideInfoCard();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
|
||||||
|
if (!connectorDelayCompleted || !context.isCurrent()) {
|
||||||
|
cardPlacement = null;
|
||||||
|
connector?.hide();
|
||||||
|
hideInfoCard();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
showInfoCard("news", {
|
||||||
|
title: item.title,
|
||||||
|
summary: item.summary || item.title || "",
|
||||||
|
}, {
|
||||||
|
x: placement.x,
|
||||||
|
y: placement.y,
|
||||||
|
absolute: true,
|
||||||
|
anchorStable: true,
|
||||||
|
});
|
||||||
|
await context.nextFrame();
|
||||||
|
return context.isCurrent();
|
||||||
|
},
|
||||||
|
async hidePresentation({ context }) {
|
||||||
|
clearSelectedNewsItem();
|
||||||
|
connector?.hide();
|
||||||
|
hideInfoCard();
|
||||||
|
await context.wait(CRUISE_PRESENTATION_HIDE_MS, { secondary: true });
|
||||||
|
cardPlacement = null;
|
||||||
|
},
|
||||||
|
repositionConnector(item) {
|
||||||
|
if (!cardPlacement || !item || !connector?.isVisible?.() || connector.isAnimating?.()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderConnector(item, { animate: false });
|
||||||
|
},
|
||||||
|
resetPresentation() {
|
||||||
|
clearSelectedNewsItem();
|
||||||
|
cardPlacement = null;
|
||||||
|
connector?.hide();
|
||||||
|
},
|
||||||
|
syncKnownEventIds() {
|
||||||
|
knownEventIds = new Set(getSortedItems().map((item) => item.id));
|
||||||
|
return knownEventIds;
|
||||||
|
},
|
||||||
|
diffNewEventIds(itemIds = []) {
|
||||||
|
return itemIds
|
||||||
|
.map((itemId) => `news:${itemId}`)
|
||||||
|
.filter((itemId) => !knownEventIds.has(itemId));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { showStatusMessage } from "./ui.js";
|
import { showStatusMessage } from "./ui.js";
|
||||||
import { isTVPanelVisible } from "./tv.js";
|
import { getActiveTVTab, isTVPanelVisible } from "./tv.js";
|
||||||
|
|
||||||
// News aggregation now lives inside the shared media panel:
|
// News aggregation now lives inside the shared media panel:
|
||||||
// - outer shell: #media-panel
|
// - outer shell: #media-panel
|
||||||
@@ -17,18 +17,20 @@ let payload = null;
|
|||||||
let lastFocus = null;
|
let lastFocus = null;
|
||||||
let lastFetchAt = 0;
|
let lastFetchAt = 0;
|
||||||
let lastRegionSwitchAt = 0;
|
let lastRegionSwitchAt = 0;
|
||||||
|
let selectedCruiseStoryId = null;
|
||||||
function getElements() {
|
function getElements() {
|
||||||
|
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||||
return {
|
return {
|
||||||
refreshBtn: document.getElementById("news-refresh"),
|
refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"),
|
||||||
openBtn: document.getElementById("news-open-external"),
|
openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"),
|
||||||
status: document.getElementById("news-board-status"),
|
status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"),
|
||||||
focusLabel: document.getElementById("news-focus-label"),
|
focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"),
|
||||||
focusCoords: document.getElementById("news-focus-coords"),
|
focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"),
|
||||||
sourceCount: document.getElementById("news-source-count"),
|
sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"),
|
||||||
regionChip: document.getElementById("news-region-chip"),
|
regionChip: document.getElementById("news-region-chip"),
|
||||||
board: document.getElementById("news-board-list"),
|
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
|
||||||
empty: document.getElementById("news-board-empty"),
|
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
|
||||||
feedAnchor: document.getElementById("news-feed-anchor"),
|
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,18 +83,33 @@ function renderPayload(nextPayload) {
|
|||||||
openBtn,
|
openBtn,
|
||||||
feedAnchor,
|
feedAnchor,
|
||||||
} = getElements();
|
} = getElements();
|
||||||
|
|
||||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
|
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
|
||||||
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
|
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
|
||||||
const focus = nextPayload?.focus || {};
|
const focus = nextPayload?.focus || {};
|
||||||
|
|
||||||
|
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
||||||
|
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||||
|
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
|
||||||
|
if (!board || !status || !focusLabel || !focusCoords || !sourceCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionChip) {
|
||||||
|
regionChip.textContent = focus.region || "global";
|
||||||
|
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||||
|
// Mobile page does not show the compact chip row.
|
||||||
|
} else if (!regionChip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
focusLabel.textContent = focus.label || "全球焦点";
|
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") {
|
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
|
||||||
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
|
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
|
||||||
@@ -138,7 +155,7 @@ function renderPayload(nextPayload) {
|
|||||||
? `<div class="news-story-summary">${item.summary}</div>`
|
? `<div class="news-story-summary">${item.summary}</div>`
|
||||||
: "";
|
: "";
|
||||||
return `
|
return `
|
||||||
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||||
<div class="news-story-meta">
|
<div class="news-story-meta">
|
||||||
<span class="news-story-source">${item.source}</span>
|
<span class="news-story-source">${item.source}</span>
|
||||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||||
@@ -153,6 +170,14 @@ function renderPayload(nextPayload) {
|
|||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
|
applyCruiseStorySelection();
|
||||||
|
window.dispatchEvent(new CustomEvent("earth:news-payload-updated", {
|
||||||
|
detail: {
|
||||||
|
payload: nextPayload,
|
||||||
|
itemIds: items.map((item) => item.id),
|
||||||
|
},
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchNews(lat, lon) {
|
async function fetchNews(lat, lon) {
|
||||||
@@ -271,32 +296,80 @@ export async function ensureNewsPanelReady() {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getNewsPayload() {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBoardSelection(board, { scrollIntoView = false } = {}) {
|
||||||
|
if (!(board instanceof HTMLElement)) return;
|
||||||
|
const cards = board.querySelectorAll("[data-news-id]");
|
||||||
|
cards.forEach((card) => {
|
||||||
|
const matches = card.getAttribute("data-news-id") === selectedCruiseStoryId;
|
||||||
|
card.classList.toggle("news-story-card--cruise", matches);
|
||||||
|
if (matches && scrollIntoView) {
|
||||||
|
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCruiseStorySelection(options = {}) {
|
||||||
|
const desktopBoard = document.getElementById("news-board-list");
|
||||||
|
const mobileBoard = document.getElementById("mobile-news-board-list");
|
||||||
|
updateBoardSelection(desktopBoard, options);
|
||||||
|
updateBoardSelection(mobileBoard, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealSelectedCruiseStory() {
|
||||||
|
if (!selectedCruiseStoryId) return;
|
||||||
|
applyCruiseStorySelection({ scrollIntoView: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectNewsItem(itemId, options = {}) {
|
||||||
|
selectedCruiseStoryId = itemId || null;
|
||||||
|
applyCruiseStorySelection(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSelectedNewsItem() {
|
||||||
|
selectedCruiseStoryId = null;
|
||||||
|
applyCruiseStorySelection();
|
||||||
|
}
|
||||||
|
|
||||||
export function initNewsPanel() {
|
export function initNewsPanel() {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
const { refreshBtn, openBtn } = getElements();
|
|
||||||
|
|
||||||
updateNewsToggleUI(isTVPanelVisible());
|
updateNewsToggleUI(isTVPanelVisible());
|
||||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||||
|
|
||||||
window.addEventListener("earth:tv-tab-change", () => {
|
window.addEventListener("earth:tv-tab-change", () => {
|
||||||
updateNewsToggleUI(isTVPanelVisible());
|
updateNewsToggleUI(isTVPanelVisible());
|
||||||
|
if (getActiveTVTab() === "news") {
|
||||||
|
revealSelectedCruiseStory();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
window.addEventListener("earth:tv-visibility-change", (event) => {
|
window.addEventListener("earth:tv-visibility-change", (event) => {
|
||||||
updateNewsToggleUI(Boolean(event.detail?.visible));
|
updateNewsToggleUI(Boolean(event.detail?.visible));
|
||||||
});
|
if (event.detail?.visible && getActiveTVTab() === "news") {
|
||||||
|
revealSelectedCruiseStory();
|
||||||
refreshBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
|
||||||
showStatusMessage("态势新闻已刷新", "info");
|
|
||||||
} catch {
|
|
||||||
showStatusMessage("态势新闻刷新失败", "error");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
["news-refresh", "mobile-news-refresh"].forEach((id) => {
|
||||||
|
const refreshBtn = document.getElementById(id);
|
||||||
|
refreshBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||||
|
showStatusMessage("态势新闻已刷新", "info");
|
||||||
|
} catch {
|
||||||
|
showStatusMessage("态势新闻刷新失败", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
["news-open-external", "mobile-news-open-external"].forEach((id) => {
|
||||||
|
const openBtn = document.getElementById(id);
|
||||||
|
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||||
|
});
|
||||||
|
|
||||||
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
282
frontend/public/earth/js/search.js
Normal file
282
frontend/public/earth/js/search.js
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
let initialized = false;
|
||||||
|
let resolveResultsFn = null;
|
||||||
|
let onSelectResultFn = null;
|
||||||
|
let currentResults = [];
|
||||||
|
let activeIndex = -1;
|
||||||
|
let searchTimerId = null;
|
||||||
|
let isOpen = false;
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElements() {
|
||||||
|
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||||
|
return {
|
||||||
|
modal: document.getElementById("search-modal"),
|
||||||
|
backdrop: document.getElementById("search-backdrop"),
|
||||||
|
input: document.getElementById(
|
||||||
|
isMobile ? "mobile-earth-search-input" : "earth-search-input",
|
||||||
|
),
|
||||||
|
clear: document.getElementById(
|
||||||
|
isMobile ? "mobile-earth-search-clear" : "earth-search-clear",
|
||||||
|
),
|
||||||
|
meta: document.getElementById(
|
||||||
|
isMobile ? "mobile-earth-search-meta" : "earth-search-meta",
|
||||||
|
),
|
||||||
|
results: document.getElementById(
|
||||||
|
isMobile ? "mobile-earth-search-results" : "earth-search-results",
|
||||||
|
),
|
||||||
|
empty: document.getElementById(
|
||||||
|
isMobile ? "mobile-earth-search-empty" : "earth-search-empty",
|
||||||
|
),
|
||||||
|
close: document.getElementById("search-close"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMeta(text) {
|
||||||
|
const { meta } = getElements();
|
||||||
|
if (meta) meta.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEmptyState(query) {
|
||||||
|
const { empty } = getElements();
|
||||||
|
if (!empty) return;
|
||||||
|
if (!query) {
|
||||||
|
empty.textContent = "支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.textContent = "未找到匹配对象,可尝试名称、地点、NORAD、ASN、前缀等关键词。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(query) {
|
||||||
|
const { results, empty } = getElements();
|
||||||
|
if (!results || !empty) return;
|
||||||
|
|
||||||
|
results.innerHTML = "";
|
||||||
|
const hasResults = currentResults.length > 0;
|
||||||
|
empty.hidden = hasResults;
|
||||||
|
updateEmptyState(query);
|
||||||
|
|
||||||
|
if (!hasResults) return;
|
||||||
|
|
||||||
|
currentResults.forEach((result, index) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "earth-search-result";
|
||||||
|
button.setAttribute("role", "option");
|
||||||
|
button.dataset.index = String(index);
|
||||||
|
button.innerHTML = `
|
||||||
|
<span class="earth-search-result-icon" aria-hidden="true">
|
||||||
|
<span class="material-symbols-rounded">${escapeHtml(result.icon || "search")}</span>
|
||||||
|
</span>
|
||||||
|
<span class="earth-search-result-copy">
|
||||||
|
<span class="earth-search-result-title">${escapeHtml(result.title)}</span>
|
||||||
|
<span class="earth-search-result-subtitle">${escapeHtml(result.subtitle || "")}</span>
|
||||||
|
</span>
|
||||||
|
<span class="earth-search-result-type">${escapeHtml(result.typeLabel || "")}</span>
|
||||||
|
`;
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
await selectResult(index);
|
||||||
|
});
|
||||||
|
results.appendChild(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
syncActiveResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActiveResult() {
|
||||||
|
const { results } = getElements();
|
||||||
|
if (!results) return;
|
||||||
|
Array.from(results.children).forEach((node, index) => {
|
||||||
|
node.classList.toggle("is-active", index === activeIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveActiveResult(delta) {
|
||||||
|
if (currentResults.length === 0) return;
|
||||||
|
activeIndex =
|
||||||
|
((activeIndex < 0 ? 0 : activeIndex) + delta + currentResults.length) %
|
||||||
|
currentResults.length;
|
||||||
|
syncActiveResult();
|
||||||
|
const { results } = getElements();
|
||||||
|
const activeNode = results?.children?.[activeIndex];
|
||||||
|
activeNode?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectResult(index) {
|
||||||
|
const result = currentResults[index];
|
||||||
|
if (!result || typeof onSelectResultFn !== "function") return;
|
||||||
|
closeSearchPanel();
|
||||||
|
try {
|
||||||
|
await onSelectResultFn(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search selection failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
const { input, clear } = getElements();
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const query = input.value.trim();
|
||||||
|
if (clear) {
|
||||||
|
clear.hidden = query.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
currentResults = [];
|
||||||
|
activeIndex = -1;
|
||||||
|
setMeta("输入关键词以搜索当前地球对象");
|
||||||
|
renderResults("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMeta("正在检索…");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextResults = await resolveResultsFn?.(query);
|
||||||
|
currentResults = Array.isArray(nextResults) ? nextResults : [];
|
||||||
|
activeIndex = currentResults.length > 0 ? 0 : -1;
|
||||||
|
setMeta(`找到 ${currentResults.length} 个结果`);
|
||||||
|
renderResults(query);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search failed:", error);
|
||||||
|
currentResults = [];
|
||||||
|
activeIndex = -1;
|
||||||
|
setMeta("搜索失败");
|
||||||
|
renderResults(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSearch() {
|
||||||
|
if (searchTimerId) {
|
||||||
|
clearTimeout(searchTimerId);
|
||||||
|
}
|
||||||
|
searchTimerId = window.setTimeout(() => {
|
||||||
|
searchTimerId = null;
|
||||||
|
runSearch();
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event) {
|
||||||
|
const { modal, input } = getElements();
|
||||||
|
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||||
|
if (!isMobile && !modal?.classList.contains("is-open")) return;
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeSearchPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target !== input) return;
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveActiveResult(1);
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveActiveResult(-1);
|
||||||
|
} else if (event.key === "Enter" && activeIndex >= 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
selectResult(activeIndex).catch((error) => {
|
||||||
|
console.warn("Selecting search result failed:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
|
||||||
|
resolveResultsFn = resolveResults;
|
||||||
|
onSelectResultFn = onSelectResult;
|
||||||
|
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
const inputs = ["earth-search-input", "mobile-earth-search-input"]
|
||||||
|
.map((id) => document.getElementById(id))
|
||||||
|
.filter((node) => node instanceof HTMLInputElement);
|
||||||
|
const clears = ["earth-search-clear", "mobile-earth-search-clear"]
|
||||||
|
.map((id) => document.getElementById(id))
|
||||||
|
.filter((node) => node instanceof HTMLButtonElement);
|
||||||
|
const close = document.getElementById("search-close");
|
||||||
|
const backdrop = document.getElementById("search-backdrop");
|
||||||
|
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
input.addEventListener("input", scheduleSearch);
|
||||||
|
input.addEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
clears.forEach((clear) => {
|
||||||
|
clear.addEventListener("click", () => {
|
||||||
|
const { input } = getElements();
|
||||||
|
if (!input) return;
|
||||||
|
input.value = "";
|
||||||
|
input.focus();
|
||||||
|
runSearch().catch((error) => {
|
||||||
|
console.warn("Clearing search failed:", error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
close?.addEventListener("click", () => {
|
||||||
|
closeSearchPanel();
|
||||||
|
});
|
||||||
|
backdrop?.addEventListener("click", () => {
|
||||||
|
closeSearchPanel();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", handleKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSearchPanel() {
|
||||||
|
const { modal, input } = getElements();
|
||||||
|
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
|
||||||
|
if (isOpen) return;
|
||||||
|
isOpen = true;
|
||||||
|
document.body.classList.add("earth-search-open");
|
||||||
|
modal?.classList.add("is-open");
|
||||||
|
modal?.setAttribute("aria-hidden", "false");
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("earth:search-open-change", { detail: { open: true } }),
|
||||||
|
);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
input?.focus();
|
||||||
|
input?.select();
|
||||||
|
runSearch().catch((error) => {
|
||||||
|
console.warn("Running search failed:", error);
|
||||||
|
});
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusSearchInput({ select = false } = {}) {
|
||||||
|
const { input } = getElements();
|
||||||
|
if (!(input instanceof HTMLInputElement)) return;
|
||||||
|
input.focus();
|
||||||
|
if (select) {
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshSearchResults() {
|
||||||
|
return runSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSearchPanel() {
|
||||||
|
const { modal } = getElements();
|
||||||
|
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
|
||||||
|
if (!isOpen) return;
|
||||||
|
isOpen = false;
|
||||||
|
document.body.classList.remove("earth-search-open");
|
||||||
|
modal?.classList.remove("is-open");
|
||||||
|
modal?.setAttribute("aria-hidden", "true");
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("earth:search-open-change", { detail: { open: false } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSearchPanelOpen() {
|
||||||
|
return isOpen;
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ const tabPanelState = {
|
|||||||
live: null,
|
live: null,
|
||||||
news: null,
|
news: null,
|
||||||
};
|
};
|
||||||
|
let mobileMetaCollapsed = false;
|
||||||
|
|
||||||
const META_AUTO_COLLAPSE_DELAY = 2500;
|
const META_AUTO_COLLAPSE_DELAY = 2500;
|
||||||
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
|
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
|
||||||
@@ -56,23 +57,24 @@ const HLS_RETRY_CONFIG = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getElements() {
|
function getElements() {
|
||||||
|
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||||
return {
|
return {
|
||||||
// Outer media shell node.
|
// Outer media shell node.
|
||||||
panel: document.getElementById("media-panel"),
|
panel: document.getElementById("media-panel"),
|
||||||
toggleBtn: document.getElementById("toggle-tv"),
|
toggleBtn: document.getElementById("toggle-tv"),
|
||||||
select: document.getElementById("tv-source-select"),
|
select: document.getElementById(isMobile ? "mobile-tv-source-select" : "tv-source-select"),
|
||||||
title: document.getElementById("tv-source-title"),
|
title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"),
|
||||||
meta: document.getElementById("tv-source-meta"),
|
meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"),
|
||||||
catalog: document.getElementById("tv-source-catalog"),
|
catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"),
|
||||||
status: document.getElementById("tv-source-status"),
|
status: document.getElementById(isMobile ? "mobile-tv-source-status" : "tv-source-status"),
|
||||||
notes: document.getElementById("tv-source-notes"),
|
notes: document.getElementById(isMobile ? "mobile-tv-source-notes" : "tv-source-notes"),
|
||||||
iframe: document.getElementById("tv-iframe"),
|
iframe: document.getElementById(isMobile ? "mobile-tv-iframe" : "tv-iframe"),
|
||||||
video: document.getElementById("tv-video"),
|
video: document.getElementById(isMobile ? "mobile-tv-video" : "tv-video"),
|
||||||
empty: document.getElementById("tv-empty-state"),
|
empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"),
|
||||||
refreshBtn: document.getElementById("tv-refresh"),
|
refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"),
|
||||||
openBtn: document.getElementById("tv-open-external"),
|
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
|
||||||
metaWrap: document.getElementById("tv-meta-wrap"),
|
metaWrap: document.getElementById(isMobile ? "mobile-tv-meta-wrap" : "tv-meta-wrap"),
|
||||||
metaToggle: document.getElementById("tv-meta-toggle"),
|
metaToggle: document.getElementById(isMobile ? "mobile-tv-meta-toggle" : "tv-meta-toggle"),
|
||||||
liveHeaderControls: document.getElementById("tv-header-controls-live"),
|
liveHeaderControls: document.getElementById("tv-header-controls-live"),
|
||||||
newsHeaderControls: document.getElementById("tv-header-controls-news"),
|
newsHeaderControls: document.getElementById("tv-header-controls-news"),
|
||||||
liveTabBtn: document.getElementById("tv-tab-live"),
|
liveTabBtn: document.getElementById("tv-tab-live"),
|
||||||
@@ -83,8 +85,43 @@ function getElements() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMobileLayout() {
|
||||||
|
return document.body.classList.contains("layout-mode-mobile");
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMetaToggleState(collapsed) {
|
||||||
|
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
|
||||||
|
if (mobileOverviewBar instanceof HTMLElement) {
|
||||||
|
mobileOverviewBar.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||||
|
}
|
||||||
|
const desktopToggle = document.getElementById("tv-meta-toggle");
|
||||||
|
if (desktopToggle instanceof HTMLButtonElement) {
|
||||||
|
desktopToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||||
|
desktopToggle.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
collapsed ? "展开新闻直播内容" : "折叠新闻直播内容",
|
||||||
|
);
|
||||||
|
desktopToggle.title = collapsed ? "展开新闻直播内容" : "折叠新闻直播内容";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMetaCollapsed() {
|
||||||
|
if (isMobileLayout()) {
|
||||||
|
return mobileMetaCollapsed;
|
||||||
|
}
|
||||||
|
return mediaPanel?.isCollapsed() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
function setMetaCollapsed(collapsed) {
|
function setMetaCollapsed(collapsed) {
|
||||||
|
if (isMobileLayout()) {
|
||||||
|
const { metaWrap } = getElements();
|
||||||
|
mobileMetaCollapsed = Boolean(collapsed);
|
||||||
|
metaWrap?.classList.toggle("is-collapsed", mobileMetaCollapsed);
|
||||||
|
syncMetaToggleState(mobileMetaCollapsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
mediaPanel?.setCollapsed(collapsed);
|
mediaPanel?.setCollapsed(collapsed);
|
||||||
|
syncMetaToggleState(Boolean(collapsed));
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPanelActiveTab(tab = activeTab) {
|
function syncPanelActiveTab(tab = activeTab) {
|
||||||
@@ -119,6 +156,10 @@ function syncNewsDefaultMaxHeight() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function autoExpandMeta() {
|
function autoExpandMeta() {
|
||||||
|
if (isMobileLayout()) {
|
||||||
|
clearTimeout(metaAutoCollapseTimer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearTimeout(metaAutoCollapseTimer);
|
clearTimeout(metaAutoCollapseTimer);
|
||||||
setMetaCollapsed(false);
|
setMetaCollapsed(false);
|
||||||
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
|
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
|
||||||
@@ -153,7 +194,7 @@ function captureTabState(tab = activeTab) {
|
|||||||
tabPanelState[tab] = {
|
tabPanelState[tab] = {
|
||||||
layout: readPanelLayoutState(panel),
|
layout: readPanelLayoutState(panel),
|
||||||
metaCollapsed:
|
metaCollapsed:
|
||||||
tab === "live" ? (mediaPanel?.isCollapsed() ?? false) : null,
|
tab === "live" ? isMetaCollapsed() : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +392,7 @@ function setPanelVisible(visible) {
|
|||||||
const { panel } = getElements();
|
const { panel } = getElements();
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
mediaPanel?.setVisible(visible);
|
mediaPanel?.setVisible(visible);
|
||||||
|
document.body.classList.toggle("earth-media-open", visible);
|
||||||
updateToggleButton(visible);
|
updateToggleButton(visible);
|
||||||
syncSettingsToggle(visible);
|
syncSettingsToggle(visible);
|
||||||
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
|
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
|
||||||
@@ -747,15 +789,59 @@ function getExternalUrl(source) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateOpenButton(source) {
|
function updateOpenButton(source) {
|
||||||
const { openBtn } = getElements();
|
|
||||||
if (!openBtn) return;
|
|
||||||
const targetUrl = getExternalUrl(source);
|
const targetUrl = getExternalUrl(source);
|
||||||
openBtn.disabled = !targetUrl;
|
[
|
||||||
openBtn.onclick = targetUrl
|
document.getElementById("mobile-tv-open-external"),
|
||||||
? () => {
|
document.getElementById("tv-open-external"),
|
||||||
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
].forEach((button) => {
|
||||||
}
|
if (!(button instanceof HTMLButtonElement)) return;
|
||||||
: null;
|
button.disabled = !targetUrl;
|
||||||
|
button.onclick = targetUrl
|
||||||
|
? () => {
|
||||||
|
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMobileOverviewSummary(source) {
|
||||||
|
const headline = document.getElementById("mobile-tv-overview-headline");
|
||||||
|
const summary = document.getElementById("mobile-tv-overview-summary");
|
||||||
|
const tags = document.getElementById("mobile-tv-overview-tags");
|
||||||
|
if (headline instanceof HTMLElement) {
|
||||||
|
headline.textContent = source?.name || "暂无可用频道";
|
||||||
|
}
|
||||||
|
if (summary instanceof HTMLElement) {
|
||||||
|
if (!source) {
|
||||||
|
summary.textContent = "点击查看当前频道来源、目录和补充说明";
|
||||||
|
} else {
|
||||||
|
const parts = [
|
||||||
|
source.provider,
|
||||||
|
source.region,
|
||||||
|
source.language,
|
||||||
|
].filter(Boolean);
|
||||||
|
summary.textContent = parts.length
|
||||||
|
? parts.join(" · ")
|
||||||
|
: "点击查看完整频道信息";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tags instanceof HTMLElement) {
|
||||||
|
const tagValues = source
|
||||||
|
? [
|
||||||
|
{ label: source.source_type || "频道", kind: "status" },
|
||||||
|
source.collector_source ? { label: `采集:${source.collector_source}`, kind: "" } : { label: "内置源", kind: "" },
|
||||||
|
source.region ? { label: source.region, kind: "" } : null,
|
||||||
|
].filter(Boolean).slice(0, 3)
|
||||||
|
: [{ label: "待加载", kind: "status" }];
|
||||||
|
tags.replaceChildren(
|
||||||
|
...tagValues.map(({ label, kind }) => {
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.className = `earth-mobile-tv-overview-tag${kind ? ` earth-mobile-tv-overview-tag--${kind}` : ""}`;
|
||||||
|
chip.textContent = label;
|
||||||
|
return chip;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function findSourceById(sourceId) {
|
function findSourceById(sourceId) {
|
||||||
@@ -924,6 +1010,7 @@ function renderSource(source) {
|
|||||||
if (notes) {
|
if (notes) {
|
||||||
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
|
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
|
||||||
}
|
}
|
||||||
|
syncMobileOverviewSummary(source);
|
||||||
|
|
||||||
if (!source || (!embeddedUrl && !videoUrl)) {
|
if (!source || (!embeddedUrl && !videoUrl)) {
|
||||||
destroyHlsPlayer();
|
destroyHlsPlayer();
|
||||||
@@ -1029,6 +1116,7 @@ export function initTVPanel() {
|
|||||||
liveTabBtn,
|
liveTabBtn,
|
||||||
newsTabBtn,
|
newsTabBtn,
|
||||||
} = getElements();
|
} = getElements();
|
||||||
|
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
|
||||||
|
|
||||||
if (panel && metaToggle) {
|
if (panel && metaToggle) {
|
||||||
mediaPanel = createHUDPanel({
|
mediaPanel = createHUDPanel({
|
||||||
@@ -1043,6 +1131,10 @@ export function initTVPanel() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mobileMetaCollapsed = true;
|
||||||
|
syncMetaToggleState(isMetaCollapsed());
|
||||||
|
setMetaCollapsed(isMetaCollapsed());
|
||||||
|
|
||||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||||
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||||
|
|
||||||
@@ -1071,22 +1163,51 @@ export function initTVPanel() {
|
|||||||
showStatusMessage("已切换到态势新闻", "info");
|
showStatusMessage("已切换到态势新闻", "info");
|
||||||
});
|
});
|
||||||
|
|
||||||
select?.addEventListener("change", (event) => {
|
[select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
|
||||||
const target = event.currentTarget;
|
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||||
if (!(target instanceof HTMLSelectElement)) return;
|
.forEach((selectEl) => {
|
||||||
currentSourceId = target.value;
|
selectEl?.addEventListener("change", (event) => {
|
||||||
renderSource(findSourceById(currentSourceId));
|
const target = event.currentTarget;
|
||||||
});
|
if (!(target instanceof HTMLSelectElement)) return;
|
||||||
|
currentSourceId = target.value;
|
||||||
|
renderSource(findSourceById(currentSourceId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
metaToggle?.addEventListener("click", () => {
|
[metaToggle, document.getElementById("tv-meta-toggle")]
|
||||||
|
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||||
|
.forEach((toggleEl) => {
|
||||||
|
toggleEl?.addEventListener("click", () => {
|
||||||
|
clearTimeout(metaAutoCollapseTimer);
|
||||||
|
const isNowCollapsed = !isMetaCollapsed();
|
||||||
|
setMetaCollapsed(isNowCollapsed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMobileMeta = (event) => {
|
||||||
|
const interactiveTarget = event.target instanceof Element
|
||||||
|
? event.target.closest("button, a, select, input, textarea, video, iframe")
|
||||||
|
: null;
|
||||||
|
if (interactiveTarget) return;
|
||||||
clearTimeout(metaAutoCollapseTimer);
|
clearTimeout(metaAutoCollapseTimer);
|
||||||
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
|
const isNowCollapsed = !isMetaCollapsed();
|
||||||
setMetaCollapsed(isNowCollapsed);
|
setMetaCollapsed(isNowCollapsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
mobileOverviewBar?.addEventListener("click", toggleMobileMeta);
|
||||||
|
mobileOverviewBar?.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
toggleMobileMeta(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
refreshBtn?.addEventListener("click", () => {
|
[refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]
|
||||||
refreshTVPanel();
|
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||||
});
|
.forEach((refreshEl) => {
|
||||||
|
refreshEl?.addEventListener("click", () => {
|
||||||
|
refreshTVPanel();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
liveTabBtn?.addEventListener("click", () => {
|
liveTabBtn?.addEventListener("click", () => {
|
||||||
setActiveTab("live");
|
setActiveTab("live");
|
||||||
@@ -1095,24 +1216,32 @@ export function initTVPanel() {
|
|||||||
setActiveTab("news");
|
setActiveTab("news");
|
||||||
});
|
});
|
||||||
|
|
||||||
iframe?.addEventListener("load", () => {
|
[iframe, document.getElementById("mobile-tv-iframe"), document.getElementById("tv-iframe")]
|
||||||
if (iframe.hidden) return;
|
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||||
clearSourceFailed(currentSourceId);
|
.forEach((iframeEl) => {
|
||||||
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
iframeEl?.addEventListener("load", () => {
|
||||||
});
|
if (iframeEl.hidden) return;
|
||||||
|
clearSourceFailed(currentSourceId);
|
||||||
|
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
video?.addEventListener("loadedmetadata", () => {
|
[video, document.getElementById("mobile-tv-video"), document.getElementById("tv-video")]
|
||||||
if (video.hidden) return;
|
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||||
clearSourceFailed(currentSourceId);
|
.forEach((videoEl) => {
|
||||||
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
videoEl?.addEventListener("loadedmetadata", () => {
|
||||||
});
|
if (videoEl.hidden) return;
|
||||||
|
clearSourceFailed(currentSourceId);
|
||||||
|
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
||||||
|
});
|
||||||
|
|
||||||
video?.addEventListener("error", () => {
|
videoEl?.addEventListener("error", () => {
|
||||||
const currentSource = getCurrentSource();
|
const currentSource = getCurrentSource();
|
||||||
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
||||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
setupResizeHandle();
|
setupResizeHandle();
|
||||||
syncPanelActiveTab("live");
|
syncPanelActiveTab("live");
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ function getElement(id) {
|
|||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEarthStatTargets(statKey) {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll(`[data-earth-stat="${statKey}"]`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setEarthStatValue(statKey, value) {
|
||||||
|
getEarthStatTargets(statKey).forEach((element) => {
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
element.textContent = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setElementDisplay(element, visible, displayValue = "block") {
|
function setElementDisplay(element, visible, displayValue = "block") {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
element.style.display = visible ? displayValue : "none";
|
element.style.display = visible ? displayValue : "none";
|
||||||
@@ -171,27 +185,14 @@ export function updateZoomDisplay(zoomLevel, distance) {
|
|||||||
|
|
||||||
// Update earth stats
|
// Update earth stats
|
||||||
export function updateEarthStats(stats) {
|
export function updateEarthStats(stats) {
|
||||||
const cableCountEl = getElement("cable-count");
|
setEarthStatValue("cable-count", String(stats.cableCount || 0));
|
||||||
const landingPointCountEl = getElement("landing-point-count");
|
setEarthStatValue("landing-point-count", String(stats.landingPointCount || 0));
|
||||||
const bgpAnomalyCountEl = getElement("bgp-anomaly-count");
|
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
|
||||||
const bgpCollectorCountEl = getElement("bgp-collector-count");
|
setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
|
||||||
const bgpStatusSummaryEl = getElement("bgp-status-summary");
|
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
|
||||||
const terrainStatusEl = getElement("terrain-status");
|
setEarthStatValue("bgp-status-summary", stats.bgpStatusSummary || "-");
|
||||||
const textureQualityEl = getElement("texture-quality");
|
setEarthStatValue("terrain-status", stats.terrainOn ? "开启" : "关闭");
|
||||||
|
setEarthStatValue("texture-quality", stats.textureQuality || "8K 卫星图");
|
||||||
if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
|
|
||||||
if (landingPointCountEl)
|
|
||||||
landingPointCountEl.textContent = stats.landingPointCount || 0;
|
|
||||||
if (bgpAnomalyCountEl)
|
|
||||||
bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
|
|
||||||
if (bgpCollectorCountEl)
|
|
||||||
bgpCollectorCountEl.textContent = stats.bgpCollectorCount || 0;
|
|
||||||
if (bgpStatusSummaryEl)
|
|
||||||
bgpStatusSummaryEl.textContent = stats.bgpStatusSummary || "-";
|
|
||||||
if (terrainStatusEl)
|
|
||||||
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
|
|
||||||
if (textureQualityEl)
|
|
||||||
textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide loading via status message
|
// Show/hide loading via status message
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import * as THREE from "three";
|
|||||||
|
|
||||||
import { CONFIG } from "./constants.js";
|
import { CONFIG } from "./constants.js";
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
// Convert latitude/longitude to 3D vector
|
// Convert latitude/longitude to 3D vector
|
||||||
export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
|
export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
|
||||||
const phi = (90 - lat) * (Math.PI / 180);
|
const phi = (90 - lat) * (Math.PI / 180);
|
||||||
@@ -90,3 +94,31 @@ export function calculateDistance(
|
|||||||
|
|
||||||
return radius * c;
|
return radius * c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSurfaceMarkerCameraScale(camera, options = {}) {
|
||||||
|
if (!camera) return 1;
|
||||||
|
|
||||||
|
const {
|
||||||
|
altitudeOffset = 0,
|
||||||
|
referenceFov = 75,
|
||||||
|
min = 0.12,
|
||||||
|
max = 3.0,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const cameraDistanceFromCenter = camera.position.length();
|
||||||
|
const surfaceDistance = Math.max(
|
||||||
|
1,
|
||||||
|
cameraDistanceFromCenter - (CONFIG.earthRadius + altitudeOffset),
|
||||||
|
);
|
||||||
|
const referenceSurfaceDistance = Math.max(
|
||||||
|
1,
|
||||||
|
CONFIG.defaultCameraZ - (CONFIG.earthRadius + altitudeOffset),
|
||||||
|
);
|
||||||
|
const cameraFovRad = (((camera.fov || referenceFov)) * Math.PI) / 180;
|
||||||
|
const referenceFovRad = (referenceFov * Math.PI) / 180;
|
||||||
|
const worldPerPixel = surfaceDistance * Math.tan(cameraFovRad / 2);
|
||||||
|
const referenceWorldPerPixel =
|
||||||
|
referenceSurfaceDistance * Math.tan(referenceFovRad / 2);
|
||||||
|
|
||||||
|
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const Earth = lazy(() => import('./pages/Earth/Earth'))
|
|||||||
const Settings = lazy(() => import('./pages/Settings/Settings'))
|
const Settings = lazy(() => import('./pages/Settings/Settings'))
|
||||||
const BGP = lazy(() => import('./pages/BGP/BGP'))
|
const BGP = lazy(() => import('./pages/BGP/BGP'))
|
||||||
const Playground = lazy(() => import('./pages/Playground/Playground'))
|
const Playground = lazy(() => import('./pages/Playground/Playground'))
|
||||||
|
const Logs = lazy(() => import('./pages/Logs/Logs'))
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { token } = useAuthStore()
|
const { token } = useAuthStore()
|
||||||
@@ -48,6 +49,7 @@ function App() {
|
|||||||
<Route path="/alerts/situational" element={<SituationalAlerts />} />
|
<Route path="/alerts/situational" element={<SituationalAlerts />} />
|
||||||
<Route path="/bgp" element={<BGP />} />
|
<Route path="/bgp" element={<BGP />} />
|
||||||
<Route path="/playground" element={<Playground />} />
|
<Route path="/playground" element={<Playground />} />
|
||||||
|
<Route path="/logs" element={<Logs />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||||
@@ -39,6 +40,7 @@ function AppLayout({ children }: AppLayoutProps) {
|
|||||||
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
|
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
|
||||||
const showBanner = true
|
const showBanner = true
|
||||||
const appVersion = `v${packageJson.version}`
|
const appVersion = `v${packageJson.version}`
|
||||||
|
const isSuperAdmin = user?.role === 'super_admin'
|
||||||
|
|
||||||
const menuItems: ItemType<MenuItemType>[] = [
|
const menuItems: ItemType<MenuItemType>[] = [
|
||||||
{
|
{
|
||||||
@@ -83,6 +85,7 @@ function AppLayout({ children }: AppLayoutProps) {
|
|||||||
label: '运维与配置',
|
label: '运维与配置',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
|
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
|
||||||
|
...(isSuperAdmin ? [{ key: '/logs', icon: <FileTextOutlined />, label: '系统日志' }] : []),
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3414,8 +3414,365 @@ body {
|
|||||||
max-height: 180px;
|
max-height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-log-console {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-console__actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-console__actions .ant-btn {
|
||||||
|
color: rgba(226, 232, 240, 0.78) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-console__actions .ant-btn:hover {
|
||||||
|
color: #f8fafc !important;
|
||||||
|
background: rgba(148, 163, 184, 0.16) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-console__scroll,
|
||||||
|
.system-log-console__scroll .scrollbar__viewport {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-console__scroll,
|
||||||
|
.system-log-console__scroll .scrollbar__viewport,
|
||||||
|
.system-log-console__content,
|
||||||
|
.system-log-console__placeholder {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-console__content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 18px 124px 18px 20px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-console__placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page {
|
||||||
|
--logs-filter-toggle-size: 32px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__header-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page .page-shell__header {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__header-desc {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__card {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__card .ant-card-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__card-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__console-shell {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 249, 252, 0.98) 100%);
|
||||||
|
border: 1px solid rgba(5, 5, 5, 0.07);
|
||||||
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__toolbar-row--primary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 220px) minmax(220px, 280px) minmax(280px, 1fr) var(--logs-filter-toggle-size);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__source-select {
|
||||||
|
width: 240px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__search-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__level-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__date-range {
|
||||||
|
width: 248px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__line-limit-select {
|
||||||
|
width: 156px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__filter-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
align-self: center;
|
||||||
|
width: var(--logs-filter-toggle-size);
|
||||||
|
height: var(--logs-filter-toggle-size);
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__filter-toggle:hover {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
border-color: rgba(5, 5, 5, 0.16);
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__filter-toggle.is-expanded {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: rgba(22, 119, 255, 0.28);
|
||||||
|
background: rgba(22, 119, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__filters-panel {
|
||||||
|
border-top: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__toolbar-row--secondary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 156px minmax(260px, 320px) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__preset-group {
|
||||||
|
display: flex;
|
||||||
|
align-self: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__preset-group .ant-space-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__preset-group .ant-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__calendar-cell {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__calendar-cell .ant-picker-cell-inner {
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.18s ease, box-shadow 0.18s ease, color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__calendar-cell--error .ant-picker-cell-inner {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__calendar-cell--warning .ant-picker-cell-inner {
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__calendar-cell--info .ant-picker-cell-inner {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__calendar-cell--debug .ant-picker-cell-inner {
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__calendar-cell:hover .ant-picker-cell-inner {
|
||||||
|
filter: saturate(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__line-limit-customizer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
border-top: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1440px), (max-height: 900px) {
|
||||||
|
.logs-page {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__header-desc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__card .ant-card-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__toolbar {
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__toolbar-row--primary {
|
||||||
|
grid-template-columns: minmax(160px, 200px) minmax(180px, 220px) minmax(0, 1fr) var(--logs-filter-toggle-size);
|
||||||
|
grid-template-areas:
|
||||||
|
"source level search toggle";
|
||||||
|
row-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__source-select,
|
||||||
|
.logs-page__line-limit-select,
|
||||||
|
.logs-page__level-select,
|
||||||
|
.logs-page__search-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__source-select {
|
||||||
|
grid-area: source;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__level-select {
|
||||||
|
grid-area: level;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__search-input {
|
||||||
|
grid-area: search;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__filter-toggle {
|
||||||
|
grid-area: toggle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__toolbar-row--secondary {
|
||||||
|
grid-template-columns: 140px minmax(240px, 280px) minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__date-range {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__console-shell {
|
||||||
|
min-height: clamp(340px, 58vh, 760px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-console__content {
|
||||||
|
padding: 16px 112px 16px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dashboard-restart-toolbar__meta {
|
.dashboard-restart-toolbar__meta {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logs-page__toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__card .ant-card-body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__toolbar-row--primary,
|
||||||
|
.logs-page__toolbar-row--secondary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__source-select,
|
||||||
|
.logs-page__search-input,
|
||||||
|
.logs-page__level-select,
|
||||||
|
.logs-page__date-range,
|
||||||
|
.logs-page__line-limit-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-page__console-shell {
|
||||||
|
min-height: clamp(280px, 50vh, 620px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
533
frontend/src/pages/Logs/Logs.tsx
Normal file
533
frontend/src/pages/Logs/Logs.tsx
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Alert, Button, Card, DatePicker, Empty, Input, InputNumber, Select, Space, Spin, Tag, Tooltip, Typography, message } from 'antd'
|
||||||
|
import { CopyOutlined, DownOutlined, InfoCircleOutlined, ReloadOutlined, UpOutlined } from '@ant-design/icons'
|
||||||
|
import axios from 'axios'
|
||||||
|
import dayjs, { Dayjs } from 'dayjs'
|
||||||
|
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
|
||||||
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const { Paragraph, Text, Title } = Typography
|
||||||
|
const { RangePicker } = DatePicker
|
||||||
|
const LOG_FILTER_STORAGE_KEY = 'planet.logs.filters'
|
||||||
|
const DATE_PRESET_OPTIONS = [
|
||||||
|
{ key: 'today', label: 'Today', days: 0 },
|
||||||
|
{ key: 'last3', label: '3 Days', days: 2 },
|
||||||
|
{ key: 'last7', label: '7 Days', days: 6 },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
interface LogSourceSummary {
|
||||||
|
source_id: string
|
||||||
|
name: string
|
||||||
|
kind: string
|
||||||
|
location: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogSourcesResponse {
|
||||||
|
items: LogSourceSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogSnapshot {
|
||||||
|
source_id: string
|
||||||
|
name: string
|
||||||
|
kind: string
|
||||||
|
location: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
status: string
|
||||||
|
level: string
|
||||||
|
selected_levels: string[]
|
||||||
|
search_query: string
|
||||||
|
available_levels: string[]
|
||||||
|
daily_markers: Array<{
|
||||||
|
date_token: string
|
||||||
|
total: number
|
||||||
|
dominant_level: 'error' | 'warning' | 'info' | 'debug'
|
||||||
|
}>
|
||||||
|
line_limit: number
|
||||||
|
line_count: number
|
||||||
|
lines: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyLogMarker {
|
||||||
|
total: number
|
||||||
|
dominantLevel: 'error' | 'warning' | 'info' | 'debug'
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_LIMIT_OPTIONS = [100, 200, 400, 800]
|
||||||
|
const LOG_LEVEL_OPTIONS = [
|
||||||
|
{ value: 'error', label: 'ERROR' },
|
||||||
|
{ value: 'warning', label: 'WARNING' },
|
||||||
|
{ value: 'info', label: 'INFO' },
|
||||||
|
{ value: 'debug', label: 'DEBUG' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function isDayjsValue(value: unknown): value is Dayjs {
|
||||||
|
return dayjs.isDayjs(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSelectedLevels(levels: string[] | null | undefined): string[] {
|
||||||
|
const allowedLevels = new Set(LOG_LEVEL_OPTIONS.map((item) => item.value))
|
||||||
|
return Array.from(new Set((levels || []).filter((level) => allowedLevels.has(level))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogLevelTagColor(level: string): string {
|
||||||
|
if (level === 'error') return 'error'
|
||||||
|
if (level === 'warning') return 'warning'
|
||||||
|
if (level === 'info') return 'success'
|
||||||
|
if (level === 'debug') return 'default'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredFilters() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawValue = window.localStorage.getItem(LOG_FILTER_STORAGE_KEY)
|
||||||
|
if (!rawValue) return null
|
||||||
|
const parsed = JSON.parse(rawValue) as {
|
||||||
|
selectedSource?: string
|
||||||
|
lineLimit?: number
|
||||||
|
selectedLevels?: string[]
|
||||||
|
selectedDateRange?: [string, string] | null
|
||||||
|
searchQuery?: string
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string): string {
|
||||||
|
if (status === 'ok') return '可用'
|
||||||
|
if (status === 'missing') return '暂无日志'
|
||||||
|
if (status === 'empty') return '暂无上报'
|
||||||
|
if (status === 'docker_unavailable') return 'Docker 不可用'
|
||||||
|
if (status === 'source_unavailable') return '日志源不可用'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusHelp(status: string): string | null {
|
||||||
|
if (status === 'missing') return '当前日志文件尚未生成,通常需要先启动对应服务。'
|
||||||
|
if (status === 'empty') return '当前日志源还没有收到任何上报事件。'
|
||||||
|
if (status === 'docker_unavailable') return '当前环境没有可用的 docker 命令,暂时无法读取容器日志。'
|
||||||
|
if (status === 'source_unavailable') return '日志源当前不可读取,请检查服务是否已启动。'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresetRange(days: number): [Dayjs, Dayjs] {
|
||||||
|
const end = dayjs().endOf('day')
|
||||||
|
const start = dayjs().subtract(days, 'day').startOf('day')
|
||||||
|
return [start, end]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateRange(
|
||||||
|
range: [Dayjs | null, Dayjs | null] | null,
|
||||||
|
): [Dayjs | null, Dayjs | null] | null {
|
||||||
|
if (!range?.[0] || !range?.[1]) return null
|
||||||
|
return [range[0].startOf('day'), range[1].endOf('day')]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveDatePreset(range: [Dayjs | null, Dayjs | null] | null): string | null {
|
||||||
|
if (!range?.[0] || !range?.[1]) return null
|
||||||
|
|
||||||
|
for (const option of DATE_PRESET_OPTIONS) {
|
||||||
|
const [presetStart, presetEnd] = resolvePresetRange(option.days)
|
||||||
|
if (range[0].isSame(presetStart, 'day') && range[1].isSame(presetEnd, 'day')) {
|
||||||
|
return option.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Logs() {
|
||||||
|
const storedFilters = readStoredFilters()
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const isSuperAdmin = user?.role === 'super_admin'
|
||||||
|
const [sources, setSources] = useState<LogSourceSummary[]>([])
|
||||||
|
const [selectedSource, setSelectedSource] = useState<string>(storedFilters?.selectedSource || 'backend')
|
||||||
|
const [lineLimit, setLineLimit] = useState<number>(storedFilters?.lineLimit || 200)
|
||||||
|
const [selectedLevels, setSelectedLevels] = useState<string[]>(
|
||||||
|
normalizeSelectedLevels(storedFilters?.selectedLevels),
|
||||||
|
)
|
||||||
|
const [selectedDateRange, setSelectedDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(
|
||||||
|
storedFilters?.selectedDateRange
|
||||||
|
? normalizeDateRange([dayjs(storedFilters.selectedDateRange[0]), dayjs(storedFilters.selectedDateRange[1])])
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>(typeof storedFilters?.searchQuery === 'string' ? storedFilters.searchQuery : '')
|
||||||
|
const [snapshot, setSnapshot] = useState<LogSnapshot | null>(null)
|
||||||
|
const [sourcesLoading, setSourcesLoading] = useState(false)
|
||||||
|
const [logLoading, setLogLoading] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [filtersExpanded, setFiltersExpanded] = useState(
|
||||||
|
Boolean(
|
||||||
|
storedFilters?.selectedLevels?.length
|
||||||
|
|| (storedFilters?.selectedDateRange?.[0] && storedFilters?.selectedDateRange?.[1]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
|
const fetchSources = async () => {
|
||||||
|
setSourcesLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await axios.get<LogSourcesResponse>('/api/v1/system/logs/sources')
|
||||||
|
setSources(res.data.items)
|
||||||
|
setErrorMessage(null)
|
||||||
|
if (res.data.items.length > 0 && !res.data.items.some((item) => item.source_id === selectedSource)) {
|
||||||
|
setSelectedSource(res.data.items[0].source_id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
|
||||||
|
setErrorMessage(typeof detail === 'string' ? detail : '加载日志源失败')
|
||||||
|
} finally {
|
||||||
|
setSourcesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSnapshot = async (
|
||||||
|
sourceId: string,
|
||||||
|
limit: number,
|
||||||
|
levels: string[],
|
||||||
|
dateRange: [Dayjs | null, Dayjs | null] | null,
|
||||||
|
searchValue: string,
|
||||||
|
) => {
|
||||||
|
setLogLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await axios.get<LogSnapshot>(`/api/v1/system/logs/${sourceId}`, {
|
||||||
|
params: {
|
||||||
|
limit,
|
||||||
|
level: levels.length === 1 ? levels[0] : 'all',
|
||||||
|
levels: levels.length > 0 ? levels.join(',') : undefined,
|
||||||
|
start_date: dateRange?.[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
|
||||||
|
end_date: dateRange?.[1] ? dateRange[1].format('YYYY-MM-DD') : undefined,
|
||||||
|
search: searchValue.trim() || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setSnapshot(res.data)
|
||||||
|
setErrorMessage(null)
|
||||||
|
} catch (error) {
|
||||||
|
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
|
||||||
|
setSnapshot(null)
|
||||||
|
setErrorMessage(typeof detail === 'string' ? detail : '加载日志内容失败')
|
||||||
|
} finally {
|
||||||
|
setLogLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSuperAdmin) return
|
||||||
|
fetchSources()
|
||||||
|
}, [isSuperAdmin])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSuperAdmin || !selectedSource) return
|
||||||
|
fetchSnapshot(selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery)
|
||||||
|
}, [isSuperAdmin, selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(
|
||||||
|
LOG_FILTER_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
selectedSource,
|
||||||
|
lineLimit,
|
||||||
|
selectedLevels,
|
||||||
|
selectedDateRange:
|
||||||
|
selectedDateRange?.[0] && selectedDateRange?.[1]
|
||||||
|
? [
|
||||||
|
selectedDateRange[0].format('YYYY-MM-DD'),
|
||||||
|
selectedDateRange[1].format('YYYY-MM-DD'),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
searchQuery,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, [lineLimit, searchQuery, selectedLevels, selectedDateRange, selectedSource])
|
||||||
|
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<Alert type="warning" showIcon message="仅超级管理员可查看系统日志" />
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedMeta = sources.find((item) => item.source_id === selectedSource)
|
||||||
|
const statusHelp = getStatusHelp(snapshot?.status || selectedMeta?.status || '')
|
||||||
|
const activeDatePreset = getActiveDatePreset(selectedDateRange)
|
||||||
|
const dailyLogMarkers = useMemo(
|
||||||
|
() =>
|
||||||
|
new Map<string, DailyLogMarker>(
|
||||||
|
(snapshot?.daily_markers || []).map((marker) => [
|
||||||
|
marker.date_token,
|
||||||
|
{
|
||||||
|
total: marker.total,
|
||||||
|
dominantLevel: marker.dominant_level,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
[snapshot?.daily_markers],
|
||||||
|
)
|
||||||
|
const currentResultLines = snapshot?.lines || []
|
||||||
|
const lineCountLabel = currentResultLines.length
|
||||||
|
const hasDateFilter = Boolean(selectedDateRange?.[0] && selectedDateRange?.[1])
|
||||||
|
const hasAdvancedFilters = selectedLevels.length > 0 || hasDateFilter
|
||||||
|
const effectiveLevelLabels = selectedLevels.length === 0
|
||||||
|
? ['ALL']
|
||||||
|
: normalizeSelectedLevels(selectedLevels).map(
|
||||||
|
(level) => LOG_LEVEL_OPTIONS.find((item) => item.value === level)?.label || level.toUpperCase(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyDatePreset = (days: number) => {
|
||||||
|
setSelectedDateRange(resolvePresetRange(days))
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderLevelTag = (props: CustomTagProps) => {
|
||||||
|
const { label, value, closable, onClose } = props
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
color={getLogLevelTagColor(String(value))}
|
||||||
|
closable={closable}
|
||||||
|
onClose={onClose}
|
||||||
|
style={{ marginInlineEnd: 4 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
{contextHolder}
|
||||||
|
<div className="page-shell logs-page">
|
||||||
|
<div className="page-shell__header">
|
||||||
|
<div className="logs-page__header-copy">
|
||||||
|
<Title level={3} style={{ marginBottom: 2 }}>系统日志</Title>
|
||||||
|
<Paragraph type="secondary" className="logs-page__header-desc" style={{ marginBottom: 0 }}>
|
||||||
|
统一查看 Planet 当前关键服务日志,并串联 Earth 浏览器端错误、后端异常与服务输出。
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-shell__body">
|
||||||
|
<Card className="logs-page__card">
|
||||||
|
<div className="logs-page__card-body">
|
||||||
|
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||||
|
|
||||||
|
<div className="logs-page__toolbar">
|
||||||
|
<div className="logs-page__toolbar-row logs-page__toolbar-row--primary">
|
||||||
|
<Select
|
||||||
|
value={selectedSource}
|
||||||
|
onChange={(value) => setSelectedSource(value)}
|
||||||
|
loading={sourcesLoading}
|
||||||
|
className="logs-page__source-select"
|
||||||
|
options={sources.map((item) => ({
|
||||||
|
value: item.source_id,
|
||||||
|
label: item.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
value={selectedLevels}
|
||||||
|
onChange={(value) => setSelectedLevels(normalizeSelectedLevels(value))}
|
||||||
|
options={LOG_LEVEL_OPTIONS}
|
||||||
|
className="logs-page__level-select"
|
||||||
|
maxTagCount="responsive"
|
||||||
|
allowClear
|
||||||
|
tagRender={renderLevelTag}
|
||||||
|
placeholder="全部级别"
|
||||||
|
/>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
onSearch={(value) => setSearchQuery(value)}
|
||||||
|
placeholder="搜索日志内容、模块名、错误关键字"
|
||||||
|
className="logs-page__search-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`logs-page__filter-toggle ${filtersExpanded ? 'is-expanded' : ''}`}
|
||||||
|
onClick={() => setFiltersExpanded((value) => !value)}
|
||||||
|
aria-expanded={filtersExpanded}
|
||||||
|
aria-label={filtersExpanded ? '收起筛选' : '展开筛选'}
|
||||||
|
title={hasAdvancedFilters ? '筛选已启用' : '更多筛选'}
|
||||||
|
>
|
||||||
|
{filtersExpanded ? <UpOutlined /> : <DownOutlined />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtersExpanded ? (
|
||||||
|
<div className="logs-page__filters-panel">
|
||||||
|
<div className="logs-page__toolbar-row logs-page__toolbar-row--secondary">
|
||||||
|
<Select
|
||||||
|
value={lineLimit}
|
||||||
|
onChange={(value) => setLineLimit(Number(value))}
|
||||||
|
options={LOG_LIMIT_OPTIONS.map((value) => ({ value, label: `最近 ${value} 行` }))}
|
||||||
|
className="logs-page__line-limit-select"
|
||||||
|
popupRender={(menu) => (
|
||||||
|
<>
|
||||||
|
{menu}
|
||||||
|
<div className="logs-page__line-limit-customizer">
|
||||||
|
<Text type="secondary">自定义行数</Text>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
value={lineLimit}
|
||||||
|
onChange={(value) => setLineLimit(Number(value) || 200)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<RangePicker
|
||||||
|
allowClear
|
||||||
|
value={selectedDateRange}
|
||||||
|
onChange={(value) => setSelectedDateRange(normalizeDateRange(value as [Dayjs | null, Dayjs | null] | null))}
|
||||||
|
cellRender={(current, info) => {
|
||||||
|
if (info.type !== 'date' || !isDayjsValue(current)) return info.originNode
|
||||||
|
|
||||||
|
const marker = dailyLogMarkers.get(current.format('YYYY-MM-DD'))
|
||||||
|
if (!marker) return info.originNode
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`logs-page__calendar-cell logs-page__calendar-cell--${marker.dominantLevel}`}
|
||||||
|
title={`${current.format('YYYY-MM-DD')} · ${marker.total} lines`}
|
||||||
|
>
|
||||||
|
{info.originNode}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
placeholder={['开始日期', '结束日期']}
|
||||||
|
className="logs-page__date-range"
|
||||||
|
/>
|
||||||
|
<Space size={6} className="logs-page__preset-group">
|
||||||
|
{DATE_PRESET_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.key}
|
||||||
|
size="small"
|
||||||
|
type={activeDatePreset === option.key ? 'primary' : 'default'}
|
||||||
|
onClick={() => applyDatePreset(option.days)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSelectedDateRange(null)}
|
||||||
|
disabled={!hasDateFilter}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="logs-page__console-shell">
|
||||||
|
<div className="system-log-console">
|
||||||
|
<div className="system-log-console__actions">
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<div><strong>{snapshot?.name || selectedMeta?.name || '未选择日志源'}</strong></div>
|
||||||
|
<div>状态: {getStatusLabel(snapshot?.status || selectedMeta?.status || 'default')}</div>
|
||||||
|
<div>类型: {(snapshot?.kind || selectedMeta?.kind || 'unknown').toUpperCase()}</div>
|
||||||
|
<div>当前显示: {lineCountLabel} 行</div>
|
||||||
|
{selectedLevels.length > 0 ? <div>级别: {effectiveLevelLabels.join(' / ')}</div> : null}
|
||||||
|
{selectedDateRange?.[0] && selectedDateRange?.[1]
|
||||||
|
? <div>日期: {selectedDateRange[0].format('YYYY-MM-DD')} ~ {selectedDateRange[1].format('YYYY-MM-DD')}</div>
|
||||||
|
: null}
|
||||||
|
{searchQuery.trim() ? <div>检索: {searchQuery.trim()}</div> : null}
|
||||||
|
<div>{snapshot?.description || selectedMeta?.description || '-'}</div>
|
||||||
|
<div>位置: {snapshot?.location || selectedMeta?.location || '-'}</div>
|
||||||
|
{statusHelp ? <div>{statusHelp}</div> : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
shape="circle"
|
||||||
|
icon={<InfoCircleOutlined />}
|
||||||
|
className="playground-message__actions-btn"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="刷新日志">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
shape="circle"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
className="playground-message__actions-btn"
|
||||||
|
onClick={() => {
|
||||||
|
void fetchSources()
|
||||||
|
if (selectedSource) {
|
||||||
|
void fetchSnapshot(selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="复制日志">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
shape="circle"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
className="playground-message__actions-btn"
|
||||||
|
onClick={async () => {
|
||||||
|
const content = currentResultLines.join('\n')
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content)
|
||||||
|
messageApi.success('日志内容已复制')
|
||||||
|
} catch {
|
||||||
|
messageApi.error('复制失败,请检查浏览器剪贴板权限')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={currentResultLines.length === 0}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{logLoading ? (
|
||||||
|
<div className="system-log-console__placeholder">
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : currentResultLines.length > 0 ? (
|
||||||
|
<Scrollbar className="system-log-console__scroll">
|
||||||
|
<pre className="system-log-console__content">{currentResultLines.join('\n')}</pre>
|
||||||
|
</Scrollbar>
|
||||||
|
) : (
|
||||||
|
<div className="system-log-console__placeholder">
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={
|
||||||
|
selectedDateRange?.[0] && selectedDateRange?.[1]
|
||||||
|
? '当前日期范围没有匹配的日志内容'
|
||||||
|
: '当前没有可显示的日志内容'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Logs
|
||||||
@@ -21,5 +21,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.tooling.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
154
planet.sh
154
planet.sh
@@ -59,6 +59,7 @@ DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
|
|||||||
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
|
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
|
||||||
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
|
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
|
||||||
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
|
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
|
||||||
|
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
|
||||||
AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256"
|
AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256"
|
||||||
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
|
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
|
||||||
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
|
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
|
||||||
@@ -371,6 +372,41 @@ log_success() {
|
|||||||
log_line "done" "$GREEN" "$1"
|
log_line "done" "$GREEN" "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_recommended_lan_ipv4() {
|
||||||
|
local candidate
|
||||||
|
|
||||||
|
while read -r candidate; do
|
||||||
|
[ -z "$candidate" ] && continue
|
||||||
|
case "$candidate" in
|
||||||
|
127.*|169.254.*|172.17.*|172.18.*|198.18.*|198.19.*|10.255.*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
|
||||||
|
printf "%s" "$candidate"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<EOF
|
||||||
|
$(hostname -I 2>/dev/null | tr ' ' '\n')
|
||||||
|
EOF
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
log_lan_access_notes() {
|
||||||
|
local frontend_port="$1"
|
||||||
|
local backend_port="$2"
|
||||||
|
local recommended_lan_ip=""
|
||||||
|
|
||||||
|
if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then
|
||||||
|
log_note "推荐访问地址: http://${recommended_lan_ip}:${frontend_port}"
|
||||||
|
log_note "后端健康检查: http://${recommended_lan_ip}:${backend_port}/health"
|
||||||
|
else
|
||||||
|
log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${frontend_port}"
|
||||||
|
log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${backend_port}/health"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
print_splash() {
|
print_splash() {
|
||||||
clear_wait_spinner
|
clear_wait_spinner
|
||||||
printf "%b" "$CYAN"
|
printf "%b" "$CYAN"
|
||||||
@@ -484,12 +520,13 @@ print_database_failure_diagnostics() {
|
|||||||
compute_ai_provider_build_fingerprint() {
|
compute_ai_provider_build_fingerprint() {
|
||||||
(
|
(
|
||||||
cd "$SCRIPT_DIR" || exit 1
|
cd "$SCRIPT_DIR" || exit 1
|
||||||
tar -cf - \
|
{
|
||||||
aiprovider \
|
tar -cf - \
|
||||||
pyproject.toml \
|
aiprovider \
|
||||||
uv.lock \
|
docker-compose.yml \
|
||||||
docker-compose.yml \
|
docker-compose.simple.yml 2>/dev/null
|
||||||
docker-compose.simple.yml 2>/dev/null
|
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py"
|
||||||
|
}
|
||||||
) | sha256sum | awk '{print $1}'
|
) | sha256sum | awk '{print $1}'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,8 +803,8 @@ ensure_frontend_deps() {
|
|||||||
|
|
||||||
cd "$SCRIPT_DIR/frontend"
|
cd "$SCRIPT_DIR/frontend"
|
||||||
|
|
||||||
set_wait_detail "检查 vite 是否已安装"
|
set_wait_detail "检查 Vite Bun 入口是否已安装"
|
||||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
||||||
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
|
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
|
||||||
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
|
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
|
||||||
if ! run_with_retry \
|
if ! run_with_retry \
|
||||||
@@ -780,9 +817,9 @@ ensure_frontend_deps() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
||||||
close_wait_session_context "$owns_wait_session"
|
close_wait_session_context "$owns_wait_session"
|
||||||
log_error "前端依赖安装失败,未找到 vite"
|
log_error "前端依赖安装失败,未找到 Vite Bun 入口"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -988,7 +1025,14 @@ start_postgres_service() {
|
|||||||
|
|
||||||
# Backend lifecycle helpers
|
# Backend lifecycle helpers
|
||||||
cleanup_backend_processes() {
|
cleanup_backend_processes() {
|
||||||
pkill -f "uvicorn" 2>/dev/null || true
|
local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
|
||||||
|
terminate_backend_processes TERM "$backend_port"
|
||||||
|
|
||||||
|
if ! wait_for_port_release "$backend_port"; then
|
||||||
|
terminate_backend_processes KILL "$backend_port"
|
||||||
|
|
||||||
|
wait_for_port_release "$backend_port" || true
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
start_backend_with_retry() {
|
start_backend_with_retry() {
|
||||||
@@ -996,8 +1040,9 @@ start_backend_with_retry() {
|
|||||||
local retry=1
|
local retry=1
|
||||||
|
|
||||||
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
|
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
|
||||||
cleanup_backend_processes
|
cleanup_backend_processes "$backend_port"
|
||||||
cd "$SCRIPT_DIR/backend"
|
cd "$SCRIPT_DIR/backend"
|
||||||
|
: > /tmp/planet_backend.log
|
||||||
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
|
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
@@ -1205,6 +1250,39 @@ collect_port_pids() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
terminate_process_group() {
|
||||||
|
local signal="$1"
|
||||||
|
local pid="$2"
|
||||||
|
local pgid=""
|
||||||
|
|
||||||
|
[ -n "$pid" ] || return 0
|
||||||
|
kill -0 "$pid" 2>/dev/null || return 0
|
||||||
|
|
||||||
|
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
|
||||||
|
[ -n "$pgid" ] || return 0
|
||||||
|
|
||||||
|
kill "-${signal}" -- "-${pgid}" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate_backend_processes() {
|
||||||
|
local signal="$1"
|
||||||
|
local backend_port="$2"
|
||||||
|
local pids=""
|
||||||
|
local pid=""
|
||||||
|
|
||||||
|
pids="$(pgrep -f "uvicorn" 2>/dev/null || true)"
|
||||||
|
for pid in $pids; do
|
||||||
|
terminate_process_group "$signal" "$pid"
|
||||||
|
terminate_process_tree "$signal" "$pid"
|
||||||
|
done
|
||||||
|
|
||||||
|
pids="$(collect_port_pids "$backend_port" || true)"
|
||||||
|
for pid in $pids; do
|
||||||
|
terminate_process_group "$signal" "$pid"
|
||||||
|
terminate_process_tree "$signal" "$pid"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
terminate_process_tree() {
|
terminate_process_tree() {
|
||||||
local signal="$1"
|
local signal="$1"
|
||||||
local pid="$2"
|
local pid="$2"
|
||||||
@@ -1324,15 +1402,15 @@ cleanup_frontend_processes() {
|
|||||||
rm -f "$FRONTEND_PID_FILE"
|
rm -f "$FRONTEND_PID_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite --port ${frontend_port} --strictPort" 2>/dev/null || true
|
pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true
|
||||||
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite" 2>/dev/null || true
|
pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true
|
||||||
pkill -f "bun run dev --port ${frontend_port}" 2>/dev/null || true
|
pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true
|
||||||
pkill -f "bun run dev" 2>/dev/null || true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start_frontend_with_retry() {
|
start_frontend_with_retry() {
|
||||||
local frontend_port="$1"
|
local frontend_port="$1"
|
||||||
local frontend_port_requested="${2:-0}"
|
local frontend_port_requested="${2:-0}"
|
||||||
|
local frontend_lan_enabled="${3:-0}"
|
||||||
local retry=1
|
local retry=1
|
||||||
|
|
||||||
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
|
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
|
||||||
@@ -1342,7 +1420,13 @@ start_frontend_with_retry() {
|
|||||||
fi
|
fi
|
||||||
cd "$SCRIPT_DIR/frontend"
|
cd "$SCRIPT_DIR/frontend"
|
||||||
: > /tmp/planet_frontend.log
|
: > /tmp/planet_frontend.log
|
||||||
nohup "$FRONTEND_RUNTIME_BIN" run dev --port "$frontend_port" --strictPort > /tmp/planet_frontend.log 2>&1 &
|
local -a frontend_args
|
||||||
|
frontend_args=("$FRONTEND_VITE_ENTRY")
|
||||||
|
if [ "$frontend_lan_enabled" -eq 1 ]; then
|
||||||
|
frontend_args+=(--host 0.0.0.0)
|
||||||
|
fi
|
||||||
|
frontend_args+=(--port "$frontend_port" --strictPort)
|
||||||
|
nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > /tmp/planet_frontend.log 2>&1 &
|
||||||
FRONTEND_PID=$!
|
FRONTEND_PID=$!
|
||||||
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
|
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
|
||||||
|
|
||||||
@@ -1368,6 +1452,7 @@ start_frontend_with_retry() {
|
|||||||
start_frontend_service() {
|
start_frontend_service() {
|
||||||
local frontend_port="$1"
|
local frontend_port="$1"
|
||||||
local frontend_port_requested="$2"
|
local frontend_port_requested="$2"
|
||||||
|
local frontend_lan_enabled="${3:-0}"
|
||||||
|
|
||||||
if [ "$frontend_port_requested" -eq 1 ]; then
|
if [ "$frontend_port_requested" -eq 1 ]; then
|
||||||
kill_port_if_requested "$frontend_port" "前端"
|
kill_port_if_requested "$frontend_port" "前端"
|
||||||
@@ -1379,8 +1464,12 @@ start_frontend_service() {
|
|||||||
log_success "前端依赖已就绪"
|
log_success "前端依赖已就绪"
|
||||||
|
|
||||||
start_wait_session "启动前端服务"
|
start_wait_session "启动前端服务"
|
||||||
set_wait_detail "启动 Vite 开发服务器"
|
if [ "$frontend_lan_enabled" -eq 1 ]; then
|
||||||
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then
|
set_wait_detail "启动 Vite 开发服务器(局域网开放)"
|
||||||
|
else
|
||||||
|
set_wait_detail "启动 Vite 开发服务器"
|
||||||
|
fi
|
||||||
|
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then
|
||||||
stop_wait_session
|
stop_wait_session
|
||||||
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
|
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
|
||||||
tail -10 /tmp/planet_frontend.log
|
tail -10 /tmp/planet_frontend.log
|
||||||
@@ -1399,6 +1488,7 @@ parse_service_args() {
|
|||||||
FRONTEND_PORT_REQUESTED=0
|
FRONTEND_PORT_REQUESTED=0
|
||||||
AI_PROVIDER_REQUESTED=0
|
AI_PROVIDER_REQUESTED=0
|
||||||
DATABASE_REQUESTED=0
|
DATABASE_REQUESTED=0
|
||||||
|
FRONTEND_LAN_ENABLED=0
|
||||||
|
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -1433,6 +1523,10 @@ parse_service_args() {
|
|||||||
DATABASE_REQUESTED=1
|
DATABASE_REQUESTED=1
|
||||||
shift 1
|
shift 1
|
||||||
;;
|
;;
|
||||||
|
--allow-lan)
|
||||||
|
FRONTEND_LAN_ENABLED=1
|
||||||
|
shift 1
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
log_error "未知参数: $1"
|
log_error "未知参数: $1"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -1468,8 +1562,8 @@ stop_container_if_running() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop_backend_service() {
|
stop_backend_service() {
|
||||||
if pgrep -f "uvicorn" >/dev/null 2>&1; then
|
if pgrep -f "uvicorn" >/dev/null 2>&1 || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
|
||||||
cleanup_backend_processes
|
cleanup_backend_processes "$DEFAULT_BACKEND_PORT"
|
||||||
log_halt "后端服务已停止"
|
log_halt "后端服务已停止"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -1479,7 +1573,7 @@ stop_ai_provider_service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop_frontend_service() {
|
stop_frontend_service() {
|
||||||
if pgrep -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite|bun run dev" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
|
if pgrep -f "${FRONTEND_VITE_ENTRY}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
|
||||||
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
|
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
|
||||||
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
|
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
|
||||||
cleanup_frontend_processes "$FRONTEND_PORT"
|
cleanup_frontend_processes "$FRONTEND_PORT"
|
||||||
@@ -1607,13 +1701,16 @@ start() {
|
|||||||
print_splash
|
print_splash
|
||||||
|
|
||||||
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
|
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
|
||||||
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
|
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
|
||||||
|
|
||||||
log_success "启动完成"
|
log_success "启动完成"
|
||||||
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
|
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
|
||||||
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
|
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
|
||||||
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
|
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
|
||||||
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
|
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
|
||||||
|
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
|
||||||
|
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
@@ -1633,7 +1730,7 @@ restart() {
|
|||||||
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
|
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
|
||||||
stop
|
stop
|
||||||
sleep 1
|
sleep 1
|
||||||
start
|
start "$@"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1658,7 +1755,7 @@ restart() {
|
|||||||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||||||
stop_frontend_service
|
stop_frontend_service
|
||||||
sleep 1
|
sleep 1
|
||||||
start_frontend_service "$FRONTEND_PORT" 1
|
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1674,6 +1771,9 @@ restart() {
|
|||||||
fi
|
fi
|
||||||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||||||
log_note "前端: http://localhost:${FRONTEND_PORT}"
|
log_note "前端: http://localhost:${FRONTEND_PORT}"
|
||||||
|
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
|
||||||
|
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1754,9 +1854,9 @@ case "$1" in
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
|
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
|
||||||
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口>"
|
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --allow-lan"
|
||||||
log_note "stop 停止服务"
|
log_note "stop 停止服务"
|
||||||
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d"
|
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan"
|
||||||
log_note "createuser 交互创建用户"
|
log_note "createuser 交互创建用户"
|
||||||
log_note "health 检查健康状态"
|
log_note "health 检查健康状态"
|
||||||
log_note "log 查看日志"
|
log_note "log 查看日志"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.33.0"
|
version = "0.39.0"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
186
scripts/compute_aiprovider_dependency_fingerprint.py
Normal file
186
scripts/compute_aiprovider_dependency_fingerprint.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
IMPORT_TO_DISTRIBUTION = {
|
||||||
|
"pydantic_settings": "pydantic-settings",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Some runtime dependencies are referenced indirectly:
|
||||||
|
# - uvicorn is launched by the container command rather than imported.
|
||||||
|
# - python-dotenv is used by pydantic-settings when loading the local .env file.
|
||||||
|
EXTRA_RUNTIME_DISTRIBUTIONS = {
|
||||||
|
"python-dotenv",
|
||||||
|
"uvicorn",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(value: str) -> str:
|
||||||
|
return value.strip().lower().replace("_", "-").replace(".", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_table(text: str, table_name: str) -> str:
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"(?ms)^\[{re.escape(table_name)}\]\s*$\n(.*?)(?=^\[[^\]]+\]\s*$|\Z)"
|
||||||
|
)
|
||||||
|
match = pattern.search(text)
|
||||||
|
return match.group(1) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_string_value(text: str, key: str) -> str | None:
|
||||||
|
match = re.search(rf'(?m)^{re.escape(key)}\s*=\s*"([^"]+)"\s*$', text)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_string_list_value(text: str, key: str) -> list[str]:
|
||||||
|
match = re.search(rf"(?ms)^{re.escape(key)}\s*=\s*\[(.*?)\]\s*$", text)
|
||||||
|
if not match:
|
||||||
|
return []
|
||||||
|
return re.findall(r'"([^"]+)"', match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def project_dependency_map(pyproject_text: str) -> tuple[str | None, dict[str, str]]:
|
||||||
|
project_block = extract_table(pyproject_text, "project")
|
||||||
|
requires_python = extract_string_value(project_block, "requires-python")
|
||||||
|
dependencies = extract_string_list_value(project_block, "dependencies")
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
for spec in dependencies:
|
||||||
|
name = spec.split(";", 1)[0].strip()
|
||||||
|
for marker in ("<", ">", "=", "!", "~"):
|
||||||
|
if marker in name:
|
||||||
|
name = name.split(marker, 1)[0].strip()
|
||||||
|
if "[" in name:
|
||||||
|
name = name.split("[", 1)[0].strip()
|
||||||
|
mapping[normalize_name(name)] = spec.strip()
|
||||||
|
return requires_python, mapping
|
||||||
|
|
||||||
|
|
||||||
|
def collect_runtime_imports(aiprovider_dir: Path) -> set[str]:
|
||||||
|
imports: set[str] = set()
|
||||||
|
stdlib = set(sys.stdlib_module_names)
|
||||||
|
|
||||||
|
for path in sorted(aiprovider_dir.rglob("*.py")):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
imports.add(alias.name.split(".", 1)[0])
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||||
|
imports.add(node.module.split(".", 1)[0])
|
||||||
|
|
||||||
|
return {
|
||||||
|
name
|
||||||
|
for name in imports
|
||||||
|
if name not in stdlib and name != "aiprovider"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_relevant_root_dependencies(
|
||||||
|
pyproject_deps: dict[str, str],
|
||||||
|
imported_modules: set[str],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
relevant_names: set[str] = set(EXTRA_RUNTIME_DISTRIBUTIONS)
|
||||||
|
|
||||||
|
for module_name in imported_modules:
|
||||||
|
mapped_name = IMPORT_TO_DISTRIBUTION.get(module_name, module_name)
|
||||||
|
normalized_name = normalize_name(mapped_name)
|
||||||
|
if normalized_name in pyproject_deps:
|
||||||
|
relevant_names.add(normalized_name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: pyproject_deps[name]
|
||||||
|
for name in sorted(relevant_names)
|
||||||
|
if name in pyproject_deps
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lock_metadata(lock_text: str) -> dict[str, str | None]:
|
||||||
|
return {
|
||||||
|
"version": extract_string_value(lock_text, "version") or re.search(r"(?m)^version\s*=\s*(\d+)\s*$", lock_text).group(1),
|
||||||
|
"revision": extract_string_value(lock_text, "revision") or re.search(r"(?m)^revision\s*=\s*(\d+)\s*$", lock_text).group(1),
|
||||||
|
"requires_python": extract_string_value(lock_text, "requires-python"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lock_package_map(lock_text: str) -> dict[str, dict[str, Any]]:
|
||||||
|
mapping: dict[str, dict[str, Any]] = {}
|
||||||
|
sections = re.split(r"(?m)^\[\[package\]\]\s*$\n?", lock_text)
|
||||||
|
for section in sections[1:]:
|
||||||
|
raw_section = section.strip()
|
||||||
|
name = extract_string_value(raw_section, "name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
version = extract_string_value(raw_section, "version")
|
||||||
|
dependency_names = [
|
||||||
|
normalize_name(dep_name)
|
||||||
|
for dep_name in re.findall(r'\{\s*name\s*=\s*"([^"]+)"', raw_section)
|
||||||
|
]
|
||||||
|
mapping[normalize_name(name)] = {
|
||||||
|
"name": name,
|
||||||
|
"version": version,
|
||||||
|
"dependencies": dependency_names,
|
||||||
|
"raw": raw_section,
|
||||||
|
}
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def dependency_closure(
|
||||||
|
lock_packages: dict[str, dict[str, Any]],
|
||||||
|
root_dependencies: dict[str, str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
pending = list(root_dependencies.keys())
|
||||||
|
visited: set[str] = set()
|
||||||
|
resolved: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
while pending:
|
||||||
|
current = pending.pop()
|
||||||
|
if current in visited:
|
||||||
|
continue
|
||||||
|
visited.add(current)
|
||||||
|
|
||||||
|
package = lock_packages.get(current)
|
||||||
|
if package is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
resolved.append(package)
|
||||||
|
for dep_name in package.get("dependencies", []):
|
||||||
|
pending.append(dep_name)
|
||||||
|
|
||||||
|
resolved.sort(key=lambda pkg: (normalize_name(pkg["name"]), pkg.get("version", "")))
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
pyproject_path = repo_root / "pyproject.toml"
|
||||||
|
uv_lock_path = repo_root / "uv.lock"
|
||||||
|
aiprovider_dir = repo_root / "aiprovider"
|
||||||
|
|
||||||
|
pyproject_text = pyproject_path.read_text(encoding="utf-8")
|
||||||
|
uv_lock_text = uv_lock_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
requires_python, pyproject_deps = project_dependency_map(pyproject_text)
|
||||||
|
imported_modules = collect_runtime_imports(aiprovider_dir)
|
||||||
|
relevant_roots = resolve_relevant_root_dependencies(pyproject_deps, imported_modules)
|
||||||
|
relevant_packages = dependency_closure(lock_package_map(uv_lock_text), relevant_roots)
|
||||||
|
|
||||||
|
fingerprint_payload = {
|
||||||
|
"requires_python": requires_python,
|
||||||
|
"relevant_root_dependencies": relevant_roots,
|
||||||
|
"lock": {**lock_metadata(uv_lock_text), "packages": relevant_packages},
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(fingerprint_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user