Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1984c7a35 | ||
|
|
bb9183b8a4 | ||
|
|
421234301a | ||
|
|
f22079d33a |
@@ -1,232 +1,93 @@
|
|||||||
---
|
---
|
||||||
description: 分析本次 git 变更,在 docs/technical/zh/ 中新建或更新对应的技术文档
|
description: Create or update repository documentation from current code changes
|
||||||
argument-hint: 可选:指定要记录的主题,或留空自动从 git diff 推断
|
argument-hint: Optional: topic to document, or leave empty to infer from git diff
|
||||||
allowed-tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]
|
allowed-tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]
|
||||||
---
|
---
|
||||||
|
|
||||||
# /docs — 技术文档写入工作流
|
# /docs — Documentation Workflow
|
||||||
|
|
||||||
## 目标
|
## Goal
|
||||||
|
|
||||||
根据当前 git 变更(或用户指定主题)在 `docs/technical/zh/` 中写入或更新技术文档,记录**为什么**这样做,而不只是记录做了什么。
|
Create or update documentation that explains why a change exists, how it behaves, and what maintainers need to know. Keep this command generic. Repository-specific coverage rules live in the repository and must be loaded separately.
|
||||||
|
|
||||||
## 执行步骤
|
## Repository Rules
|
||||||
|
|
||||||
### Step 1 — 理解变更范围
|
Before deciding scope, check whether the repository has a documentation rules file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git diff HEAD --stat # 变更文件一览
|
test -f docs/documentation-coverage-rules.md && sed -n '1,240p' docs/documentation-coverage-rules.md
|
||||||
git diff HEAD --name-only # 变更文件列表
|
|
||||||
git log --oneline -10 # 近期 commit 上下文
|
|
||||||
```
|
```
|
||||||
|
|
||||||
若 `$ARGUMENTS` 指定了主题,优先聚焦该主题;否则从文件列表和 diff stat 推断变更主题。不要默认读取完整仓库 diff;只对决定文档主题所需的文件读取 focused diff:
|
If it exists, apply it as the project-specific coverage checklist. If it does not exist, continue with the generic workflow below.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1 — Understand The Change
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff HEAD --stat
|
||||||
|
git diff HEAD --name-only
|
||||||
|
git log --oneline -10
|
||||||
|
rg --files docs
|
||||||
|
```
|
||||||
|
|
||||||
|
If `$ARGUMENTS` specifies a topic, focus on that topic. Otherwise infer the documentation topic from the changed files. Do not read the full repository diff by default; inspect focused files only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git diff HEAD -- <path>
|
git diff HEAD -- <path>
|
||||||
rg -n "class |def |function |export |router|@router|interface |type " <path>
|
rg -n "class |def |function |export |router|@router|interface |type " <path>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2 — 确认文档范围
|
### Step 2 — Decide Scope
|
||||||
|
|
||||||
分析变更,判断:
|
- Prefer updating an existing relevant document over creating a duplicate.
|
||||||
|
- Use one document for one coherent topic.
|
||||||
|
- Split documents only when the change crosses meaningful domains.
|
||||||
|
- Keep filenames lowercase and hyphenated.
|
||||||
|
- Apply the repository-specific rules file before writing.
|
||||||
|
|
||||||
1. **应写几篇文档**:单一主题写一篇,跨领域变更可拆分(如后端性能优化 + 运维启动脚本分开写)
|
For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly.
|
||||||
2. **是新建还是更新**:检查 `docs/technical/zh/` 中是否已有相关文档
|
|
||||||
3. **文档命名**:按 `领域-主题-副题.md` 格式,全小写,用连字符,如:
|
|
||||||
- `backend-datasources-api-performance.md`
|
|
||||||
- `ops-planet-sh-startup.md`
|
|
||||||
- `earth-bgp-context.md`
|
|
||||||
|
|
||||||
```bash
|
### Step 3 — Write
|
||||||
ls docs/technical/zh/ # 查看现有文档
|
|
||||||
|
Explain:
|
||||||
|
|
||||||
|
- Background/problem: what was wrong or missing before.
|
||||||
|
- Core design decisions and rationale.
|
||||||
|
- Operational or user-facing impact.
|
||||||
|
- Relevant code paths, only when useful for future maintainers.
|
||||||
|
|
||||||
|
Style:
|
||||||
|
|
||||||
|
- Follow the repository’s existing language and heading conventions.
|
||||||
|
- Use fenced code blocks with language tags.
|
||||||
|
- Prefer tables for comparisons or parameter lists.
|
||||||
|
- Keep snippets concise and relevant.
|
||||||
|
|
||||||
|
### Step 4 — Verify
|
||||||
|
|
||||||
|
- Read the completed docs once for clarity and stale statements.
|
||||||
|
- Verify referenced paths exist with `test -e` or `rg --files`.
|
||||||
|
- Run applicable checks from `docs/documentation-coverage-rules.md`.
|
||||||
|
- Check Markdown links use readable user-facing titles unless repository rules allow otherwise.
|
||||||
|
|
||||||
|
### Step 5 — Report
|
||||||
|
|
||||||
|
Summarize changed docs and verification:
|
||||||
|
|
||||||
|
```md
|
||||||
|
Updated:
|
||||||
|
- path/to/doc.md — what changed
|
||||||
|
|
||||||
|
Verified:
|
||||||
|
- checks that passed
|
||||||
|
- checks that could not be run, if any
|
||||||
```
|
```
|
||||||
|
|
||||||
**先输出写作计划供用户确认**(若变更明确且范围小,可直接执行):
|
## Hard Constraints
|
||||||
|
|
||||||
```
|
- Do not leave placeholder docs.
|
||||||
文档计划:
|
- Do not duplicate bilingual files byte-for-byte.
|
||||||
新建:docs/technical/zh/ops-planet-sh-startup.md — planet.sh 启动性能优化
|
- Do not reference PR numbers, issue numbers, or the current conversation unless explicitly requested.
|
||||||
更新:docs/technical/zh/backend-datasources-api-performance.md — 补充并行化细节
|
- Do not write changelog-style lists without the reasoning and tradeoffs behind the change.
|
||||||
```
|
- Keep docs maintainable and concise.
|
||||||
|
|
||||||
### Step 2.5 — 覆盖范围检查
|
|
||||||
|
|
||||||
写文档前必须按变更类型检查配套文档,不要只更新一篇专题文档:
|
|
||||||
|
|
||||||
- 用户可见流程变化:更新 `docs/technical/zh/manual.md`,通常也更新 `docs/technical/zh/quickstart.md`。
|
|
||||||
- `manual.md`、`quickstart.md` 这类用户手册存在英文版时,同步更新 `docs/technical/en/...`,至少避免英文版与中文版互相矛盾。
|
|
||||||
- 控制台页面职责、路由入口、表格/抽屉/设置页行为变化:更新 `docs/technical/zh/frontend-admin-frontend-context.md`。
|
|
||||||
- Earth 前端行为、HUD、巡航、图层、图例、交互变化:更新 `docs/technical/zh/earth-frontend-context.md`。
|
|
||||||
- 新增 Earth 图层、调整 `renderOrder`、半径/高度偏移、深度策略、拾取策略、legend mode、图层面板顺序或启动加载顺序:更新 `docs/technical/zh/earth-render-layer-order.md`。
|
|
||||||
- Earth 图层视觉样式、颜色、图例符号语义变化:若影响样式索引,同步更新 `docs/technical/zh/earth-layer-style-reference.md`。
|
|
||||||
- 采集器、数据源、凭证、设置页、连接检查、scheduler、后端 API 变化:更新相关后端文档,优先检查 `docs/technical/zh/backend-collectors.md` 和 datasource/settings 专题文档。
|
|
||||||
- 如果某个旧 plan 的假设已经被当前实现推翻,在对应 `docs/plans/*.md` 增加现状修正或更新该段,不要让计划文档继续给出相反方向。
|
|
||||||
- 新增 technical 文档后,如果需要被发现,更新 `docs/technical/zh/README.md`。
|
|
||||||
- 如果 technical 文档需要在公开 Docs 页面显示,或从 technical README 链接进入,必须同步更新 `frontend/src/pages/Docs/docs-content.ts` 的 `DOCS_METADATA`。前端使用这份白名单,`docs/technical/{zh,en}/` 中存在 `.md` 文件并不会自动生成路由。
|
|
||||||
- 公开 technical 文档必须按同名文件维护中英文双语版本:`docs/technical/zh/<name>.md` 与 `docs/technical/en/<name>.md`。如果某篇文档刻意只保留单语,完成说明中必须明确写出原因。
|
|
||||||
- 对本次变更提取旧词做 stale search,例如旧 tab 名、旧路由职责、旧认证假设、改名前 UI 文案:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "旧文案|旧路由职责|旧认证假设" docs/technical docs/plans
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3 — 写文档
|
|
||||||
|
|
||||||
遵循以下原则:
|
|
||||||
|
|
||||||
**记录 WHY,不只记录 WHAT**
|
|
||||||
- 好:`将戳文件从 /tmp 移到 ~/.cache/planet/,因为 WSL 重启后 /tmp 被清空`
|
|
||||||
- 差:`修改了 AI_PROVIDER_BUILD_STAMP_FILE 的值`
|
|
||||||
|
|
||||||
**必须包含的内容**:
|
|
||||||
- 背景/问题:改动之前存在什么问题,为什么要改
|
|
||||||
- 核心设计决策及其理由
|
|
||||||
- 关键代码片段(用 diff 或 before/after 展示)
|
|
||||||
- 相关文件列表
|
|
||||||
|
|
||||||
**格式要求**:
|
|
||||||
- 使用 `##` 和 `###` 分级,不要超过三级
|
|
||||||
- 代码块注明语言(python / bash / typescript / sql)
|
|
||||||
- 表格用于对比多个选项或列出参数
|
|
||||||
- 中文写作,技术术语保留英文原文
|
|
||||||
- `docs/technical/zh/` 中的文档不得用英文原文占位;如果存在 `docs/technical/en/` 对应文件,禁止逐字复制成中文文件
|
|
||||||
- 中文文档内部链接应指向 `docs/technical/zh/...`,除非明确引用英文专属文档
|
|
||||||
- 公开文档的 Markdown 链接显示文字应使用可读标题,不要直接暴露 `manual.md`、`earth-frontend-context.md` 这类裸文件名
|
|
||||||
|
|
||||||
**文档结构模板**:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# 标题(说明做了什么)
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
为什么要做这个改动,改动前存在什么问题。
|
|
||||||
|
|
||||||
## 核心变更
|
|
||||||
|
|
||||||
### 子主题一
|
|
||||||
|
|
||||||
before/after 或决策说明 + 关键代码
|
|
||||||
|
|
||||||
### 子主题二
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
## 相关文件
|
|
||||||
|
|
||||||
- `path/to/file.py` — 简短说明
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4 — 验证
|
|
||||||
|
|
||||||
- 读一遍写好的文档,确认逻辑清晰、代码片段无明显错误
|
|
||||||
- 用 `rg --files` 或 `test -e` 确认文档中的文件路径在项目中真实存在,避免凭记忆判断:
|
|
||||||
- 检查中文文档没有误复制英文版:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python - <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
same = []
|
|
||||||
for en in sorted(Path("docs/technical/en").glob("*.md")):
|
|
||||||
zh = Path("docs/technical/zh") / en.name
|
|
||||||
if zh.exists() and en.read_text() == zh.read_text():
|
|
||||||
same.append(en.name)
|
|
||||||
if same:
|
|
||||||
raise SystemExit("identical en/zh docs: " + ", ".join(same))
|
|
||||||
print("no identical en/zh docs")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
|
|
||||||
- 检查中文文档内部链接没有继续指向无语言目录:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "/home/ray/dev/linkong/planet/docs/technical/(?!zh|en)" docs/technical/zh --pcre2
|
|
||||||
```
|
|
||||||
|
|
||||||
- 检查公开文档链接已进入 Docs 前端白名单。凡是 `docs/technical/{zh,en}/README.md` 中链接到的 technical `.md`,都必须存在于 `DOCS_METADATA`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python - <<'PY'
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
metadata = Path("frontend/src/pages/Docs/docs-content.ts").read_text()
|
|
||||||
known = set(re.findall(r"'([^']+\.md)':\s*\{", metadata))
|
|
||||||
known.add("README.md")
|
|
||||||
|
|
||||||
missing = []
|
|
||||||
for readme in [Path("docs/technical/zh/README.md"), Path("docs/technical/en/README.md")]:
|
|
||||||
if not readme.exists():
|
|
||||||
continue
|
|
||||||
for href in re.findall(r"\]\(([^)]+\.md)\)", readme.read_text()):
|
|
||||||
path = Path(href)
|
|
||||||
if "docs/technical/" not in href:
|
|
||||||
continue
|
|
||||||
filename = path.name
|
|
||||||
if filename not in known:
|
|
||||||
missing.append(f"{readme}: {filename}")
|
|
||||||
|
|
||||||
if missing:
|
|
||||||
raise SystemExit("docs README links missing DOCS_METADATA: " + ", ".join(missing))
|
|
||||||
print("docs README links are whitelisted")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
|
|
||||||
- 检查公开文档双语同名文件齐备。除 `README.md` 外,所有白名单文档都应同时存在 zh/en 文件,除非本次说明中明确豁免:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python - <<'PY'
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
metadata = Path("frontend/src/pages/Docs/docs-content.ts").read_text()
|
|
||||||
filenames = sorted(set(re.findall(r"'([^']+\.md)':\s*\{", metadata)) - {"README.md"})
|
|
||||||
missing = []
|
|
||||||
for filename in filenames:
|
|
||||||
for lang in ("zh", "en"):
|
|
||||||
path = Path("docs/technical") / lang / filename
|
|
||||||
if not path.exists():
|
|
||||||
missing.append(str(path))
|
|
||||||
if missing:
|
|
||||||
raise SystemExit("missing bilingual docs: " + ", ".join(missing))
|
|
||||||
print("public docs have zh/en file pairs")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
|
|
||||||
- 检查公开文档里没有用裸 `.md` 文件名当链接标题。这个命令在 polished public docs 中应无输出:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "\[[^]]+\.md\]\(" docs/technical/zh docs/technical/en
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 对文档中提到的关键路径做快速验证
|
|
||||||
ls <mentioned_paths>
|
|
||||||
```
|
|
||||||
|
|
||||||
如需检查大量链接,优先用确定性提取:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "\]\(([^)]+)\)" docs/technical/zh/<doc>.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5 — 完成确认
|
|
||||||
|
|
||||||
输出摘要:
|
|
||||||
|
|
||||||
```
|
|
||||||
✓ 新建:docs/technical/zh/ops-planet-sh-startup.md(约 xxx 字)
|
|
||||||
✓ 更新:docs/technical/zh/backend-datasources-api-performance.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- 不要写流水账式的"改了 A、改了 B、改了 C",要写改动背后的约束和权衡
|
|
||||||
- 不要在文档中引用 PR 号、issue 号、或当前对话——这些会随时间失效
|
|
||||||
- 代码片段保持简洁,只保留说明问题的关键部分,省略无关样板代码
|
|
||||||
- 如果某个变更已有文档记录,优先在原文档中追加,而不是新建
|
|
||||||
- 公开 technical 文档没有注册 `DOCS_METADATA` 时,Docs 页面不会显示;不要只创建 `.md` 文件就结束。
|
|
||||||
- 公开 technical 文档默认需要 zh/en 同名文件,不要只补一个语言版本。
|
|
||||||
- 链接可见文字使用文档标题或语义标题,不要使用裸文件名。
|
|
||||||
- 文档是给未来的开发者看的,假设读者熟悉项目但不了解这次改动的背景
|
|
||||||
|
|||||||
@@ -1,187 +1,72 @@
|
|||||||
---
|
---
|
||||||
name: docs
|
name: docs
|
||||||
description: Analyze current Planet repo changes and create or update technical documentation under docs/technical/zh. Use when the user asks to write docs, update technical docs, summarize implementation changes into documentation, or port the Claude docs-codex workflow into Codex.
|
description: Create or update repository documentation from current code changes. Use when the user asks to write docs, update docs, summarize implementation changes into docs, or check documentation coverage. Load repository-specific coverage rules from docs/documentation-coverage-rules.md when present.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Docs
|
# Docs
|
||||||
|
|
||||||
Use this skill when the user asks to create or update Planet technical documentation, especially under `docs/technical/zh/`.
|
Use this skill when the task is documentation work: creating, updating, checking, or summarizing docs for code or behavior changes.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Write or update technical docs that explain why a change exists, not only what files changed.
|
Write documentation that explains why a change exists, how it behaves, and what maintainers need to know. Keep the skill generic; repository-specific rules belong in the repository, not in this skill.
|
||||||
|
|
||||||
Default target directory:
|
## Repository Rules
|
||||||
|
|
||||||
- `docs/technical/zh/`
|
Before deciding scope, check whether the repository has a documentation rules file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -f docs/documentation-coverage-rules.md && sed -n '1,240p' docs/documentation-coverage-rules.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If it exists, apply it as the project-specific coverage checklist. If it does not exist, continue with the generic workflow below.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Gather change context:
|
1. Gather focused context:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git diff HEAD --stat
|
git diff HEAD --stat
|
||||||
git diff HEAD --name-only
|
git diff HEAD --name-only
|
||||||
git log --oneline -10
|
git log --oneline -10
|
||||||
ls docs/technical/zh/
|
rg --files docs
|
||||||
```
|
```
|
||||||
|
|
||||||
If the user gives a specific topic, focus on that topic. Otherwise infer the documentation topic from the file list and diff stat. Do **not** read the full repository diff by default; inspect focused diffs only for the files that define the doc topic:
|
If the user gives a topic, focus on that topic. Otherwise infer the doc topic from changed files. Avoid reading large full diffs by default; inspect focused files and symbols:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git diff HEAD -- <path>
|
git diff HEAD -- <path>
|
||||||
rg -n "class |def |function |export |router|@router|interface |type " <path>
|
rg -n "class |def |function |export |router|@router|interface |type " <path>
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Decide document scope:
|
2. Decide scope:
|
||||||
|
|
||||||
- Use one document for one coherent topic.
|
|
||||||
- Split documents when the changes cross meaningful domains, such as backend performance and ops startup behavior.
|
|
||||||
- Prefer updating an existing relevant doc over creating a duplicate.
|
- Prefer updating an existing relevant doc over creating a duplicate.
|
||||||
- Name new files as lowercase hyphenated `domain-topic-detail.md`, for example:
|
- Use one document for one coherent topic.
|
||||||
- `backend-datasources-api-performance.md`
|
- Split documents only when changes cross meaningful domains.
|
||||||
- `ops-planet-sh-startup.md`
|
- Keep filenames lowercase and hyphenated.
|
||||||
- `earth-bgp-context.md`
|
|
||||||
|
|
||||||
3. Apply the documentation coverage checklist before writing:
|
3. Write the doc:
|
||||||
|
|
||||||
- User-visible workflow changes must update `docs/technical/zh/manual.md` and usually `docs/technical/zh/quickstart.md`.
|
- Explain background/problem, design decisions, constraints, and operational impact.
|
||||||
- If an English counterpart exists for user-facing docs such as `manual.md` or `quickstart.md`, update `docs/technical/en/...` enough that it does not contradict the Chinese source.
|
- Keep code snippets short and directly relevant.
|
||||||
- Control console page responsibility changes must update `docs/technical/zh/frontend-admin-frontend-context.md`.
|
- List related files only when they help future maintainers navigate.
|
||||||
- Earth frontend behavior changes must update `docs/technical/zh/earth-frontend-context.md`.
|
- Use the repository’s existing language, heading style, and naming conventions.
|
||||||
- Earth layer additions, `renderOrder`, altitude/radius offsets, depth strategy, pointer picking, legend modes, or layer panel/startup ordering must update `docs/technical/zh/earth-render-layer-order.md`.
|
|
||||||
- Earth layer visual style or legend symbol/color semantics should also update `docs/technical/zh/earth-layer-style-reference.md` when that reference is affected.
|
|
||||||
- Collector, datasource, credential, settings, connectivity, scheduler, or API changes must update the relevant backend docs, especially `docs/technical/zh/backend-collectors.md` and any datasource/settings-specific doc.
|
|
||||||
- When a change turns an old plan assumption into current behavior, update the relevant `docs/plans/*.md` with a status note instead of leaving contradictory instructions.
|
|
||||||
- If adding a new technical document, add it to `docs/technical/zh/README.md` when it should be discoverable from the technical docs index.
|
|
||||||
- If a technical document should be visible in the public Docs page or linked from a technical README, register it in `frontend/src/pages/Docs/docs-content.ts` under `DOCS_METADATA`. The frontend uses this whitelist; files under `docs/technical/{zh,en}/` are not automatically routable.
|
|
||||||
- For every public technical doc, keep the bilingual file pair in sync by filename: `docs/technical/zh/<name>.md` and `docs/technical/en/<name>.md`. If the content is intentionally Chinese-only or English-only, state that intentionally in the final note.
|
|
||||||
- Search docs for stale terms introduced by the change, for example old tab names, old route responsibilities, obsolete auth assumptions, or renamed UI labels.
|
|
||||||
|
|
||||||
4. Write the doc in Chinese:
|
4. Verify:
|
||||||
|
|
||||||
- Write Chinese prose for `docs/technical/zh/`.
|
- Read the completed doc once for clarity and stale statements.
|
||||||
- Keep technical identifiers, API paths, config keys, code symbols, and standard product names in English where appropriate.
|
- Verify important referenced paths exist with `test -e` or `rg --files`.
|
||||||
- Use `##` and `###` headings; avoid going deeper than three levels.
|
- Run repository-specific doc checks from `docs/documentation-coverage-rules.md` when present.
|
||||||
- Use fenced code blocks with language tags.
|
- For Markdown links, check that user-facing titles are readable and not raw filenames unless the repository rules allow it.
|
||||||
- Use tables when comparing options or listing parameters.
|
|
||||||
|
|
||||||
5. Required content:
|
|
||||||
|
|
||||||
- Background/problem: what was wrong before and why the change was needed.
|
|
||||||
- Core design decisions and rationale.
|
|
||||||
- Key code snippets, preferably before/after or focused excerpts.
|
|
||||||
- Related files and what each file contributes.
|
|
||||||
|
|
||||||
6. Verification:
|
|
||||||
|
|
||||||
- Read the completed doc and check that the reasoning is clear.
|
|
||||||
- Verify important referenced paths exist.
|
|
||||||
- Use `rg --files` or `test -e` for path existence instead of relying on memory.
|
|
||||||
- Run a quick duplicate-language check when editing bilingual docs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python - <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
same = []
|
|
||||||
for en in sorted(Path("docs/technical/en").glob("*.md")):
|
|
||||||
zh = Path("docs/technical/zh") / en.name
|
|
||||||
if zh.exists() and en.read_text() == zh.read_text():
|
|
||||||
same.append(en.name)
|
|
||||||
if same:
|
|
||||||
raise SystemExit("identical en/zh docs: " + ", ".join(same))
|
|
||||||
print("no identical en/zh docs")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
|
|
||||||
Also check that Chinese docs do not link to the old language-less technical docs path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "/home/ray/dev/linkong/planet/docs/technical/(?!zh|en)" docs/technical/zh --pcre2
|
|
||||||
```
|
|
||||||
|
|
||||||
This command should return no matches.
|
|
||||||
|
|
||||||
Check that public docs are whitelisted in the frontend Docs registry. Any `.md` linked from `docs/technical/{zh,en}/README.md` and located under `docs/technical/{zh,en}/` must have a matching `DOCS_METADATA` key:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python - <<'PY'
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
metadata = Path("frontend/src/pages/Docs/docs-content.ts").read_text()
|
|
||||||
known = set(re.findall(r"'([^']+\.md)':\s*\{", metadata))
|
|
||||||
known.add("README.md")
|
|
||||||
|
|
||||||
missing = []
|
|
||||||
for readme in [Path("docs/technical/zh/README.md"), Path("docs/technical/en/README.md")]:
|
|
||||||
if not readme.exists():
|
|
||||||
continue
|
|
||||||
for href in re.findall(r"\]\(([^)]+\.md)\)", readme.read_text()):
|
|
||||||
path = Path(href)
|
|
||||||
if "docs/technical/" not in href:
|
|
||||||
continue
|
|
||||||
filename = path.name
|
|
||||||
if filename not in known:
|
|
||||||
missing.append(f"{readme}: {filename}")
|
|
||||||
|
|
||||||
if missing:
|
|
||||||
raise SystemExit("docs README links missing DOCS_METADATA: " + ", ".join(missing))
|
|
||||||
print("docs README links are whitelisted")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
|
|
||||||
Check bilingual parity for public docs. Every whitelisted document except `README.md` should exist in both language directories unless intentionally documented otherwise:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python - <<'PY'
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
metadata = Path("frontend/src/pages/Docs/docs-content.ts").read_text()
|
|
||||||
filenames = sorted(set(re.findall(r"'([^']+\.md)':\s*\{", metadata)) - {"README.md"})
|
|
||||||
missing = []
|
|
||||||
for filename in filenames:
|
|
||||||
for lang in ("zh", "en"):
|
|
||||||
path = Path("docs/technical") / lang / filename
|
|
||||||
if not path.exists():
|
|
||||||
missing.append(str(path))
|
|
||||||
if missing:
|
|
||||||
raise SystemExit("missing bilingual docs: " + ", ".join(missing))
|
|
||||||
print("public docs have zh/en file pairs")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
|
|
||||||
Check that Markdown links do not expose raw filenames as user-facing titles. This should return no matches for polished public docs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "\[[^]]+\.md\]\(" docs/technical/zh docs/technical/en
|
|
||||||
```
|
|
||||||
|
|
||||||
If checking many links, prefer deterministic extraction:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "\]\(([^)]+)\)" docs/technical/zh/<doc>.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Also run focused stale-term searches derived from the change, for example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "old label|old route purpose|obsolete provider assumption" docs/technical docs/plans
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hard Constraints
|
## Hard Constraints
|
||||||
|
|
||||||
- A file under `docs/technical/zh/` must not be an English source file copied as a placeholder.
|
- Do not leave placeholder docs or copied source text pretending to be documentation.
|
||||||
- Do not leave a Chinese doc with only an English title and English first-screen content.
|
- Do not duplicate bilingual files byte-for-byte.
|
||||||
- When an English counterpart exists in `docs/technical/en/`, never duplicate it byte-for-byte into `docs/technical/zh/`.
|
- Do not reference PR numbers, issue numbers, or the current conversation unless explicitly requested.
|
||||||
- Internal links inside `docs/technical/zh/` should point to `docs/technical/zh/...` for Chinese docs, unless intentionally linking to an English-only file.
|
- Do not write changelog-style lists without the reasoning, constraints, and tradeoffs behind the change.
|
||||||
- Public technical documents must be registered in `frontend/src/pages/Docs/docs-content.ts` before considering them available in the Docs UI.
|
- Keep docs concise enough to maintain.
|
||||||
- Public technical documents should have both zh and en files with the same filename, unless intentionally exempted.
|
|
||||||
- Markdown link text in public docs should be a readable title, not a raw filename such as `manual.md`.
|
|
||||||
- Do not reference PR numbers, issue numbers, or the current conversation.
|
|
||||||
- Do not write changelog-style lists like "changed A, changed B, changed C" without the constraints and tradeoffs behind those changes.
|
|
||||||
- Keep code snippets concise and relevant.
|
|
||||||
|
|
||||||
## Recommended Output
|
## Recommended Output
|
||||||
|
|
||||||
@@ -189,12 +74,9 @@ After editing, summarize:
|
|||||||
|
|
||||||
```md
|
```md
|
||||||
Updated:
|
Updated:
|
||||||
- docs/technical/zh/example.md — what changed
|
- path/to/doc.md — what changed
|
||||||
|
|
||||||
Verified:
|
Verified:
|
||||||
- no identical en/zh docs
|
- checks that passed
|
||||||
- no language-less docs/technical links in zh docs
|
- checks that could not be run, if any
|
||||||
- public docs are registered in DOCS_METADATA
|
|
||||||
- public docs have zh/en file pairs
|
|
||||||
- no raw `.md` filenames as public link titles
|
|
||||||
```
|
```
|
||||||
|
|||||||
7
TODO.md
7
TODO.md
@@ -26,6 +26,13 @@
|
|||||||
- [ ] 重写控制台 UI,逐步抛弃 Ant Design,建立自有组件体系,并统一采用 `tabler.io` / Tabler Icons 作为控制台主图标库
|
- [ ] 重写控制台 UI,逐步抛弃 Ant Design,建立自有组件体系,并统一采用 `tabler.io` / Tabler Icons 作为控制台主图标库
|
||||||
- [ ] 把 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
|
||||||
|
- [ ] AIS v3.1:修复船只聚合完整性,`/geo/vessels` 合并 raw observation 聚合结果与 legacy `vessel_position + vessel_static` 最新结果,确保 BarentsWatch-only 船只不会因为 AISStream 子集存在而消失,并增加 raw/legacy/final unique MMSI 诊断统计
|
||||||
|
- [ ] AIS v3.2:把 AISStream 从收满 `max_messages` 后结束的批采集改成长连接 streaming service,持续写入 raw observations,通过内部 `/ws` 的 `vessels` channel 推送新船、位置和航向增量,Earth 前端按 MMSI upsert marker
|
||||||
|
- [ ] AIS v3.3:修正 AISStream 采集页面状态语义,使用 connecting/streaming/reconnecting/stopped 与 indeterminate 状态,展示运行时长、消息数、unique MMSI、message rate、最近消息和错误,不再用一次性 REST 进度条表示长连接
|
||||||
|
- [ ] AIS v3.4:修复船只身份字段和名称聚合,MMSI/IMO/callsign 按字符串显示且不带千分位符;查询并列出所有仍以 MMSI 号码或 `MMSI <number>` 作为船名的记录,标注来源、最近观测、message types 和缺失原因,并把这批 fallback-name 船只纳入名称聚合修复集合
|
||||||
|
- [ ] Earth Live Sync:建立统一态势实时同步链路,新增 `earth_summary` WS channel,任意采集器成功后广播轻量 summary invalidation,前端收到后重新拉 `/api/v1/visualization/geo/summary` 并更新 HUD;同时为 BGP 增加 `bgp` WS channel,使 BGP incidents/anomalies/collectors 在不刷新页面时也能 upsert 图层;卫星采集完成后触发 summary 刷新,必要时按 TLE 版本重新 hydrate 卫星数据
|
||||||
|
- [ ] AIS v4:开放船只多源聚合策略配置,支持 source priority、字段级规则、freshness 窗口和高级保护开关;保存时校验未知字段、非法模式和危险动态字段锁定,并在聚合接口返回命中的配置版本
|
||||||
|
- [ ] AIS v5:实现船舶资料 enrichment 与冲突治理,按 `mmsi + imo + name + callsign` 异步补充船型细分、AIS 大类、旗国、尺寸、建造年份、运营方和图片缓存;详情面板展示缓存资料和字段来源,不在实时 AIS 请求链路现场抓第三方页面
|
||||||
- [ ] 为 Earth 地球表面增加一层与基础纹理对齐的材质/纹理 overlay,并在同层叠加国界轮廓参考线;要求国界线与底图稳定对齐,且 hover 到国家轮廓时能高亮当前国家,便于校准地表和增强交互
|
- [ ] 为 Earth 地球表面增加一层与基础纹理对齐的材质/纹理 overlay,并在同层叠加国界轮廓参考线;要求国界线与底图稳定对齐,且 hover 到国家轮廓时能高亮当前国家,便于校准地表和增强交互
|
||||||
- [ ] 把 Earth 新闻接入通用巡航队列:按新闻发生地和时间排序生成巡航目标,巡航聚焦到新闻事件时显示对应新闻卡片,并保持实现边界为“通用巡航层 + 新闻业务适配层”,不要再把新闻逻辑直接耦合回 `main.js` 状态机
|
- [ ] 把 Earth 新闻接入通用巡航队列:按新闻发生地和时间排序生成巡航目标,巡航聚焦到新闻事件时显示对应新闻卡片,并保持实现边界为“通用巡航层 + 新闻业务适配层”,不要再把新闻逻辑直接耦合回 `main.js` 状态机
|
||||||
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
|
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.api.v1 import (
|
|||||||
users,
|
users,
|
||||||
datasource_config,
|
datasource_config,
|
||||||
datasources,
|
datasources,
|
||||||
|
docs,
|
||||||
tasks,
|
tasks,
|
||||||
dashboard,
|
dashboard,
|
||||||
websocket,
|
websocket,
|
||||||
@@ -12,6 +13,7 @@ from app.api.v1 import (
|
|||||||
settings,
|
settings,
|
||||||
collected_data,
|
collected_data,
|
||||||
visualization,
|
visualization,
|
||||||
|
vessel_aggregation,
|
||||||
bgp,
|
bgp,
|
||||||
news,
|
news,
|
||||||
system_control,
|
system_control,
|
||||||
@@ -28,12 +30,18 @@ api_router.include_router(
|
|||||||
)
|
)
|
||||||
api_router.include_router(datasources.router, prefix="/datasources", tags=["datasources"])
|
api_router.include_router(datasources.router, prefix="/datasources", tags=["datasources"])
|
||||||
api_router.include_router(collected_data.router, prefix="/collected", tags=["collected-data"])
|
api_router.include_router(collected_data.router, prefix="/collected", tags=["collected-data"])
|
||||||
|
api_router.include_router(docs.router, prefix="/docs", tags=["docs"])
|
||||||
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
||||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||||
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(system_control.router, prefix="/system", tags=["system"])
|
api_router.include_router(system_control.router, prefix="/system", tags=["system"])
|
||||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||||
|
api_router.include_router(
|
||||||
|
vessel_aggregation.router,
|
||||||
|
prefix="/vessel-aggregation",
|
||||||
|
tags=["vessel-aggregation"],
|
||||||
|
)
|
||||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async def login(
|
|||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE username = :username"
|
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE username = :username"
|
||||||
),
|
),
|
||||||
{"username": form_data.username},
|
{"username": form_data.username},
|
||||||
)
|
)
|
||||||
@@ -46,6 +46,7 @@ async def login(
|
|||||||
user.password_hash = row[3]
|
user.password_hash = row[3]
|
||||||
user.role = row[4]
|
user.role = row[4]
|
||||||
user.is_active = row[5]
|
user.is_active = row[5]
|
||||||
|
user.gatekeeper_groups = row[6] or []
|
||||||
|
|
||||||
if not verify_password(form_data.password, user.password_hash):
|
if not verify_password(form_data.password, user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -73,6 +74,7 @@ async def login(
|
|||||||
"id": user.id,
|
"id": user.id,
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
|
"gatekeeper_groups": user.gatekeeper_groups or [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ async def refresh_token(
|
|||||||
"id": current_user.id,
|
"id": current_user.id,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
"role": current_user.role,
|
"role": current_user.role,
|
||||||
|
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +114,7 @@ async def get_me(current_user: User = Depends(get_current_user)):
|
|||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
"email": current_user.email,
|
"email": current_user.email,
|
||||||
"role": current_user.role,
|
"role": current_user.role,
|
||||||
|
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||||
"is_active": current_user.is_active,
|
"is_active": current_user.is_active,
|
||||||
"created_at": current_user.created_at,
|
"created_at": current_user.created_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.bgp_anomaly import BGPAnomaly
|
from app.models.bgp_anomaly import BGPAnomaly
|
||||||
from app.models.bgp_incident import BGPIncident
|
from app.models.bgp_incident import BGPIncident
|
||||||
from app.models.bgp_observation import BGPObservation
|
from app.models.bgp_observation import BGPObservation
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services.bgp_collector_locations import (
|
||||||
|
collect_bgp_collector_location_candidates,
|
||||||
|
get_bgp_collector_location_dict,
|
||||||
|
)
|
||||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -264,6 +270,77 @@ async def get_bgp_collector_summary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CollectBGPCollectorLocationRequest(BaseModel):
|
||||||
|
city: Optional[str] = None
|
||||||
|
country: Optional[str] = None
|
||||||
|
site: Optional[str] = None
|
||||||
|
operator: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collectors/{collector_id}/collect-location")
|
||||||
|
async def collect_bgp_collector_location(
|
||||||
|
collector_id: str,
|
||||||
|
payload: CollectBGPCollectorLocationRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Run the shared location pipeline for a BGP route collector.
|
||||||
|
|
||||||
|
Mirrors ``POST /api/v1/visualization/compute-centers/{source_id}/collect-location``.
|
||||||
|
Returns ranked candidates from source coordinates and Nominatim queries
|
||||||
|
built around the collector's stored context (IXP / city / country). Stored
|
||||||
|
collector locations provide context only; they are not emitted as
|
||||||
|
candidates.
|
||||||
|
"""
|
||||||
|
if not collector_id or not collector_id.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="collector_id is required")
|
||||||
|
|
||||||
|
legacy = get_bgp_collector_location_dict(collector_id) or {}
|
||||||
|
site = payload.site or legacy.get("matched_location_name")
|
||||||
|
city = payload.city or legacy.get("city")
|
||||||
|
country = payload.country or legacy.get("country")
|
||||||
|
operator = payload.operator or "RIPE NCC"
|
||||||
|
|
||||||
|
candidates, attempted_queries = collect_bgp_collector_location_candidates(
|
||||||
|
collector=collector_id,
|
||||||
|
site=site,
|
||||||
|
city=city,
|
||||||
|
country=country,
|
||||||
|
operator=operator,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"collector": collector_id,
|
||||||
|
"site": site,
|
||||||
|
"city": city,
|
||||||
|
"country": country,
|
||||||
|
"operator": operator,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return {
|
||||||
|
"collector_id": collector_id,
|
||||||
|
"name": collector_id,
|
||||||
|
"success": False,
|
||||||
|
"failure_reason": (
|
||||||
|
"No source coordinates or online geocoding result reached"
|
||||||
|
" city-level precision for this collector."
|
||||||
|
),
|
||||||
|
"candidates": [],
|
||||||
|
"attempted_queries": list(attempted_queries),
|
||||||
|
"context": context,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"collector_id": collector_id,
|
||||||
|
"name": collector_id,
|
||||||
|
"success": True,
|
||||||
|
"candidates": [candidate.to_dict() for candidate in candidates],
|
||||||
|
"best_candidate": candidates[0].to_dict(),
|
||||||
|
"attempted_queries": list(attempted_queries),
|
||||||
|
"context": context,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview/summary")
|
@router.get("/overview/summary")
|
||||||
async def get_bgp_overview_summary(
|
async def get_bgp_overview_summary(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from datetime import datetime
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import delete, select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
import httpx
|
import httpx
|
||||||
@@ -17,6 +17,8 @@ from app.db.session import get_db
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.datasource_config import DataSourceConfig
|
from app.models.datasource_config import DataSourceConfig
|
||||||
from app.models.datasource_mapping import DataSourceMappingTemplate
|
from app.models.datasource_mapping import DataSourceMappingTemplate
|
||||||
|
from app.models.collected_data import CollectedData
|
||||||
|
from app.models.vessel import AISRawObservation, AISSourceHealth
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.core.cache import cache
|
from app.core.cache import cache
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
@@ -26,10 +28,19 @@ from app.services.datasource_mapping import (
|
|||||||
MappingError,
|
MappingError,
|
||||||
build_heuristic_mapping,
|
build_heuristic_mapping,
|
||||||
execute_mapping,
|
execute_mapping,
|
||||||
persist_mapped_records,
|
|
||||||
redact_for_llm,
|
redact_for_llm,
|
||||||
stable_payload_hash,
|
stable_payload_hash,
|
||||||
)
|
)
|
||||||
|
from app.services.custom_datasource_runtime import (
|
||||||
|
CustomDatasourceRuntimeError,
|
||||||
|
fetch_rest_payload,
|
||||||
|
get_custom_stream_status,
|
||||||
|
run_mapped_rest_config,
|
||||||
|
run_mapped_websocket_config,
|
||||||
|
start_custom_stream,
|
||||||
|
stop_custom_stream,
|
||||||
|
test_websocket_config,
|
||||||
|
)
|
||||||
from app.services.datasource_connectivity import (
|
from app.services.datasource_connectivity import (
|
||||||
get_builtin_connection_status,
|
get_builtin_connection_status,
|
||||||
save_connectivity_success,
|
save_connectivity_success,
|
||||||
@@ -43,7 +54,7 @@ router = APIRouter()
|
|||||||
class DataSourceConfigCreate(BaseModel):
|
class DataSourceConfigCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
source_type: str = Field(..., description="http, api, database")
|
source_type: str = Field(..., description="rest, websocket, http, api, database")
|
||||||
endpoint: str = Field(..., max_length=500)
|
endpoint: str = Field(..., max_length=500)
|
||||||
auth_type: str = Field(default="none", description="none, bearer, api_key, basic")
|
auth_type: str = Field(default="none", description="none, bearer, api_key, basic")
|
||||||
auth_config: dict = Field(default={})
|
auth_config: dict = Field(default={})
|
||||||
@@ -219,6 +230,8 @@ def _build_query_params(auth_type: str, auth_config: dict, config: dict) -> dict
|
|||||||
|
|
||||||
|
|
||||||
async def fetch_custom_sample_from_config(config: DataSourceConfig, limit_bytes: int) -> Any:
|
async def fetch_custom_sample_from_config(config: DataSourceConfig, limit_bytes: int) -> Any:
|
||||||
|
if str(config.source_type or "").lower() in {"websocket", "ws"}:
|
||||||
|
raise HTTPException(status_code=400, detail="WebSocket sources must use connection test or run-mapped stream.")
|
||||||
request_config = config.config or {}
|
request_config = config.config or {}
|
||||||
method = str(request_config.get("method") or request_config.get("request_method") or "GET").upper()
|
method = str(request_config.get("method") or request_config.get("request_method") or "GET").upper()
|
||||||
if method not in {"GET", "POST"}:
|
if method not in {"GET", "POST"}:
|
||||||
@@ -318,7 +331,7 @@ async def list_configs(
|
|||||||
"""List all user-defined data source configurations"""
|
"""List all user-defined data source configurations"""
|
||||||
query = select(DataSourceConfig)
|
query = select(DataSourceConfig)
|
||||||
if active_only:
|
if active_only:
|
||||||
query = query.where(DataSourceConfig.is_active == True)
|
query = query.where(DataSourceConfig.is_active)
|
||||||
query = query.order_by(DataSourceConfig.created_at.desc())
|
query = query.order_by(DataSourceConfig.created_at.desc())
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
@@ -374,6 +387,11 @@ async def list_all_datasources(
|
|||||||
"is_active": db_config.is_active if db_config else True,
|
"is_active": db_config.is_active if db_config else True,
|
||||||
"source_type": db_config.source_type if db_config else "http",
|
"source_type": db_config.source_type if db_config else "http",
|
||||||
"auth_type": db_config.auth_type if db_config else "none",
|
"auth_type": db_config.auth_type if db_config else "none",
|
||||||
|
"auth_configured": {
|
||||||
|
"api_key": bool((db_config.auth_config or {}).get("api_key"))
|
||||||
|
if db_config
|
||||||
|
else False,
|
||||||
|
},
|
||||||
"headers": db_config.headers if db_config else {},
|
"headers": db_config.headers if db_config else {},
|
||||||
"config": strip_connectivity_validation(db_config.config if db_config else {}),
|
"config": strip_connectivity_validation(db_config.config if db_config else {}),
|
||||||
"config_id": db_config.id if db_config else None,
|
"config_id": db_config.id if db_config else None,
|
||||||
@@ -464,6 +482,8 @@ async def update_config(
|
|||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
if field == "config":
|
if field == "config":
|
||||||
value = strip_connectivity_validation(value)
|
value = strip_connectivity_validation(value)
|
||||||
|
if field == "auth_config" and value == {} and (config.auth_config or {}):
|
||||||
|
continue
|
||||||
setattr(config, field, value)
|
setattr(config, field, value)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -481,6 +501,8 @@ async def update_config(
|
|||||||
@router.delete("/configs/{config_id}")
|
@router.delete("/configs/{config_id}")
|
||||||
async def delete_config(
|
async def delete_config(
|
||||||
config_id: int,
|
config_id: int,
|
||||||
|
delete_mappings: bool = Query(False),
|
||||||
|
delete_source_data: bool = Query(False),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -491,12 +513,59 @@ async def delete_config(
|
|||||||
if not config:
|
if not config:
|
||||||
raise HTTPException(status_code=404, detail="Configuration not found")
|
raise HTTPException(status_code=404, detail="Configuration not found")
|
||||||
|
|
||||||
|
deleted_mappings = 0
|
||||||
|
deleted_records = {
|
||||||
|
"collected_data": 0,
|
||||||
|
"ais_raw_observations": 0,
|
||||||
|
"ais_source_health": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if delete_source_data:
|
||||||
|
collected_result = await db.execute(
|
||||||
|
delete(CollectedData).where(CollectedData.source == config.name)
|
||||||
|
)
|
||||||
|
raw_result = await db.execute(
|
||||||
|
delete(AISRawObservation).where(AISRawObservation.source == config.name)
|
||||||
|
)
|
||||||
|
health_result = await db.execute(
|
||||||
|
delete(AISSourceHealth).where(AISSourceHealth.source == config.name)
|
||||||
|
)
|
||||||
|
deleted_records = {
|
||||||
|
"collected_data": collected_result.rowcount or 0,
|
||||||
|
"ais_raw_observations": raw_result.rowcount or 0,
|
||||||
|
"ais_source_health": health_result.rowcount or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if delete_mappings or delete_source_data:
|
||||||
|
mapping_result = await db.execute(
|
||||||
|
delete(DataSourceMappingTemplate).where(
|
||||||
|
DataSourceMappingTemplate.datasource_config_id == config_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
deleted_mappings = mapping_result.rowcount or 0
|
||||||
|
|
||||||
await db.delete(config)
|
await db.delete(config)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
cache.delete_pattern("datasource_configs:*")
|
cache.delete_pattern("datasource_configs:*")
|
||||||
|
|
||||||
return {"message": "Configuration deleted successfully"}
|
if delete_source_data and (config.config or {}).get("target_schema") == "vessel_ais":
|
||||||
|
from app.core.websocket.broadcaster import broadcaster
|
||||||
|
|
||||||
|
await broadcaster.broadcast_custom(
|
||||||
|
"vessels",
|
||||||
|
{
|
||||||
|
"action": "reload",
|
||||||
|
"source": config.name,
|
||||||
|
"reason": "custom_source_deleted",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Configuration deleted successfully",
|
||||||
|
"deleted_mappings": deleted_mappings,
|
||||||
|
"deleted_records": deleted_records,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/configs/{config_id}/test")
|
@router.post("/configs/{config_id}/test")
|
||||||
@@ -513,6 +582,8 @@ async def test_config(
|
|||||||
raise HTTPException(status_code=404, detail="Configuration not found")
|
raise HTTPException(status_code=404, detail="Configuration not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if str(config.source_type or "").lower() in {"websocket", "ws"}:
|
||||||
|
return await test_websocket_config(config)
|
||||||
result = await test_endpoint(
|
result = await test_endpoint(
|
||||||
endpoint=config.endpoint,
|
endpoint=config.endpoint,
|
||||||
auth_type=config.auth_type,
|
auth_type=config.auth_type,
|
||||||
@@ -543,6 +614,18 @@ async def test_new_config(
|
|||||||
):
|
):
|
||||||
"""Test a new data source configuration without saving"""
|
"""Test a new data source configuration without saving"""
|
||||||
try:
|
try:
|
||||||
|
if str(config_data.source_type or "").lower() in {"websocket", "ws"}:
|
||||||
|
config = DataSourceConfig(
|
||||||
|
name=config_data.name,
|
||||||
|
description=config_data.description,
|
||||||
|
source_type=config_data.source_type,
|
||||||
|
endpoint=config_data.endpoint,
|
||||||
|
auth_type=config_data.auth_type,
|
||||||
|
auth_config=config_data.auth_config,
|
||||||
|
headers=config_data.headers,
|
||||||
|
config=config_data.config,
|
||||||
|
)
|
||||||
|
return await test_websocket_config(config)
|
||||||
result = await test_endpoint(
|
result = await test_endpoint(
|
||||||
endpoint=config_data.endpoint,
|
endpoint=config_data.endpoint,
|
||||||
auth_type=config_data.auth_type,
|
auth_type=config_data.auth_type,
|
||||||
@@ -601,6 +684,7 @@ async def connect_builtin_config(
|
|||||||
config_data.headers,
|
config_data.headers,
|
||||||
config_data.config,
|
config_data.config,
|
||||||
db,
|
db,
|
||||||
|
config_data.auth_config,
|
||||||
)
|
)
|
||||||
if result.get("success") and result.get("checksum"):
|
if result.get("success") and result.get("checksum"):
|
||||||
validation = await save_connectivity_success(
|
validation = await save_connectivity_success(
|
||||||
@@ -867,6 +951,8 @@ async def update_datasource_mapping(
|
|||||||
@router.post("/{config_id}/run-mapped")
|
@router.post("/{config_id}/run-mapped")
|
||||||
async def run_mapped_datasource(
|
async def run_mapped_datasource(
|
||||||
config_id: int,
|
config_id: int,
|
||||||
|
background: bool = Query(False, description="For WebSocket sources, start a background stream task."),
|
||||||
|
debug_max_messages: int | None = Query(None, ge=1),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -875,20 +961,24 @@ async def run_mapped_datasource(
|
|||||||
if not datasource:
|
if not datasource:
|
||||||
raise HTTPException(status_code=404, detail="Configuration not found")
|
raise HTTPException(status_code=404, detail="Configuration not found")
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(DataSourceMappingTemplate)
|
|
||||||
.where(DataSourceMappingTemplate.datasource_config_id == config_id)
|
|
||||||
.where(DataSourceMappingTemplate.is_active.is_(True))
|
|
||||||
.order_by(DataSourceMappingTemplate.version.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
mapping = result.scalar_one_or_none()
|
|
||||||
if not mapping:
|
|
||||||
raise HTTPException(status_code=404, detail="No active mapping template found")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sample = await fetch_custom_sample_from_config(datasource, 5_000_000)
|
if str(datasource.source_type or "").lower() in {"websocket", "ws"}:
|
||||||
mapped = execute_mapping(sample, mapping.mapping_json, mapping.target_schema)
|
if background and debug_max_messages is None:
|
||||||
|
started = start_custom_stream(config_id)
|
||||||
|
if not started:
|
||||||
|
raise HTTPException(status_code=409, detail="Custom WebSocket source is already running")
|
||||||
|
return {
|
||||||
|
"status": "started",
|
||||||
|
"datasource_config_id": config_id,
|
||||||
|
"stream": get_custom_stream_status(config_id),
|
||||||
|
}
|
||||||
|
return await run_mapped_websocket_config(
|
||||||
|
db,
|
||||||
|
datasource,
|
||||||
|
debug_max_messages=debug_max_messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await run_mapped_rest_config(db, datasource)
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=exc.response.status_code,
|
status_code=exc.response.status_code,
|
||||||
@@ -896,36 +986,26 @@ async def run_mapped_datasource(
|
|||||||
) from exc
|
) from exc
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"Datasource request failed: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"Datasource request failed: {exc}") from exc
|
||||||
except (MappingError, ValueError) as exc:
|
except (CustomDatasourceRuntimeError, MappingError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"Mapping failed: {exc}") from exc
|
raise HTTPException(status_code=400, detail=f"Mapping failed: {exc}") from exc
|
||||||
|
|
||||||
if mapped["failed_count"] > 0:
|
|
||||||
return {
|
|
||||||
"status": "failed",
|
|
||||||
"datasource_config_id": config_id,
|
|
||||||
"mapping_id": mapping.id,
|
|
||||||
"mapping_version": mapping.version,
|
|
||||||
"target_schema": mapping.target_schema,
|
|
||||||
"mapped_count": mapped["mapped_count"],
|
|
||||||
"failed_count": mapped["failed_count"],
|
|
||||||
"errors": mapped["errors"][:20],
|
|
||||||
}
|
|
||||||
|
|
||||||
written_count = await persist_mapped_records(
|
@router.post("/{config_id}/stop-mapped")
|
||||||
db,
|
async def stop_mapped_datasource(
|
||||||
datasource_name=datasource.name,
|
config_id: int,
|
||||||
datasource_config_id=datasource.id,
|
current_user: User = Depends(get_current_user),
|
||||||
target_schema=mapping.target_schema,
|
):
|
||||||
records=mapped["records"],
|
stopped = await stop_custom_stream(config_id)
|
||||||
mapping_version=mapping.version,
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "stopped" if stopped else "not_running",
|
||||||
"datasource_config_id": config_id,
|
"datasource_config_id": config_id,
|
||||||
"mapping_id": mapping.id,
|
"stream": get_custom_stream_status(config_id),
|
||||||
"mapping_version": mapping.version,
|
|
||||||
"target_schema": mapping.target_schema,
|
|
||||||
"fetched_count": mapped["total_items"],
|
|
||||||
"mapped_count": mapped["mapped_count"],
|
|
||||||
"written_count": written_count,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{config_id}/stream-status")
|
||||||
|
async def get_mapped_stream_status(
|
||||||
|
config_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return get_custom_stream_status(config_id)
|
||||||
|
|||||||
102
backend/app/api/v1/docs.py
Normal file
102
backend/app/api/v1/docs.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Authenticated documentation APIs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.security import decode_token
|
||||||
|
from app.db.session import async_session_factory
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.docs_gatekeeper import (
|
||||||
|
DOCS_BY_SLUG,
|
||||||
|
VALID_DOCS_LANGS,
|
||||||
|
can_read_doc,
|
||||||
|
catalog_for_user,
|
||||||
|
doc_path_for,
|
||||||
|
title_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
optional_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_optional_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||||
|
) -> User | None:
|
||||||
|
if credentials is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
if payload is None or payload.get("type") != "access" or payload.get("sub") is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||||
|
),
|
||||||
|
{"id": int(payload["sub"])},
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if row is None or not row[5]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found or inactive",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User()
|
||||||
|
user.id = row[0]
|
||||||
|
user.username = row[1]
|
||||||
|
user.email = row[2]
|
||||||
|
user.password_hash = row[3]
|
||||||
|
user.role = row[4]
|
||||||
|
user.is_active = row[5]
|
||||||
|
user.gatekeeper_groups = row[6] or []
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog")
|
||||||
|
async def get_docs_catalog(current_user: User | None = Depends(get_optional_current_user)):
|
||||||
|
return {
|
||||||
|
"items": catalog_for_user(current_user),
|
||||||
|
"authenticated": current_user is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{lang}/{slug}")
|
||||||
|
async def get_doc_content(
|
||||||
|
lang: str,
|
||||||
|
slug: str,
|
||||||
|
current_user: User | None = Depends(get_optional_current_user),
|
||||||
|
):
|
||||||
|
if lang not in VALID_DOCS_LANGS:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
|
entry = DOCS_BY_SLUG.get(slug)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
|
path = doc_path_for(entry, lang)
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
|
if not can_read_doc(entry, current_user):
|
||||||
|
if current_user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient Docs permissions")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"slug": entry.slug,
|
||||||
|
"filename": entry.filename,
|
||||||
|
"lang": lang,
|
||||||
|
"title": title_for(entry, lang),
|
||||||
|
"group": entry.group,
|
||||||
|
"order": entry.order,
|
||||||
|
"access": entry.access,
|
||||||
|
"markdown": path.read_text(encoding="utf-8"),
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ from app.models.datasource import DataSource
|
|||||||
from app.models.datasource_config import DataSourceConfig
|
from app.models.datasource_config import DataSourceConfig
|
||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.vessel import AISSourceHealth
|
||||||
from app.services.barentswatch import (
|
from app.services.barentswatch import (
|
||||||
BarentsWatchConfig,
|
BarentsWatchConfig,
|
||||||
check_barentswatch_config,
|
check_barentswatch_config,
|
||||||
@@ -368,7 +369,12 @@ def format_frequency_label(minutes: int) -> str:
|
|||||||
return f"{minutes}m"
|
return f"{minutes}m"
|
||||||
|
|
||||||
|
|
||||||
def serialize_collector(datasource: DataSource) -> dict:
|
async def get_ais_source_health_by_source(db: AsyncSession) -> dict[str, dict]:
|
||||||
|
result = await db.execute(select(AISSourceHealth))
|
||||||
|
return {item.source: item.to_dict() for item in result.scalars().all()}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_collector(datasource: DataSource, ais_health_by_source: dict[str, dict] | None = None) -> dict:
|
||||||
defaults = DEFAULT_DATASOURCES.get(datasource.source, {})
|
defaults = DEFAULT_DATASOURCES.get(datasource.source, {})
|
||||||
return {
|
return {
|
||||||
"id": datasource.id,
|
"id": datasource.id,
|
||||||
@@ -387,6 +393,7 @@ def serialize_collector(datasource: DataSource) -> dict:
|
|||||||
"requires_credentials": bool(defaults.get("requires_credentials", False)),
|
"requires_credentials": bool(defaults.get("requires_credentials", False)),
|
||||||
"credential_provider": defaults.get("credential_provider"),
|
"credential_provider": defaults.get("credential_provider"),
|
||||||
"credential_status": defaults.get("credential_status", "none"),
|
"credential_status": defaults.get("credential_status", "none"),
|
||||||
|
"ais_health": (ais_health_by_source or {}).get(datasource.source),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -599,7 +606,8 @@ async def get_collector_settings(
|
|||||||
):
|
):
|
||||||
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
|
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
|
||||||
datasources = result.scalars().all()
|
datasources = result.scalars().all()
|
||||||
return {"collectors": [serialize_collector(datasource) for datasource in datasources]}
|
ais_health_by_source = await get_ais_source_health_by_source(db)
|
||||||
|
return {"collectors": [serialize_collector(datasource, ais_health_by_source) for datasource in datasources]}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/collectors/{datasource_id}")
|
@router.put("/collectors/{datasource_id}")
|
||||||
@@ -619,7 +627,8 @@ async def update_collector_settings(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(datasource)
|
await db.refresh(datasource)
|
||||||
await sync_datasource_job(datasource.id)
|
await sync_datasource_job(datasource.id)
|
||||||
return {"status": "updated", "collector": serialize_collector(datasource)}
|
ais_health_by_source = await get_ais_source_health_by_source(db)
|
||||||
|
return {"status": "updated", "collector": serialize_collector(datasource, ais_health_by_source)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -633,12 +642,13 @@ async def get_all_settings(
|
|||||||
db,
|
db,
|
||||||
["system", "notifications", "security"],
|
["system", "notifications", "security"],
|
||||||
)
|
)
|
||||||
|
ais_health_by_source = await get_ais_source_health_by_source(db)
|
||||||
return {
|
return {
|
||||||
"system": setting_payloads["system"],
|
"system": setting_payloads["system"],
|
||||||
"notifications": setting_payloads["notifications"],
|
"notifications": setting_payloads["notifications"],
|
||||||
"security": setting_payloads["security"],
|
"security": setting_payloads["security"],
|
||||||
"tv": await get_tv_settings_payload(db),
|
"tv": await get_tv_settings_payload(db),
|
||||||
"integrations": await serialize_external_integrations(db),
|
"integrations": await serialize_external_integrations(db),
|
||||||
"collectors": [serialize_collector(datasource) for datasource in datasources],
|
"collectors": [serialize_collector(datasource, ais_health_by_source) for datasource in datasources],
|
||||||
"generated_at": to_iso8601_utc(datetime.now(UTC)),
|
"generated_at": to_iso8601_utc(datetime.now(UTC)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
@@ -7,10 +8,12 @@ from sqlalchemy import text
|
|||||||
from app.core.security import get_current_user, get_password_hash
|
from app.core.security import get_current_user, get_password_hash
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.user import UserCreate, UserResponse, UserUpdate
|
from app.schemas.user import UserCreate, UserUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
VALID_GATEKEEPER_GROUPS = {"docs_user", "docs_developer", "docs_admin"}
|
||||||
|
|
||||||
|
|
||||||
def check_permission(current_user: User, required_roles: List[str]) -> bool:
|
def check_permission(current_user: User, required_roles: List[str]) -> bool:
|
||||||
user_role_value = (
|
user_role_value = (
|
||||||
@@ -52,7 +55,7 @@ async def list_users(
|
|||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
query = text(
|
query = text(
|
||||||
f"SELECT id, username, email, role, is_active, last_login_at, created_at FROM users WHERE {where_sql} ORDER BY created_at DESC LIMIT {page_size} OFFSET {offset}"
|
f"SELECT id, username, email, role, is_active, last_login_at, created_at, gatekeeper_groups FROM users WHERE {where_sql} ORDER BY created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||||
)
|
)
|
||||||
count_query = text(f"SELECT COUNT(*) FROM users WHERE {where_sql}")
|
count_query = text(f"SELECT COUNT(*) FROM users WHERE {where_sql}")
|
||||||
|
|
||||||
@@ -75,6 +78,7 @@ async def list_users(
|
|||||||
"is_active": u[4],
|
"is_active": u[4],
|
||||||
"last_login_at": u[5],
|
"last_login_at": u[5],
|
||||||
"created_at": u[6],
|
"created_at": u[6],
|
||||||
|
"gatekeeper_groups": u[7] or [],
|
||||||
}
|
}
|
||||||
for u in users
|
for u in users
|
||||||
],
|
],
|
||||||
@@ -95,7 +99,7 @@ async def get_user(
|
|||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT id, username, email, role, is_active, last_login_at, created_at FROM users WHERE id = :id"
|
"SELECT id, username, email, role, is_active, last_login_at, created_at, gatekeeper_groups FROM users WHERE id = :id"
|
||||||
),
|
),
|
||||||
{"id": user_id},
|
{"id": user_id},
|
||||||
)
|
)
|
||||||
@@ -114,6 +118,7 @@ async def get_user(
|
|||||||
"is_active": user[4],
|
"is_active": user[4],
|
||||||
"last_login_at": user[5],
|
"last_login_at": user[5],
|
||||||
"created_at": user[6],
|
"created_at": user[6],
|
||||||
|
"gatekeeper_groups": user[7] or [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +133,12 @@ async def create_user(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Only super_admin can create users",
|
detail="Only super_admin can create users",
|
||||||
)
|
)
|
||||||
|
invalid_groups = sorted(set(user_data.gatekeeper_groups) - VALID_GATEKEEPER_GROUPS)
|
||||||
|
if invalid_groups:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Unsupported Gatekeeper groups: {', '.join(invalid_groups)}",
|
||||||
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text("SELECT id FROM users WHERE username = :username OR email = :email"),
|
text("SELECT id FROM users WHERE username = :username OR email = :email"),
|
||||||
@@ -142,13 +153,14 @@ async def create_user(
|
|||||||
hashed_password = get_password_hash(user_data.password)
|
hashed_password = get_password_hash(user_data.password)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
text("""INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
text("""INSERT INTO users (username, email, password_hash, role, gatekeeper_groups, is_active, created_at, updated_at)
|
||||||
VALUES (:username, :email, :password_hash, :role, :is_active, NOW(), NOW())"""),
|
VALUES (:username, :email, :password_hash, :role, CAST(:gatekeeper_groups AS jsonb), :is_active, NOW(), NOW())"""),
|
||||||
{
|
{
|
||||||
"username": user_data.username,
|
"username": user_data.username,
|
||||||
"email": user_data.email,
|
"email": user_data.email,
|
||||||
"password_hash": hashed_password,
|
"password_hash": hashed_password,
|
||||||
"role": user_data.role,
|
"role": user_data.role,
|
||||||
|
"gatekeeper_groups": json.dumps(user_data.gatekeeper_groups),
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -172,6 +184,7 @@ async def create_user(
|
|||||||
"username": user_data.username,
|
"username": user_data.username,
|
||||||
"email": user_data.email,
|
"email": user_data.email,
|
||||||
"role": user_data.role,
|
"role": user_data.role,
|
||||||
|
"gatekeeper_groups": user_data.gatekeeper_groups,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +207,18 @@ async def update_user(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Only super_admin can change user role",
|
detail="Only super_admin can change user role",
|
||||||
)
|
)
|
||||||
|
if not check_permission(current_user, ["super_admin"]) and user_data.gatekeeper_groups is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only super_admin can change Gatekeeper groups",
|
||||||
|
)
|
||||||
|
if user_data.gatekeeper_groups is not None:
|
||||||
|
invalid_groups = sorted(set(user_data.gatekeeper_groups) - VALID_GATEKEEPER_GROUPS)
|
||||||
|
if invalid_groups:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Unsupported Gatekeeper groups: {', '.join(invalid_groups)}",
|
||||||
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text("SELECT id FROM users WHERE id = :id"),
|
text("SELECT id FROM users WHERE id = :id"),
|
||||||
@@ -213,6 +238,9 @@ async def update_user(
|
|||||||
if user_data.role is not None:
|
if user_data.role is not None:
|
||||||
update_fields.append("role = :role")
|
update_fields.append("role = :role")
|
||||||
params["role"] = user_data.role
|
params["role"] = user_data.role
|
||||||
|
if user_data.gatekeeper_groups is not None:
|
||||||
|
update_fields.append("gatekeeper_groups = CAST(:gatekeeper_groups AS jsonb)")
|
||||||
|
params["gatekeeper_groups"] = json.dumps(user_data.gatekeeper_groups)
|
||||||
if user_data.is_active is not None:
|
if user_data.is_active is not None:
|
||||||
update_fields.append("is_active = :is_active")
|
update_fields.append("is_active = :is_active")
|
||||||
params["is_active"] = user_data.is_active
|
params["is_active"] = user_data.is_active
|
||||||
|
|||||||
132
backend/app/api/v1/vessel_aggregation.py
Normal file
132
backend/app/api/v1/vessel_aggregation.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""v4 strategy + v5 conflict-promotion + enrichment APIs for vessel_ais."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.vessel import AISConflictRecord
|
||||||
|
from app.services.vessel_aggregation_strategy import (
|
||||||
|
StrategyValidationError,
|
||||||
|
load_strategy,
|
||||||
|
reset_strategy,
|
||||||
|
save_strategy,
|
||||||
|
)
|
||||||
|
from app.services.vessel_enrichment import (
|
||||||
|
get_vessel_enrichment_bundle,
|
||||||
|
upsert_vessel_media_enrichment,
|
||||||
|
upsert_vessel_profile_enrichment,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/strategy")
|
||||||
|
async def get_aggregation_strategy(db: AsyncSession = Depends(get_db)):
|
||||||
|
return await load_strategy(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/strategy")
|
||||||
|
async def put_aggregation_strategy(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await save_strategy(db, payload)
|
||||||
|
except StrategyValidationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/strategy")
|
||||||
|
async def reset_aggregation_strategy(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await reset_strategy(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conflicts/{mmsi}/{field}/promote-to-rule")
|
||||||
|
async def promote_conflict_to_rule(
|
||||||
|
mmsi: int,
|
||||||
|
field: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Lift the current conflict resolution into a persistent strategy rule."""
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(AISConflictRecord)
|
||||||
|
.where(AISConflictRecord.target_schema == "vessel_ais")
|
||||||
|
.where(AISConflictRecord.entity_key == str(mmsi))
|
||||||
|
.where(AISConflictRecord.field == field)
|
||||||
|
.order_by(AISConflictRecord.updated_at.desc(), AISConflictRecord.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record is None or not record.selected_source:
|
||||||
|
raise HTTPException(status_code=404, detail="Conflict record with selected_source not found")
|
||||||
|
|
||||||
|
strategy = await load_strategy(db)
|
||||||
|
vessel_ais = dict(strategy.get("vessel_ais") or {})
|
||||||
|
field_rules = dict(vessel_ais.get("field_rules") or {})
|
||||||
|
field_rules[field] = {"mode": "source_priority", "source_priority": [record.selected_source]}
|
||||||
|
vessel_ais["field_rules"] = field_rules
|
||||||
|
|
||||||
|
incoming = {"version": int(strategy.get("version") or 0), "vessel_ais": vessel_ais}
|
||||||
|
try:
|
||||||
|
return await save_strategy(db, incoming)
|
||||||
|
except StrategyValidationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/conflicts/{mmsi}/{field}/promote-to-rule")
|
||||||
|
async def revert_conflict_rule(
|
||||||
|
mmsi: int,
|
||||||
|
field: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
strategy = await load_strategy(db)
|
||||||
|
vessel_ais = dict(strategy.get("vessel_ais") or {})
|
||||||
|
field_rules = dict(vessel_ais.get("field_rules") or {})
|
||||||
|
if field in field_rules:
|
||||||
|
del field_rules[field]
|
||||||
|
vessel_ais["field_rules"] = field_rules
|
||||||
|
|
||||||
|
incoming = {"version": int(strategy.get("version") or 0), "vessel_ais": vessel_ais}
|
||||||
|
try:
|
||||||
|
return await save_strategy(db, incoming)
|
||||||
|
except StrategyValidationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enrichment/{mmsi}")
|
||||||
|
async def get_vessel_enrichment(mmsi: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
return await get_vessel_enrichment_bundle(db, mmsi)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/enrichment/{mmsi}/profile")
|
||||||
|
async def put_vessel_profile_enrichment(
|
||||||
|
mmsi: int,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await upsert_vessel_profile_enrichment(db, mmsi=mmsi, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/enrichment/{mmsi}/media")
|
||||||
|
async def put_vessel_media_enrichment(
|
||||||
|
mmsi: int,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await upsert_vessel_media_enrichment(db, mmsi=mmsi, payload=payload)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -40,16 +40,16 @@ async def authenticate_token(token: str) -> Optional[dict]:
|
|||||||
@router.websocket("/ws")
|
@router.websocket("/ws")
|
||||||
async def websocket_endpoint(
|
async def websocket_endpoint(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
token: str = Query(...),
|
token: str | None = Query(None),
|
||||||
):
|
):
|
||||||
"""WebSocket endpoint for real-time data"""
|
"""WebSocket endpoint for real-time data"""
|
||||||
logger.info_event(
|
logger.info_event(
|
||||||
"WebSocket connection attempt",
|
"WebSocket connection attempt",
|
||||||
event="auth.websocket.connection_attempt",
|
event="auth.websocket.connection_attempt",
|
||||||
context={"token_preview": f"{token[:8]}..."},
|
context={"token_preview": f"{token[:8]}..." if token else "anonymous"},
|
||||||
)
|
)
|
||||||
payload = await authenticate_token(token)
|
payload = await authenticate_token(token) if token else None
|
||||||
if payload is None:
|
if token and payload is None:
|
||||||
logger.warning_event(
|
logger.warning_event(
|
||||||
"WebSocket authentication failed, closing connection",
|
"WebSocket authentication failed, closing connection",
|
||||||
event="auth.websocket.connection_rejected",
|
event="auth.websocket.connection_rejected",
|
||||||
@@ -57,7 +57,17 @@ async def websocket_endpoint(
|
|||||||
await websocket.close(code=4001)
|
await websocket.close(code=4001)
|
||||||
return
|
return
|
||||||
|
|
||||||
user_id = str(payload.get("sub"))
|
is_anonymous = payload is None
|
||||||
|
user_id = str(payload.get("sub")) if payload else f"anonymous:{id(websocket)}"
|
||||||
|
supported_channels = ["vessels"] if is_anonymous else [
|
||||||
|
"gpu_clusters",
|
||||||
|
"submarine_cables",
|
||||||
|
"ixp_nodes",
|
||||||
|
"alerts",
|
||||||
|
"dashboard",
|
||||||
|
"datasource_tasks",
|
||||||
|
"vessels",
|
||||||
|
]
|
||||||
await manager.connect(websocket, user_id)
|
await manager.connect(websocket, user_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -68,14 +78,7 @@ async def websocket_endpoint(
|
|||||||
"connection_id": f"conn_{user_id}",
|
"connection_id": f"conn_{user_id}",
|
||||||
"server_version": settings.VERSION,
|
"server_version": settings.VERSION,
|
||||||
"heartbeat_interval": 30,
|
"heartbeat_interval": 30,
|
||||||
"supported_channels": [
|
"supported_channels": supported_channels,
|
||||||
"gpu_clusters",
|
|
||||||
"submarine_cables",
|
|
||||||
"ixp_nodes",
|
|
||||||
"alerts",
|
|
||||||
"dashboard",
|
|
||||||
"datasource_tasks",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -93,12 +96,24 @@ async def websocket_endpoint(
|
|||||||
)
|
)
|
||||||
elif data.get("type") == "subscribe":
|
elif data.get("type") == "subscribe":
|
||||||
channels = data.get("data", {}).get("channels", [])
|
channels = data.get("data", {}).get("channels", [])
|
||||||
|
if is_anonymous:
|
||||||
|
channels = [channel for channel in channels if channel in supported_channels]
|
||||||
|
manager.subscribe(websocket, channels)
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "subscription_confirmed",
|
"type": "subscription_confirmed",
|
||||||
"data": {"action": "subscribe", "channels": channels},
|
"data": {"action": "subscribe", "channels": channels},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
elif data.get("type") == "unsubscribe":
|
||||||
|
channels = data.get("data", {}).get("channels", [])
|
||||||
|
manager.unsubscribe(websocket, channels)
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "subscription_confirmed",
|
||||||
|
"data": {"action": "unsubscribe", "channels": channels},
|
||||||
|
}
|
||||||
|
)
|
||||||
elif data.get("type") == "control_frame":
|
elif data.get("type") == "control_frame":
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "control_acknowledged", "data": {"received": True}}
|
{"type": "control_acknowledged", "data": {"received": True}}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from typing import Any, Dict, Optional
|
|||||||
FIELD_ALIASES = {
|
FIELD_ALIASES = {
|
||||||
"country": ("country",),
|
"country": ("country",),
|
||||||
"city": ("city",),
|
"city": ("city",),
|
||||||
"latitude": ("latitude",),
|
"latitude": ("latitude", "lat"),
|
||||||
"longitude": ("longitude",),
|
"longitude": ("longitude", "lon", "lng"),
|
||||||
"value": ("value",),
|
"value": ("value",),
|
||||||
"unit": ("unit",),
|
"unit": ("unit",),
|
||||||
"cores": ("cores",),
|
"cores": ("cores",),
|
||||||
@@ -14,6 +14,28 @@ FIELD_ALIASES = {
|
|||||||
"power": ("power",),
|
"power": ("power",),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NESTED_FIELD_ALIASES = {
|
||||||
|
"latitude": (
|
||||||
|
("location", "latitude"),
|
||||||
|
("location", "lat"),
|
||||||
|
("geo", "latitude"),
|
||||||
|
("geo", "lat"),
|
||||||
|
("coordinates", "latitude"),
|
||||||
|
("coordinates", "lat"),
|
||||||
|
),
|
||||||
|
"longitude": (
|
||||||
|
("location", "longitude"),
|
||||||
|
("location", "lon"),
|
||||||
|
("location", "lng"),
|
||||||
|
("geo", "longitude"),
|
||||||
|
("geo", "lon"),
|
||||||
|
("geo", "lng"),
|
||||||
|
("coordinates", "longitude"),
|
||||||
|
("coordinates", "lon"),
|
||||||
|
("coordinates", "lng"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback: Any = None) -> Any:
|
def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback: Any = None) -> Any:
|
||||||
if isinstance(metadata, dict):
|
if isinstance(metadata, dict):
|
||||||
@@ -21,9 +43,34 @@ def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback:
|
|||||||
value = metadata.get(key)
|
value = metadata.get(key)
|
||||||
if value not in (None, ""):
|
if value not in (None, ""):
|
||||||
return value
|
return value
|
||||||
|
for path in NESTED_FIELD_ALIASES.get(field, ()):
|
||||||
|
current: Any = metadata
|
||||||
|
for key in path:
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = None
|
||||||
|
break
|
||||||
|
current = current.get(key)
|
||||||
|
if current not in (None, ""):
|
||||||
|
return current
|
||||||
|
if field in {"latitude", "longitude"}:
|
||||||
|
value = _get_coordinate_sequence_value(metadata, field)
|
||||||
|
if value not in (None, ""):
|
||||||
|
return value
|
||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _get_coordinate_sequence_value(metadata: Dict[str, Any], field: str) -> Any:
|
||||||
|
for key in ("coordinates", "coord", "coords"):
|
||||||
|
value = metadata.get(key)
|
||||||
|
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||||
|
continue
|
||||||
|
# GeoJSON uses [longitude, latitude]. Most raw collector tuples in this
|
||||||
|
# codebase use explicit field names, so only sequence aliases are treated
|
||||||
|
# as GeoJSON-shaped to avoid guessing.
|
||||||
|
return value[1] if field == "latitude" else value[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_dynamic_metadata(
|
def build_dynamic_metadata(
|
||||||
metadata: Optional[Dict[str, Any]],
|
metadata: Optional[Dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
COLLECTOR_URL_KEYS = {
|
COLLECTOR_URL_KEYS = {
|
||||||
@@ -32,6 +31,7 @@ COLLECTOR_URL_KEYS = {
|
|||||||
"nro_delegated_prefix_geo": "nro.delegated_stats_url",
|
"nro_delegated_prefix_geo": "nro.delegated_stats_url",
|
||||||
"news_live_streams": "news_live_streams.channels_url",
|
"news_live_streams": "news_live_streams.channels_url",
|
||||||
"barentswatch_vessels": "barentswatch_vessels.url",
|
"barentswatch_vessels": "barentswatch_vessels.url",
|
||||||
|
"aisstream_vessels": "aisstream_vessels.url",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ class DataSourcesConfig:
|
|||||||
from app.models.datasource_config import DataSourceConfig
|
from app.models.datasource_config import DataSourceConfig
|
||||||
|
|
||||||
query = select(DataSourceConfig).where(
|
query = select(DataSourceConfig).where(
|
||||||
DataSourceConfig.name == collector_name, DataSourceConfig.is_active == True
|
DataSourceConfig.name == collector_name, DataSourceConfig.is_active
|
||||||
)
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
db_config = result.scalar_one_or_none()
|
db_config = result.scalar_one_or_none()
|
||||||
|
|||||||
@@ -98,3 +98,7 @@ news_live_streams:
|
|||||||
barentswatch_vessels:
|
barentswatch_vessels:
|
||||||
# BarentsWatch Live AIS latest combined endpoint. Requires an AIS bearer token.
|
# BarentsWatch Live AIS latest combined endpoint. Requires an AIS bearer token.
|
||||||
url: "https://live.ais.barentswatch.no/v1/latest/combined"
|
url: "https://live.ais.barentswatch.no/v1/latest/combined"
|
||||||
|
|
||||||
|
aisstream_vessels:
|
||||||
|
# AISStream realtime WebSocket endpoint. Requires an AISStream API key.
|
||||||
|
url: "wss://stream.aisstream.io/v0/stream"
|
||||||
|
|||||||
@@ -245,6 +245,18 @@ DEFAULT_DATASOURCES = {
|
|||||||
"credential_provider": "barentswatch",
|
"credential_provider": "barentswatch",
|
||||||
"credential_status": "supported",
|
"credential_status": "supported",
|
||||||
},
|
},
|
||||||
|
"aisstream_vessels": {
|
||||||
|
"id": 28,
|
||||||
|
"name": "AISStream Vessels",
|
||||||
|
"display_name": "AISStream 实时船舶",
|
||||||
|
"module": "L4",
|
||||||
|
"priority": "P1",
|
||||||
|
"frequency_minutes": 1,
|
||||||
|
"is_free": True,
|
||||||
|
"requires_credentials": True,
|
||||||
|
"credential_provider": "aisstream",
|
||||||
|
"credential_status": "supported",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}
|
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async def get_current_user(
|
|||||||
)
|
)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE id = :id"
|
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||||
),
|
),
|
||||||
{"id": int(user_id)},
|
{"id": int(user_id)},
|
||||||
)
|
)
|
||||||
@@ -122,6 +122,7 @@ async def get_current_user(
|
|||||||
user.password_hash = row[3]
|
user.password_hash = row[3]
|
||||||
user.role = row[4]
|
user.role = row[4]
|
||||||
user.is_active = row[5]
|
user.is_active = row[5]
|
||||||
|
user.gatekeeper_groups = row[6] or []
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ async def get_current_user_refresh(
|
|||||||
)
|
)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE id = :id"
|
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||||
),
|
),
|
||||||
{"id": int(user_id)},
|
{"id": int(user_id)},
|
||||||
)
|
)
|
||||||
@@ -161,6 +162,7 @@ async def get_current_user_refresh(
|
|||||||
user.password_hash = row[3]
|
user.password_hash = row[3]
|
||||||
user.role = row[4]
|
user.role = row[4]
|
||||||
user.is_active = row[5]
|
user.is_active = row[5]
|
||||||
|
user.gatekeeper_groups = row[6] or []
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ class VesselAISRecord(BaseModel):
|
|||||||
sog: float | None = None
|
sog: float | None = None
|
||||||
cog: float | None = Field(default=None, ge=0, le=360)
|
cog: float | None = Field(default=None, ge=0, le=360)
|
||||||
heading: int | None = Field(default=None, ge=0, le=511)
|
heading: int | None = Field(default=None, ge=0, le=511)
|
||||||
|
nav_status: int | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
callsign: str | None = None
|
||||||
vessel_type: str | int | None = None
|
vessel_type: str | int | None = None
|
||||||
|
vessel_type_name: str | None = None
|
||||||
received_at: datetime | None = None
|
received_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -104,8 +107,11 @@ TARGET_SCHEMAS: dict[str, TargetSchema] = {
|
|||||||
TargetField("sog", "float", False, "对地航速,单位节", 12.4),
|
TargetField("sog", "float", False, "对地航速,单位节", 12.4),
|
||||||
TargetField("cog", "float", False, "对地航向,0-360 度", 184.5),
|
TargetField("cog", "float", False, "对地航向,0-360 度", 184.5),
|
||||||
TargetField("heading", "integer", False, "船首向,0-511", 186),
|
TargetField("heading", "integer", False, "船首向,0-511", 186),
|
||||||
|
TargetField("nav_status", "integer", False, "导航状态码", 0),
|
||||||
TargetField("name", "string", False, "船名", "OSLO EXPRESS"),
|
TargetField("name", "string", False, "船名", "OSLO EXPRESS"),
|
||||||
TargetField("vessel_type", "string", False, "船型", "cargo"),
|
TargetField("callsign", "string", False, "呼号", "LAAB"),
|
||||||
|
TargetField("vessel_type", "string", False, "船型代码", 70),
|
||||||
|
TargetField("vessel_type_name", "string", False, "船型名称", "Cargo"),
|
||||||
TargetField("received_at", "datetime", False, "数据接收时间", "2026-04-28T00:00:00Z"),
|
TargetField("received_at", "datetime", False, "数据接收时间", "2026-04-28T00:00:00Z"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class DataBroadcaster:
|
|||||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||||
"payload": data,
|
"payload": data,
|
||||||
},
|
},
|
||||||
channel=channel if channel in manager.active_connections else "all",
|
channel=channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
|
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"""WebSocket Connection Manager"""
|
"""WebSocket Connection Manager"""
|
||||||
|
|
||||||
import json
|
|
||||||
import asyncio
|
|
||||||
from typing import Dict, Set, Optional
|
from typing import Dict, Set, Optional
|
||||||
from datetime import datetime
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
|
|
||||||
@@ -15,6 +12,8 @@ class ConnectionManager:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
|
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
|
||||||
|
self.channel_subscriptions: Dict[str, Set[WebSocket]] = {}
|
||||||
|
self.websocket_channels: Dict[WebSocket, Set[str]] = {}
|
||||||
self.redis_client: Optional[redis.Redis] = None
|
self.redis_client: Optional[redis.Redis] = None
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, user_id: str):
|
async def connect(self, websocket: WebSocket, user_id: str):
|
||||||
@@ -40,6 +39,39 @@ class ConnectionManager:
|
|||||||
self.active_connections[user_id].discard(websocket)
|
self.active_connections[user_id].discard(websocket)
|
||||||
if not self.active_connections[user_id]:
|
if not self.active_connections[user_id]:
|
||||||
del self.active_connections[user_id]
|
del self.active_connections[user_id]
|
||||||
|
self.unsubscribe_all(websocket)
|
||||||
|
|
||||||
|
def subscribe(self, websocket: WebSocket, channels: list[str]):
|
||||||
|
normalized_channels = {
|
||||||
|
str(channel).strip()
|
||||||
|
for channel in channels
|
||||||
|
if str(channel).strip()
|
||||||
|
}
|
||||||
|
if not normalized_channels:
|
||||||
|
return
|
||||||
|
|
||||||
|
socket_channels = self.websocket_channels.setdefault(websocket, set())
|
||||||
|
for channel in normalized_channels:
|
||||||
|
self.channel_subscriptions.setdefault(channel, set()).add(websocket)
|
||||||
|
socket_channels.add(channel)
|
||||||
|
|
||||||
|
def unsubscribe(self, websocket: WebSocket, channels: list[str]):
|
||||||
|
for channel in {str(channel).strip() for channel in channels if str(channel).strip()}:
|
||||||
|
subscribers = self.channel_subscriptions.get(channel)
|
||||||
|
if subscribers is not None:
|
||||||
|
subscribers.discard(websocket)
|
||||||
|
if not subscribers:
|
||||||
|
del self.channel_subscriptions[channel]
|
||||||
|
socket_channels = self.websocket_channels.get(websocket)
|
||||||
|
if socket_channels is not None:
|
||||||
|
socket_channels.discard(channel)
|
||||||
|
if not socket_channels:
|
||||||
|
del self.websocket_channels[websocket]
|
||||||
|
|
||||||
|
def unsubscribe_all(self, websocket: WebSocket):
|
||||||
|
channels = list(self.websocket_channels.get(websocket, set()))
|
||||||
|
if channels:
|
||||||
|
self.unsubscribe(websocket, channels)
|
||||||
|
|
||||||
async def send_personal_message(self, message: dict, user_id: str):
|
async def send_personal_message(self, message: dict, user_id: str):
|
||||||
if user_id in self.active_connections:
|
if user_id in self.active_connections:
|
||||||
@@ -54,13 +86,19 @@ class ConnectionManager:
|
|||||||
for user_id in self.active_connections:
|
for user_id in self.active_connections:
|
||||||
await self.send_personal_message(message, user_id)
|
await self.send_personal_message(message, user_id)
|
||||||
else:
|
else:
|
||||||
await self.send_personal_message(message, channel)
|
for connection in list(self.channel_subscriptions.get(channel, set())):
|
||||||
|
try:
|
||||||
|
await connection.send_json(message)
|
||||||
|
except Exception:
|
||||||
|
self.unsubscribe_all(connection)
|
||||||
|
|
||||||
async def close_all(self):
|
async def close_all(self):
|
||||||
for user_id in self.active_connections:
|
for user_id in self.active_connections:
|
||||||
for connection in self.active_connections[user_id]:
|
for connection in self.active_connections[user_id]:
|
||||||
await connection.close()
|
await connection.close()
|
||||||
self.active_connections.clear()
|
self.active_connections.clear()
|
||||||
|
self.channel_subscriptions.clear()
|
||||||
|
self.websocket_channels.clear()
|
||||||
|
|
||||||
|
|
||||||
manager = ConnectionManager()
|
manager = ConnectionManager()
|
||||||
|
|||||||
328
backend/app/data/seeds/ripe_ris_collector_locations_seed.json
Normal file
328
backend/app/data/seeds/ripe_ris_collector_locations_seed.json
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Seed payload for the bgp_collector_locations DB table. Coordinates were migrated from the legacy RIPE_RIS_COLLECTOR_COORDS table and default to city-center; seeded rows are unverified and should be upgraded in the database with source evidence when known.",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc00",
|
||||||
|
"aliases": ["rrc00", "RIPE RIS rrc00", "AMS-IX"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "AMS-IX",
|
||||||
|
"city": "Amsterdam",
|
||||||
|
"country": "Netherlands",
|
||||||
|
"latitude": 52.3676,
|
||||||
|
"longitude": 4.9041,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc01",
|
||||||
|
"aliases": ["rrc01", "RIPE RIS rrc01", "LINX"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "LINX",
|
||||||
|
"city": "London",
|
||||||
|
"country": "United Kingdom",
|
||||||
|
"latitude": 51.5072,
|
||||||
|
"longitude": -0.1276,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc03",
|
||||||
|
"aliases": ["rrc03", "RIPE RIS rrc03", "AMS-IX"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "AMS-IX",
|
||||||
|
"city": "Amsterdam",
|
||||||
|
"country": "Netherlands",
|
||||||
|
"latitude": 52.3676,
|
||||||
|
"longitude": 4.9041,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc04",
|
||||||
|
"aliases": ["rrc04", "RIPE RIS rrc04", "CIXP", "CERN Internet Exchange Point"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "CIXP",
|
||||||
|
"city": "Geneva",
|
||||||
|
"country": "Switzerland",
|
||||||
|
"latitude": 46.2044,
|
||||||
|
"longitude": 6.1432,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc05",
|
||||||
|
"aliases": ["rrc05", "RIPE RIS rrc05", "VIX", "Vienna Internet Exchange"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "VIX",
|
||||||
|
"city": "Vienna",
|
||||||
|
"country": "Austria",
|
||||||
|
"latitude": 48.2082,
|
||||||
|
"longitude": 16.3738,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc06",
|
||||||
|
"aliases": ["rrc06", "RIPE RIS rrc06", "JPIX", "Otemachi"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "JPIX",
|
||||||
|
"city": "Otemachi",
|
||||||
|
"country": "Japan",
|
||||||
|
"latitude": 35.686,
|
||||||
|
"longitude": 139.7671,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc07",
|
||||||
|
"aliases": ["rrc07", "RIPE RIS rrc07", "Netnod", "Netnod Stockholm"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "Netnod Stockholm",
|
||||||
|
"city": "Stockholm",
|
||||||
|
"country": "Sweden",
|
||||||
|
"latitude": 59.3293,
|
||||||
|
"longitude": 18.0686,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc10",
|
||||||
|
"aliases": ["rrc10", "RIPE RIS rrc10", "MIX", "Milan Internet Exchange"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "MIX",
|
||||||
|
"city": "Milan",
|
||||||
|
"country": "Italy",
|
||||||
|
"latitude": 45.4642,
|
||||||
|
"longitude": 9.19,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc11",
|
||||||
|
"aliases": ["rrc11", "RIPE RIS rrc11", "NYIIX", "New York International Internet Exchange"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "NYIIX",
|
||||||
|
"city": "New York",
|
||||||
|
"country": "United States",
|
||||||
|
"latitude": 40.7128,
|
||||||
|
"longitude": -74.006,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc12",
|
||||||
|
"aliases": ["rrc12", "RIPE RIS rrc12", "DE-CIX", "DE-CIX Frankfurt"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "DE-CIX Frankfurt",
|
||||||
|
"city": "Frankfurt",
|
||||||
|
"country": "Germany",
|
||||||
|
"latitude": 50.1109,
|
||||||
|
"longitude": 8.6821,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc13",
|
||||||
|
"aliases": ["rrc13", "RIPE RIS rrc13", "MSK-IX"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "MSK-IX",
|
||||||
|
"city": "Moscow",
|
||||||
|
"country": "Russia",
|
||||||
|
"latitude": 55.7558,
|
||||||
|
"longitude": 37.6173,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc14",
|
||||||
|
"aliases": ["rrc14", "RIPE RIS rrc14", "PAIX", "Palo Alto Internet Exchange"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "PAIX",
|
||||||
|
"city": "Palo Alto",
|
||||||
|
"country": "United States",
|
||||||
|
"latitude": 37.4419,
|
||||||
|
"longitude": -122.143,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc15",
|
||||||
|
"aliases": ["rrc15", "RIPE RIS rrc15", "PTT.br Sao Paulo", "PTTMetro Sao Paulo"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "PTT.br",
|
||||||
|
"city": "Sao Paulo",
|
||||||
|
"country": "Brazil",
|
||||||
|
"latitude": -23.5558,
|
||||||
|
"longitude": -46.6396,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc16",
|
||||||
|
"aliases": ["rrc16", "RIPE RIS rrc16", "Equinix Miami", "NOTA Miami"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "Equinix Miami",
|
||||||
|
"city": "Miami",
|
||||||
|
"country": "United States",
|
||||||
|
"latitude": 25.7617,
|
||||||
|
"longitude": -80.1918,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc18",
|
||||||
|
"aliases": ["rrc18", "RIPE RIS rrc18", "CATNIX"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "CATNIX",
|
||||||
|
"city": "Barcelona",
|
||||||
|
"country": "Spain",
|
||||||
|
"latitude": 41.3874,
|
||||||
|
"longitude": 2.1686,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc19",
|
||||||
|
"aliases": ["rrc19", "RIPE RIS rrc19", "NAPAfrica", "JINX", "NAPAfrica Johannesburg"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "NAPAfrica Johannesburg",
|
||||||
|
"city": "Johannesburg",
|
||||||
|
"country": "South Africa",
|
||||||
|
"latitude": -26.2041,
|
||||||
|
"longitude": 28.0473,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc20",
|
||||||
|
"aliases": ["rrc20", "RIPE RIS rrc20", "SwissIX"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "SwissIX",
|
||||||
|
"city": "Zurich",
|
||||||
|
"country": "Switzerland",
|
||||||
|
"latitude": 47.3769,
|
||||||
|
"longitude": 8.5417,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc21",
|
||||||
|
"aliases": ["rrc21", "RIPE RIS rrc21", "France-IX Paris"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "France-IX Paris",
|
||||||
|
"city": "Paris",
|
||||||
|
"country": "France",
|
||||||
|
"latitude": 48.8566,
|
||||||
|
"longitude": 2.3522,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc22",
|
||||||
|
"aliases": ["rrc22", "RIPE RIS rrc22", "InterLAN Bucharest"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "InterLAN Bucharest",
|
||||||
|
"city": "Bucharest",
|
||||||
|
"country": "Romania",
|
||||||
|
"latitude": 44.4268,
|
||||||
|
"longitude": 26.1025,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc23",
|
||||||
|
"aliases": ["rrc23", "RIPE RIS rrc23", "Equinix Singapore"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "Equinix Singapore",
|
||||||
|
"city": "Singapore",
|
||||||
|
"country": "Singapore",
|
||||||
|
"latitude": 1.3521,
|
||||||
|
"longitude": 103.8198,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc24",
|
||||||
|
"aliases": ["rrc24", "RIPE RIS rrc24", "LACNIC Montevideo"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "LACNIC Montevideo",
|
||||||
|
"city": "Montevideo",
|
||||||
|
"country": "Uruguay",
|
||||||
|
"latitude": -34.9011,
|
||||||
|
"longitude": -56.1645,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc25",
|
||||||
|
"aliases": ["rrc25", "RIPE RIS rrc25", "AMS-IX"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "AMS-IX",
|
||||||
|
"city": "Amsterdam",
|
||||||
|
"country": "Netherlands",
|
||||||
|
"latitude": 52.3676,
|
||||||
|
"longitude": 4.9041,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "RIPE RIS rrc26",
|
||||||
|
"aliases": ["rrc26", "RIPE RIS rrc26", "UAE-IX"],
|
||||||
|
"operator": "RIPE NCC",
|
||||||
|
"site": "UAE-IX",
|
||||||
|
"city": "Dubai",
|
||||||
|
"country": "United Arab Emirates",
|
||||||
|
"latitude": 25.2048,
|
||||||
|
"longitude": 55.2708,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||||
|
"verified_at": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"city_fallbacks": []
|
||||||
|
}
|
||||||
@@ -103,14 +103,17 @@ async def init_db():
|
|||||||
import app.models.datasource_config # noqa: F401
|
import app.models.datasource_config # noqa: F401
|
||||||
import app.models.alert # noqa: F401
|
import app.models.alert # noqa: F401
|
||||||
import app.models.bgp_anomaly # noqa: F401
|
import app.models.bgp_anomaly # noqa: F401
|
||||||
|
import app.models.bgp_collector_location # noqa: F401
|
||||||
import app.models.bgp_incident # noqa: F401
|
import app.models.bgp_incident # noqa: F401
|
||||||
import app.models.bgp_observation # noqa: F401
|
import app.models.bgp_observation # noqa: F401
|
||||||
import app.models.collected_data # noqa: F401
|
import app.models.collected_data # noqa: F401
|
||||||
|
import app.models.compute_center_location # noqa: F401
|
||||||
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
|
import app.models.system_log # noqa: F401
|
||||||
import app.models.vessel # noqa: F401
|
import app.models.vessel # noqa: F401
|
||||||
|
import app.models.vessel_enrichment # noqa: F401
|
||||||
import app.models.datasource_mapping # noqa: F401
|
import app.models.datasource_mapping # noqa: F401
|
||||||
|
|
||||||
logger.warning_event(
|
logger.warning_event(
|
||||||
@@ -127,6 +130,14 @@ async def init_db():
|
|||||||
|
|
||||||
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)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS gatekeeper_groups JSONB DEFAULT '[]'::jsonb
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
@@ -163,6 +174,30 @@ async def init_db():
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collected_data_source_current_id
|
||||||
|
ON collected_data (source, is_current, id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collected_data_source_task_id
|
||||||
|
ON collected_data (source, task_id, id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ais_raw_schema_observed_entity
|
||||||
|
ON ais_raw_observations (target_schema, observed_at, entity_key)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
@@ -183,5 +218,14 @@ async def init_db():
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
|
from app.services.bgp_collector_locations import (
|
||||||
|
seed_default_bgp_collector_locations,
|
||||||
|
)
|
||||||
|
from app.services.compute_center_locations import (
|
||||||
|
seed_compute_center_locations_from_source_coords,
|
||||||
|
)
|
||||||
|
|
||||||
|
await seed_default_bgp_collector_locations(session)
|
||||||
|
await seed_compute_center_locations_from_source_coords(session)
|
||||||
await seed_default_datasources(session)
|
await seed_default_datasources(session)
|
||||||
await ensure_default_admin_user(session)
|
await ensure_default_admin_user(session)
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ from app.models.datasource import DataSource
|
|||||||
from app.models.datasource_config import DataSourceConfig
|
from app.models.datasource_config import DataSourceConfig
|
||||||
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||||
from app.models.bgp_anomaly import BGPAnomaly
|
from app.models.bgp_anomaly import BGPAnomaly
|
||||||
|
from app.models.bgp_collector_location import BGPCollectorLocation
|
||||||
from app.models.bgp_incident import BGPIncident
|
from app.models.bgp_incident import BGPIncident
|
||||||
from app.models.bgp_observation import BGPObservation
|
from app.models.bgp_observation import BGPObservation
|
||||||
|
from app.models.compute_center_location import ComputeCenterLocationRecord
|
||||||
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
|
from app.models.system_log import SystemLog, AuditLog
|
||||||
from app.models.vessel import VesselPosition, VesselStatic
|
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth, VesselPosition, VesselStatic
|
||||||
from app.models.datasource_mapping import DataSourceMappingTemplate
|
from app.models.datasource_mapping import DataSourceMappingTemplate
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -27,11 +29,18 @@ __all__ = [
|
|||||||
"AlertSeverity",
|
"AlertSeverity",
|
||||||
"AlertStatus",
|
"AlertStatus",
|
||||||
"BGPAnomaly",
|
"BGPAnomaly",
|
||||||
|
"BGPCollectorLocation",
|
||||||
"BGPIncident",
|
"BGPIncident",
|
||||||
"BGPObservation",
|
"BGPObservation",
|
||||||
|
"ComputeCenterLocationRecord",
|
||||||
"SystemLog",
|
"SystemLog",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
|
"PlaygroundSession",
|
||||||
|
"PlaygroundMessage",
|
||||||
"VesselPosition",
|
"VesselPosition",
|
||||||
"VesselStatic",
|
"VesselStatic",
|
||||||
|
"AISRawObservation",
|
||||||
|
"AISConflictRecord",
|
||||||
|
"AISSourceHealth",
|
||||||
"DataSourceMappingTemplate",
|
"DataSourceMappingTemplate",
|
||||||
]
|
]
|
||||||
|
|||||||
52
backend/app/models/bgp_collector_location.py
Normal file
52
backend/app/models/bgp_collector_location.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Stored BGP route-collector locations."""
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.core.time import to_iso8601_utc
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BGPCollectorLocation(Base):
|
||||||
|
"""Current known location for a BGP route collector."""
|
||||||
|
|
||||||
|
__tablename__ = "bgp_collector_locations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
collector_id = Column(String(100), nullable=False, unique=True, index=True)
|
||||||
|
operator = Column(String(255), nullable=True)
|
||||||
|
site = Column(String(255), nullable=True)
|
||||||
|
city = Column(String(255), nullable=True)
|
||||||
|
country = Column(String(255), nullable=True)
|
||||||
|
latitude = Column(Float, nullable=True)
|
||||||
|
longitude = Column(Float, nullable=True)
|
||||||
|
precision = Column(String(30), nullable=False, default="city")
|
||||||
|
confidence = Column(Float, nullable=True)
|
||||||
|
source = Column(String(80), nullable=False, default="legacy_seed", index=True)
|
||||||
|
source_url = Column(String(500), nullable=True)
|
||||||
|
source_note = Column(Text, nullable=True)
|
||||||
|
raw_payload = Column(JSON, nullable=False, default=dict)
|
||||||
|
needs_confirmation = Column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
verification_status = Column(String(30), nullable=False, default="unverified", index=True)
|
||||||
|
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
def to_location_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"city": self.city,
|
||||||
|
"country": self.country,
|
||||||
|
"latitude": self.latitude,
|
||||||
|
"longitude": self.longitude,
|
||||||
|
"precision": self.precision,
|
||||||
|
"source": self.source,
|
||||||
|
"needs_confirmation": self.needs_confirmation,
|
||||||
|
"matched_location_name": self.site or self.collector_id,
|
||||||
|
"verified_at": to_iso8601_utc(self.verified_at),
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"operator": self.operator,
|
||||||
|
"site": self.site,
|
||||||
|
"verification_status": self.verification_status,
|
||||||
|
"source_note": self.source_note,
|
||||||
|
"source_url": self.source_url,
|
||||||
|
}
|
||||||
@@ -48,6 +48,8 @@ class CollectedData(Base):
|
|||||||
# Indexes for common queries
|
# Indexes for common queries
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_collected_data_source_collected", "source", "collected_at"),
|
Index("idx_collected_data_source_collected", "source", "collected_at"),
|
||||||
|
Index("idx_collected_data_source_current_id", "source", "is_current", "id"),
|
||||||
|
Index("idx_collected_data_source_task_id", "source", "task_id", "id"),
|
||||||
Index("idx_collected_data_source_type", "source", "data_type"),
|
Index("idx_collected_data_source_type", "source", "data_type"),
|
||||||
Index("idx_collected_data_source_source_id", "source", "source_id"),
|
Index("idx_collected_data_source_source_id", "source", "source_id"),
|
||||||
)
|
)
|
||||||
|
|||||||
60
backend/app/models/compute_center_location.py
Normal file
60
backend/app/models/compute_center_location.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Stored compute-center locations."""
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.core.time import to_iso8601_utc
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeCenterLocationRecord(Base):
|
||||||
|
"""Current known location for a compute-center record."""
|
||||||
|
|
||||||
|
__tablename__ = "compute_center_locations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("source", "source_id", name="uq_compute_center_location_source_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
source = Column(String(100), nullable=False, index=True)
|
||||||
|
source_id = Column(String(255), nullable=False, index=True)
|
||||||
|
name = Column(String(500), nullable=True)
|
||||||
|
operator = Column(String(255), nullable=True)
|
||||||
|
site = Column(String(255), nullable=True)
|
||||||
|
city = Column(String(255), nullable=True)
|
||||||
|
country = Column(String(255), nullable=True)
|
||||||
|
latitude = Column(Float, nullable=True)
|
||||||
|
longitude = Column(Float, nullable=True)
|
||||||
|
precision = Column(String(30), nullable=False, default="city")
|
||||||
|
confidence = Column(Float, nullable=True)
|
||||||
|
location_source = Column(String(80), nullable=False, default="stored_compute_center_location", index=True)
|
||||||
|
source_url = Column(String(500), nullable=True)
|
||||||
|
source_note = Column(Text, nullable=True)
|
||||||
|
raw_payload = Column(JSON, nullable=False, default=dict)
|
||||||
|
needs_confirmation = Column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
verification_status = Column(String(30), nullable=False, default="verified", index=True)
|
||||||
|
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
def to_location_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"source": self.source,
|
||||||
|
"source_id": self.source_id,
|
||||||
|
"name": self.name,
|
||||||
|
"operator": self.operator,
|
||||||
|
"site": self.site,
|
||||||
|
"city": self.city,
|
||||||
|
"country": self.country,
|
||||||
|
"latitude": self.latitude,
|
||||||
|
"longitude": self.longitude,
|
||||||
|
"precision": self.precision,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"location_source": self.location_source,
|
||||||
|
"source_url": self.source_url,
|
||||||
|
"source_note": self.source_note,
|
||||||
|
"raw_payload": self.raw_payload or {},
|
||||||
|
"needs_confirmation": self.needs_confirmation,
|
||||||
|
"verification_status": self.verification_status,
|
||||||
|
"verified_at": to_iso8601_utc(self.verified_at),
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Boolean, Column, Integer, String, DateTime
|
from sqlalchemy import Boolean, Column, DateTime, Integer, JSON, String
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
@@ -12,6 +12,7 @@ class User(Base):
|
|||||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
role = Column(String(20), default="viewer")
|
role = Column(String(20), default="viewer")
|
||||||
|
gatekeeper_groups = Column(JSON, default=list)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
last_login_at = Column(DateTime(timezone=True))
|
last_login_at = Column(DateTime(timezone=True))
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Vessel AIS models for live maritime tracking."""
|
"""Vessel AIS models for live maritime tracking."""
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Column, DateTime, Float, Index, Integer, SmallInteger, String
|
from sqlalchemy import BigInteger, Column, DateTime, Float, Index, Integer, JSON, SmallInteger, String
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
@@ -73,3 +73,114 @@ class VesselPosition(Base):
|
|||||||
"nav_status": self.nav_status,
|
"nav_status": self.nav_status,
|
||||||
"received_at": to_iso8601_utc(self.received_at),
|
"received_at": to_iso8601_utc(self.received_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AISRawObservation(Base):
|
||||||
|
"""Source-level AIS fact before aggregation and conflict resolution."""
|
||||||
|
|
||||||
|
__tablename__ = "ais_raw_observations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
target_schema = Column(String(64), nullable=False, default="vessel_ais", index=True)
|
||||||
|
source = Column(String(100), nullable=False, index=True)
|
||||||
|
entity_key = Column(String(64), nullable=False, index=True)
|
||||||
|
delivery_mode = Column(String(32), nullable=False, index=True)
|
||||||
|
transport = Column(String(32), nullable=False, index=True)
|
||||||
|
message_type = Column(String(64), nullable=True, index=True)
|
||||||
|
source_message_id = Column(String(128), nullable=True, index=True)
|
||||||
|
observation_hash = Column(String(64), nullable=False, unique=True, index=True)
|
||||||
|
observed_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
||||||
|
normalized_payload = Column(JSON, default=dict)
|
||||||
|
raw_payload = Column(JSON, default=dict)
|
||||||
|
quality_flags = Column(JSON, default=list)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_ais_raw_entity_observed", "target_schema", "entity_key", "observed_at"),
|
||||||
|
Index("idx_ais_raw_schema_observed_entity", "target_schema", "observed_at", "entity_key"),
|
||||||
|
Index("idx_ais_raw_source_entity", "source", "entity_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"target_schema": self.target_schema,
|
||||||
|
"source": self.source,
|
||||||
|
"entity_key": self.entity_key,
|
||||||
|
"delivery_mode": self.delivery_mode,
|
||||||
|
"transport": self.transport,
|
||||||
|
"message_type": self.message_type,
|
||||||
|
"source_message_id": self.source_message_id,
|
||||||
|
"observation_hash": self.observation_hash,
|
||||||
|
"observed_at": to_iso8601_utc(self.observed_at),
|
||||||
|
"collected_at": to_iso8601_utc(self.collected_at),
|
||||||
|
"normalized_payload": self.normalized_payload or {},
|
||||||
|
"raw_payload": self.raw_payload or {},
|
||||||
|
"quality_flags": self.quality_flags or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AISConflictRecord(Base):
|
||||||
|
"""Recorded field-level disagreement between AIS sources."""
|
||||||
|
|
||||||
|
__tablename__ = "ais_conflict_records"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
target_schema = Column(String(64), nullable=False, default="vessel_ais", index=True)
|
||||||
|
entity_key = Column(String(64), nullable=False, index=True)
|
||||||
|
field = Column(String(64), nullable=False, index=True)
|
||||||
|
candidates = Column(JSON, default=dict)
|
||||||
|
selected_source = Column(String(100), nullable=True, index=True)
|
||||||
|
selected_value = Column(JSON, nullable=True)
|
||||||
|
selected_reason = Column(String(64), nullable=True, index=True)
|
||||||
|
resolved_by = Column(String(32), nullable=False, default="system", index=True)
|
||||||
|
status = Column(String(32), nullable=False, default="open", index=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_ais_conflict_entity_field", "target_schema", "entity_key", "field"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"target_schema": self.target_schema,
|
||||||
|
"entity_key": self.entity_key,
|
||||||
|
"field": self.field,
|
||||||
|
"candidates": self.candidates or {},
|
||||||
|
"selected_source": self.selected_source,
|
||||||
|
"selected_value": self.selected_value,
|
||||||
|
"selected_reason": self.selected_reason,
|
||||||
|
"resolved_by": self.resolved_by,
|
||||||
|
"status": self.status,
|
||||||
|
"created_at": to_iso8601_utc(self.created_at),
|
||||||
|
"updated_at": to_iso8601_utc(self.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AISSourceHealth(Base):
|
||||||
|
"""Runtime health signal for an AIS collector source."""
|
||||||
|
|
||||||
|
__tablename__ = "ais_source_health"
|
||||||
|
|
||||||
|
source = Column(String(100), primary_key=True)
|
||||||
|
connection_state = Column(String(32), nullable=False, default="disconnected", index=True)
|
||||||
|
last_seen_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
last_success_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
last_error = Column(String(500), nullable=True)
|
||||||
|
message_rate = Column(Float, nullable=True)
|
||||||
|
lag_seconds = Column(Float, nullable=True)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"source": self.source,
|
||||||
|
"connection_state": self.connection_state,
|
||||||
|
"last_seen_at": to_iso8601_utc(self.last_seen_at),
|
||||||
|
"last_success_at": to_iso8601_utc(self.last_success_at),
|
||||||
|
"last_error": self.last_error,
|
||||||
|
"message_rate": self.message_rate,
|
||||||
|
"lag_seconds": self.lag_seconds,
|
||||||
|
"updated_at": to_iso8601_utc(self.updated_at),
|
||||||
|
}
|
||||||
|
|||||||
63
backend/app/models/vessel_enrichment.py
Normal file
63
backend/app/models/vessel_enrichment.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Vessel enrichment cache tables (v5).
|
||||||
|
|
||||||
|
Profile and media enrichment are stored separately so cache TTLs can differ
|
||||||
|
and so the conflict-resolution + display layers can read either independently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Column, DateTime, Float, JSON, String
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.core.time import to_iso8601_utc
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class VesselProfileEnrichment(Base):
|
||||||
|
"""Cached static vessel profile (type, flag, dimensions, operator, etc.)."""
|
||||||
|
|
||||||
|
__tablename__ = "vessel_profile_enrichment"
|
||||||
|
|
||||||
|
mmsi = Column(BigInteger, primary_key=True)
|
||||||
|
source = Column(String(100), nullable=False, default="system")
|
||||||
|
payload = Column(JSON, nullable=False, default=dict)
|
||||||
|
fetched_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
confidence = Column(Float, nullable=True)
|
||||||
|
reference_url = Column(String(500), nullable=True)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"mmsi": self.mmsi,
|
||||||
|
"source": self.source,
|
||||||
|
"payload": self.payload or {},
|
||||||
|
"fetched_at": to_iso8601_utc(self.fetched_at),
|
||||||
|
"expires_at": to_iso8601_utc(self.expires_at),
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"reference_url": self.reference_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VesselMediaEnrichment(Base):
|
||||||
|
"""Cached vessel imagery / external detail references."""
|
||||||
|
|
||||||
|
__tablename__ = "vessel_media_enrichment"
|
||||||
|
|
||||||
|
mmsi = Column(BigInteger, primary_key=True)
|
||||||
|
source = Column(String(100), nullable=False, default="system")
|
||||||
|
payload = Column(JSON, nullable=False, default=dict)
|
||||||
|
fetched_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
confidence = Column(Float, nullable=True)
|
||||||
|
reference_url = Column(String(500), nullable=True)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"mmsi": self.mmsi,
|
||||||
|
"source": self.source,
|
||||||
|
"payload": self.payload or {},
|
||||||
|
"fetched_at": to_iso8601_utc(self.fetched_at),
|
||||||
|
"expires_at": to_iso8601_utc(self.expires_at),
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"reference_url": self.reference_url,
|
||||||
|
}
|
||||||
@@ -12,17 +12,20 @@ class UserBase(BaseModel):
|
|||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: str = Field(..., min_length=8)
|
password: str = Field(..., min_length=8)
|
||||||
role: str = "viewer"
|
role: str = "viewer"
|
||||||
|
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
role: Optional[str] = None
|
role: Optional[str] = None
|
||||||
|
gatekeeper_groups: Optional[list[str]] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class UserInDB(UserBase):
|
class UserInDB(UserBase):
|
||||||
id: int
|
id: int
|
||||||
role: str
|
role: str
|
||||||
|
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||||
is_active: bool
|
is_active: bool
|
||||||
last_login_at: Optional[datetime]
|
last_login_at: Optional[datetime]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -34,6 +37,7 @@ class UserInDB(UserBase):
|
|||||||
class UserResponse(UserBase):
|
class UserResponse(UserBase):
|
||||||
id: int
|
id: int
|
||||||
role: str
|
role: str
|
||||||
|
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|||||||
306
backend/app/services/bgp_collector_locations.py
Normal file
306
backend/app/services/bgp_collector_locations.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
"""BGP route-collector location resolver.
|
||||||
|
|
||||||
|
Collector positions are stored in the ``bgp_collector_locations`` database
|
||||||
|
table. The old JSON registry is now only a seed payload used during database
|
||||||
|
initialization, not a runtime resolver or candidate source.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.bgp_collector_location import BGPCollectorLocation
|
||||||
|
from app.services.location import (
|
||||||
|
LocationCandidate,
|
||||||
|
LocationPipeline,
|
||||||
|
LocationQuery,
|
||||||
|
NominatimResolver,
|
||||||
|
ResolutionResult,
|
||||||
|
ResolverOutput,
|
||||||
|
SourceCoordinatesResolver,
|
||||||
|
build_default_nominatim_geocoder,
|
||||||
|
coerce_str,
|
||||||
|
normalize_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
SEED_PATH = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "data"
|
||||||
|
/ "seeds"
|
||||||
|
/ "ripe_ris_collector_locations_seed.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Geocoder (kept at module level for monkeypatching + cache_clear) ──
|
||||||
|
|
||||||
|
_geocode_online = build_default_nominatim_geocoder()
|
||||||
|
|
||||||
|
|
||||||
|
# ── In-process compatibility cache ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _collector_record_to_dict(record: BGPCollectorLocation) -> dict[str, Any]:
|
||||||
|
return record.to_location_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def set_bgp_collector_location_cache(
|
||||||
|
locations: dict[str, dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Replace the legacy compatibility cache in-place."""
|
||||||
|
RIPE_RIS_COLLECTOR_COORDS.clear()
|
||||||
|
RIPE_RIS_COLLECTOR_COORDS.update(
|
||||||
|
{coerce_str(key): dict(value) for key, value in locations.items()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_bgp_collector_location_cache(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
result = await session.execute(select(BGPCollectorLocation))
|
||||||
|
records = result.scalars().all()
|
||||||
|
cache = {
|
||||||
|
record.collector_id: _collector_record_to_dict(record)
|
||||||
|
for record in records
|
||||||
|
if record.collector_id
|
||||||
|
}
|
||||||
|
set_bgp_collector_location_cache(cache)
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
def _load_seed_payload() -> dict[str, Any]:
|
||||||
|
with SEED_PATH.open("r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_entry_to_record_kwargs(entry: dict[str, Any], collector_id: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"collector_id": collector_id,
|
||||||
|
"operator": entry.get("operator") or "RIPE NCC",
|
||||||
|
"site": entry.get("site"),
|
||||||
|
"city": entry.get("city"),
|
||||||
|
"country": entry.get("country"),
|
||||||
|
"latitude": entry.get("latitude"),
|
||||||
|
"longitude": entry.get("longitude"),
|
||||||
|
"precision": entry.get("precision") or "city",
|
||||||
|
"confidence": entry.get("confidence"),
|
||||||
|
"source": "legacy_seed",
|
||||||
|
"source_url": None,
|
||||||
|
"source_note": entry.get("source_note")
|
||||||
|
or "Seeded from legacy RIPE RIS collector coordinates",
|
||||||
|
"raw_payload": entry,
|
||||||
|
"needs_confirmation": True,
|
||||||
|
"verification_status": "unverified",
|
||||||
|
"verified_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_default_bgp_collector_locations(session: AsyncSession) -> None:
|
||||||
|
"""Seed default RIPE RIS collector locations without overwriting users."""
|
||||||
|
payload = _load_seed_payload()
|
||||||
|
for entry in payload.get("locations", []):
|
||||||
|
aliases = entry.get("aliases") or []
|
||||||
|
collector_ids = [
|
||||||
|
coerce_str(alias)
|
||||||
|
for alias in aliases
|
||||||
|
if coerce_str(alias).startswith("rrc")
|
||||||
|
]
|
||||||
|
if not collector_ids:
|
||||||
|
continue
|
||||||
|
collector_id = collector_ids[0]
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(BGPCollectorLocation).where(
|
||||||
|
BGPCollectorLocation.collector_id == collector_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
session.add(
|
||||||
|
BGPCollectorLocation(
|
||||||
|
**_seed_entry_to_record_kwargs(entry, collector_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await refresh_bgp_collector_location_cache(session)
|
||||||
|
|
||||||
|
|
||||||
|
def get_bgp_collector_location_dict(collector_name: str) -> dict[str, Any]:
|
||||||
|
"""Return the current cached collector location dict, or ``{}`` if unknown."""
|
||||||
|
return dict(RIPE_RIS_COLLECTOR_COORDS.get(coerce_str(collector_name), {}))
|
||||||
|
|
||||||
|
|
||||||
|
def iter_known_collector_names() -> Iterator[str]:
|
||||||
|
"""Yield every collector technical name (rrcXX) known in the cache."""
|
||||||
|
return iter(sorted(RIPE_RIS_COLLECTOR_COORDS.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pipeline construction ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class StoredCollectorLocationResolver:
|
||||||
|
"""Resolve a collector through the DB-backed compatibility cache."""
|
||||||
|
|
||||||
|
name = "stored_collector_location"
|
||||||
|
|
||||||
|
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||||
|
collector = coerce_str(query.name)
|
||||||
|
if not collector:
|
||||||
|
for alias in query.aliases:
|
||||||
|
collector = coerce_str(alias)
|
||||||
|
if collector:
|
||||||
|
break
|
||||||
|
if not collector:
|
||||||
|
return ResolverOutput()
|
||||||
|
location = get_bgp_collector_location_dict(collector)
|
||||||
|
if not location:
|
||||||
|
return ResolverOutput()
|
||||||
|
latitude = location.get("latitude")
|
||||||
|
longitude = location.get("longitude")
|
||||||
|
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||||
|
return ResolverOutput()
|
||||||
|
return ResolverOutput(
|
||||||
|
candidates=(
|
||||||
|
LocationCandidate(
|
||||||
|
latitude=float(latitude),
|
||||||
|
longitude=float(longitude),
|
||||||
|
display_name=location.get("matched_location_name") or collector,
|
||||||
|
precision=location.get("precision") or "city",
|
||||||
|
confidence=float(location.get("confidence") or 0.85),
|
||||||
|
query=f"stored_collector_location::{collector}",
|
||||||
|
source=location.get("source") or self.name,
|
||||||
|
source_note=location.get("source_note"),
|
||||||
|
matched_fields=("collector",),
|
||||||
|
needs_confirmation=bool(location.get("needs_confirmation")),
|
||||||
|
city=location.get("city"),
|
||||||
|
region=None,
|
||||||
|
country=location.get("country"),
|
||||||
|
matched_location_name=(
|
||||||
|
location.get("matched_location_name") or collector
|
||||||
|
),
|
||||||
|
location_verified_at=location.get("verified_at"),
|
||||||
|
suggested_registry_entry=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bgp_collector_query_plan(
|
||||||
|
query: LocationQuery,
|
||||||
|
) -> list[tuple[str, tuple[str, ...]]]:
|
||||||
|
"""Build the Nominatim query plan for a BGP collector."""
|
||||||
|
extra = query.extra or {}
|
||||||
|
site = str(extra.get("site") or "")
|
||||||
|
operator = str(extra.get("operator") or "")
|
||||||
|
city = query.city or ""
|
||||||
|
country = query.country or ""
|
||||||
|
|
||||||
|
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||||
|
|
||||||
|
def add(parts: list[tuple[str, str]]) -> None:
|
||||||
|
non_empty = [(field, value) for field, value in parts if value]
|
||||||
|
if not non_empty:
|
||||||
|
return
|
||||||
|
seen: set[str] = set()
|
||||||
|
cleaned: list[str] = []
|
||||||
|
fields: list[str] = []
|
||||||
|
for field, value in non_empty:
|
||||||
|
key = normalize_text(value)
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
cleaned.append(value)
|
||||||
|
fields.append(field)
|
||||||
|
if not cleaned:
|
||||||
|
return
|
||||||
|
composed = ", ".join(cleaned)
|
||||||
|
if not any(composed == existing for existing, _ in plan):
|
||||||
|
plan.append((composed, tuple(fields)))
|
||||||
|
|
||||||
|
add([("site", site), ("city", city), ("country", country)])
|
||||||
|
add([("site", site), ("country", country)])
|
||||||
|
add([("operator", operator), ("city", city), ("country", country)])
|
||||||
|
add([("city", city), ("country", country)])
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
BGP_COLLECTOR_PIPELINE = LocationPipeline(
|
||||||
|
[
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
StoredCollectorLocationResolver(),
|
||||||
|
],
|
||||||
|
failure_reason=(
|
||||||
|
"Could not resolve BGP collector to renderable coordinates from"
|
||||||
|
" source coordinates or stored collector location."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline(
|
||||||
|
[
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
NominatimResolver(
|
||||||
|
query_plan_builder=_bgp_collector_query_plan,
|
||||||
|
# Late-binding so tests can monkeypatch ``_geocode_online``.
|
||||||
|
geocoder=lambda q: _geocode_online(q),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
failure_reason=(
|
||||||
|
"Could not resolve BGP collector to renderable coordinates from"
|
||||||
|
" source coordinates or online geocoding."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bgp_collector_location(
|
||||||
|
collector_name: str,
|
||||||
|
*,
|
||||||
|
city: str | None = None,
|
||||||
|
country: str | None = None,
|
||||||
|
site: str | None = None,
|
||||||
|
operator: str | None = None,
|
||||||
|
) -> ResolutionResult:
|
||||||
|
"""Resolve a BGP collector to its best-known stored location."""
|
||||||
|
stored = get_bgp_collector_location_dict(collector_name)
|
||||||
|
name = coerce_str(collector_name) or None
|
||||||
|
query = LocationQuery(
|
||||||
|
name=name,
|
||||||
|
aliases=tuple(filter(None, (collector_name,))),
|
||||||
|
city=coerce_str(city or stored.get("city")) or None,
|
||||||
|
country=coerce_str(country or stored.get("country")) or None,
|
||||||
|
extra={
|
||||||
|
"site": coerce_str(site or stored.get("site")),
|
||||||
|
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return BGP_COLLECTOR_PIPELINE.resolve_best(query)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_bgp_collector_location_candidates(
|
||||||
|
*,
|
||||||
|
collector: str | None = None,
|
||||||
|
city: str | None = None,
|
||||||
|
country: str | None = None,
|
||||||
|
site: str | None = None,
|
||||||
|
operator: str | None = None,
|
||||||
|
) -> tuple[list[LocationCandidate], list[str]]:
|
||||||
|
stored = get_bgp_collector_location_dict(collector or "")
|
||||||
|
name = coerce_str(collector) or None
|
||||||
|
query = LocationQuery(
|
||||||
|
name=name,
|
||||||
|
aliases=tuple(filter(None, (collector,))),
|
||||||
|
city=coerce_str(city or stored.get("city")) or None,
|
||||||
|
country=coerce_str(country or stored.get("country")) or None,
|
||||||
|
extra={
|
||||||
|
"site": coerce_str(site or stored.get("site")),
|
||||||
|
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query)
|
||||||
155
backend/app/services/bgp_event_locations.py
Normal file
155
backend/app/services/bgp_event_locations.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""BGP event location resolver.
|
||||||
|
|
||||||
|
A BGP event (announcement / withdrawal / RIB entry) is geographically tied to
|
||||||
|
the route collector that observed it. This module defines the pipeline that
|
||||||
|
turns an event payload into renderable coordinates.
|
||||||
|
|
||||||
|
Current resolver chain:
|
||||||
|
|
||||||
|
SourceCoordinates → event payload itself carries lat/lon (rare; some
|
||||||
|
enriched feeds do).
|
||||||
|
InheritFromCollector → look up the owning collector via
|
||||||
|
:func:`resolve_bgp_collector_location`.
|
||||||
|
|
||||||
|
Future plug-ins (no consumer changes required, just append to the list):
|
||||||
|
|
||||||
|
ASNFacilityResolver — origin/peer ASN → peeringdb facility.
|
||||||
|
PrefixGeoResolver — prefix → IP range geo lookup (iptoasn / opengeofeed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.bgp_collector_locations import (
|
||||||
|
get_bgp_collector_location_dict,
|
||||||
|
)
|
||||||
|
from app.services.location import (
|
||||||
|
InheritFromAnotherEntityResolver,
|
||||||
|
LocationCandidate,
|
||||||
|
LocationPipeline,
|
||||||
|
LocationQuery,
|
||||||
|
ResolutionResult,
|
||||||
|
SourceCoordinatesResolver,
|
||||||
|
coerce_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _inherit_from_owning_collector(
|
||||||
|
query: LocationQuery,
|
||||||
|
) -> LocationCandidate | None:
|
||||||
|
"""Look up the event's owning collector by exact name in the DB-backed cache."""
|
||||||
|
extra = query.extra or {}
|
||||||
|
collector_name = coerce_str(extra.get("collector"))
|
||||||
|
if not collector_name:
|
||||||
|
return None
|
||||||
|
legacy = get_bgp_collector_location_dict(collector_name)
|
||||||
|
if not legacy:
|
||||||
|
return None
|
||||||
|
latitude = legacy.get("latitude")
|
||||||
|
longitude = legacy.get("longitude")
|
||||||
|
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||||
|
return None
|
||||||
|
return LocationCandidate(
|
||||||
|
latitude=float(latitude),
|
||||||
|
longitude=float(longitude),
|
||||||
|
display_name=legacy.get("matched_location_name") or collector_name,
|
||||||
|
precision=legacy.get("precision") or "city",
|
||||||
|
confidence=float(legacy.get("confidence") or 0.85),
|
||||||
|
query=f"inherit_from_collector::{collector_name}",
|
||||||
|
source="inherited_from_collector",
|
||||||
|
source_note=(
|
||||||
|
f"Inherited from owning collector {collector_name}"
|
||||||
|
),
|
||||||
|
matched_fields=("collector",),
|
||||||
|
needs_confirmation=bool(legacy.get("needs_confirmation")),
|
||||||
|
city=legacy.get("city"),
|
||||||
|
region=None,
|
||||||
|
country=legacy.get("country"),
|
||||||
|
matched_location_name=legacy.get("matched_location_name"),
|
||||||
|
location_verified_at=legacy.get("verified_at"),
|
||||||
|
suggested_registry_entry=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BGP_EVENT_PIPELINE = LocationPipeline(
|
||||||
|
[
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
InheritFromAnotherEntityResolver(
|
||||||
|
source_lookup=_inherit_from_owning_collector,
|
||||||
|
name="inherited_from_collector",
|
||||||
|
),
|
||||||
|
# Plug new resolvers (peeringdb / ASN facility / prefix-geo) here.
|
||||||
|
],
|
||||||
|
failure_reason=(
|
||||||
|
"Could not resolve BGP event coordinates: no source coords, owning"
|
||||||
|
" collector unknown, and no fallback resolver matched."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bgp_event_location(
|
||||||
|
*,
|
||||||
|
collector: str,
|
||||||
|
source_latitude: float | None = None,
|
||||||
|
source_longitude: float | None = None,
|
||||||
|
site: str | None = None,
|
||||||
|
operator: str | None = None,
|
||||||
|
peer_asn: int | None = None,
|
||||||
|
origin_asn: int | None = None,
|
||||||
|
prefix: str | None = None,
|
||||||
|
) -> ResolutionResult:
|
||||||
|
"""Resolve a BGP event to its renderable coordinates.
|
||||||
|
|
||||||
|
The ``peer_asn`` / ``origin_asn`` / ``prefix`` arguments are accepted
|
||||||
|
today so future resolvers (ASN→facility, prefix→geo) can consume them
|
||||||
|
without callers needing to change.
|
||||||
|
"""
|
||||||
|
query = LocationQuery(
|
||||||
|
name=collector or None,
|
||||||
|
aliases=tuple(filter(None, (collector,))),
|
||||||
|
source_latitude=source_latitude,
|
||||||
|
source_longitude=source_longitude,
|
||||||
|
extra={
|
||||||
|
"collector": collector or "",
|
||||||
|
"site": coerce_str(site),
|
||||||
|
"operator": coerce_str(operator),
|
||||||
|
"peer_asn": peer_asn,
|
||||||
|
"origin_asn": origin_asn,
|
||||||
|
"prefix": coerce_str(prefix),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return BGP_EVENT_PIPELINE.resolve_best(query)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bgp_event_geo_dict(
|
||||||
|
collector: str,
|
||||||
|
*,
|
||||||
|
source_latitude: float | None = None,
|
||||||
|
source_longitude: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Convenience wrapper returning the legacy ``collector_geo`` dict shape.
|
||||||
|
|
||||||
|
Preserves ``city``/``country``/``latitude``/``longitude`` keys (consumed
|
||||||
|
by existing detectors / enrichment / DB serialization) and adds
|
||||||
|
``precision``/``source``/``needs_confirmation`` for richer downstream use.
|
||||||
|
"""
|
||||||
|
result = resolve_bgp_event_location(
|
||||||
|
collector=collector,
|
||||||
|
source_latitude=source_latitude,
|
||||||
|
source_longitude=source_longitude,
|
||||||
|
)
|
||||||
|
candidate = result.location
|
||||||
|
if candidate is None:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"city": candidate.city,
|
||||||
|
"country": candidate.country,
|
||||||
|
"latitude": candidate.latitude,
|
||||||
|
"longitude": candidate.longitude,
|
||||||
|
"precision": candidate.precision,
|
||||||
|
"source": candidate.source,
|
||||||
|
"needs_confirmation": candidate.needs_confirmation,
|
||||||
|
"matched_location_name": candidate.matched_location_name,
|
||||||
|
"confidence": candidate.confidence,
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
|
|||||||
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
|
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
|
||||||
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
|
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
|
||||||
from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
|
from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
|
||||||
|
from app.services.collectors.aisstream import AISStreamCollector
|
||||||
from app.services.collectors.vessel_ais import VesselAISCollector
|
from app.services.collectors.vessel_ais import VesselAISCollector
|
||||||
|
|
||||||
collector_registry.register(TOP500Collector())
|
collector_registry.register(TOP500Collector())
|
||||||
@@ -65,3 +66,40 @@ collector_registry.register(OpenGeoFeedPrefixGeoCollector())
|
|||||||
collector_registry.register(NRODelegatedPrefixGeoCollector())
|
collector_registry.register(NRODelegatedPrefixGeoCollector())
|
||||||
collector_registry.register(NewsLiveStreamsCollector())
|
collector_registry.register(NewsLiveStreamsCollector())
|
||||||
collector_registry.register(VesselAISCollector())
|
collector_registry.register(VesselAISCollector())
|
||||||
|
collector_registry.register(AISStreamCollector())
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseCollector",
|
||||||
|
"HTTPCollector",
|
||||||
|
"IntervalCollector",
|
||||||
|
"collector_registry",
|
||||||
|
"CollectorRegistry",
|
||||||
|
"TOP500Collector",
|
||||||
|
"EpochAIGPUCollector",
|
||||||
|
"HuggingFaceModelCollector",
|
||||||
|
"HuggingFaceDatasetCollector",
|
||||||
|
"HuggingFaceSpacesCollector",
|
||||||
|
"PeeringDBIXPCollector",
|
||||||
|
"PeeringDBNetworkCollector",
|
||||||
|
"PeeringDBFacilityCollector",
|
||||||
|
"TeleGeographyCableCollector",
|
||||||
|
"TeleGeographyLandingPointCollector",
|
||||||
|
"TeleGeographyCableSystemCollector",
|
||||||
|
"CloudflareRadarDeviceCollector",
|
||||||
|
"CloudflareRadarTrafficCollector",
|
||||||
|
"CloudflareRadarTopASCollector",
|
||||||
|
"ArcGISCableCollector",
|
||||||
|
"FAOLandingPointCollector",
|
||||||
|
"ArcGISLandingPointCollector",
|
||||||
|
"ArcGISCableLandingRelationCollector",
|
||||||
|
"SpaceTrackTLECollector",
|
||||||
|
"CelesTrakTLECollector",
|
||||||
|
"RISLiveCollector",
|
||||||
|
"BGPStreamBackfillCollector",
|
||||||
|
"IPtoASNPrefixGeoCollector",
|
||||||
|
"OpenGeoFeedPrefixGeoCollector",
|
||||||
|
"NRODelegatedPrefixGeoCollector",
|
||||||
|
"NewsLiveStreamsCollector",
|
||||||
|
"VesselAISCollector",
|
||||||
|
"AISStreamCollector",
|
||||||
|
]
|
||||||
|
|||||||
491
backend/app/services/collectors/aisstream.py
Normal file
491
backend/app/services/collectors/aisstream.py
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
"""AISStream WebSocket collector for realtime vessel AIS observations."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.data_sources import get_data_sources_config
|
||||||
|
from app.core.time import to_iso8601_utc
|
||||||
|
from app.core.websocket.broadcaster import broadcaster
|
||||||
|
from app.models.datasource_config import DataSourceConfig
|
||||||
|
from app.models.task import CollectionTask
|
||||||
|
from app.services.collectors.base import BaseCollector
|
||||||
|
from app.services.vessel_ais_aggregation import (
|
||||||
|
AISSTREAM_DELIVERY_MODE,
|
||||||
|
AISSTREAM_TRANSPORT,
|
||||||
|
record_vessel_ais_observation,
|
||||||
|
update_ais_source_health,
|
||||||
|
)
|
||||||
|
from app.services.vessel_types import normalize_vessel_type_name
|
||||||
|
|
||||||
|
DEFAULT_AISSTREAM_URL = "wss://stream.aisstream.io/v0/stream"
|
||||||
|
DEFAULT_BOUNDING_BOXES = [[[-90, -180], [90, 180]]]
|
||||||
|
DEFAULT_MESSAGE_TYPES = ["PositionReport", "ShipStaticData"]
|
||||||
|
|
||||||
|
|
||||||
|
class AISStreamCollector(BaseCollector):
|
||||||
|
"""Collect AISStream WebSocket messages into the raw AIS observation layer."""
|
||||||
|
|
||||||
|
name = "aisstream_vessels"
|
||||||
|
priority = "P1"
|
||||||
|
module = "L4"
|
||||||
|
frequency_hours = 1
|
||||||
|
data_type = "vessel_ais"
|
||||||
|
fail_on_empty = False
|
||||||
|
|
||||||
|
async def _load_datasource_config(self) -> DataSourceConfig | None:
|
||||||
|
if self._db_session is None:
|
||||||
|
return None
|
||||||
|
result = await self._db_session.execute(
|
||||||
|
select(DataSourceConfig)
|
||||||
|
.where(DataSourceConfig.name == self.name)
|
||||||
|
.where(DataSourceConfig.is_active.is_(True))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def _get_effective_config(self) -> dict[str, Any]:
|
||||||
|
datasource_config = await self._load_datasource_config()
|
||||||
|
config = dict(datasource_config.config or {}) if datasource_config else {}
|
||||||
|
auth_config = dict(datasource_config.auth_config or {}) if datasource_config else {}
|
||||||
|
endpoint = (
|
||||||
|
(datasource_config.endpoint if datasource_config else None)
|
||||||
|
or self._resolved_url
|
||||||
|
or get_data_sources_config().get_yaml_url(self.name)
|
||||||
|
or DEFAULT_AISSTREAM_URL
|
||||||
|
)
|
||||||
|
api_key = (
|
||||||
|
auth_config.get("api_key")
|
||||||
|
or config.get("api_key")
|
||||||
|
or os.getenv("AISSTREAM_API_KEY")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"api_key": api_key,
|
||||||
|
"bounding_boxes": config.get("bounding_boxes") or DEFAULT_BOUNDING_BOXES,
|
||||||
|
"message_types": config.get("message_types") or DEFAULT_MESSAGE_TYPES,
|
||||||
|
"max_messages": int(config.get("max_messages") or 500),
|
||||||
|
"streaming_enabled": config.get("streaming_enabled", True) is not False,
|
||||||
|
"streaming_commit_interval": int(config.get("streaming_commit_interval") or 1),
|
||||||
|
"streaming_max_messages": int(config.get("streaming_max_messages") or 0),
|
||||||
|
"reconnect_delay_seconds": float(config.get("reconnect_delay_seconds") or 5),
|
||||||
|
"receive_timeout_seconds": float(config.get("receive_timeout_seconds") or 30),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_subscription(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"APIKey": config["api_key"],
|
||||||
|
"BoundingBoxes": config["bounding_boxes"],
|
||||||
|
"FilterMessageTypes": config["message_types"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fetch(self) -> list[dict[str, Any]]:
|
||||||
|
config = await self._get_effective_config()
|
||||||
|
if not config["api_key"]:
|
||||||
|
raise RuntimeError("AISStream API key is not configured")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("Python package 'websockets' is required for AISStream") from exc
|
||||||
|
|
||||||
|
subscription = self._build_subscription(config)
|
||||||
|
|
||||||
|
messages: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
async with websockets.connect(config["endpoint"]) as websocket:
|
||||||
|
await websocket.send(json.dumps(subscription))
|
||||||
|
while len(messages) < config["max_messages"]:
|
||||||
|
try:
|
||||||
|
raw_message = await asyncio.wait_for(
|
||||||
|
websocket.recv(),
|
||||||
|
timeout=config["receive_timeout_seconds"],
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
break
|
||||||
|
payload = json.loads(raw_message)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
messages.append(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
if self._db_session is not None:
|
||||||
|
await update_ais_source_health(
|
||||||
|
self._db_session,
|
||||||
|
source=self.name,
|
||||||
|
connection_state="disconnected",
|
||||||
|
last_error=f"{exc.__class__.__name__}: {exc}",
|
||||||
|
)
|
||||||
|
await self._db_session.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
async def run(self, db: AsyncSession) -> dict[str, Any]:
|
||||||
|
"""Run AISStream as a long-lived streaming collector by default."""
|
||||||
|
config = await self._get_effective_config()
|
||||||
|
if not config.get("streaming_enabled", True):
|
||||||
|
return await super().run(db)
|
||||||
|
if not config["api_key"]:
|
||||||
|
return {"status": "failed", "error": "AISStream API key is not configured"}
|
||||||
|
|
||||||
|
from app.services.collectors.registry import collector_registry
|
||||||
|
|
||||||
|
if not collector_registry.is_active(self.name):
|
||||||
|
return {"status": "skipped", "reason": "Collector is disabled"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError as exc:
|
||||||
|
return {"status": "failed", "error": "Python package 'websockets' is required for AISStream"}
|
||||||
|
|
||||||
|
start_time = datetime.now(UTC)
|
||||||
|
task = CollectionTask(
|
||||||
|
datasource_id=getattr(self, "_datasource_id", 1),
|
||||||
|
status="running",
|
||||||
|
phase="connecting",
|
||||||
|
phase_message="正在连接 AISStream 实时流",
|
||||||
|
phase_unit="messages",
|
||||||
|
started_at=start_time,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
await db.commit()
|
||||||
|
self._current_task = task
|
||||||
|
self._db_session = db
|
||||||
|
self._last_broadcast_progress = None
|
||||||
|
await self.resolve_url(db)
|
||||||
|
await self._publish_task_update(force=True)
|
||||||
|
|
||||||
|
records_added = 0
|
||||||
|
messages_seen = 0
|
||||||
|
unique_mmsi: set[str] = set()
|
||||||
|
reconnect_delay = config["reconnect_delay_seconds"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
config = await self._get_effective_config()
|
||||||
|
subscription = self._build_subscription(config)
|
||||||
|
try:
|
||||||
|
await update_ais_source_health(
|
||||||
|
db,
|
||||||
|
source=self.name,
|
||||||
|
connection_state="connecting",
|
||||||
|
)
|
||||||
|
await self.set_phase("connecting", message="正在连接 AISStream 实时流")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async with websockets.connect(config["endpoint"]) as websocket:
|
||||||
|
await websocket.send(json.dumps(subscription))
|
||||||
|
await update_ais_source_health(
|
||||||
|
db,
|
||||||
|
source=self.name,
|
||||||
|
connection_state="connected",
|
||||||
|
last_success_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
await self.set_phase(
|
||||||
|
"streaming",
|
||||||
|
message="正在接收 AISStream 实时消息",
|
||||||
|
reset_progress=False,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw_message = await asyncio.wait_for(
|
||||||
|
websocket.recv(),
|
||||||
|
timeout=config["receive_timeout_seconds"],
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
await update_ais_source_health(
|
||||||
|
db,
|
||||||
|
source=self.name,
|
||||||
|
connection_state="connected",
|
||||||
|
last_success_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = json.loads(raw_message)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
messages_seen += 1
|
||||||
|
record = self._normalize_message(payload)
|
||||||
|
if not record:
|
||||||
|
continue
|
||||||
|
unique_mmsi.add(str(record["mmsi"]))
|
||||||
|
created = await self._save_stream_record(db, record)
|
||||||
|
if created:
|
||||||
|
records_added += 1
|
||||||
|
|
||||||
|
task.records_processed = messages_seen
|
||||||
|
task.total_records = None
|
||||||
|
task.progress = None
|
||||||
|
task.phase = "streaming"
|
||||||
|
task.phase_message = "正在接收 AISStream 实时消息"
|
||||||
|
task.phase_current = messages_seen
|
||||||
|
task.phase_total = None
|
||||||
|
task.phase_unit = "messages"
|
||||||
|
await self._publish_task_update(force=True)
|
||||||
|
|
||||||
|
if config["streaming_max_messages"] and messages_seen >= config["streaming_max_messages"]:
|
||||||
|
task.status = "success"
|
||||||
|
task.phase = "stopped"
|
||||||
|
task.phase_message = "AISStream 测试流已停止"
|
||||||
|
task.completed_at = datetime.now(UTC)
|
||||||
|
await db.commit()
|
||||||
|
await self._publish_task_update(force=True)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"task_id": task.id,
|
||||||
|
"records_processed": records_added,
|
||||||
|
"messages_seen": messages_seen,
|
||||||
|
"unique_mmsi": len(unique_mmsi),
|
||||||
|
"execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(),
|
||||||
|
}
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await update_ais_source_health(
|
||||||
|
db,
|
||||||
|
source=self.name,
|
||||||
|
connection_state="reconnecting",
|
||||||
|
last_error=f"{exc.__class__.__name__}: {exc}",
|
||||||
|
)
|
||||||
|
task.phase = "reconnecting"
|
||||||
|
task.phase_message = "AISStream 连接中断,正在重连"
|
||||||
|
task.error_message = f"{exc.__class__.__name__}: {exc}"
|
||||||
|
await db.commit()
|
||||||
|
await self._publish_task_update(force=True)
|
||||||
|
await asyncio.sleep(reconnect_delay)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
task.status = "cancelled"
|
||||||
|
task.phase = "stopped"
|
||||||
|
task.phase_message = "AISStream 实时流已停止"
|
||||||
|
task.completed_at = datetime.now(UTC)
|
||||||
|
await update_ais_source_health(
|
||||||
|
db,
|
||||||
|
source=self.name,
|
||||||
|
connection_state="disconnected",
|
||||||
|
last_error=None,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await self._publish_task_update(force=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def transform(self, raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
records = []
|
||||||
|
for item in raw_data:
|
||||||
|
record = self._normalize_message(item)
|
||||||
|
if record:
|
||||||
|
records.append(record)
|
||||||
|
return records
|
||||||
|
|
||||||
|
async def _save_data(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
task_id: int | None = None,
|
||||||
|
snapshot_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
records_added = 0
|
||||||
|
latest_observed_at = now
|
||||||
|
for index, item in enumerate(data):
|
||||||
|
observed_at = item.get("received_at") or now
|
||||||
|
observation = await record_vessel_ais_observation(
|
||||||
|
db,
|
||||||
|
source=self.name,
|
||||||
|
normalized_payload=item,
|
||||||
|
raw_payload=item.get("_raw_payload") or item,
|
||||||
|
delivery_mode=AISSTREAM_DELIVERY_MODE,
|
||||||
|
transport=AISSTREAM_TRANSPORT,
|
||||||
|
message_type=item.get("_message_type") or "PositionReport",
|
||||||
|
source_message_id=item.get("_source_message_id"),
|
||||||
|
observed_at=observed_at,
|
||||||
|
collected_at=now,
|
||||||
|
)
|
||||||
|
if observation is not None:
|
||||||
|
records_added += 1
|
||||||
|
if isinstance(observed_at, datetime) and observed_at > latest_observed_at:
|
||||||
|
latest_observed_at = observed_at
|
||||||
|
if (index + 1) % 1000 == 0:
|
||||||
|
await self.update_progress(index + 1, commit=True)
|
||||||
|
|
||||||
|
await update_ais_source_health(
|
||||||
|
db,
|
||||||
|
source=self.name,
|
||||||
|
connection_state="connected",
|
||||||
|
observed_count=len(data),
|
||||||
|
last_seen_at=latest_observed_at,
|
||||||
|
last_success_at=now if data else None,
|
||||||
|
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await self.update_progress(records_added, force=True)
|
||||||
|
return records_added
|
||||||
|
|
||||||
|
async def _save_stream_record(self, db: AsyncSession, item: dict[str, Any]) -> bool:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
observed_at = item.get("received_at") or now
|
||||||
|
observation = await record_vessel_ais_observation(
|
||||||
|
db,
|
||||||
|
source=self.name,
|
||||||
|
normalized_payload=item,
|
||||||
|
raw_payload=item.get("_raw_payload") or item,
|
||||||
|
delivery_mode=AISSTREAM_DELIVERY_MODE,
|
||||||
|
transport=AISSTREAM_TRANSPORT,
|
||||||
|
message_type=item.get("_message_type") or "PositionReport",
|
||||||
|
source_message_id=item.get("_source_message_id"),
|
||||||
|
observed_at=observed_at,
|
||||||
|
collected_at=now,
|
||||||
|
)
|
||||||
|
await update_ais_source_health(
|
||||||
|
db,
|
||||||
|
source=self.name,
|
||||||
|
connection_state="connected",
|
||||||
|
observed_count=1,
|
||||||
|
last_seen_at=observed_at if isinstance(observed_at, datetime) else now,
|
||||||
|
last_success_at=now,
|
||||||
|
lag_seconds=max((now - observed_at).total_seconds(), 0) if isinstance(observed_at, datetime) else None,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await self._broadcast_vessel_delta(item, created=observation is not None)
|
||||||
|
return observation is not None
|
||||||
|
|
||||||
|
async def _broadcast_vessel_delta(self, item: dict[str, Any], *, created: bool) -> None:
|
||||||
|
await broadcaster.broadcast_custom(
|
||||||
|
"vessels",
|
||||||
|
{
|
||||||
|
"action": "upsert",
|
||||||
|
"source": self.name,
|
||||||
|
"created": created,
|
||||||
|
"vessels": [
|
||||||
|
{
|
||||||
|
"mmsi": item.get("mmsi"),
|
||||||
|
"mmsi_display": str(item.get("mmsi")) if item.get("mmsi") is not None else None,
|
||||||
|
"name": item.get("name"),
|
||||||
|
"lat": item.get("lat"),
|
||||||
|
"lon": item.get("lon"),
|
||||||
|
"sog": item.get("sog"),
|
||||||
|
"cog": item.get("cog"),
|
||||||
|
"heading": item.get("heading"),
|
||||||
|
"nav_status": item.get("nav_status"),
|
||||||
|
"vessel_type": item.get("vessel_type"),
|
||||||
|
"vessel_type_name": item.get("vessel_type_name"),
|
||||||
|
"received_at": to_iso8601_utc(item.get("received_at")),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _normalize_message(self, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
message_type = str(item.get("MessageType") or item.get("message_type") or "")
|
||||||
|
metadata = item.get("MetaData") if isinstance(item.get("MetaData"), dict) else {}
|
||||||
|
message = item.get("Message") if isinstance(item.get("Message"), dict) else {}
|
||||||
|
body = message.get(message_type) if isinstance(message.get(message_type), dict) else message
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
mmsi = _as_int(_pick(metadata, "MMSI", "mmsi") or _pick(body, "MMSI", "mmsi"))
|
||||||
|
if mmsi is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
received_at = _parse_datetime(
|
||||||
|
_pick(metadata, "time_utc", "Time_UTC", "timestamp")
|
||||||
|
or _pick(body, "Timestamp", "timestamp", "time")
|
||||||
|
)
|
||||||
|
ship_name = _clean_text(
|
||||||
|
_pick(body, "Name", "ShipName", "name")
|
||||||
|
or _pick(metadata, "ShipName", "ship_name", "name")
|
||||||
|
)
|
||||||
|
record: dict[str, Any] = {
|
||||||
|
"mmsi": mmsi,
|
||||||
|
"received_at": received_at,
|
||||||
|
"_message_type": message_type or None,
|
||||||
|
"_source_message_id": item.get("MessageID") or item.get("message_id"),
|
||||||
|
"_raw_payload": item,
|
||||||
|
}
|
||||||
|
|
||||||
|
lat = _as_float(_pick(body, "Latitude", "lat", "latitude"))
|
||||||
|
lon = _as_float(_pick(body, "Longitude", "lon", "lng", "longitude"))
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
||||||
|
return None
|
||||||
|
record.update(
|
||||||
|
{
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"sog": _as_float(_pick(body, "Sog", "SOG", "speedOverGround")),
|
||||||
|
"cog": _as_float(_pick(body, "Cog", "COG", "courseOverGround")),
|
||||||
|
"heading": _as_int(_pick(body, "TrueHeading", "Heading", "heading")),
|
||||||
|
"nav_status": _as_int(_pick(body, "NavigationalStatus", "nav_status")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
vessel_type = _as_int(_pick(body, "Type", "ShipType", "vessel_type"))
|
||||||
|
record.update(
|
||||||
|
{
|
||||||
|
"name": ship_name,
|
||||||
|
"callsign": _pick(body, "CallSign", "callsign"),
|
||||||
|
"imo": _as_int(_pick(body, "ImoNumber", "IMO", "imo")),
|
||||||
|
"vessel_type": vessel_type,
|
||||||
|
"vessel_type_name": _pick(body, "TypeName", "ShipTypeName", "vessel_type_name")
|
||||||
|
or normalize_vessel_type_name(vessel_type),
|
||||||
|
"length": _as_float(_pick(body, "DimensionToBow", "Length", "length")),
|
||||||
|
"width": _as_float(_pick(body, "DimensionToPort", "Width", "width")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(item: dict[str, Any], *keys: str) -> Any:
|
||||||
|
for key in keys:
|
||||||
|
if key in item and item[key] not in (None, ""):
|
||||||
|
return item[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value: Any) -> str | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(value: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
return int(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime(value: Any) -> datetime | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
timestamp = float(value)
|
||||||
|
if timestamp > 10_000_000_000:
|
||||||
|
timestamp /= 1000
|
||||||
|
return datetime.fromtimestamp(timestamp, UTC)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
@@ -13,6 +13,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.models.bgp_anomaly import BGPAnomaly
|
from app.models.bgp_anomaly import BGPAnomaly
|
||||||
from app.models.bgp_observation import BGPObservation
|
from app.models.bgp_observation import BGPObservation
|
||||||
from app.models.collected_data import CollectedData
|
from app.models.collected_data import CollectedData
|
||||||
|
from app.services.bgp_collector_locations import (
|
||||||
|
RIPE_RIS_COLLECTOR_COORDS,
|
||||||
|
get_bgp_collector_location_dict,
|
||||||
|
)
|
||||||
|
from app.services.bgp_event_locations import resolve_bgp_event_geo_dict
|
||||||
from app.services.bgp_incidents import create_bgp_incidents_for_anomalies
|
from app.services.bgp_incidents import create_bgp_incidents_for_anomalies
|
||||||
from app.services.bgp_detectors import (
|
from app.services.bgp_detectors import (
|
||||||
detect_mass_withdrawal_anomalies,
|
detect_mass_withdrawal_anomalies,
|
||||||
@@ -23,32 +28,17 @@ from app.services.bgp_detectors import (
|
|||||||
)
|
)
|
||||||
from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields
|
from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields
|
||||||
|
|
||||||
|
# Re-exported for backward compatibility with anything that imports
|
||||||
RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {
|
# ``RIPE_RIS_COLLECTOR_COORDS`` from this module. New code should call
|
||||||
"rrc00": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
# ``app.services.bgp_collector_locations.get_bgp_collector_location_dict()``
|
||||||
"rrc01": {"city": "London", "country": "United Kingdom", "latitude": 51.5072, "longitude": -0.1276},
|
# or ``resolve_bgp_collector_location()`` instead — those use the DB-backed
|
||||||
"rrc03": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
# collector-location cache.
|
||||||
"rrc04": {"city": "Geneva", "country": "Switzerland", "latitude": 46.2044, "longitude": 6.1432},
|
__all__ = [
|
||||||
"rrc05": {"city": "Vienna", "country": "Austria", "latitude": 48.2082, "longitude": 16.3738},
|
"RIPE_RIS_COLLECTOR_COORDS",
|
||||||
"rrc06": {"city": "Otemachi", "country": "Japan", "latitude": 35.686, "longitude": 139.7671},
|
"normalize_bgp_event",
|
||||||
"rrc07": {"city": "Stockholm", "country": "Sweden", "latitude": 59.3293, "longitude": 18.0686},
|
"save_bgp_observations_for_batch",
|
||||||
"rrc10": {"city": "Milan", "country": "Italy", "latitude": 45.4642, "longitude": 9.19},
|
"create_bgp_anomalies_for_batch",
|
||||||
"rrc11": {"city": "New York", "country": "United States", "latitude": 40.7128, "longitude": -74.006},
|
]
|
||||||
"rrc12": {"city": "Frankfurt", "country": "Germany", "latitude": 50.1109, "longitude": 8.6821},
|
|
||||||
"rrc13": {"city": "Moscow", "country": "Russia", "latitude": 55.7558, "longitude": 37.6173},
|
|
||||||
"rrc14": {"city": "Palo Alto", "country": "United States", "latitude": 37.4419, "longitude": -122.143},
|
|
||||||
"rrc15": {"city": "Sao Paulo", "country": "Brazil", "latitude": -23.5558, "longitude": -46.6396},
|
|
||||||
"rrc16": {"city": "Miami", "country": "United States", "latitude": 25.7617, "longitude": -80.1918},
|
|
||||||
"rrc18": {"city": "Barcelona", "country": "Spain", "latitude": 41.3874, "longitude": 2.1686},
|
|
||||||
"rrc19": {"city": "Johannesburg", "country": "South Africa", "latitude": -26.2041, "longitude": 28.0473},
|
|
||||||
"rrc20": {"city": "Zurich", "country": "Switzerland", "latitude": 47.3769, "longitude": 8.5417},
|
|
||||||
"rrc21": {"city": "Paris", "country": "France", "latitude": 48.8566, "longitude": 2.3522},
|
|
||||||
"rrc22": {"city": "Bucharest", "country": "Romania", "latitude": 44.4268, "longitude": 26.1025},
|
|
||||||
"rrc23": {"city": "Singapore", "country": "Singapore", "latitude": 1.3521, "longitude": 103.8198},
|
|
||||||
"rrc24": {"city": "Montevideo", "country": "Uruguay", "latitude": -34.9011, "longitude": -56.1645},
|
|
||||||
"rrc25": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
|
||||||
"rrc26": {"city": "Dubai", "country": "United Arab Emirates", "latitude": 25.2048, "longitude": 55.2708},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_int(value: Any) -> int | None:
|
def _safe_int(value: Any) -> int | None:
|
||||||
@@ -131,7 +121,19 @@ def normalize_bgp_event(payload: dict[str, Any], *, project: str) -> dict[str, A
|
|||||||
)
|
)
|
||||||
source_id = hashlib.sha1(source_material.encode("utf-8")).hexdigest()[:24]
|
source_id = hashlib.sha1(source_material.encode("utf-8")).hexdigest()[:24]
|
||||||
|
|
||||||
collector_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
|
# Routes through the BGP event pipeline: source coords (if any) →
|
||||||
|
# collector inheritance. Returned dict keeps the legacy
|
||||||
|
# {city, country, latitude, longitude} keys plus richer
|
||||||
|
# {precision, source, needs_confirmation, matched_location_name, confidence}.
|
||||||
|
collector_location = resolve_bgp_event_geo_dict(
|
||||||
|
collector,
|
||||||
|
source_latitude=payload.get("latitude"),
|
||||||
|
source_longitude=payload.get("longitude"),
|
||||||
|
)
|
||||||
|
# Empty result (unknown collector & no source coords) — keep the
|
||||||
|
# downstream-expected dict shape so detectors / serializers don't crash.
|
||||||
|
if not collector_location:
|
||||||
|
collector_location = get_bgp_collector_location_dict(collector)
|
||||||
network_fields = extract_bgp_network_fields(prefix)
|
network_fields = extract_bgp_network_fields(prefix)
|
||||||
metadata = {
|
metadata = {
|
||||||
"project": project,
|
"project": project,
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
"""BarentsWatch AIS collector for vessel tracking."""
|
"""BarentsWatch AIS collector for vessel tracking."""
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import delete, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.vessel import VesselPosition, VesselStatic
|
from app.core.time import to_iso8601_utc
|
||||||
|
from app.core.websocket.broadcaster import broadcaster
|
||||||
from app.services.barentswatch import (
|
from app.services.barentswatch import (
|
||||||
BARENTSWATCH_LATEST_URL,
|
BARENTSWATCH_LATEST_URL,
|
||||||
fetch_barentswatch_access_token,
|
fetch_barentswatch_access_token,
|
||||||
resolve_barentswatch_config,
|
resolve_barentswatch_config,
|
||||||
)
|
)
|
||||||
from app.services.collectors.base import BaseCollector
|
from app.services.collectors.base import BaseCollector
|
||||||
|
from app.services.vessel_ais_aggregation import (
|
||||||
|
BARENTSWATCH_DELIVERY_MODE,
|
||||||
VESSEL_TYPE_NAMES = {
|
BARENTSWATCH_TRANSPORT,
|
||||||
30: "Fishing",
|
record_vessel_ais_observation,
|
||||||
35: "Military",
|
update_ais_source_health,
|
||||||
60: "Passenger",
|
)
|
||||||
70: "Cargo",
|
from app.services.vessel_types import normalize_vessel_type_name
|
||||||
80: "Tanker",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class VesselAISCollector(BaseCollector):
|
class VesselAISCollector(BaseCollector):
|
||||||
@@ -92,51 +90,75 @@ class VesselAISCollector(BaseCollector):
|
|||||||
records_added = 0
|
records_added = 0
|
||||||
|
|
||||||
for index, item in enumerate(data):
|
for index, item in enumerate(data):
|
||||||
static = await db.get(VesselStatic, item["mmsi"])
|
observed_at = item.get("received_at") or now
|
||||||
if static is None:
|
await record_vessel_ais_observation(
|
||||||
static = VesselStatic(mmsi=item["mmsi"])
|
db,
|
||||||
db.add(static)
|
source=self.name,
|
||||||
|
normalized_payload=item,
|
||||||
for field in (
|
raw_payload=item,
|
||||||
"name",
|
delivery_mode=BARENTSWATCH_DELIVERY_MODE,
|
||||||
"callsign",
|
transport=BARENTSWATCH_TRANSPORT,
|
||||||
"vessel_type",
|
observed_at=observed_at,
|
||||||
"vessel_type_name",
|
collected_at=now,
|
||||||
"flag",
|
|
||||||
"length",
|
|
||||||
"width",
|
|
||||||
"draught",
|
|
||||||
"imo",
|
|
||||||
):
|
|
||||||
value = item.get(field)
|
|
||||||
if value not in (None, ""):
|
|
||||||
setattr(static, field, value)
|
|
||||||
static.updated_at = now
|
|
||||||
|
|
||||||
db.add(
|
|
||||||
VesselPosition(
|
|
||||||
mmsi=item["mmsi"],
|
|
||||||
lat=item["lat"],
|
|
||||||
lon=item["lon"],
|
|
||||||
sog=item.get("sog"),
|
|
||||||
cog=item.get("cog"),
|
|
||||||
heading=item.get("heading"),
|
|
||||||
nav_status=item.get("nav_status"),
|
|
||||||
received_at=item.get("received_at") or now,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
records_added += 1
|
records_added += 1
|
||||||
|
|
||||||
if (index + 1) % 1000 == 0:
|
if (index + 1) % 1000 == 0:
|
||||||
await self.update_progress(index + 1, commit=True)
|
await self.update_progress(index + 1, commit=True)
|
||||||
|
|
||||||
await db.execute(
|
latest_observed_at = max(
|
||||||
delete(VesselPosition).where(VesselPosition.received_at < now - timedelta(hours=24))
|
(item.get("received_at") for item in data if item.get("received_at")),
|
||||||
|
default=now,
|
||||||
|
)
|
||||||
|
await update_ais_source_health(
|
||||||
|
db,
|
||||||
|
source=self.name,
|
||||||
|
connection_state="connected",
|
||||||
|
observed_count=len(data),
|
||||||
|
last_seen_at=latest_observed_at,
|
||||||
|
last_success_at=now if data else None,
|
||||||
|
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await self._broadcast_vessel_snapshot(data)
|
||||||
await self.update_progress(records_added, force=True)
|
await self.update_progress(records_added, force=True)
|
||||||
return records_added
|
return records_added
|
||||||
|
|
||||||
|
async def _broadcast_vessel_snapshot(self, data: list[dict[str, Any]]) -> None:
|
||||||
|
"""Push REST collector updates through the same realtime vessel channel."""
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
|
||||||
|
batch_size = 500
|
||||||
|
for offset in range(0, len(data), batch_size):
|
||||||
|
batch = data[offset : offset + batch_size]
|
||||||
|
await broadcaster.broadcast_custom(
|
||||||
|
"vessels",
|
||||||
|
{
|
||||||
|
"action": "upsert",
|
||||||
|
"source": self.name,
|
||||||
|
"created": True,
|
||||||
|
"vessels": [
|
||||||
|
{
|
||||||
|
"mmsi": item.get("mmsi"),
|
||||||
|
"mmsi_display": str(item.get("mmsi")) if item.get("mmsi") is not None else None,
|
||||||
|
"name": item.get("name"),
|
||||||
|
"callsign": item.get("callsign"),
|
||||||
|
"lat": item.get("lat"),
|
||||||
|
"lon": item.get("lon"),
|
||||||
|
"sog": item.get("sog"),
|
||||||
|
"cog": item.get("cog"),
|
||||||
|
"heading": item.get("heading"),
|
||||||
|
"nav_status": item.get("nav_status"),
|
||||||
|
"vessel_type": item.get("vessel_type"),
|
||||||
|
"vessel_type_name": item.get("vessel_type_name"),
|
||||||
|
"received_at": to_iso8601_utc(item.get("received_at")),
|
||||||
|
}
|
||||||
|
for item in batch
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def _normalize_record(self, item: dict[str, Any]) -> dict[str, Any] | None:
|
def _normalize_record(self, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
mmsi = _as_int(_pick(item, "mmsi", "MMSI", "Mmsi"))
|
mmsi = _as_int(_pick(item, "mmsi", "MMSI", "Mmsi"))
|
||||||
lat = _as_float(_pick(item, "lat", "latitude", "Latitude"))
|
lat = _as_float(_pick(item, "lat", "latitude", "Latitude"))
|
||||||
@@ -156,7 +178,7 @@ class VesselAISCollector(BaseCollector):
|
|||||||
vessel_type = _as_int(_pick(item, "vessel_type", "shipType", "ship_type", "ShipType"))
|
vessel_type = _as_int(_pick(item, "vessel_type", "shipType", "ship_type", "ShipType"))
|
||||||
vessel_type_name = (
|
vessel_type_name = (
|
||||||
_pick(item, "vessel_type_name", "shipTypeName", "ship_type_name", "VesselTypeName")
|
_pick(item, "vessel_type_name", "shipTypeName", "ship_type_name", "VesselTypeName")
|
||||||
or _vessel_type_name(vessel_type)
|
or normalize_vessel_type_name(vessel_type)
|
||||||
)
|
)
|
||||||
received_at = _parse_datetime(_pick(item, "received_at", "timestamp", "time", "msgtime"))
|
received_at = _parse_datetime(_pick(item, "received_at", "timestamp", "time", "msgtime"))
|
||||||
|
|
||||||
@@ -255,19 +277,3 @@ def _parse_datetime(value: Any) -> datetime | None:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _vessel_type_name(vessel_type: int | None) -> str:
|
|
||||||
if vessel_type is None:
|
|
||||||
return "Other"
|
|
||||||
if 70 <= vessel_type <= 79:
|
|
||||||
return "Cargo"
|
|
||||||
if 80 <= vessel_type <= 89:
|
|
||||||
return "Tanker"
|
|
||||||
if 60 <= vessel_type <= 69:
|
|
||||||
return "Passenger"
|
|
||||||
if vessel_type == 30:
|
|
||||||
return "Fishing"
|
|
||||||
if vessel_type == 35:
|
|
||||||
return "Military"
|
|
||||||
return VESSEL_TYPE_NAMES.get(vessel_type, "Other")
|
|
||||||
|
|||||||
863
backend/app/services/compute_center_locations.py
Normal file
863
backend/app/services/compute_center_locations.py
Normal file
@@ -0,0 +1,863 @@
|
|||||||
|
"""Compute-center location resolver, built on the shared location pipeline.
|
||||||
|
|
||||||
|
This module is a thin domain wrapper that wires up
|
||||||
|
:mod:`app.services.location` for compute centers:
|
||||||
|
|
||||||
|
SourceCoordinates
|
||||||
|
|
||||||
|
The online Nominatim step is intentionally reserved for the user-triggered
|
||||||
|
``collect-location`` flow. The regular GeoJSON endpoint runs during Earth
|
||||||
|
startup, so it must stay local and deterministic.
|
||||||
|
|
||||||
|
For the full design and the reason behind the abstraction (compute centers,
|
||||||
|
BGP collectors, BGP events, and future entities all share one pipeline),
|
||||||
|
see ``docs/plans/location-resolver-shared-pipeline-plan.md``.
|
||||||
|
|
||||||
|
The ``ComputeCenterLocation`` dataclass and the public function signatures are
|
||||||
|
preserved verbatim so existing callers and tests do not need to change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.collected_data_fields import get_record_field
|
||||||
|
from app.models.collected_data import CollectedData
|
||||||
|
from app.models.compute_center_location import ComputeCenterLocationRecord
|
||||||
|
|
||||||
|
from app.services.location import (
|
||||||
|
LocationCandidate,
|
||||||
|
LocationPipeline,
|
||||||
|
LocationQuery,
|
||||||
|
NominatimResolver,
|
||||||
|
ResolverOutput,
|
||||||
|
SourceCoordinatesResolver,
|
||||||
|
build_default_nominatim_geocoder,
|
||||||
|
coerce_str,
|
||||||
|
normalize_country_text,
|
||||||
|
normalize_text,
|
||||||
|
parse_float,
|
||||||
|
)
|
||||||
|
|
||||||
|
ROR_SEARCH_URL = "https://api.ror.org/v2/organizations"
|
||||||
|
DEFAULT_ROR_USER_AGENT = "planet-earth-location-resolver/1.0"
|
||||||
|
DEFAULT_ROR_TIMEOUT_SECONDS = 8.0
|
||||||
|
RENDERABLE_PRECISIONS: tuple[str, ...] = ("precise", "site", "city")
|
||||||
|
FORBIDDEN_PRECISIONS: tuple[str, ...] = (
|
||||||
|
"country",
|
||||||
|
"estimated_country",
|
||||||
|
"country_major_compute_city",
|
||||||
|
"region",
|
||||||
|
"unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Public dataclasses ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ComputeCenterLocation:
|
||||||
|
latitude: float | None
|
||||||
|
longitude: float | None
|
||||||
|
location_precision: str
|
||||||
|
geography_mode: str
|
||||||
|
is_estimated: bool
|
||||||
|
estimated_reason: str | None = None
|
||||||
|
location_confidence: float | None = None
|
||||||
|
location_source: str | None = None
|
||||||
|
location_source_note: str | None = None
|
||||||
|
location_verified_at: str | None = None
|
||||||
|
matched_location_name: str | None = None
|
||||||
|
needs_confirmation: bool = False
|
||||||
|
city: str | None = None
|
||||||
|
region: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_renderable(self) -> bool:
|
||||||
|
if self.latitude in (None, 0.0) or self.longitude in (None, 0.0):
|
||||||
|
return False
|
||||||
|
return self.location_precision in RENDERABLE_PRECISIONS
|
||||||
|
|
||||||
|
def to_geojson_properties(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"latitude": self.latitude,
|
||||||
|
"longitude": self.longitude,
|
||||||
|
"location_precision": self.location_precision,
|
||||||
|
"geography_mode": self.geography_mode,
|
||||||
|
"is_estimated": self.is_estimated,
|
||||||
|
"estimated_reason": self.estimated_reason,
|
||||||
|
"location_confidence": self.location_confidence,
|
||||||
|
"location_source": self.location_source,
|
||||||
|
"location_source_note": self.location_source_note,
|
||||||
|
"location_verified_at": self.location_verified_at,
|
||||||
|
"matched_location_name": self.matched_location_name,
|
||||||
|
"needs_confirmation": self.needs_confirmation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResolutionDiagnostic:
|
||||||
|
failure_reason: str
|
||||||
|
attempted_queries: tuple[str, ...] = ()
|
||||||
|
record_id: int | None = None
|
||||||
|
source: str | None = None
|
||||||
|
source_id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
site: str | None = None
|
||||||
|
operator: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"failure_reason": self.failure_reason,
|
||||||
|
"attempted_queries": list(self.attempted_queries),
|
||||||
|
"record_id": self.record_id,
|
||||||
|
"source": self.source,
|
||||||
|
"source_id": self.source_id,
|
||||||
|
"name": self.name,
|
||||||
|
"country": self.country,
|
||||||
|
"city": self.city,
|
||||||
|
"site": self.site,
|
||||||
|
"operator": self.operator,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResolutionResult:
|
||||||
|
location: ComputeCenterLocation | None
|
||||||
|
diagnostic: ResolutionDiagnostic | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_resolved(self) -> bool:
|
||||||
|
return bool(self.location and self.location.is_renderable)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Geocoder (kept at module level so tests can monkeypatch + cache_clear) ──
|
||||||
|
|
||||||
|
_geocode_online = build_default_nominatim_geocoder()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stored location cache ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
COMPUTE_CENTER_LOCATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(source: str | None, source_id: str | None) -> str:
|
||||||
|
return f"{coerce_str(source)}:{coerce_str(source_id)}"
|
||||||
|
|
||||||
|
|
||||||
|
def set_compute_center_location_cache(
|
||||||
|
locations: dict[str, dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
COMPUTE_CENTER_LOCATION_CACHE.clear()
|
||||||
|
COMPUTE_CENTER_LOCATION_CACHE.update(
|
||||||
|
{coerce_str(key): dict(value) for key, value in locations.items()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_compute_center_location_cache(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
result = await session.execute(select(ComputeCenterLocationRecord))
|
||||||
|
records = result.scalars().all()
|
||||||
|
cache = {}
|
||||||
|
for record in records:
|
||||||
|
if not hasattr(record, "to_location_dict"):
|
||||||
|
continue
|
||||||
|
if not record.source or not record.source_id:
|
||||||
|
continue
|
||||||
|
cache[_cache_key(record.source, record.source_id)] = record.to_location_dict()
|
||||||
|
set_compute_center_location_cache(cache)
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
def get_compute_center_location_dict(
|
||||||
|
source: str | None,
|
||||||
|
source_id: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return dict(COMPUTE_CENTER_LOCATION_CACHE.get(_cache_key(source, source_id), {}))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pipeline construction ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=512)
|
||||||
|
def _lookup_ror_organization(query: str) -> dict[str, Any] | None:
|
||||||
|
"""Lookup a research organization in ROR for user-triggered candidates."""
|
||||||
|
if not query:
|
||||||
|
return None
|
||||||
|
response = httpx.get(
|
||||||
|
ROR_SEARCH_URL,
|
||||||
|
params={"query": query},
|
||||||
|
headers={"User-Agent": DEFAULT_ROR_USER_AGENT},
|
||||||
|
timeout=DEFAULT_ROR_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
items = payload.get("items") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(items, list) or not items:
|
||||||
|
return None
|
||||||
|
first = items[0]
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
return None
|
||||||
|
organization = first.get("organization")
|
||||||
|
if isinstance(organization, dict):
|
||||||
|
return organization
|
||||||
|
return first
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_center_ror_query_plan(
|
||||||
|
query: LocationQuery,
|
||||||
|
) -> list[tuple[str, tuple[str, ...]]]:
|
||||||
|
extra = query.extra or {}
|
||||||
|
raw_parts: list[tuple[str, str]] = [
|
||||||
|
("site", coerce_str(extra.get("site"))),
|
||||||
|
("operator", coerce_str(extra.get("operator"))),
|
||||||
|
("organization", coerce_str(extra.get("organization"))),
|
||||||
|
]
|
||||||
|
for field, value in tuple(raw_parts):
|
||||||
|
if "/" not in value:
|
||||||
|
continue
|
||||||
|
raw_parts.extend(
|
||||||
|
(field, part.strip())
|
||||||
|
for part in value.split("/")
|
||||||
|
if len(part.strip()) >= 3
|
||||||
|
)
|
||||||
|
|
||||||
|
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for field, value in raw_parts:
|
||||||
|
key = normalize_text(value)
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
plan.append((value, (field,)))
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def _organization_label(organization: dict[str, Any], fallback: str) -> str:
|
||||||
|
names = organization.get("names")
|
||||||
|
if isinstance(names, list):
|
||||||
|
for name in names:
|
||||||
|
if not isinstance(name, dict):
|
||||||
|
continue
|
||||||
|
types = name.get("types")
|
||||||
|
if isinstance(types, list) and "ror_display" in types:
|
||||||
|
value = coerce_str(name.get("value"))
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
for name in names:
|
||||||
|
if isinstance(name, dict):
|
||||||
|
value = coerce_str(name.get("value"))
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
class ROROrganizationResolver:
|
||||||
|
"""Resolve source-provided organization/site text through the open ROR API."""
|
||||||
|
|
||||||
|
name = "ror_organization_registry"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
query_plan_builder=_compute_center_ror_query_plan,
|
||||||
|
lookup=lambda q: _lookup_ror_organization(q),
|
||||||
|
confidence: float = 0.68,
|
||||||
|
) -> None:
|
||||||
|
self._query_plan_builder = query_plan_builder
|
||||||
|
self._lookup = lookup
|
||||||
|
self._confidence = confidence
|
||||||
|
|
||||||
|
def resolve(self, query: LocationQuery):
|
||||||
|
from app.services.location import ResolverOutput
|
||||||
|
from app.services.location.text import parse_float
|
||||||
|
|
||||||
|
attempted: list[str] = []
|
||||||
|
candidates: list[LocationCandidate] = []
|
||||||
|
context_country = normalize_text(normalize_country_text(query.country))
|
||||||
|
|
||||||
|
for ror_query, matched_fields in self._query_plan_builder(query):
|
||||||
|
attempted.append(f"ror:{ror_query}")
|
||||||
|
try:
|
||||||
|
organization = self._lookup(ror_query)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not isinstance(organization, dict):
|
||||||
|
continue
|
||||||
|
locations = organization.get("locations")
|
||||||
|
if not isinstance(locations, list) or not locations:
|
||||||
|
continue
|
||||||
|
location = locations[0]
|
||||||
|
if not isinstance(location, dict):
|
||||||
|
continue
|
||||||
|
details = location.get("geonames_details")
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
continue
|
||||||
|
latitude = parse_float(details.get("lat"))
|
||||||
|
longitude = parse_float(details.get("lng"))
|
||||||
|
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||||
|
continue
|
||||||
|
|
||||||
|
country = normalize_country_text(details.get("country_name"))
|
||||||
|
if context_country and normalize_text(country) != context_country:
|
||||||
|
continue
|
||||||
|
|
||||||
|
city = coerce_str(details.get("name")) or None
|
||||||
|
region = coerce_str(details.get("country_subdivision_name")) or None
|
||||||
|
display_name = _organization_label(organization, ror_query)
|
||||||
|
ror_id = coerce_str(organization.get("id"))
|
||||||
|
geonames_id = location.get("geonames_id")
|
||||||
|
source_note = (
|
||||||
|
f"ROR organization match: {display_name}"
|
||||||
|
+ (f" ({ror_id})" if ror_id else "")
|
||||||
|
+ (f"; GeoNames {geonames_id}" if geonames_id else "")
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
LocationCandidate(
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
display_name=display_name,
|
||||||
|
precision="city",
|
||||||
|
confidence=self._confidence,
|
||||||
|
query=ror_query,
|
||||||
|
source=self.name,
|
||||||
|
source_note=source_note,
|
||||||
|
matched_fields=matched_fields,
|
||||||
|
needs_confirmation=True,
|
||||||
|
city=city,
|
||||||
|
region=region,
|
||||||
|
country=country or query.country,
|
||||||
|
matched_location_name=display_name,
|
||||||
|
location_verified_at=None,
|
||||||
|
suggested_registry_entry=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResolverOutput(
|
||||||
|
candidates=tuple(candidates),
|
||||||
|
attempted_queries=tuple(attempted),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StoredComputeCenterLocationResolver:
|
||||||
|
"""Resolve a compute center through the DB-backed current-location cache."""
|
||||||
|
|
||||||
|
name = "stored_compute_center_location"
|
||||||
|
|
||||||
|
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||||
|
extra = query.extra or {}
|
||||||
|
stored = get_compute_center_location_dict(
|
||||||
|
coerce_str(extra.get("source")),
|
||||||
|
coerce_str(extra.get("source_id")),
|
||||||
|
)
|
||||||
|
if not stored:
|
||||||
|
return ResolverOutput()
|
||||||
|
latitude = parse_float(stored.get("latitude"))
|
||||||
|
longitude = parse_float(stored.get("longitude"))
|
||||||
|
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||||
|
return ResolverOutput()
|
||||||
|
return ResolverOutput(
|
||||||
|
candidates=(
|
||||||
|
LocationCandidate(
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
display_name=stored.get("name") or query.name or "Compute center",
|
||||||
|
precision=stored.get("precision") or "city",
|
||||||
|
confidence=float(stored.get("confidence") or 0.85),
|
||||||
|
query=f"stored_compute_center_location::{stored.get('source')}:{stored.get('source_id')}",
|
||||||
|
source=self.name,
|
||||||
|
source_note=stored.get("source_note"),
|
||||||
|
matched_fields=("source", "source_id"),
|
||||||
|
needs_confirmation=bool(stored.get("needs_confirmation")),
|
||||||
|
city=stored.get("city") or query.city,
|
||||||
|
region=None,
|
||||||
|
country=stored.get("country") or query.country,
|
||||||
|
matched_location_name=stored.get("site") or stored.get("name") or query.name,
|
||||||
|
location_verified_at=stored.get("verified_at"),
|
||||||
|
suggested_registry_entry=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _short_system_name(name: Any) -> str:
|
||||||
|
"""Strip vendor/system suffix from TOP500 names like ``"El Capitan - HPE Cray ..."``."""
|
||||||
|
text = coerce_str(name)
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
head = text.split(" - ", 1)[0].strip()
|
||||||
|
return head or text
|
||||||
|
|
||||||
|
|
||||||
|
def _record_context(record: Any, metadata: dict[str, Any]) -> dict[str, str]:
|
||||||
|
name = coerce_str(getattr(record, "name", None))
|
||||||
|
return {
|
||||||
|
"source": coerce_str(getattr(record, "source", None)),
|
||||||
|
"source_id": coerce_str(getattr(record, "source_id", None)),
|
||||||
|
"name": name,
|
||||||
|
"name_short": _short_system_name(name),
|
||||||
|
"city": coerce_str(get_record_field(record, "city")),
|
||||||
|
"country": coerce_str(get_record_field(record, "country")),
|
||||||
|
"site": coerce_str(metadata.get("site") or metadata.get("organization")),
|
||||||
|
"operator": coerce_str(
|
||||||
|
metadata.get("operator")
|
||||||
|
or metadata.get("organization")
|
||||||
|
or metadata.get("owner")
|
||||||
|
or metadata.get("manufacturer")
|
||||||
|
),
|
||||||
|
"organization": coerce_str(metadata.get("organization")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _context_to_query(
|
||||||
|
context: dict[str, str],
|
||||||
|
*,
|
||||||
|
source_lat: float | None = None,
|
||||||
|
source_lon: float | None = None,
|
||||||
|
) -> LocationQuery:
|
||||||
|
name = context.get("name") or None
|
||||||
|
name_short = context.get("name_short") or ""
|
||||||
|
aliases: tuple[str, ...] = ()
|
||||||
|
if name_short and name_short != name:
|
||||||
|
aliases = (name_short,)
|
||||||
|
return LocationQuery(
|
||||||
|
name=name,
|
||||||
|
aliases=aliases,
|
||||||
|
city=context.get("city") or None,
|
||||||
|
country=context.get("country") or None,
|
||||||
|
source_latitude=source_lat,
|
||||||
|
source_longitude=source_lon,
|
||||||
|
extra={
|
||||||
|
"source": context.get("source") or "",
|
||||||
|
"source_id": context.get("source_id") or "",
|
||||||
|
"site": context.get("site") or "",
|
||||||
|
"operator": context.get("operator") or "",
|
||||||
|
"organization": context.get("organization") or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_center_query_plan(
|
||||||
|
query: LocationQuery,
|
||||||
|
) -> list[tuple[str, tuple[str, ...]]]:
|
||||||
|
"""Build the Nominatim query plan for a compute-center query.
|
||||||
|
|
||||||
|
Mirrors the legacy ``_build_online_query_plan`` ordering exactly.
|
||||||
|
"""
|
||||||
|
name = query.name or ""
|
||||||
|
name_short = (query.aliases[0] if query.aliases else "") or name
|
||||||
|
extra = query.extra or {}
|
||||||
|
site = str(extra.get("site") or "")
|
||||||
|
operator = str(extra.get("operator") or "")
|
||||||
|
city = query.city or ""
|
||||||
|
country = query.country or ""
|
||||||
|
|
||||||
|
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||||
|
|
||||||
|
def add(parts: list[tuple[str, str]]) -> None:
|
||||||
|
non_empty = [(field, value) for field, value in parts if value]
|
||||||
|
if not non_empty:
|
||||||
|
return
|
||||||
|
seen: set[str] = set()
|
||||||
|
cleaned: list[str] = []
|
||||||
|
fields: list[str] = []
|
||||||
|
for field, value in non_empty:
|
||||||
|
key = normalize_text(value)
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
cleaned.append(value)
|
||||||
|
fields.append(field)
|
||||||
|
if not cleaned:
|
||||||
|
return
|
||||||
|
composed = ", ".join(cleaned)
|
||||||
|
if not any(composed == existing for existing, _ in plan):
|
||||||
|
plan.append((composed, tuple(fields)))
|
||||||
|
|
||||||
|
add([("site", site), ("country", country)])
|
||||||
|
add([("operator", operator), ("city", city), ("country", country)])
|
||||||
|
add([("name", name_short), ("operator", operator), ("country", country)])
|
||||||
|
add([("name", name_short), ("site", site)])
|
||||||
|
add([("name", name_short), ("country", country)])
|
||||||
|
add([("name", name_short), ("city", city), ("country", country)])
|
||||||
|
add([("city", city), ("country", country)])
|
||||||
|
if name and name != name_short:
|
||||||
|
add([("name", name), ("country", country)])
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
COMPUTE_CENTER_PIPELINE = LocationPipeline(
|
||||||
|
[
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
StoredComputeCenterLocationResolver(),
|
||||||
|
],
|
||||||
|
failure_reason=(
|
||||||
|
"Could not resolve to city-level coordinates from source coords"
|
||||||
|
" or stored compute-center location."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
COMPUTE_CENTER_COLLECTION_PIPELINE = LocationPipeline(
|
||||||
|
[
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
ROROrganizationResolver(),
|
||||||
|
NominatimResolver(
|
||||||
|
query_plan_builder=_compute_center_query_plan,
|
||||||
|
# Late-binding so test monkeypatching of ``_geocode_online`` works.
|
||||||
|
geocoder=lambda q: _geocode_online(q),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
failure_reason=(
|
||||||
|
"Could not resolve to city-level coordinates from source coords"
|
||||||
|
", ROR organization lookup, or online geocoding."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Candidate → ComputeCenterLocation conversion ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
_GEOGRAPHY_MODE_BY_SOURCE = {
|
||||||
|
"source_coordinates": "source_coordinates",
|
||||||
|
"stored_compute_center_location": "stored_compute_center_location",
|
||||||
|
"ror_organization_registry": "ror_organization",
|
||||||
|
"nominatim_online_geocode": "online_geocode",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_to_location(
|
||||||
|
candidate: LocationCandidate,
|
||||||
|
*,
|
||||||
|
context: dict[str, str],
|
||||||
|
) -> ComputeCenterLocation:
|
||||||
|
geography_mode = _GEOGRAPHY_MODE_BY_SOURCE.get(candidate.source, "online_geocode")
|
||||||
|
is_estimated = candidate.needs_confirmation or candidate.source.startswith(
|
||||||
|
"nominatim"
|
||||||
|
)
|
||||||
|
estimated_reason: str | None
|
||||||
|
if candidate.source == "source_coordinates":
|
||||||
|
estimated_reason = None
|
||||||
|
elif candidate.source == "stored_compute_center_location":
|
||||||
|
estimated_reason = candidate.source_note
|
||||||
|
elif candidate.source == "ror_organization_registry":
|
||||||
|
fields_summary = ", ".join(candidate.matched_fields) or "organization"
|
||||||
|
estimated_reason = (
|
||||||
|
f"Resolved by ROR organization lookup '{candidate.query}' "
|
||||||
|
f"(matched fields: {fields_summary})"
|
||||||
|
)
|
||||||
|
elif candidate.source == "nominatim_online_geocode":
|
||||||
|
fields_summary = ", ".join(candidate.matched_fields) or "name"
|
||||||
|
estimated_reason = (
|
||||||
|
f"Resolved by online geocoding query '{candidate.query}' "
|
||||||
|
f"(matched fields: {fields_summary})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
estimated_reason = candidate.source_note
|
||||||
|
|
||||||
|
country = (
|
||||||
|
candidate.country
|
||||||
|
or normalize_country_text(context.get("country"))
|
||||||
|
or context.get("country")
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
return ComputeCenterLocation(
|
||||||
|
latitude=candidate.latitude,
|
||||||
|
longitude=candidate.longitude,
|
||||||
|
location_precision=candidate.precision,
|
||||||
|
geography_mode=geography_mode,
|
||||||
|
is_estimated=is_estimated,
|
||||||
|
estimated_reason=estimated_reason,
|
||||||
|
location_confidence=candidate.confidence,
|
||||||
|
location_source=candidate.source,
|
||||||
|
location_source_note=candidate.source_note,
|
||||||
|
location_verified_at=candidate.location_verified_at,
|
||||||
|
matched_location_name=candidate.matched_location_name
|
||||||
|
or context.get("name")
|
||||||
|
or None,
|
||||||
|
needs_confirmation=candidate.needs_confirmation,
|
||||||
|
city=candidate.city or context.get("city") or None,
|
||||||
|
region=candidate.region,
|
||||||
|
country=country,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostic_for(
|
||||||
|
record: Any,
|
||||||
|
context: dict[str, str],
|
||||||
|
*,
|
||||||
|
failure_reason: str,
|
||||||
|
attempted_queries: tuple[str, ...] = (),
|
||||||
|
) -> ResolutionDiagnostic:
|
||||||
|
return ResolutionDiagnostic(
|
||||||
|
failure_reason=failure_reason,
|
||||||
|
attempted_queries=attempted_queries,
|
||||||
|
record_id=getattr(record, "id", None),
|
||||||
|
source=getattr(record, "source", None),
|
||||||
|
source_id=getattr(record, "source_id", None),
|
||||||
|
name=context.get("name") or getattr(record, "name", None),
|
||||||
|
country=context.get("country") or None,
|
||||||
|
city=context.get("city") or None,
|
||||||
|
site=context.get("site") or None,
|
||||||
|
operator=context.get("operator") or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_compute_center_location(
|
||||||
|
record: Any,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> ComputeCenterLocation:
|
||||||
|
"""Backwards-compatible thin wrapper returning the renderable location only.
|
||||||
|
|
||||||
|
Records that cannot be resolved to city-level get a placeholder
|
||||||
|
:class:`ComputeCenterLocation` with ``location_precision='unknown'``.
|
||||||
|
Callers should generally prefer :func:`resolve_compute_center_location_full`.
|
||||||
|
"""
|
||||||
|
full = resolve_compute_center_location_full(record, metadata)
|
||||||
|
return full.location or ComputeCenterLocation(
|
||||||
|
latitude=None,
|
||||||
|
longitude=None,
|
||||||
|
location_precision="unknown",
|
||||||
|
geography_mode="unresolved",
|
||||||
|
is_estimated=True,
|
||||||
|
estimated_reason="No resolvable location hints",
|
||||||
|
location_confidence=0.0,
|
||||||
|
location_source="unknown",
|
||||||
|
location_source_note=(
|
||||||
|
"No source coordinates, ROR organization match, or online"
|
||||||
|
" geocoding result."
|
||||||
|
),
|
||||||
|
matched_location_name=None,
|
||||||
|
needs_confirmation=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_compute_center_location_full(
|
||||||
|
record: Any,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
allow_online: bool = False,
|
||||||
|
) -> ResolutionResult:
|
||||||
|
metadata = metadata or {}
|
||||||
|
context = _record_context(record, metadata)
|
||||||
|
|
||||||
|
from app.services.location.text import parse_float as _parse_float
|
||||||
|
|
||||||
|
source_lat = _parse_float(get_record_field(record, "latitude"))
|
||||||
|
source_lon = _parse_float(get_record_field(record, "longitude"))
|
||||||
|
if source_lat in (None, 0.0):
|
||||||
|
source_lat = None
|
||||||
|
if source_lon in (None, 0.0):
|
||||||
|
source_lon = None
|
||||||
|
|
||||||
|
query = _context_to_query(
|
||||||
|
context, source_lat=source_lat, source_lon=source_lon
|
||||||
|
)
|
||||||
|
pipeline = (
|
||||||
|
COMPUTE_CENTER_COLLECTION_PIPELINE
|
||||||
|
if allow_online
|
||||||
|
else COMPUTE_CENTER_PIPELINE
|
||||||
|
)
|
||||||
|
pipeline_result = pipeline.resolve_best(query)
|
||||||
|
|
||||||
|
if pipeline_result.location and pipeline_result.location.precision in RENDERABLE_PRECISIONS:
|
||||||
|
location = _candidate_to_location(pipeline_result.location, context=context)
|
||||||
|
return ResolutionResult(location=location, diagnostic=None)
|
||||||
|
|
||||||
|
return ResolutionResult(
|
||||||
|
location=None,
|
||||||
|
diagnostic=_diagnostic_for(
|
||||||
|
record,
|
||||||
|
context,
|
||||||
|
failure_reason=(
|
||||||
|
"Could not resolve to city-level coordinates from source coords"
|
||||||
|
", ROR organization lookup, or online geocoding."
|
||||||
|
if allow_online
|
||||||
|
else (
|
||||||
|
"Could not resolve to city-level coordinates from source coords"
|
||||||
|
" or stored compute-center location."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
attempted_queries=pipeline_result.attempted_queries,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_location_candidates(
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
source_id: str | None = None,
|
||||||
|
operator: str | None = None,
|
||||||
|
site: str | None = None,
|
||||||
|
city: str | None = None,
|
||||||
|
country: str | None = None,
|
||||||
|
organization: str | None = None,
|
||||||
|
record_id: int | None = None,
|
||||||
|
) -> tuple[list[LocationCandidate], list[str]]:
|
||||||
|
"""Run the full resolution chain and return ranked candidates with attempted queries.
|
||||||
|
|
||||||
|
The unused ``source`` / ``source_id`` / ``record_id`` arguments are kept
|
||||||
|
for backward compatibility with the API handler that calls this function.
|
||||||
|
"""
|
||||||
|
name_value = coerce_str(name)
|
||||||
|
context: dict[str, str] = {
|
||||||
|
"source": coerce_str(source),
|
||||||
|
"source_id": coerce_str(source_id),
|
||||||
|
"name": name_value,
|
||||||
|
"name_short": _short_system_name(name_value),
|
||||||
|
"city": coerce_str(city),
|
||||||
|
"country": coerce_str(country),
|
||||||
|
"site": coerce_str(site or organization),
|
||||||
|
"operator": coerce_str(operator or organization),
|
||||||
|
"organization": coerce_str(organization),
|
||||||
|
}
|
||||||
|
query = _context_to_query(context)
|
||||||
|
return COMPUTE_CENTER_COLLECTION_PIPELINE.collect_candidates(query)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_operator(metadata: dict[str, Any]) -> str | None:
|
||||||
|
return coerce_str(
|
||||||
|
metadata.get("operator")
|
||||||
|
or metadata.get("organization")
|
||||||
|
or metadata.get("owner")
|
||||||
|
or metadata.get("manufacturer")
|
||||||
|
) or None
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_compute_center_locations_from_source_coords(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Seed stored compute-center locations only from real source coordinates."""
|
||||||
|
stmt = (
|
||||||
|
select(CollectedData)
|
||||||
|
.where(CollectedData.source.in_(["top500", "epoch_ai_gpu"]))
|
||||||
|
.where(CollectedData.is_current.is_(True))
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
records = result.scalars().all()
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
source_value = coerce_str(getattr(record, "source", None))
|
||||||
|
source_id = coerce_str(getattr(record, "source_id", None))
|
||||||
|
if not source_value or not source_id:
|
||||||
|
continue
|
||||||
|
latitude = parse_float(get_record_field(record, "latitude"))
|
||||||
|
longitude = parse_float(get_record_field(record, "longitude"))
|
||||||
|
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||||
|
continue
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ComputeCenterLocationRecord)
|
||||||
|
.where(ComputeCenterLocationRecord.source == source_value)
|
||||||
|
.where(ComputeCenterLocationRecord.source_id == source_id)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
metadata = record.extra_data or {}
|
||||||
|
session.add(
|
||||||
|
ComputeCenterLocationRecord(
|
||||||
|
source=source_value,
|
||||||
|
source_id=source_id,
|
||||||
|
name=getattr(record, "name", None),
|
||||||
|
operator=_record_operator(metadata),
|
||||||
|
site=coerce_str(metadata.get("site") or metadata.get("organization")) or None,
|
||||||
|
city=coerce_str(get_record_field(record, "city")) or None,
|
||||||
|
country=coerce_str(get_record_field(record, "country")) or None,
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
precision="precise",
|
||||||
|
confidence=1.0,
|
||||||
|
location_source="source_coordinates",
|
||||||
|
source_note="Seeded from source-provided compute-center coordinates",
|
||||||
|
raw_payload={
|
||||||
|
"record_id": getattr(record, "id", None),
|
||||||
|
"source": source_value,
|
||||||
|
"source_id": source_id,
|
||||||
|
},
|
||||||
|
needs_confirmation=False,
|
||||||
|
verification_status="source_provided",
|
||||||
|
verified_at=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
await session.commit()
|
||||||
|
await refresh_compute_center_location_cache(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_compute_center_location(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
source_id: str,
|
||||||
|
name: str | None = None,
|
||||||
|
operator: str | None = None,
|
||||||
|
site: str | None = None,
|
||||||
|
city: str | None = None,
|
||||||
|
country: str | None = None,
|
||||||
|
latitude: float,
|
||||||
|
longitude: float,
|
||||||
|
precision: str = "city",
|
||||||
|
confidence: float | None = None,
|
||||||
|
location_source: str = "manual_selection",
|
||||||
|
source_url: str | None = None,
|
||||||
|
source_note: str | None = None,
|
||||||
|
raw_payload: dict[str, Any] | None = None,
|
||||||
|
needs_confirmation: bool = False,
|
||||||
|
verification_status: str = "verified",
|
||||||
|
) -> ComputeCenterLocationRecord:
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ComputeCenterLocationRecord)
|
||||||
|
.where(ComputeCenterLocationRecord.source == source)
|
||||||
|
.where(ComputeCenterLocationRecord.source_id == source_id)
|
||||||
|
)
|
||||||
|
verified_at = None if needs_confirmation else datetime.now(UTC)
|
||||||
|
values = {
|
||||||
|
"name": name,
|
||||||
|
"operator": operator,
|
||||||
|
"site": site,
|
||||||
|
"city": city,
|
||||||
|
"country": country,
|
||||||
|
"latitude": latitude,
|
||||||
|
"longitude": longitude,
|
||||||
|
"precision": precision,
|
||||||
|
"confidence": confidence,
|
||||||
|
"location_source": location_source,
|
||||||
|
"source_url": source_url,
|
||||||
|
"source_note": source_note,
|
||||||
|
"raw_payload": raw_payload or {},
|
||||||
|
"needs_confirmation": needs_confirmation,
|
||||||
|
"verification_status": verification_status,
|
||||||
|
"verified_at": verified_at,
|
||||||
|
}
|
||||||
|
if existing:
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(existing, key, value)
|
||||||
|
record = existing
|
||||||
|
else:
|
||||||
|
record = ComputeCenterLocationRecord(
|
||||||
|
source=source,
|
||||||
|
source_id=source_id,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
session.add(record)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(record)
|
||||||
|
await refresh_compute_center_location_cache(session)
|
||||||
|
return record
|
||||||
@@ -66,9 +66,68 @@ BARENTSWATCH_DEFAULT_GUIDE = CredentialGuideDefault(
|
|||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AISSTREAM_DEFAULT_GUIDE = CredentialGuideDefault(
|
||||||
|
provider="aisstream",
|
||||||
|
title="AISStream API Key 获取教程",
|
||||||
|
prompt=(
|
||||||
|
"请生成一份中文教程,指导开发者获取 AISStream 的 API Key 并配置到 Planet。"
|
||||||
|
"教程要面向已经有本地开发环境的人,包含注册/登录 AISStream、获取 API Key、"
|
||||||
|
"理解免费额度和订阅范围、在 Planet 设置中心填写 API Key、配置 bounding boxes "
|
||||||
|
"和 message types、验证连接、常见失败排查。必须提醒用户以 AISStream 当前官网和"
|
||||||
|
"服务条款为准,不要编造具体页面按钮文案。"
|
||||||
|
),
|
||||||
|
markdown="""## AISStream API Key 获取
|
||||||
|
|
||||||
|
官方入口:https://aisstream.io/
|
||||||
|
|
||||||
|
1. 打开 AISStream 官网,按当前页面指引注册或登录账号。
|
||||||
|
2. 在账号/API 管理页面创建或复制你的 API Key。
|
||||||
|
3. 先确认当前账号额度、使用条款和可订阅区域。实时 AIS 流量可能很大,不建议一开始订阅全球范围。
|
||||||
|
4. 回到 Planet 的 `设置 -> 采集器设置 -> AISStream 实时船舶`。
|
||||||
|
5. 在 `AISStream 凭证` 中填入 API Key。
|
||||||
|
6. Endpoint 通常保持默认:`wss://stream.aisstream.io/v0/stream`。
|
||||||
|
7. 按需配置 `Bounding Boxes JSON` 和 `消息类型`。
|
||||||
|
8. 点击连接测试,确认系统能读取凭证且 WebSocket endpoint 格式有效。
|
||||||
|
9. 保存采集器设置后再运行 `aisstream_vessels` collector。
|
||||||
|
|
||||||
|
### 推荐配置
|
||||||
|
|
||||||
|
默认消息类型:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["PositionReport", "ShipStaticData"]
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 Bounding Boxes 示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[[[-90, -180], [90, 180]]]
|
||||||
|
```
|
||||||
|
|
||||||
|
这个示例表示全球范围。实际使用时建议先改成较小区域,降低消息量和处理压力。
|
||||||
|
|
||||||
|
### 请求规则
|
||||||
|
|
||||||
|
- Endpoint:`wss://stream.aisstream.io/v0/stream`
|
||||||
|
- 传输方式:WebSocket
|
||||||
|
- API Key 放在订阅 payload 中,不放在 HTTP header。
|
||||||
|
- Planet 会把 AISStream 标记为 `delivery_mode = realtime_stream`、`transport = websocket`。
|
||||||
|
- AISStream collector 只写入 AIS raw observations,不直接覆盖最终船只展示表。
|
||||||
|
|
||||||
|
### 常见排查
|
||||||
|
|
||||||
|
- `未找到凭证`:确认 API Key 已保存到采集器设置,或设置了 `AISSTREAM_API_KEY` 环境变量 / `~/.zshrc`。
|
||||||
|
- `endpoint 必须是 ws:// 或 wss://`:AISStream 是 WebSocket 流接口,不要填普通 `https://` API 地址。
|
||||||
|
- 采集量过大:缩小 `Bounding Boxes JSON`,减少 `message_types`,或降低单次最大消息数。
|
||||||
|
- 没有船只数据:确认订阅区域内确实有 AIS 活动,并检查 API Key 当前额度和权限。
|
||||||
|
- 连接中断:实时流可能受网络和上游限流影响,collector 会记录源健康状态供聚合服务回退。
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CREDENTIAL_GUIDES = {
|
DEFAULT_CREDENTIAL_GUIDES = {
|
||||||
BARENTSWATCH_DEFAULT_GUIDE.provider: BARENTSWATCH_DEFAULT_GUIDE,
|
BARENTSWATCH_DEFAULT_GUIDE.provider: BARENTSWATCH_DEFAULT_GUIDE,
|
||||||
|
AISSTREAM_DEFAULT_GUIDE.provider: AISSTREAM_DEFAULT_GUIDE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
391
backend/app/services/custom_datasource_runtime.py
Normal file
391
backend/app/services/custom_datasource_runtime.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
"""Runtime helpers for mapped custom data sources."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.target_schema_registry import TARGET_SCHEMAS
|
||||||
|
from app.db.session import async_session_factory
|
||||||
|
from app.models.datasource_config import DataSourceConfig
|
||||||
|
from app.models.datasource_mapping import DataSourceMappingTemplate
|
||||||
|
from app.services.datasource_mapping import (
|
||||||
|
MappingError,
|
||||||
|
execute_mapping,
|
||||||
|
extract_path,
|
||||||
|
persist_mapped_records,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_MAPPING_TEMPLATES: dict[str, dict[str, Any]] = {
|
||||||
|
"vessel_ais": {
|
||||||
|
"source": {"items_path": "$"},
|
||||||
|
"fields": {
|
||||||
|
"mmsi": {"path": "$.mmsi", "type": "integer"},
|
||||||
|
"name": {"path": "$.name", "type": "string", "default": None},
|
||||||
|
"lat": {"path": "$.lat", "type": "float"},
|
||||||
|
"lon": {"path": "$.lon", "type": "float"},
|
||||||
|
"sog": {"path": "$.sog", "type": "float", "default": None},
|
||||||
|
"cog": {"path": "$.cog", "type": "float", "default": None},
|
||||||
|
"heading": {"path": "$.heading", "type": "integer", "default": None},
|
||||||
|
"nav_status": {"path": "$.nav_status", "type": "integer", "default": None},
|
||||||
|
"callsign": {"path": "$.callsign", "type": "string", "default": None},
|
||||||
|
"vessel_type": {"path": "$.vessel_type", "type": "string", "default": None},
|
||||||
|
"vessel_type_name": {"path": "$.vessel_type_name", "type": "string", "default": None},
|
||||||
|
"received_at": {"path": "$.received_at", "type": "datetime", "default": None},
|
||||||
|
},
|
||||||
|
"meta": {"generated_by": "default_template", "requires_review": False},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RUNNING_CUSTOM_STREAM_TASKS: dict[int, asyncio.Task[Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class CustomDatasourceRuntimeError(RuntimeError):
|
||||||
|
"""Raised when a custom datasource cannot run."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_request_headers(auth_type: str, auth_config: dict, headers: dict) -> dict[str, str]:
|
||||||
|
request_headers = {str(key): str(value) for key, value in (headers or {}).items()}
|
||||||
|
auth_type = str(auth_type or "none").lower()
|
||||||
|
auth_config = auth_config or {}
|
||||||
|
|
||||||
|
if auth_type == "bearer" and auth_config.get("token"):
|
||||||
|
request_headers["Authorization"] = f"Bearer {auth_config['token']}"
|
||||||
|
elif auth_type == "api_key" and auth_config.get("api_key"):
|
||||||
|
location = str(auth_config.get("in") or auth_config.get("location") or "header").lower()
|
||||||
|
if location != "query":
|
||||||
|
key_name = auth_config.get("key_name", "X-API-Key")
|
||||||
|
request_headers[str(key_name)] = str(auth_config["api_key"])
|
||||||
|
elif auth_type == "basic":
|
||||||
|
username = auth_config.get("username", "")
|
||||||
|
password = auth_config.get("password", "")
|
||||||
|
credentials = f"{username}:{password}"
|
||||||
|
encoded = base64.b64encode(credentials.encode()).decode()
|
||||||
|
request_headers["Authorization"] = f"Basic {encoded}"
|
||||||
|
return request_headers
|
||||||
|
|
||||||
|
|
||||||
|
def build_query_params(auth_type: str, auth_config: dict, config: dict) -> dict[str, Any]:
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
candidate = (config or {}).get("params") or (config or {}).get("query_params")
|
||||||
|
if isinstance(candidate, dict):
|
||||||
|
params.update(candidate)
|
||||||
|
|
||||||
|
auth_type = str(auth_type or "none").lower()
|
||||||
|
auth_config = auth_config or {}
|
||||||
|
if auth_type == "api_key" and auth_config.get("api_key"):
|
||||||
|
location = str(auth_config.get("in") or auth_config.get("location") or "header").lower()
|
||||||
|
if location == "query":
|
||||||
|
key_name = auth_config.get("key_name") or auth_config.get("param_name") or "api_key"
|
||||||
|
params[str(key_name)] = auth_config["api_key"]
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
async def load_active_mapping(
|
||||||
|
db: AsyncSession,
|
||||||
|
datasource_config_id: int,
|
||||||
|
) -> DataSourceMappingTemplate:
|
||||||
|
result = await db.execute(
|
||||||
|
select(DataSourceMappingTemplate)
|
||||||
|
.where(DataSourceMappingTemplate.datasource_config_id == datasource_config_id)
|
||||||
|
.where(DataSourceMappingTemplate.is_active.is_(True))
|
||||||
|
.order_by(DataSourceMappingTemplate.version.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
mapping = result.scalar_one_or_none()
|
||||||
|
if mapping is not None:
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
datasource = await db.get(DataSourceConfig, datasource_config_id)
|
||||||
|
if datasource is None:
|
||||||
|
raise CustomDatasourceRuntimeError("Configuration not found")
|
||||||
|
target_schema = (datasource.config or {}).get("target_schema")
|
||||||
|
template_body = DEFAULT_MAPPING_TEMPLATES.get(str(target_schema or "")) if target_schema else None
|
||||||
|
if not template_body or target_schema not in TARGET_SCHEMAS:
|
||||||
|
raise CustomDatasourceRuntimeError(
|
||||||
|
"No active mapping template found and no default template available for this target schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
mapping = DataSourceMappingTemplate(
|
||||||
|
datasource_config_id=datasource_config_id,
|
||||||
|
target_schema=str(target_schema),
|
||||||
|
mapping_json=template_body,
|
||||||
|
sample_payload_hash=None,
|
||||||
|
validation_status="valid",
|
||||||
|
version=1,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(mapping)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(mapping)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_rest_payload(config: DataSourceConfig, limit_bytes: int) -> Any:
|
||||||
|
request_config = config.config or {}
|
||||||
|
method = str(request_config.get("method") or request_config.get("request_method") or "GET").upper()
|
||||||
|
if method not in {"GET", "POST"}:
|
||||||
|
raise CustomDatasourceRuntimeError("Only GET and POST sample requests are supported.")
|
||||||
|
|
||||||
|
headers = build_request_headers(config.auth_type, config.auth_config or {}, config.headers or {})
|
||||||
|
params = build_query_params(config.auth_type, config.auth_config or {}, request_config)
|
||||||
|
timeout = float(request_config.get("timeout", 30))
|
||||||
|
json_body = request_config.get("json_body")
|
||||||
|
if json_body is None and str(request_config.get("body_type") or "").lower() in {"json", ""}:
|
||||||
|
candidate = request_config.get("body")
|
||||||
|
if isinstance(candidate, (dict, list)):
|
||||||
|
json_body = candidate
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||||
|
response = await client.request(
|
||||||
|
method,
|
||||||
|
config.endpoint,
|
||||||
|
headers=headers,
|
||||||
|
params=params or None,
|
||||||
|
json=json_body,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.content[:limit_bytes]
|
||||||
|
if "application/json" in response.headers.get("content-type", ""):
|
||||||
|
return json.loads(content.decode(response.encoding or "utf-8"))
|
||||||
|
return {"text": content.decode(response.encoding or "utf-8", errors="replace")}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_mapped_rest_config(
|
||||||
|
db: AsyncSession,
|
||||||
|
datasource: DataSourceConfig,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
mapping = await load_active_mapping(db, datasource.id)
|
||||||
|
sample = await fetch_rest_payload(datasource, 5_000_000)
|
||||||
|
mapped = execute_mapping(sample, mapping.mapping_json, mapping.target_schema)
|
||||||
|
if mapped["failed_count"] > 0:
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"datasource_config_id": datasource.id,
|
||||||
|
"mapping_id": mapping.id,
|
||||||
|
"mapping_version": mapping.version,
|
||||||
|
"target_schema": mapping.target_schema,
|
||||||
|
"mapped_count": mapped["mapped_count"],
|
||||||
|
"failed_count": mapped["failed_count"],
|
||||||
|
"errors": mapped["errors"][:20],
|
||||||
|
}
|
||||||
|
|
||||||
|
request_config = datasource.config or {}
|
||||||
|
written_count = await persist_mapped_records(
|
||||||
|
db,
|
||||||
|
datasource_name=datasource.name,
|
||||||
|
datasource_config_id=datasource.id,
|
||||||
|
target_schema=mapping.target_schema,
|
||||||
|
records=mapped["records"],
|
||||||
|
mapping_version=mapping.version,
|
||||||
|
delivery_mode=request_config.get("delivery_mode") or "polling",
|
||||||
|
transport="http",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"datasource_config_id": datasource.id,
|
||||||
|
"mapping_id": mapping.id,
|
||||||
|
"mapping_version": mapping.version,
|
||||||
|
"target_schema": mapping.target_schema,
|
||||||
|
"fetched_count": mapped["total_items"],
|
||||||
|
"mapped_count": mapped["mapped_count"],
|
||||||
|
"written_count": written_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _items_from_ws_message(payload: Any, config: dict) -> Any:
|
||||||
|
message_path = config.get("ws_message_path")
|
||||||
|
items_path = config.get("ws_items_path")
|
||||||
|
value = extract_path(payload, message_path) if message_path else payload
|
||||||
|
return extract_path(value, items_path) if items_path else value
|
||||||
|
|
||||||
|
|
||||||
|
async def _connect_websocket(endpoint: str, headers: dict[str, str]):
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await websockets.connect(endpoint, additional_headers=headers or None)
|
||||||
|
except TypeError:
|
||||||
|
return await websockets.connect(endpoint, extra_headers=headers or None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_websocket_config(config: DataSourceConfig) -> dict[str, Any]:
|
||||||
|
if not str(config.endpoint or "").startswith(("ws://", "wss://")):
|
||||||
|
raise CustomDatasourceRuntimeError("WebSocket datasource endpoint must start with ws:// or wss://")
|
||||||
|
|
||||||
|
runtime_config = config.config or {}
|
||||||
|
headers = build_request_headers(config.auth_type, config.auth_config or {}, config.headers or {})
|
||||||
|
receive_timeout = float(runtime_config.get("receive_timeout_seconds") or runtime_config.get("timeout") or 10)
|
||||||
|
async with await _connect_websocket(config.endpoint, headers) as websocket:
|
||||||
|
subscribe_message = runtime_config.get("ws_subscribe_message")
|
||||||
|
if isinstance(subscribe_message, (dict, list)):
|
||||||
|
await websocket.send(json.dumps(subscribe_message))
|
||||||
|
elif isinstance(subscribe_message, str) and subscribe_message.strip():
|
||||||
|
await websocket.send(subscribe_message)
|
||||||
|
raw_message = await asyncio.wait_for(websocket.recv(), timeout=receive_timeout)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message_preview": raw_message[:1000] if isinstance(raw_message, str) else str(raw_message)[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_mapped_websocket_config(
|
||||||
|
db: AsyncSession,
|
||||||
|
datasource: DataSourceConfig,
|
||||||
|
*,
|
||||||
|
debug_max_messages: int | None = None,
|
||||||
|
use_config_debug_max_messages: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not str(datasource.endpoint or "").startswith(("ws://", "wss://")):
|
||||||
|
raise CustomDatasourceRuntimeError("WebSocket datasource endpoint must start with ws:// or wss://")
|
||||||
|
|
||||||
|
mapping = await load_active_mapping(db, datasource.id)
|
||||||
|
runtime_config = datasource.config or {}
|
||||||
|
max_messages = debug_max_messages
|
||||||
|
if max_messages is None and use_config_debug_max_messages:
|
||||||
|
max_messages = runtime_config.get("debug_max_messages")
|
||||||
|
max_messages = int(max_messages) if max_messages else None
|
||||||
|
receive_timeout = float(runtime_config.get("receive_timeout_seconds") or runtime_config.get("timeout") or 30)
|
||||||
|
reconnect = bool(runtime_config.get("ws_reconnect", True))
|
||||||
|
reconnect_delay = float(runtime_config.get("reconnect_delay_seconds") or 3)
|
||||||
|
headers = build_request_headers(datasource.auth_type, datasource.auth_config or {}, datasource.headers or {})
|
||||||
|
|
||||||
|
messages_seen = 0
|
||||||
|
mapped_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
written_count = 0
|
||||||
|
errors: list[dict[str, Any]] = []
|
||||||
|
started_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with await _connect_websocket(datasource.endpoint, headers) as websocket:
|
||||||
|
subscribe_message = runtime_config.get("ws_subscribe_message")
|
||||||
|
if isinstance(subscribe_message, (dict, list)):
|
||||||
|
await websocket.send(json.dumps(subscribe_message))
|
||||||
|
elif isinstance(subscribe_message, str) and subscribe_message.strip():
|
||||||
|
await websocket.send(subscribe_message)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
raw_message = await asyncio.wait_for(websocket.recv(), timeout=receive_timeout)
|
||||||
|
messages_seen += 1
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_message)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
failed_count += 1
|
||||||
|
errors.append({"message": "invalid_json", "error": str(exc)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
extracted = _items_from_ws_message(payload, runtime_config)
|
||||||
|
try:
|
||||||
|
mapped = execute_mapping(extracted, mapping.mapping_json, mapping.target_schema)
|
||||||
|
except (MappingError, ValueError) as exc:
|
||||||
|
failed_count += 1
|
||||||
|
errors.append({"message": "mapping_failed", "error": str(exc)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
mapped_count += mapped["mapped_count"]
|
||||||
|
failed_count += mapped["failed_count"]
|
||||||
|
if mapped["errors"]:
|
||||||
|
errors.extend(mapped["errors"][:5])
|
||||||
|
if mapped["records"]:
|
||||||
|
written_count += await persist_mapped_records(
|
||||||
|
db,
|
||||||
|
datasource_name=datasource.name,
|
||||||
|
datasource_config_id=datasource.id,
|
||||||
|
target_schema=mapping.target_schema,
|
||||||
|
records=mapped["records"],
|
||||||
|
mapping_version=mapping.version,
|
||||||
|
delivery_mode=runtime_config.get("delivery_mode") or "realtime_stream",
|
||||||
|
transport="websocket",
|
||||||
|
)
|
||||||
|
|
||||||
|
if max_messages and messages_seen >= max_messages:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"datasource_config_id": datasource.id,
|
||||||
|
"mapping_id": mapping.id,
|
||||||
|
"mapping_version": mapping.version,
|
||||||
|
"target_schema": mapping.target_schema,
|
||||||
|
"messages_seen": messages_seen,
|
||||||
|
"mapped_count": mapped_count,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"written_count": written_count,
|
||||||
|
"errors": errors[:20],
|
||||||
|
"execution_time_seconds": (datetime.now(UTC) - started_at).total_seconds(),
|
||||||
|
}
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
failed_count += 1
|
||||||
|
errors.append({"message": "websocket_error", "error": f"{exc.__class__.__name__}: {exc}"})
|
||||||
|
if not reconnect or max_messages:
|
||||||
|
return {
|
||||||
|
"status": "failed" if written_count == 0 else "partial",
|
||||||
|
"datasource_config_id": datasource.id,
|
||||||
|
"mapping_id": mapping.id,
|
||||||
|
"mapping_version": mapping.version,
|
||||||
|
"target_schema": mapping.target_schema,
|
||||||
|
"messages_seen": messages_seen,
|
||||||
|
"mapped_count": mapped_count,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"written_count": written_count,
|
||||||
|
"errors": errors[:20],
|
||||||
|
}
|
||||||
|
await asyncio.sleep(reconnect_delay)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_custom_stream_by_id(config_id: int) -> dict[str, Any]:
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
datasource = await db.get(DataSourceConfig, config_id)
|
||||||
|
if not datasource:
|
||||||
|
raise CustomDatasourceRuntimeError("Configuration not found")
|
||||||
|
return await run_mapped_websocket_config(
|
||||||
|
db,
|
||||||
|
datasource,
|
||||||
|
use_config_debug_max_messages=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_custom_stream(config_id: int) -> bool:
|
||||||
|
existing = RUNNING_CUSTOM_STREAM_TASKS.get(config_id)
|
||||||
|
if existing is not None and not existing.done():
|
||||||
|
return False
|
||||||
|
task = asyncio.create_task(run_custom_stream_by_id(config_id), name=f"custom-stream:{config_id}")
|
||||||
|
RUNNING_CUSTOM_STREAM_TASKS[config_id] = task
|
||||||
|
|
||||||
|
def _cleanup(done_task: asyncio.Task[Any]) -> None:
|
||||||
|
if RUNNING_CUSTOM_STREAM_TASKS.get(config_id) is done_task:
|
||||||
|
RUNNING_CUSTOM_STREAM_TASKS.pop(config_id, None)
|
||||||
|
|
||||||
|
task.add_done_callback(_cleanup)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_custom_stream(config_id: int) -> bool:
|
||||||
|
task = RUNNING_CUSTOM_STREAM_TASKS.get(config_id)
|
||||||
|
if task is None or task.done():
|
||||||
|
RUNNING_CUSTOM_STREAM_TASKS.pop(config_id, None)
|
||||||
|
return False
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return True
|
||||||
|
return task.cancelled()
|
||||||
|
|
||||||
|
|
||||||
|
def get_custom_stream_status(config_id: int) -> dict[str, Any]:
|
||||||
|
task = RUNNING_CUSTOM_STREAM_TASKS.get(config_id)
|
||||||
|
return {
|
||||||
|
"config_id": config_id,
|
||||||
|
"running": bool(task and not task.done()),
|
||||||
|
"done": bool(task and task.done()),
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ from app.services.barentswatch import (
|
|||||||
|
|
||||||
CONNECTIVITY_VALIDATION_KEY = "connectivity_validation"
|
CONNECTIVITY_VALIDATION_KEY = "connectivity_validation"
|
||||||
CONNECTIVITY_STORE_CATEGORY = "datasource_connectivity_validations"
|
CONNECTIVITY_STORE_CATEGORY = "datasource_connectivity_validations"
|
||||||
|
SUPPORTED_CREDENTIAL_PROVIDERS = {"barentswatch", "spacetrack", "aisstream"}
|
||||||
|
|
||||||
|
|
||||||
def _sha256_json(payload: Any) -> str:
|
def _sha256_json(payload: Any) -> str:
|
||||||
@@ -43,6 +44,36 @@ def _resolve_spacetrack_credentials() -> tuple[str, str, str]:
|
|||||||
return username, password, source or "missing"
|
return username, password, source or "missing"
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_aisstream_api_key(
|
||||||
|
db=None,
|
||||||
|
credential_override: dict[str, str] | None = None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
if credential_override and credential_override.get("api_key"):
|
||||||
|
return str(credential_override["api_key"]), "draft"
|
||||||
|
|
||||||
|
env_key = os.getenv("AISSTREAM_API_KEY")
|
||||||
|
zshrc_key = _read_zshrc_env().get("AISSTREAM_API_KEY")
|
||||||
|
if db is not None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(DataSourceConfig)
|
||||||
|
.where(DataSourceConfig.name == "aisstream_vessels")
|
||||||
|
.where(DataSourceConfig.is_active.is_(True))
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record:
|
||||||
|
auth_config = record.auth_config or {}
|
||||||
|
runtime_config = record.config or {}
|
||||||
|
api_key = auth_config.get("api_key") or runtime_config.get("api_key")
|
||||||
|
if api_key:
|
||||||
|
return str(api_key), "datasource_config"
|
||||||
|
|
||||||
|
if env_key:
|
||||||
|
return env_key, "environment"
|
||||||
|
if zshrc_key:
|
||||||
|
return zshrc_key, "~/.zshrc"
|
||||||
|
return "", "missing"
|
||||||
|
|
||||||
|
|
||||||
def strip_connectivity_validation(config: dict | None) -> dict:
|
def strip_connectivity_validation(config: dict | None) -> dict:
|
||||||
cleaned = dict(config or {})
|
cleaned = dict(config or {})
|
||||||
cleaned.pop(CONNECTIVITY_VALIDATION_KEY, None)
|
cleaned.pop(CONNECTIVITY_VALIDATION_KEY, None)
|
||||||
@@ -103,6 +134,10 @@ async def build_builtin_connectivity_checksum(
|
|||||||
"password": password,
|
"password": password,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
elif credential_provider == "aisstream":
|
||||||
|
api_key, credential_source = await _resolve_aisstream_api_key(db, credential_override)
|
||||||
|
has_credentials = bool(api_key)
|
||||||
|
credential_fingerprint = _sha256_json({"api_key": api_key})
|
||||||
elif defaults.get("requires_credentials"):
|
elif defaults.get("requires_credentials"):
|
||||||
credential_source = str(credential_provider or "unsupported")
|
credential_source = str(credential_provider or "unsupported")
|
||||||
|
|
||||||
@@ -130,6 +165,7 @@ async def test_builtin_connectivity(
|
|||||||
headers: dict | None,
|
headers: dict | None,
|
||||||
config: dict | None,
|
config: dict | None,
|
||||||
db=None,
|
db=None,
|
||||||
|
credential_override: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
defaults = DEFAULT_DATASOURCES.get(source)
|
defaults = DEFAULT_DATASOURCES.get(source)
|
||||||
if not defaults:
|
if not defaults:
|
||||||
@@ -145,6 +181,7 @@ async def test_builtin_connectivity(
|
|||||||
headers,
|
headers,
|
||||||
config,
|
config,
|
||||||
db,
|
db,
|
||||||
|
credential_override,
|
||||||
)
|
)
|
||||||
if credential_context["requires_credentials"] and not credential_context["has_credentials"]:
|
if credential_context["requires_credentials"] and not credential_context["has_credentials"]:
|
||||||
return {
|
return {
|
||||||
@@ -155,10 +192,9 @@ async def test_builtin_connectivity(
|
|||||||
"settings_tab": "collector_credentials",
|
"settings_tab": "collector_credentials",
|
||||||
**credential_context,
|
**credential_context,
|
||||||
}
|
}
|
||||||
supported_credential_providers = {"barentswatch", "spacetrack"}
|
|
||||||
if (
|
if (
|
||||||
credential_context["requires_credentials"]
|
credential_context["requires_credentials"]
|
||||||
and credential_context["credential_provider"] not in supported_credential_providers
|
and credential_context["credential_provider"] not in SUPPORTED_CREDENTIAL_PROVIDERS
|
||||||
):
|
):
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -174,6 +210,23 @@ async def test_builtin_connectivity(
|
|||||||
timeout = float(request_config.get("timeout") or 30)
|
timeout = float(request_config.get("timeout") or 30)
|
||||||
request_endpoint = endpoint
|
request_endpoint = endpoint
|
||||||
|
|
||||||
|
if credential_context["credential_provider"] == "aisstream":
|
||||||
|
if not str(request_endpoint).startswith(("ws://", "wss://")):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"checksum": checksum,
|
||||||
|
"stage": "endpoint",
|
||||||
|
"message": "AISStream endpoint 必须是 ws:// 或 wss:// WebSocket 地址。",
|
||||||
|
**credential_context,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"checksum": checksum,
|
||||||
|
"stage": "credentials",
|
||||||
|
"message": "AISStream 凭证已配置,WebSocket endpoint 格式有效。",
|
||||||
|
**credential_context,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||||
if credential_context["credential_provider"] == "barentswatch":
|
if credential_context["credential_provider"] == "barentswatch":
|
||||||
|
|||||||
@@ -290,25 +290,77 @@ async def persist_mapped_records(
|
|||||||
target_schema: str,
|
target_schema: str,
|
||||||
records: list[dict[str, Any]],
|
records: list[dict[str, Any]],
|
||||||
mapping_version: int,
|
mapping_version: int,
|
||||||
|
delivery_mode: str | None = None,
|
||||||
|
transport: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Persist validated mapped records to the destination for a target schema."""
|
"""Persist validated mapped records to the destination for a target schema."""
|
||||||
if target_schema == "vessel_ais":
|
if target_schema == "vessel_ais":
|
||||||
from app.models.vessel import VesselPosition
|
from app.core.time import to_iso8601_utc
|
||||||
|
from app.core.websocket.broadcaster import broadcaster
|
||||||
|
from app.services.vessel_ais_aggregation import (
|
||||||
|
record_vessel_ais_observation,
|
||||||
|
update_ais_source_health,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
latest_observed_at = now
|
||||||
|
written_count = 0
|
||||||
for record in records:
|
for record in records:
|
||||||
db.add(
|
observed_at = _parse_datetime(record.get("received_at")) or now
|
||||||
VesselPosition(
|
observation = await record_vessel_ais_observation(
|
||||||
mmsi=record["mmsi"],
|
db,
|
||||||
lat=record["lat"],
|
source=datasource_name,
|
||||||
lon=record["lon"],
|
normalized_payload=record,
|
||||||
sog=record.get("sog"),
|
raw_payload=record,
|
||||||
cog=record.get("cog"),
|
delivery_mode=delivery_mode or "polling",
|
||||||
heading=record.get("heading"),
|
transport=transport or "http",
|
||||||
received_at=_parse_datetime(record.get("received_at")) or datetime.now(UTC),
|
message_type="PositionReport",
|
||||||
)
|
observed_at=observed_at,
|
||||||
|
collected_at=now,
|
||||||
)
|
)
|
||||||
|
if observation is not None:
|
||||||
|
written_count += 1
|
||||||
|
if observed_at > latest_observed_at:
|
||||||
|
latest_observed_at = observed_at
|
||||||
|
|
||||||
|
await update_ais_source_health(
|
||||||
|
db,
|
||||||
|
source=datasource_name,
|
||||||
|
connection_state="connected",
|
||||||
|
observed_count=len(records),
|
||||||
|
last_seen_at=latest_observed_at,
|
||||||
|
last_success_at=now if records else None,
|
||||||
|
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return len(records)
|
if records:
|
||||||
|
await broadcaster.broadcast_custom(
|
||||||
|
"vessels",
|
||||||
|
{
|
||||||
|
"action": "upsert",
|
||||||
|
"source": datasource_name,
|
||||||
|
"created": True,
|
||||||
|
"vessels": [
|
||||||
|
{
|
||||||
|
"mmsi": record.get("mmsi"),
|
||||||
|
"mmsi_display": str(record.get("mmsi")) if record.get("mmsi") is not None else None,
|
||||||
|
"name": record.get("name"),
|
||||||
|
"callsign": record.get("callsign"),
|
||||||
|
"lat": record.get("lat"),
|
||||||
|
"lon": record.get("lon"),
|
||||||
|
"sog": record.get("sog"),
|
||||||
|
"cog": record.get("cog"),
|
||||||
|
"heading": record.get("heading"),
|
||||||
|
"nav_status": record.get("nav_status"),
|
||||||
|
"vessel_type": record.get("vessel_type"),
|
||||||
|
"vessel_type_name": record.get("vessel_type_name"),
|
||||||
|
"received_at": to_iso8601_utc(_parse_datetime(record.get("received_at"))),
|
||||||
|
}
|
||||||
|
for record in records
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return written_count
|
||||||
|
|
||||||
from app.models.collected_data import CollectedData
|
from app.models.collected_data import CollectedData
|
||||||
|
|
||||||
|
|||||||
119
backend/app/services/docs_gatekeeper.py
Normal file
119
backend/app/services/docs_gatekeeper.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Server-side Docs metadata and Gatekeeper authorization helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
DocsAccess = Literal["public", "docs_user", "docs_developer", "docs_admin"]
|
||||||
|
DocsLang = Literal["zh", "en"]
|
||||||
|
|
||||||
|
VALID_DOCS_LANGS = {"zh", "en"}
|
||||||
|
DOCS_README_FILENAME = "README.md"
|
||||||
|
DEFAULT_DOCS_SLUG = "overview"
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
TECHNICAL_DOCS_ROOT = REPO_ROOT / "docs" / "technical"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DocsMetadata:
|
||||||
|
filename: str
|
||||||
|
slug: str
|
||||||
|
access: DocsAccess
|
||||||
|
group: str
|
||||||
|
order: int
|
||||||
|
zh_title: str
|
||||||
|
en_title: str
|
||||||
|
|
||||||
|
|
||||||
|
DOCS_METADATA: tuple[DocsMetadata, ...] = (
|
||||||
|
DocsMetadata(DOCS_README_FILENAME, DEFAULT_DOCS_SLUG, "public", "Overview", 0, "技术文档", "Technical Docs"),
|
||||||
|
DocsMetadata("quickstart.md", "quickstart", "public", "Manual", 1, "快速开始", "Quickstart"),
|
||||||
|
DocsMetadata("manual.md", "manual", "public", "Manual", 2, "Planet 使用手册", "Planet Manual"),
|
||||||
|
DocsMetadata("location-pipeline-user.md", "location-pipeline-user", "public", "Manual", 3, "Earth 位置候选采集使用手册", "Earth Location Candidate Collection User Guide"),
|
||||||
|
DocsMetadata("earth-frontend-context.md", "earth-frontend-context", "docs_developer", "Earth", 10, "Earth 前端结构", "Earth Frontend Context"),
|
||||||
|
DocsMetadata("earth-layer-style-reference.md", "earth-layer-style-reference", "docs_developer", "Earth", 11, "Earth 图层样式属性索引", "Earth Layer Style Reference"),
|
||||||
|
DocsMetadata("earth-render-layer-order.md", "earth-render-layer-order", "docs_developer", "Earth", 12, "Earth 渲染图层顺序", "Earth Render Layer Order"),
|
||||||
|
DocsMetadata("earth-satellite-footprint-policy.md", "earth-satellite-footprint-policy", "docs_developer", "Earth", 13, "Earth 卫星覆盖策略", "Earth Satellite Footprint Policy"),
|
||||||
|
DocsMetadata("earth-bgp-context.md", "earth-bgp-context", "docs_developer", "Earth", 14, "BGP 态势上下文", "BGP Context"),
|
||||||
|
DocsMetadata("earth-news-live-streams-collector-format.md", "earth-news-live-streams-collector-format", "docs_developer", "Earth", 15, "新闻直播采集格式", "News Live Streams Collector Format"),
|
||||||
|
DocsMetadata("earth-interactable-usage.md", "earth-interactable-usage", "docs_developer", "Earth", 16, "Earth 可交互图标接入", "Earth Interactable Usage"),
|
||||||
|
DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 17, "Earth 工具栏与浮层协同", "Earth Toolbar and Overlay Coordination"),
|
||||||
|
DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"),
|
||||||
|
DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"),
|
||||||
|
DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Frontend", 22, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"),
|
||||||
|
DocsMetadata("backend-collectors.md", "backend-collectors", "docs_developer", "Backend", 30, "数据采集系统", "Data Collectors"),
|
||||||
|
DocsMetadata("backend-system-service-control.md", "backend-system-service-control", "docs_admin", "Backend", 31, "系统服务控制", "System Service Control"),
|
||||||
|
DocsMetadata("datasource-collector-settings-connectivity.md", "datasource-collector-settings-connectivity", "docs_developer", "Backend", 32, "数据源、采集器设置与连接验证", "Datasource Collector Settings and Connectivity"),
|
||||||
|
DocsMetadata("backend-datasources-api-performance.md", "backend-datasources-api-performance", "docs_developer", "Backend", 33, "数据源 API 性能", "Datasource API Performance"),
|
||||||
|
DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 34, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"),
|
||||||
|
DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"),
|
||||||
|
DocsMetadata("ops-docker-compose-buildx-upgrade.md", "ops-docker-compose-buildx-upgrade", "docs_admin", "Ops", 50, "Docker + Compose + Buildx 升级", "Docker + Compose + Buildx Upgrade"),
|
||||||
|
DocsMetadata("ops-planet-sh-startup.md", "ops-planet-sh-startup", "docs_admin", "Ops", 51, "planet.sh 启动机制", "planet.sh Startup"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DOCS_BY_SLUG = {entry.slug: entry for entry in DOCS_METADATA}
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_gatekeeper_groups(user: User | None) -> set[str]:
|
||||||
|
if user is None:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
role = user.role.value if hasattr(user.role, "value") else str(user.role or "")
|
||||||
|
if role == "super_admin":
|
||||||
|
return {"docs_user", "docs_developer", "docs_admin"}
|
||||||
|
if role == "admin":
|
||||||
|
return {"docs_user", "docs_developer", "docs_admin"}
|
||||||
|
|
||||||
|
groups = set()
|
||||||
|
raw_groups = user.gatekeeper_groups or []
|
||||||
|
if isinstance(raw_groups, list):
|
||||||
|
groups.update(str(group) for group in raw_groups)
|
||||||
|
|
||||||
|
if "docs_admin" in groups:
|
||||||
|
groups.update({"docs_developer", "docs_user"})
|
||||||
|
if "docs_developer" in groups:
|
||||||
|
groups.add("docs_user")
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_doc(entry: DocsMetadata, user: User | None) -> bool:
|
||||||
|
if entry.access == "public":
|
||||||
|
return True
|
||||||
|
return entry.access in get_user_gatekeeper_groups(user)
|
||||||
|
|
||||||
|
|
||||||
|
def doc_path_for(entry: DocsMetadata, lang: str) -> Path:
|
||||||
|
if lang not in VALID_DOCS_LANGS:
|
||||||
|
raise ValueError("Unsupported docs language")
|
||||||
|
return TECHNICAL_DOCS_ROOT / lang / entry.filename
|
||||||
|
|
||||||
|
|
||||||
|
def title_for(entry: DocsMetadata, lang: str) -> str:
|
||||||
|
return entry.zh_title if lang == "zh" else entry.en_title
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_for_user(user: User | None) -> list[dict]:
|
||||||
|
items: list[dict] = []
|
||||||
|
for entry in DOCS_METADATA:
|
||||||
|
if not can_read_doc(entry, user):
|
||||||
|
continue
|
||||||
|
for lang in sorted(VALID_DOCS_LANGS):
|
||||||
|
if not doc_path_for(entry, lang).exists():
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"slug": entry.slug,
|
||||||
|
"filename": entry.filename,
|
||||||
|
"lang": lang,
|
||||||
|
"title": title_for(entry, lang),
|
||||||
|
"group": entry.group,
|
||||||
|
"order": entry.order,
|
||||||
|
"access": entry.access,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(items, key=lambda item: (item["lang"], item["order"], item["title"]))
|
||||||
57
backend/app/services/location/__init__.py
Normal file
57
backend/app/services/location/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Shared location-resolution pipeline.
|
||||||
|
|
||||||
|
A reusable abstraction for "given a record, decide its lat/lon" — used by
|
||||||
|
compute centers, BGP collectors, BGP events, and any future entity that needs
|
||||||
|
location estimation.
|
||||||
|
|
||||||
|
Each domain wires its own :class:`LocationPipeline` from a sequence of
|
||||||
|
:class:`LocationResolver` instances. Future algorithms (peeringdb, IXP tables,
|
||||||
|
user-confirmed coordinates, …) plug in by implementing the protocol — no
|
||||||
|
changes needed to consumers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
LocationCandidate,
|
||||||
|
LocationQuery,
|
||||||
|
ResolutionDiagnostic,
|
||||||
|
ResolutionResult,
|
||||||
|
ResolverOutput,
|
||||||
|
)
|
||||||
|
from .pipeline import LocationPipeline, LocationResolver
|
||||||
|
from .resolvers.inherit import InheritFromAnotherEntityResolver
|
||||||
|
from .resolvers.nominatim import (
|
||||||
|
NominatimResolver,
|
||||||
|
build_default_nominatim_geocoder,
|
||||||
|
interpret_geocode_result,
|
||||||
|
)
|
||||||
|
from .resolvers.registry import RegistryResolver, default_score_alias_match
|
||||||
|
from .resolvers.source_coordinates import SourceCoordinatesResolver
|
||||||
|
from .text import (
|
||||||
|
city_key,
|
||||||
|
coerce_str,
|
||||||
|
normalize_country_text,
|
||||||
|
normalize_text,
|
||||||
|
parse_float,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LocationCandidate",
|
||||||
|
"LocationPipeline",
|
||||||
|
"LocationQuery",
|
||||||
|
"LocationResolver",
|
||||||
|
"ResolutionDiagnostic",
|
||||||
|
"ResolutionResult",
|
||||||
|
"ResolverOutput",
|
||||||
|
"InheritFromAnotherEntityResolver",
|
||||||
|
"NominatimResolver",
|
||||||
|
"RegistryResolver",
|
||||||
|
"SourceCoordinatesResolver",
|
||||||
|
"build_default_nominatim_geocoder",
|
||||||
|
"city_key",
|
||||||
|
"coerce_str",
|
||||||
|
"default_score_alias_match",
|
||||||
|
"interpret_geocode_result",
|
||||||
|
"normalize_country_text",
|
||||||
|
"normalize_text",
|
||||||
|
"parse_float",
|
||||||
|
]
|
||||||
126
backend/app/services/location/models.py
Normal file
126
backend/app/services/location/models.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Domain-neutral data structures for the location pipeline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
# Renderable precision tiers, ordered from most precise to least.
|
||||||
|
RENDERABLE_PRECISIONS: tuple[str, ...] = ("precise", "site", "city")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocationQuery:
|
||||||
|
"""Domain-neutral input for the resolution pipeline.
|
||||||
|
|
||||||
|
``name`` and ``aliases`` are matched against registry alias indexes;
|
||||||
|
``city`` / ``country`` / ``region`` provide geographic context for both
|
||||||
|
registry lookups and Nominatim queries; ``source_latitude`` /
|
||||||
|
``source_longitude`` short-circuit when the record already carries
|
||||||
|
coordinates; ``extra`` carries domain-specific fields (operator, site,
|
||||||
|
organization, asn, peer_ip, …) that resolvers can opt into.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str | None = None
|
||||||
|
aliases: tuple[str, ...] = ()
|
||||||
|
city: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
region: str | None = None
|
||||||
|
source_latitude: float | None = None
|
||||||
|
source_longitude: float | None = None
|
||||||
|
extra: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocationCandidate:
|
||||||
|
"""A resolved location candidate produced by a resolver."""
|
||||||
|
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
display_name: str
|
||||||
|
precision: str # "precise" | "site" | "city" | (rejected: country/unknown)
|
||||||
|
confidence: float
|
||||||
|
query: str
|
||||||
|
source: str
|
||||||
|
source_note: str | None
|
||||||
|
matched_fields: tuple[str, ...]
|
||||||
|
needs_confirmation: bool
|
||||||
|
city: str | None = None
|
||||||
|
region: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
matched_location_name: str | None = None
|
||||||
|
location_verified_at: str | None = None
|
||||||
|
suggested_registry_entry: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"latitude": self.latitude,
|
||||||
|
"longitude": self.longitude,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"precision": self.precision,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"query": self.query,
|
||||||
|
"source": self.source,
|
||||||
|
"source_note": self.source_note,
|
||||||
|
"matched_fields": list(self.matched_fields),
|
||||||
|
"needs_confirmation": self.needs_confirmation,
|
||||||
|
"city": self.city,
|
||||||
|
"region": self.region,
|
||||||
|
"country": self.country,
|
||||||
|
"matched_location_name": self.matched_location_name,
|
||||||
|
"location_verified_at": self.location_verified_at,
|
||||||
|
"suggested_registry_entry": self.suggested_registry_entry,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResolverOutput:
|
||||||
|
"""What a single resolver returns from one ``resolve()`` call."""
|
||||||
|
|
||||||
|
candidates: tuple[LocationCandidate, ...] = ()
|
||||||
|
attempted_queries: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResolutionDiagnostic:
|
||||||
|
"""Why we could not resolve, plus what we tried."""
|
||||||
|
|
||||||
|
failure_reason: str
|
||||||
|
attempted_queries: tuple[str, ...] = ()
|
||||||
|
record_id: int | None = None
|
||||||
|
source: str | None = None
|
||||||
|
source_id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
site: str | None = None
|
||||||
|
operator: str | None = None
|
||||||
|
extra: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"failure_reason": self.failure_reason,
|
||||||
|
"attempted_queries": list(self.attempted_queries),
|
||||||
|
"record_id": self.record_id,
|
||||||
|
"source": self.source,
|
||||||
|
"source_id": self.source_id,
|
||||||
|
"name": self.name,
|
||||||
|
"country": self.country,
|
||||||
|
"city": self.city,
|
||||||
|
"site": self.site,
|
||||||
|
"operator": self.operator,
|
||||||
|
**({"extra": dict(self.extra)} if self.extra else {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResolutionResult:
|
||||||
|
"""Pipeline output: best candidate (if any) + diagnostic on miss."""
|
||||||
|
|
||||||
|
location: LocationCandidate | None
|
||||||
|
diagnostic: ResolutionDiagnostic | None
|
||||||
|
attempted_queries: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_resolved(self) -> bool:
|
||||||
|
return bool(self.location)
|
||||||
126
backend/app/services/location/pipeline.py
Normal file
126
backend/app/services/location/pipeline.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Pipeline that runs a sequence of :class:`LocationResolver` instances."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, Sequence
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
LocationCandidate,
|
||||||
|
LocationQuery,
|
||||||
|
ResolutionDiagnostic,
|
||||||
|
ResolutionResult,
|
||||||
|
ResolverOutput,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationResolver(Protocol):
|
||||||
|
"""Pluggable location resolution step.
|
||||||
|
|
||||||
|
Implementations: ``SourceCoordinatesResolver``, ``RegistryResolver``,
|
||||||
|
``NominatimResolver``, ``InheritFromAnotherEntityResolver`` — see the
|
||||||
|
``resolvers`` subpackage. New algorithms (peeringdb / IXP / user-confirmed
|
||||||
|
coordinates) plug in by implementing this protocol; the pipeline does not
|
||||||
|
care how candidates are produced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def resolve(self, query: LocationQuery) -> ResolverOutput: ...
|
||||||
|
|
||||||
|
|
||||||
|
def default_candidate_sort_key(
|
||||||
|
candidate: LocationCandidate,
|
||||||
|
) -> tuple[int, int, float]:
|
||||||
|
precision_rank = {"precise": 0, "site": 1, "city": 2}.get(
|
||||||
|
candidate.precision, 9
|
||||||
|
)
|
||||||
|
source_rank = {
|
||||||
|
"source_coordinates": 0,
|
||||||
|
"stored_compute_center_location": 1,
|
||||||
|
"stored_collector_location": 1,
|
||||||
|
"ror_organization_registry": 2,
|
||||||
|
"inherited": 3,
|
||||||
|
"nominatim_online_geocode": 4,
|
||||||
|
"local_registry": 8,
|
||||||
|
"local_registry_city": 9,
|
||||||
|
}.get(candidate.source, 9)
|
||||||
|
return (source_rank, precision_rank, -float(candidate.confidence or 0))
|
||||||
|
|
||||||
|
|
||||||
|
class LocationPipeline:
|
||||||
|
"""Orchestrate a sequence of resolvers.
|
||||||
|
|
||||||
|
``collect_candidates`` runs every resolver and returns *all* deduped
|
||||||
|
candidates plus the queries each resolver attempted (useful for
|
||||||
|
user-facing "why didn't this work?" diagnostics).
|
||||||
|
|
||||||
|
``resolve_best`` returns the top candidate per
|
||||||
|
:func:`default_candidate_sort_key` (or a custom sort).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
resolvers: Sequence[LocationResolver],
|
||||||
|
*,
|
||||||
|
sort_key=default_candidate_sort_key,
|
||||||
|
failure_reason: str = (
|
||||||
|
"Could not resolve to renderable coordinates from any configured resolver."
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
self._resolvers = list(resolvers)
|
||||||
|
self._sort_key = sort_key
|
||||||
|
self._failure_reason = failure_reason
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolvers(self) -> tuple[LocationResolver, ...]:
|
||||||
|
return tuple(self._resolvers)
|
||||||
|
|
||||||
|
def collect_candidates(
|
||||||
|
self, query: LocationQuery
|
||||||
|
) -> tuple[list[LocationCandidate], list[str]]:
|
||||||
|
candidates: list[LocationCandidate] = []
|
||||||
|
attempted: list[str] = []
|
||||||
|
seen_keys: set[tuple[str, str, str]] = set()
|
||||||
|
|
||||||
|
for resolver in self._resolvers:
|
||||||
|
output = resolver.resolve(query)
|
||||||
|
for q in output.attempted_queries:
|
||||||
|
if q and q not in attempted:
|
||||||
|
attempted.append(q)
|
||||||
|
for candidate in output.candidates:
|
||||||
|
key = (
|
||||||
|
candidate.source,
|
||||||
|
f"{candidate.latitude:.4f}",
|
||||||
|
f"{candidate.longitude:.4f}",
|
||||||
|
)
|
||||||
|
if key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(key)
|
||||||
|
candidates.append(candidate)
|
||||||
|
|
||||||
|
candidates.sort(key=self._sort_key)
|
||||||
|
return candidates, attempted
|
||||||
|
|
||||||
|
def resolve_best(self, query: LocationQuery) -> ResolutionResult:
|
||||||
|
candidates, attempted = self.collect_candidates(query)
|
||||||
|
if candidates:
|
||||||
|
return ResolutionResult(
|
||||||
|
location=candidates[0],
|
||||||
|
diagnostic=None,
|
||||||
|
attempted_queries=tuple(attempted),
|
||||||
|
)
|
||||||
|
return ResolutionResult(
|
||||||
|
location=None,
|
||||||
|
diagnostic=ResolutionDiagnostic(
|
||||||
|
failure_reason=self._failure_reason,
|
||||||
|
attempted_queries=tuple(attempted),
|
||||||
|
name=query.name,
|
||||||
|
country=query.country,
|
||||||
|
city=query.city,
|
||||||
|
site=str(query.extra.get("site")) if query.extra.get("site") else None,
|
||||||
|
operator=str(query.extra.get("operator"))
|
||||||
|
if query.extra.get("operator")
|
||||||
|
else None,
|
||||||
|
),
|
||||||
|
attempted_queries=tuple(attempted),
|
||||||
|
)
|
||||||
20
backend/app/services/location/resolvers/__init__.py
Normal file
20
backend/app/services/location/resolvers/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Built-in resolver implementations."""
|
||||||
|
|
||||||
|
from .inherit import InheritFromAnotherEntityResolver
|
||||||
|
from .nominatim import (
|
||||||
|
NominatimResolver,
|
||||||
|
build_default_nominatim_geocoder,
|
||||||
|
interpret_geocode_result,
|
||||||
|
)
|
||||||
|
from .registry import RegistryResolver, default_score_alias_match
|
||||||
|
from .source_coordinates import SourceCoordinatesResolver
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"InheritFromAnotherEntityResolver",
|
||||||
|
"NominatimResolver",
|
||||||
|
"RegistryResolver",
|
||||||
|
"SourceCoordinatesResolver",
|
||||||
|
"build_default_nominatim_geocoder",
|
||||||
|
"default_score_alias_match",
|
||||||
|
"interpret_geocode_result",
|
||||||
|
]
|
||||||
31
backend/app/services/location/resolvers/inherit.py
Normal file
31
backend/app/services/location/resolvers/inherit.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Resolver that inherits a candidate from another entity's resolution.
|
||||||
|
|
||||||
|
Used by BGP events to pick up the location of their owning collector. The
|
||||||
|
``source_lookup`` callable is the only domain coupling — it receives the
|
||||||
|
incoming :class:`LocationQuery` and returns either an already-resolved
|
||||||
|
:class:`LocationCandidate` (typically by querying another pipeline) or
|
||||||
|
``None`` to signal "no parent location available".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||||
|
|
||||||
|
|
||||||
|
class InheritFromAnotherEntityResolver:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_lookup: Callable[[LocationQuery], LocationCandidate | None],
|
||||||
|
name: str = "inherited",
|
||||||
|
) -> None:
|
||||||
|
self.name = name
|
||||||
|
self._lookup = source_lookup
|
||||||
|
|
||||||
|
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||||
|
result = self._lookup(query)
|
||||||
|
if result is None:
|
||||||
|
return ResolverOutput()
|
||||||
|
return ResolverOutput(candidates=(result,))
|
||||||
292
backend/app/services/location/resolvers/nominatim.py
Normal file
292
backend/app/services/location/resolvers/nominatim.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""Nominatim-backed online geocoder.
|
||||||
|
|
||||||
|
The actual HTTP call is encapsulated in :func:`build_default_nominatim_geocoder`
|
||||||
|
which returns an ``lru_cache``-wrapped function. Domain modules typically:
|
||||||
|
|
||||||
|
1. Build a default geocoder via :func:`build_default_nominatim_geocoder`.
|
||||||
|
2. Re-export it under a stable module-level name (e.g. ``_geocode_online``).
|
||||||
|
3. Pass a *late-binding lambda* (``lambda q: _geocode_online(q)``) to
|
||||||
|
:class:`NominatimResolver`.
|
||||||
|
|
||||||
|
This ensures tests that ``monkeypatch.setattr(module, "_geocode_online", ...)``
|
||||||
|
can swap the geocoder behavior without touching pipeline construction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||||
|
from ..text import (
|
||||||
|
coerce_str,
|
||||||
|
normalize_country_text,
|
||||||
|
normalize_text,
|
||||||
|
parse_float,
|
||||||
|
)
|
||||||
|
|
||||||
|
NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search"
|
||||||
|
DEFAULT_USER_AGENT = "planet-earth-location-resolver/1.0"
|
||||||
|
DEFAULT_MIN_INTERVAL_SECONDS = 1.1
|
||||||
|
DEFAULT_TIMEOUT_SECONDS = 8.0
|
||||||
|
|
||||||
|
|
||||||
|
def build_default_nominatim_geocoder(
|
||||||
|
*,
|
||||||
|
user_agent: str = DEFAULT_USER_AGENT,
|
||||||
|
min_interval_seconds: float = DEFAULT_MIN_INTERVAL_SECONDS,
|
||||||
|
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
cache_size: int = 512,
|
||||||
|
) -> Callable[[str], dict[str, Any] | None]:
|
||||||
|
"""Return a cached, rate-limited Nominatim geocoder."""
|
||||||
|
|
||||||
|
last_request_at = [0.0]
|
||||||
|
|
||||||
|
@lru_cache(maxsize=cache_size)
|
||||||
|
def geocode(query: str) -> dict[str, Any] | None:
|
||||||
|
if not query:
|
||||||
|
return None
|
||||||
|
elapsed = time.monotonic() - last_request_at[0]
|
||||||
|
if elapsed < min_interval_seconds:
|
||||||
|
time.sleep(min_interval_seconds - elapsed)
|
||||||
|
last_request_at[0] = time.monotonic()
|
||||||
|
response = httpx.get(
|
||||||
|
NOMINATIM_SEARCH_URL,
|
||||||
|
params={
|
||||||
|
"q": query,
|
||||||
|
"format": "jsonv2",
|
||||||
|
"limit": 1,
|
||||||
|
"addressdetails": 1,
|
||||||
|
},
|
||||||
|
headers={"User-Agent": user_agent},
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, list) or not payload:
|
||||||
|
return None
|
||||||
|
result = payload[0]
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return None
|
||||||
|
return result
|
||||||
|
|
||||||
|
return geocode
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_SITE_CATEGORIES = frozenset(
|
||||||
|
{
|
||||||
|
"amenity",
|
||||||
|
"office",
|
||||||
|
"building",
|
||||||
|
"industrial",
|
||||||
|
"research",
|
||||||
|
"university",
|
||||||
|
"education",
|
||||||
|
"tourism",
|
||||||
|
"shop",
|
||||||
|
"man_made",
|
||||||
|
"campus",
|
||||||
|
"research_institute",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_geocode_result(
|
||||||
|
result: dict[str, Any],
|
||||||
|
*,
|
||||||
|
matched_fields: tuple[str, ...],
|
||||||
|
context_country: str | None,
|
||||||
|
site_categories: frozenset[str] = _DEFAULT_SITE_CATEGORIES,
|
||||||
|
site_promoting_match_fields: frozenset[str] = frozenset(
|
||||||
|
{"site", "operator", "name"}
|
||||||
|
),
|
||||||
|
) -> tuple[float, float, dict[str, Any], str] | None:
|
||||||
|
"""Validate a Nominatim raw result. Returns (lat, lon, address, classification)."""
|
||||||
|
latitude = parse_float(result.get("lat"))
|
||||||
|
longitude = parse_float(result.get("lon"))
|
||||||
|
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||||
|
return None
|
||||||
|
|
||||||
|
address = result.get("address") if isinstance(result.get("address"), dict) else {}
|
||||||
|
if not isinstance(address, dict):
|
||||||
|
address = {}
|
||||||
|
|
||||||
|
has_city_level = bool(
|
||||||
|
address.get("city")
|
||||||
|
or address.get("town")
|
||||||
|
or address.get("village")
|
||||||
|
or address.get("municipality")
|
||||||
|
or address.get("hamlet")
|
||||||
|
or address.get("suburb")
|
||||||
|
)
|
||||||
|
osm_class = str(result.get("class") or "").lower()
|
||||||
|
osm_type = str(result.get("type") or "").lower()
|
||||||
|
is_site_like = osm_class in site_categories or osm_type in site_categories
|
||||||
|
if not has_city_level and not is_site_like:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if context_country:
|
||||||
|
normalized_context = normalize_text(normalize_country_text(context_country))
|
||||||
|
normalized_result = normalize_text(
|
||||||
|
normalize_country_text(address.get("country"))
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
normalized_context
|
||||||
|
and normalized_result
|
||||||
|
and normalized_context != normalized_result
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
classification = (
|
||||||
|
"site"
|
||||||
|
if (
|
||||||
|
is_site_like
|
||||||
|
and has_city_level
|
||||||
|
and any(field in site_promoting_match_fields for field in matched_fields)
|
||||||
|
)
|
||||||
|
else "city"
|
||||||
|
)
|
||||||
|
return float(latitude), float(longitude), address, classification
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_from_geocode(
|
||||||
|
*,
|
||||||
|
query: LocationQuery,
|
||||||
|
geocode_query: str,
|
||||||
|
matched_fields: tuple[str, ...],
|
||||||
|
raw_result: dict[str, Any],
|
||||||
|
interpret: Callable[..., tuple[float, float, dict[str, Any], str] | None],
|
||||||
|
source: str,
|
||||||
|
site_confidence: float,
|
||||||
|
city_confidence: float,
|
||||||
|
) -> LocationCandidate | None:
|
||||||
|
interpreted = interpret(
|
||||||
|
raw_result,
|
||||||
|
matched_fields=matched_fields,
|
||||||
|
context_country=query.country,
|
||||||
|
)
|
||||||
|
if not interpreted:
|
||||||
|
return None
|
||||||
|
latitude, longitude, address, classification = interpreted
|
||||||
|
city = (
|
||||||
|
address.get("city")
|
||||||
|
or address.get("town")
|
||||||
|
or address.get("village")
|
||||||
|
or address.get("municipality")
|
||||||
|
or query.city
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
region = address.get("state") or address.get("region")
|
||||||
|
country = address.get("country") or query.country or None
|
||||||
|
display_name = raw_result.get("display_name") or geocode_query
|
||||||
|
confidence = city_confidence if classification == "city" else site_confidence
|
||||||
|
|
||||||
|
extra = query.extra or {}
|
||||||
|
suggested_registry_entry = {
|
||||||
|
"canonical_name": (
|
||||||
|
(query.aliases[0] if query.aliases else None)
|
||||||
|
or query.name
|
||||||
|
or display_name
|
||||||
|
),
|
||||||
|
"aliases": list(
|
||||||
|
{
|
||||||
|
value
|
||||||
|
for value in [
|
||||||
|
query.name,
|
||||||
|
*query.aliases,
|
||||||
|
coerce_str(extra.get("operator")),
|
||||||
|
coerce_str(extra.get("site")),
|
||||||
|
]
|
||||||
|
if value
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"operator": coerce_str(extra.get("operator")) or None,
|
||||||
|
"site": coerce_str(extra.get("site"))
|
||||||
|
or coerce_str(extra.get("organization"))
|
||||||
|
or None,
|
||||||
|
"country": country,
|
||||||
|
"city": city,
|
||||||
|
"region": region,
|
||||||
|
"latitude": latitude,
|
||||||
|
"longitude": longitude,
|
||||||
|
"precision": classification,
|
||||||
|
"confidence": confidence,
|
||||||
|
"source_note": (
|
||||||
|
f"Resolved via Nominatim query '{geocode_query}' → {display_name}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return LocationCandidate(
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
display_name=display_name,
|
||||||
|
precision=classification,
|
||||||
|
confidence=confidence,
|
||||||
|
query=geocode_query,
|
||||||
|
source=source,
|
||||||
|
source_note=f"Nominatim search result: {display_name}",
|
||||||
|
matched_fields=matched_fields,
|
||||||
|
needs_confirmation=True,
|
||||||
|
city=city,
|
||||||
|
region=region,
|
||||||
|
country=country,
|
||||||
|
matched_location_name=display_name,
|
||||||
|
location_verified_at=None,
|
||||||
|
suggested_registry_entry=suggested_registry_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NominatimResolver:
|
||||||
|
"""Run a domain-specific query plan against Nominatim."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
query_plan_builder: Callable[
|
||||||
|
[LocationQuery], list[tuple[str, tuple[str, ...]]]
|
||||||
|
],
|
||||||
|
geocoder: Callable[[str], dict[str, Any] | None],
|
||||||
|
name: str = "nominatim_online_geocode",
|
||||||
|
site_confidence: float = 0.72,
|
||||||
|
city_confidence: float = 0.62,
|
||||||
|
interpret: Callable[..., tuple[float, float, dict[str, Any], str] | None] = (
|
||||||
|
interpret_geocode_result
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
self.name = name
|
||||||
|
self._query_plan_builder = query_plan_builder
|
||||||
|
self._geocoder = geocoder
|
||||||
|
self._site_confidence = site_confidence
|
||||||
|
self._city_confidence = city_confidence
|
||||||
|
self._interpret = interpret
|
||||||
|
|
||||||
|
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||||
|
plan = self._query_plan_builder(query)
|
||||||
|
candidates: list[LocationCandidate] = []
|
||||||
|
attempted: list[str] = []
|
||||||
|
for geocode_query, matched_fields in plan:
|
||||||
|
attempted.append(geocode_query)
|
||||||
|
try:
|
||||||
|
raw_result = self._geocoder(geocode_query)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not raw_result:
|
||||||
|
continue
|
||||||
|
candidate = _candidate_from_geocode(
|
||||||
|
query=query,
|
||||||
|
geocode_query=geocode_query,
|
||||||
|
matched_fields=matched_fields,
|
||||||
|
raw_result=raw_result,
|
||||||
|
interpret=self._interpret,
|
||||||
|
source=self.name,
|
||||||
|
site_confidence=self._site_confidence,
|
||||||
|
city_confidence=self._city_confidence,
|
||||||
|
)
|
||||||
|
if candidate is not None:
|
||||||
|
candidates.append(candidate)
|
||||||
|
return ResolverOutput(
|
||||||
|
candidates=tuple(candidates),
|
||||||
|
attempted_queries=tuple(attempted),
|
||||||
|
)
|
||||||
323
backend/app/services/location/resolvers/registry.py
Normal file
323
backend/app/services/location/resolvers/registry.py
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
"""Resolver that matches a query against a local JSON registry.
|
||||||
|
|
||||||
|
Registry schema (a single JSON file):
|
||||||
|
|
||||||
|
{
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"canonical_name": "...",
|
||||||
|
"aliases": ["...", "..."],
|
||||||
|
"operator": "...",
|
||||||
|
"site": "...",
|
||||||
|
"city": "...",
|
||||||
|
"country": "...",
|
||||||
|
"region": "...",
|
||||||
|
"latitude": 0.0,
|
||||||
|
"longitude": 0.0,
|
||||||
|
"precision": "precise" | "site" | "city",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"verification_status": "verified",
|
||||||
|
"source_note": "...",
|
||||||
|
"verified_at": "YYYY-MM-DD"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"city_fallbacks": [ {city, country, latitude, longitude, ...} ]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
RENDERABLE_PRECISIONS,
|
||||||
|
LocationCandidate,
|
||||||
|
LocationQuery,
|
||||||
|
ResolverOutput,
|
||||||
|
)
|
||||||
|
from ..text import (
|
||||||
|
city_key,
|
||||||
|
normalize_country_text,
|
||||||
|
normalize_text,
|
||||||
|
parse_float,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Field-priority weights when scoring "this query field text contains this
|
||||||
|
# alias text". Tuned to match the legacy compute-center ordering — name beats
|
||||||
|
# site beats operator beats city — which generalizes well to other domains.
|
||||||
|
_DEFAULT_FIELD_PRIORITY = {
|
||||||
|
"name": 8,
|
||||||
|
"site": 6,
|
||||||
|
"operator": 5,
|
||||||
|
"city": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_score_alias_match(
|
||||||
|
alias_field: str, record_field: str, alias_text: str
|
||||||
|
) -> int:
|
||||||
|
score = max(0, len(alias_text))
|
||||||
|
score += _DEFAULT_FIELD_PRIORITY.get(alias_field, 1)
|
||||||
|
if alias_field == record_field:
|
||||||
|
score += 4
|
||||||
|
if alias_field == "name" and record_field in {"name", "name_short", "alias"}:
|
||||||
|
score += 6
|
||||||
|
if alias_field == "site" and record_field in {"site", "organization"}:
|
||||||
|
score += 4
|
||||||
|
if alias_field == "operator" and record_field in {"operator", "organization"}:
|
||||||
|
score += 4
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32)
|
||||||
|
def _load_registry_file(path: str) -> dict[str, Any]:
|
||||||
|
with Path(path).open("r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32)
|
||||||
|
def _build_alias_index(
|
||||||
|
path: str,
|
||||||
|
) -> tuple[tuple[dict[str, Any], tuple[tuple[str, str], ...]], ...]:
|
||||||
|
index: list[tuple[dict[str, Any], tuple[tuple[str, str], ...]]] = []
|
||||||
|
for entry in _load_registry_file(path).get("locations", []):
|
||||||
|
aliases: list[tuple[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for alias in [entry.get("canonical_name"), *(entry.get("aliases") or [])]:
|
||||||
|
normalized = normalize_text(alias)
|
||||||
|
if normalized and normalized not in seen:
|
||||||
|
aliases.append(("name", normalized))
|
||||||
|
seen.add(normalized)
|
||||||
|
for field_name in ("operator", "site", "city"):
|
||||||
|
value = entry.get(field_name)
|
||||||
|
normalized = normalize_text(value)
|
||||||
|
if normalized and normalized not in seen:
|
||||||
|
aliases.append((field_name, normalized))
|
||||||
|
seen.add(normalized)
|
||||||
|
index.append((entry, tuple(aliases)))
|
||||||
|
return tuple(index)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_corpus(query: LocationQuery) -> dict[str, str]:
|
||||||
|
"""Map a query into normalized strings keyed by source field."""
|
||||||
|
fields: dict[str, str] = {
|
||||||
|
"name": query.name or "",
|
||||||
|
"city": query.city or "",
|
||||||
|
"country": query.country or "",
|
||||||
|
}
|
||||||
|
for alias in query.aliases:
|
||||||
|
if alias and alias != query.name:
|
||||||
|
fields["name_short"] = alias
|
||||||
|
break
|
||||||
|
extra = query.extra or {}
|
||||||
|
for key in ("site", "operator", "organization"):
|
||||||
|
value = extra.get(key)
|
||||||
|
if value:
|
||||||
|
fields[key] = str(value)
|
||||||
|
return {key: normalize_text(value) for key, value in fields.items() if value}
|
||||||
|
|
||||||
|
|
||||||
|
def _country_compatible(entry: dict[str, Any], query: LocationQuery) -> bool:
|
||||||
|
record_country = normalize_country_text(query.country)
|
||||||
|
entry_country = normalize_country_text(entry.get("country"))
|
||||||
|
if not record_country or not entry_country:
|
||||||
|
return True
|
||||||
|
return normalize_text(record_country) == normalize_text(entry_country)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_alias_matches(alias_normalized: str, record_text: str) -> bool:
|
||||||
|
alias_tokens = alias_normalized.split()
|
||||||
|
record_tokens = record_text.split()
|
||||||
|
if not alias_tokens or not record_tokens:
|
||||||
|
return False
|
||||||
|
if len(alias_tokens) == 1:
|
||||||
|
return alias_tokens[0] in record_tokens
|
||||||
|
window_size = len(alias_tokens)
|
||||||
|
return any(
|
||||||
|
record_tokens[index : index + window_size] == alias_tokens
|
||||||
|
for index in range(0, len(record_tokens) - window_size + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_to_candidate(
|
||||||
|
entry: dict[str, Any],
|
||||||
|
*,
|
||||||
|
matched_alias: str,
|
||||||
|
matched_fields: Iterable[str],
|
||||||
|
source: str,
|
||||||
|
score_explainer: str,
|
||||||
|
confidence_floor: float,
|
||||||
|
) -> LocationCandidate:
|
||||||
|
canonical_name = entry.get("canonical_name") or matched_alias
|
||||||
|
# Registry entries are treated as candidates unless explicitly verified.
|
||||||
|
# This prevents migrated hard-coded hints from appearing as factual
|
||||||
|
# location evidence.
|
||||||
|
is_verified = entry.get("verification_status") == "verified"
|
||||||
|
precision = entry.get("precision") or "city"
|
||||||
|
if precision not in RENDERABLE_PRECISIONS:
|
||||||
|
precision = "city"
|
||||||
|
fields_summary = ", ".join(sorted(set(matched_fields))) or "name"
|
||||||
|
confidence_value = parse_float(entry.get("confidence"))
|
||||||
|
confidence = (
|
||||||
|
float(confidence_value)
|
||||||
|
if confidence_value is not None
|
||||||
|
else confidence_floor
|
||||||
|
)
|
||||||
|
return LocationCandidate(
|
||||||
|
latitude=float(parse_float(entry.get("latitude")) or 0.0),
|
||||||
|
longitude=float(parse_float(entry.get("longitude")) or 0.0),
|
||||||
|
display_name=canonical_name,
|
||||||
|
precision=precision,
|
||||||
|
confidence=confidence,
|
||||||
|
query=f"local_registry::{matched_alias or canonical_name}",
|
||||||
|
source=source,
|
||||||
|
source_note=entry.get("source_note")
|
||||||
|
or f"{score_explainer}: matched {fields_summary}",
|
||||||
|
matched_fields=tuple(sorted(set(matched_fields))) or ("name",),
|
||||||
|
needs_confirmation=bool(entry.get("needs_confirmation")) or not is_verified,
|
||||||
|
city=entry.get("city"),
|
||||||
|
region=entry.get("region"),
|
||||||
|
country=entry.get("country"),
|
||||||
|
matched_location_name=canonical_name,
|
||||||
|
location_verified_at=entry.get("verified_at") if is_verified else None,
|
||||||
|
suggested_registry_entry=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryResolver:
|
||||||
|
"""Match a query against a JSON registry (plus its city_fallbacks table)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
registry_path: Path | str,
|
||||||
|
name: str = "local_registry",
|
||||||
|
city_fallback_source: str = "local_registry_city",
|
||||||
|
city_fallback_confidence_default: float = 0.65,
|
||||||
|
confidence_default: float = 0.85,
|
||||||
|
score_alias_match: Callable[[str, str, str], int] = default_score_alias_match,
|
||||||
|
) -> None:
|
||||||
|
self.name = name
|
||||||
|
self._registry_path = str(Path(registry_path))
|
||||||
|
self._city_fallback_source = city_fallback_source
|
||||||
|
self._city_fallback_confidence_default = city_fallback_confidence_default
|
||||||
|
self._confidence_default = confidence_default
|
||||||
|
self._score = score_alias_match
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
"""Drop the cached registry — useful when the JSON file is edited."""
|
||||||
|
_load_registry_file.cache_clear()
|
||||||
|
_build_alias_index.cache_clear()
|
||||||
|
|
||||||
|
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||||
|
candidates: list[LocationCandidate] = []
|
||||||
|
candidates.extend(self._registry_candidates(query))
|
||||||
|
city_candidate = self._city_fallback_candidate(query)
|
||||||
|
if city_candidate is not None:
|
||||||
|
candidates.append(city_candidate)
|
||||||
|
return ResolverOutput(candidates=tuple(candidates))
|
||||||
|
|
||||||
|
# ── internals ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _registry_candidates(
|
||||||
|
self, query: LocationQuery
|
||||||
|
) -> list[LocationCandidate]:
|
||||||
|
corpus = _query_corpus(query)
|
||||||
|
if not corpus:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# When the query carries a name (a record-specific identifier), require
|
||||||
|
# at least one alias match against a name-class field — otherwise a
|
||||||
|
# generic shared field like operator="RIPE NCC" would promote every
|
||||||
|
# registry entry that lists that operator, regardless of whether the
|
||||||
|
# name matches.
|
||||||
|
query_has_name = bool(corpus.get("name") or corpus.get("name_short"))
|
||||||
|
|
||||||
|
results: list[LocationCandidate] = []
|
||||||
|
for entry, aliases in _build_alias_index(self._registry_path):
|
||||||
|
best_alias = ""
|
||||||
|
best_score = 0
|
||||||
|
matched_fields: list[str] = []
|
||||||
|
matched_via_name_alias = False
|
||||||
|
for alias_field, alias_normalized in aliases:
|
||||||
|
for record_field, record_text in corpus.items():
|
||||||
|
if not _normalized_alias_matches(alias_normalized, record_text):
|
||||||
|
continue
|
||||||
|
score = self._score(
|
||||||
|
alias_field, record_field, alias_normalized
|
||||||
|
)
|
||||||
|
if score > best_score or (
|
||||||
|
score == best_score
|
||||||
|
and len(alias_normalized) > len(best_alias)
|
||||||
|
):
|
||||||
|
best_score = score
|
||||||
|
best_alias = alias_normalized
|
||||||
|
if record_field not in matched_fields:
|
||||||
|
matched_fields.append(record_field)
|
||||||
|
if alias_field == "name" and record_field in {"name", "name_short"}:
|
||||||
|
matched_via_name_alias = True
|
||||||
|
if not matched_fields or best_score <= 0:
|
||||||
|
continue
|
||||||
|
if query_has_name and not matched_via_name_alias:
|
||||||
|
continue
|
||||||
|
if not _country_compatible(entry, query):
|
||||||
|
continue
|
||||||
|
results.append(
|
||||||
|
_entry_to_candidate(
|
||||||
|
entry,
|
||||||
|
matched_alias=best_alias,
|
||||||
|
matched_fields=matched_fields,
|
||||||
|
source=self.name,
|
||||||
|
score_explainer="Registry alias match",
|
||||||
|
confidence_floor=self._confidence_default,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _city_fallback_candidate(
|
||||||
|
self, query: LocationQuery
|
||||||
|
) -> LocationCandidate | None:
|
||||||
|
country = normalize_country_text(query.country)
|
||||||
|
city = city_key(query.city)
|
||||||
|
if not country or not city:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for fallback in _load_registry_file(self._registry_path).get(
|
||||||
|
"city_fallbacks", []
|
||||||
|
):
|
||||||
|
fallback_country = normalize_country_text(fallback.get("country"))
|
||||||
|
fallback_city = city_key(fallback.get("city"))
|
||||||
|
if fallback_country != country or fallback_city != city:
|
||||||
|
continue
|
||||||
|
confidence_value = parse_float(fallback.get("confidence"))
|
||||||
|
confidence = (
|
||||||
|
float(confidence_value)
|
||||||
|
if confidence_value is not None
|
||||||
|
else self._city_fallback_confidence_default
|
||||||
|
)
|
||||||
|
return LocationCandidate(
|
||||||
|
latitude=float(parse_float(fallback.get("latitude")) or 0.0),
|
||||||
|
longitude=float(parse_float(fallback.get("longitude")) or 0.0),
|
||||||
|
display_name=fallback.get("city") or "",
|
||||||
|
precision="city",
|
||||||
|
confidence=confidence,
|
||||||
|
query=(
|
||||||
|
f"city_fallback::{fallback.get('city')}, "
|
||||||
|
f"{fallback.get('country')}"
|
||||||
|
),
|
||||||
|
source=self._city_fallback_source,
|
||||||
|
source_note=fallback.get("source_note")
|
||||||
|
or f"City fallback for {fallback.get('city')}, {fallback.get('country')}",
|
||||||
|
matched_fields=("city", "country"),
|
||||||
|
needs_confirmation=False,
|
||||||
|
city=fallback.get("city"),
|
||||||
|
region=fallback.get("region"),
|
||||||
|
country=fallback.get("country"),
|
||||||
|
matched_location_name=fallback.get("city"),
|
||||||
|
location_verified_at=fallback.get("verified_at"),
|
||||||
|
suggested_registry_entry=None,
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Resolver that consumes lat/lon already present on the source record."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||||
|
from ..text import normalize_country_text
|
||||||
|
|
||||||
|
|
||||||
|
class SourceCoordinatesResolver:
|
||||||
|
"""Pass-through for records that already carry valid coordinates."""
|
||||||
|
|
||||||
|
name = "source_coordinates"
|
||||||
|
|
||||||
|
def __init__(self, *, source: str = "source_coordinates") -> None:
|
||||||
|
self._source = source
|
||||||
|
|
||||||
|
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||||
|
lat = query.source_latitude
|
||||||
|
lon = query.source_longitude
|
||||||
|
if lat in (None, 0.0) or lon in (None, 0.0):
|
||||||
|
return ResolverOutput()
|
||||||
|
|
||||||
|
country = normalize_country_text(query.country) or query.country
|
||||||
|
candidate = LocationCandidate(
|
||||||
|
latitude=float(lat),
|
||||||
|
longitude=float(lon),
|
||||||
|
display_name=query.name or "",
|
||||||
|
precision="precise",
|
||||||
|
confidence=1.0,
|
||||||
|
query="source_coordinates",
|
||||||
|
source=self._source,
|
||||||
|
source_note="Source record provided valid coordinates.",
|
||||||
|
matched_fields=("source_coordinates",),
|
||||||
|
needs_confirmation=False,
|
||||||
|
city=query.city,
|
||||||
|
region=query.region,
|
||||||
|
country=country,
|
||||||
|
matched_location_name=query.name,
|
||||||
|
location_verified_at=None,
|
||||||
|
suggested_registry_entry=None,
|
||||||
|
)
|
||||||
|
return ResolverOutput(candidates=(candidate,))
|
||||||
41
backend/app/services/location/text.py
Normal file
41
backend/app/services/location/text.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Text-normalization helpers shared by every resolver."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.countries import normalize_country
|
||||||
|
|
||||||
|
|
||||||
|
def parse_float(value: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_str(value: Any) -> str:
|
||||||
|
if value in (None, ""):
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(value: Any) -> str:
|
||||||
|
if value in (None, ""):
|
||||||
|
return ""
|
||||||
|
normalized = str(value).casefold()
|
||||||
|
normalized = re.sub(r"[^a-z0-9一-鿿]+", " ", normalized)
|
||||||
|
return re.sub(r"\s+", " ", normalized).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_country_text(value: Any) -> str:
|
||||||
|
normalized = normalize_country(value)
|
||||||
|
return normalized or coerce_str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def city_key(city: Any) -> str:
|
||||||
|
text = coerce_str(city).split(",", 1)[0]
|
||||||
|
return normalize_text(text)
|
||||||
198
backend/app/services/vessel_aggregation_strategy.py
Normal file
198
backend/app/services/vessel_aggregation_strategy.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""Persistence + validation for the v4 vessel_ais aggregation strategy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.system_setting import SystemSetting
|
||||||
|
|
||||||
|
VESSEL_AGGREGATION_STRATEGY_CATEGORY = "vessel_aggregation_strategy"
|
||||||
|
|
||||||
|
DYNAMIC_FIELDS: tuple[str, ...] = ("lat", "lon", "sog", "cog", "heading", "nav_status")
|
||||||
|
STATIC_FIELDS: tuple[str, ...] = (
|
||||||
|
"name",
|
||||||
|
"callsign",
|
||||||
|
"imo",
|
||||||
|
"flag",
|
||||||
|
"vessel_type",
|
||||||
|
"vessel_type_name",
|
||||||
|
"length",
|
||||||
|
"width",
|
||||||
|
"draught",
|
||||||
|
)
|
||||||
|
ALLOWED_FIELDS: frozenset[str] = frozenset(DYNAMIC_FIELDS + STATIC_FIELDS)
|
||||||
|
ALLOWED_DYNAMIC_MODES: frozenset[str] = frozenset({"newest"})
|
||||||
|
ALLOWED_STATIC_MODES: frozenset[str] = frozenset({"source_priority", "non_empty", "newest", "locked"})
|
||||||
|
ALLOWED_LOCKED_DYNAMIC_MODES: frozenset[str] = frozenset({"newest", "source_priority", "locked"})
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STRATEGY: dict[str, Any] = {
|
||||||
|
"version": 1,
|
||||||
|
"vessel_ais": {
|
||||||
|
"source_priority": ["aisstream_vessels", "barentswatch_vessels"],
|
||||||
|
"field_rules": {},
|
||||||
|
"freshness": {
|
||||||
|
"realtime_stream_seconds": 900,
|
||||||
|
"polling_seconds": 3600,
|
||||||
|
},
|
||||||
|
"allow_dynamic_lock": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyValidationError(ValueError):
|
||||||
|
"""Raised when a saved strategy payload is malformed."""
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_str_list(value: Any, *, label: str) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise StrategyValidationError(f"{label} must be a list of source names")
|
||||||
|
out: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, str) or not item.strip():
|
||||||
|
raise StrategyValidationError(f"{label} entries must be non-empty strings")
|
||||||
|
out.append(item.strip())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validate_strategy(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Validate and normalize a strategy payload. Raise StrategyValidationError on issues."""
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise StrategyValidationError("strategy payload must be an object")
|
||||||
|
|
||||||
|
vessel_ais = payload.get("vessel_ais")
|
||||||
|
if not isinstance(vessel_ais, dict):
|
||||||
|
raise StrategyValidationError("strategy.vessel_ais is required and must be an object")
|
||||||
|
|
||||||
|
allow_dynamic_lock = bool(vessel_ais.get("allow_dynamic_lock", False))
|
||||||
|
source_priority = _coerce_str_list(
|
||||||
|
vessel_ais.get("source_priority"),
|
||||||
|
label="vessel_ais.source_priority",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_rules = vessel_ais.get("field_rules") or {}
|
||||||
|
if not isinstance(raw_rules, dict):
|
||||||
|
raise StrategyValidationError("vessel_ais.field_rules must be an object")
|
||||||
|
field_rules: dict[str, dict[str, Any]] = {}
|
||||||
|
for field, rule in raw_rules.items():
|
||||||
|
if field not in ALLOWED_FIELDS:
|
||||||
|
raise StrategyValidationError(f"unknown vessel_ais field: {field}")
|
||||||
|
if not isinstance(rule, dict):
|
||||||
|
raise StrategyValidationError(f"field_rules.{field} must be an object")
|
||||||
|
mode = str(rule.get("mode") or "").strip()
|
||||||
|
if not mode:
|
||||||
|
raise StrategyValidationError(f"field_rules.{field}.mode is required")
|
||||||
|
is_dynamic = field in DYNAMIC_FIELDS
|
||||||
|
if is_dynamic:
|
||||||
|
allowed_modes = ALLOWED_LOCKED_DYNAMIC_MODES if allow_dynamic_lock else ALLOWED_DYNAMIC_MODES
|
||||||
|
if mode not in allowed_modes:
|
||||||
|
if not allow_dynamic_lock:
|
||||||
|
raise StrategyValidationError(
|
||||||
|
f"field_rules.{field}.mode='{mode}' requires allow_dynamic_lock=true"
|
||||||
|
)
|
||||||
|
raise StrategyValidationError(
|
||||||
|
f"field_rules.{field}.mode must be one of {sorted(allowed_modes)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if mode not in ALLOWED_STATIC_MODES:
|
||||||
|
raise StrategyValidationError(
|
||||||
|
f"field_rules.{field}.mode must be one of {sorted(ALLOWED_STATIC_MODES)}"
|
||||||
|
)
|
||||||
|
normalized_rule: dict[str, Any] = {"mode": mode}
|
||||||
|
rule_priority = rule.get("source_priority")
|
||||||
|
if rule_priority is not None:
|
||||||
|
normalized_rule["source_priority"] = _coerce_str_list(
|
||||||
|
rule_priority,
|
||||||
|
label=f"field_rules.{field}.source_priority",
|
||||||
|
)
|
||||||
|
if mode == "locked":
|
||||||
|
locked_source = rule.get("locked_source")
|
||||||
|
if not isinstance(locked_source, str) or not locked_source.strip():
|
||||||
|
raise StrategyValidationError(
|
||||||
|
f"field_rules.{field}.locked_source must be a non-empty string when mode=locked"
|
||||||
|
)
|
||||||
|
normalized_rule["locked_source"] = locked_source.strip()
|
||||||
|
field_rules[field] = normalized_rule
|
||||||
|
|
||||||
|
raw_freshness = vessel_ais.get("freshness") or {}
|
||||||
|
if not isinstance(raw_freshness, dict):
|
||||||
|
raise StrategyValidationError("vessel_ais.freshness must be an object")
|
||||||
|
freshness: dict[str, int] = {}
|
||||||
|
for key in ("realtime_stream_seconds", "polling_seconds"):
|
||||||
|
value = raw_freshness.get(key, DEFAULT_STRATEGY["vessel_ais"]["freshness"][key])
|
||||||
|
try:
|
||||||
|
seconds = int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise StrategyValidationError(f"freshness.{key} must be an integer") from exc
|
||||||
|
if seconds < 0:
|
||||||
|
raise StrategyValidationError(f"freshness.{key} must be non-negative")
|
||||||
|
freshness[key] = seconds
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": int(payload.get("version") or 0) + 1,
|
||||||
|
"vessel_ais": {
|
||||||
|
"source_priority": source_priority,
|
||||||
|
"field_rules": field_rules,
|
||||||
|
"freshness": freshness,
|
||||||
|
"allow_dynamic_lock": allow_dynamic_lock,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _select_setting(db: AsyncSession) -> SystemSetting | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.category == VESSEL_AGGREGATION_STRATEGY_CATEGORY)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _current_version(setting: SystemSetting | None) -> int:
|
||||||
|
if setting is None:
|
||||||
|
return 0
|
||||||
|
payload = setting.payload or {}
|
||||||
|
return int(payload.get("version") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_strategy(db: AsyncSession) -> dict[str, Any]:
|
||||||
|
setting = await _select_setting(db)
|
||||||
|
if setting is None or not isinstance(setting.payload, dict):
|
||||||
|
return DEFAULT_STRATEGY
|
||||||
|
payload = setting.payload
|
||||||
|
if "vessel_ais" not in payload:
|
||||||
|
return DEFAULT_STRATEGY
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def save_strategy(db: AsyncSession, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Validate + persist; bumps version automatically."""
|
||||||
|
|
||||||
|
existing = await _select_setting(db)
|
||||||
|
incoming = dict(payload)
|
||||||
|
incoming.setdefault("version", _current_version(existing))
|
||||||
|
validated = validate_strategy(incoming)
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
existing = SystemSetting(category=VESSEL_AGGREGATION_STRATEGY_CATEGORY, payload=validated)
|
||||||
|
db.add(existing)
|
||||||
|
else:
|
||||||
|
existing.payload = validated
|
||||||
|
await db.commit()
|
||||||
|
return validated
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_strategy(db: AsyncSession) -> dict[str, Any]:
|
||||||
|
existing = await _select_setting(db)
|
||||||
|
payload = {**DEFAULT_STRATEGY, "version": _current_version(existing) + 1}
|
||||||
|
if existing is None:
|
||||||
|
existing = SystemSetting(category=VESSEL_AGGREGATION_STRATEGY_CATEGORY, payload=payload)
|
||||||
|
db.add(existing)
|
||||||
|
else:
|
||||||
|
existing.payload = payload
|
||||||
|
await db.commit()
|
||||||
|
return payload
|
||||||
698
backend/app/services/vessel_ais_aggregation.py
Normal file
698
backend/app/services/vessel_ais_aggregation.py
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
"""AIS raw observation and aggregation support for vessel collectors."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth
|
||||||
|
from app.services.vessel_aggregation_strategy import (
|
||||||
|
DEFAULT_STRATEGY,
|
||||||
|
load_strategy,
|
||||||
|
)
|
||||||
|
from app.services.vessel_types import normalize_vessel_type_name
|
||||||
|
|
||||||
|
VESSEL_AIS_SCHEMA = "vessel_ais"
|
||||||
|
DEFAULT_AGGREGATION_WINDOW_HOURS = 24
|
||||||
|
BARENTSWATCH_DELIVERY_MODE = "polling"
|
||||||
|
BARENTSWATCH_TRANSPORT = "http"
|
||||||
|
AISSTREAM_DELIVERY_MODE = "realtime_stream"
|
||||||
|
AISSTREAM_TRANSPORT = "websocket"
|
||||||
|
DELIVERY_MODE_PRIORITY = {
|
||||||
|
"realtime_stream": 40,
|
||||||
|
"batch_stream": 30,
|
||||||
|
"polling": 20,
|
||||||
|
"snapshot": 10,
|
||||||
|
}
|
||||||
|
DYNAMIC_FIELDS = ("lat", "lon", "sog", "cog", "heading", "nav_status")
|
||||||
|
CONFLICT_FIELDS = (
|
||||||
|
"name",
|
||||||
|
"callsign",
|
||||||
|
"imo",
|
||||||
|
"flag",
|
||||||
|
"vessel_type",
|
||||||
|
"vessel_type_name",
|
||||||
|
"length",
|
||||||
|
"width",
|
||||||
|
"draught",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(value: Any) -> Any:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.astimezone(UTC).isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_payload(value: Any) -> str:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=_json_default)
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonable(value: Any) -> Any:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.astimezone(UTC).isoformat()
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_jsonable(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_datetime(value: Any) -> datetime | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
timestamp = float(value)
|
||||||
|
if timestamp > 10_000_000_000:
|
||||||
|
timestamp /= 1000
|
||||||
|
return datetime.fromtimestamp(timestamp, UTC)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_observation_hash(
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
entity_key: str,
|
||||||
|
message_type: str | None,
|
||||||
|
observed_at: datetime,
|
||||||
|
normalized_payload: dict[str, Any],
|
||||||
|
source_message_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build a deterministic idempotency key for one source-level AIS observation."""
|
||||||
|
|
||||||
|
if source_message_id:
|
||||||
|
basis = {
|
||||||
|
"source": source,
|
||||||
|
"entity_key": entity_key,
|
||||||
|
"source_message_id": source_message_id,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
basis = {
|
||||||
|
"source": source,
|
||||||
|
"entity_key": entity_key,
|
||||||
|
"message_type": message_type,
|
||||||
|
"observed_at": observed_at.astimezone(UTC).isoformat(),
|
||||||
|
"payload": normalized_payload,
|
||||||
|
}
|
||||||
|
return sha256(_stable_payload(basis).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_field_conflict_candidates(
|
||||||
|
observations: Iterable[AISRawObservation],
|
||||||
|
fields: Iterable[str] = CONFLICT_FIELDS,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return current field disagreements from raw observations without mutating state."""
|
||||||
|
|
||||||
|
candidates_by_field: dict[str, dict[str, Any]] = {}
|
||||||
|
for observation in observations:
|
||||||
|
payload = observation.normalized_payload or {}
|
||||||
|
for field in fields:
|
||||||
|
value = payload.get(field)
|
||||||
|
if value in (None, ""):
|
||||||
|
continue
|
||||||
|
field_candidates = candidates_by_field.setdefault(field, {})
|
||||||
|
field_candidates[observation.source] = value
|
||||||
|
|
||||||
|
conflicts = []
|
||||||
|
for field, candidates in sorted(candidates_by_field.items()):
|
||||||
|
unique_values = {_stable_payload(value) for value in candidates.values()}
|
||||||
|
if len(unique_values) <= 1:
|
||||||
|
continue
|
||||||
|
conflicts.append(
|
||||||
|
{
|
||||||
|
"field": field,
|
||||||
|
"candidates": candidates,
|
||||||
|
"status": "candidate",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_value(payload: dict[str, Any], field: str) -> Any:
|
||||||
|
value = payload.get(field)
|
||||||
|
return None if value in (None, "") else value
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value: Any) -> str | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_metadata_value(observation: AISRawObservation, field: str) -> Any:
|
||||||
|
raw_payload = observation.raw_payload or {}
|
||||||
|
metadata = raw_payload.get("MetaData") if isinstance(raw_payload, dict) else None
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return None
|
||||||
|
if field == "name":
|
||||||
|
return _clean_text(metadata.get("ShipName") or metadata.get("ship_name") or metadata.get("name"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_priority(observation: AISRawObservation) -> int:
|
||||||
|
return DELIVERY_MODE_PRIORITY.get(str(observation.delivery_mode or ""), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_valid_position(payload: dict[str, Any]) -> bool:
|
||||||
|
try:
|
||||||
|
lat = float(payload.get("lat"))
|
||||||
|
lon = float(payload.get("lon"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return -90 <= lat <= 90 and -180 <= lon <= 180
|
||||||
|
|
||||||
|
|
||||||
|
def _is_future_observation(observation: AISRawObservation, now: datetime) -> bool:
|
||||||
|
return observation.observed_at > now
|
||||||
|
|
||||||
|
|
||||||
|
def _strategy_source_rank(
|
||||||
|
source: str,
|
||||||
|
strategy: dict[str, Any],
|
||||||
|
) -> int:
|
||||||
|
priority = (strategy.get("vessel_ais") or {}).get("source_priority") or []
|
||||||
|
if source in priority:
|
||||||
|
return len(priority) - priority.index(source)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_stream_stale(
|
||||||
|
observation: AISRawObservation,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
strategy: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
delivery_mode = str(observation.delivery_mode or "")
|
||||||
|
freshness = (strategy.get("vessel_ais") or {}).get("freshness") or {}
|
||||||
|
if delivery_mode == "realtime_stream":
|
||||||
|
window = int(freshness.get("realtime_stream_seconds", 0) or 0)
|
||||||
|
else:
|
||||||
|
window = int(freshness.get("polling_seconds", 0) or 0)
|
||||||
|
if window <= 0:
|
||||||
|
return False
|
||||||
|
return (now - observation.observed_at).total_seconds() > window
|
||||||
|
|
||||||
|
|
||||||
|
def _select_position_observation(
|
||||||
|
observations: list[AISRawObservation],
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
strategy: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[AISRawObservation | None, list[str]]:
|
||||||
|
strategy = strategy or DEFAULT_STRATEGY
|
||||||
|
rejected_flags: list[str] = []
|
||||||
|
fresh_candidates: list[AISRawObservation] = []
|
||||||
|
stale_candidates: list[AISRawObservation] = []
|
||||||
|
for observation in observations:
|
||||||
|
payload = observation.normalized_payload or {}
|
||||||
|
if not _has_valid_position(payload):
|
||||||
|
rejected_flags.append("invalid_position")
|
||||||
|
continue
|
||||||
|
if _is_future_observation(observation, now):
|
||||||
|
rejected_flags.append("future_timestamp")
|
||||||
|
continue
|
||||||
|
if _is_stream_stale(observation, now=now, strategy=strategy):
|
||||||
|
stale_candidates.append(observation)
|
||||||
|
rejected_flags.append("freshness_fallback")
|
||||||
|
continue
|
||||||
|
fresh_candidates.append(observation)
|
||||||
|
|
||||||
|
candidates = fresh_candidates or stale_candidates
|
||||||
|
if not candidates:
|
||||||
|
return None, sorted(set(rejected_flags))
|
||||||
|
|
||||||
|
candidates.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
item.observed_at,
|
||||||
|
_delivery_priority(item),
|
||||||
|
_strategy_source_rank(item.source, strategy),
|
||||||
|
item.collected_at,
|
||||||
|
item.id or 0,
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return candidates[0], sorted(set(rejected_flags))
|
||||||
|
|
||||||
|
|
||||||
|
def _select_static_field(
|
||||||
|
observations: list[AISRawObservation],
|
||||||
|
field: str,
|
||||||
|
strategy: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[Any, str | None, str | None]:
|
||||||
|
strategy = strategy or DEFAULT_STRATEGY
|
||||||
|
candidates = []
|
||||||
|
for observation in observations:
|
||||||
|
value = _payload_value(observation.normalized_payload or {}, field)
|
||||||
|
if value is None:
|
||||||
|
value = _raw_metadata_value(observation, field)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
candidates.append((observation, value))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
field_rules = (strategy.get("vessel_ais") or {}).get("field_rules") or {}
|
||||||
|
rule = field_rules.get(field) or {"mode": "source_priority"}
|
||||||
|
mode = rule.get("mode")
|
||||||
|
|
||||||
|
if mode == "locked":
|
||||||
|
locked_source = rule.get("locked_source")
|
||||||
|
for observation, value in candidates:
|
||||||
|
if observation.source == locked_source:
|
||||||
|
return value, observation.source, "locked"
|
||||||
|
|
||||||
|
if mode in ("source_priority", "locked"):
|
||||||
|
priority = rule.get("source_priority") or (strategy.get("vessel_ais") or {}).get("source_priority") or []
|
||||||
|
ranked = sorted(
|
||||||
|
candidates,
|
||||||
|
key=lambda item: (
|
||||||
|
priority.index(item[0].source) if item[0].source in priority else len(priority) + 1,
|
||||||
|
-_delivery_priority(item[0]),
|
||||||
|
-(item[0].observed_at.timestamp() if item[0].observed_at else 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
observation, value = ranked[0]
|
||||||
|
return value, observation.source, "source_priority"
|
||||||
|
|
||||||
|
if mode == "newest":
|
||||||
|
ranked = sorted(
|
||||||
|
candidates,
|
||||||
|
key=lambda item: (item[0].observed_at, _delivery_priority(item[0]), item[0].id or 0),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
observation, value = ranked[0]
|
||||||
|
return value, observation.source, "newest_observation"
|
||||||
|
|
||||||
|
# default / non_empty: prefer delivery mode priority, then newest
|
||||||
|
candidates.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
_delivery_priority(item[0]),
|
||||||
|
item[0].observed_at,
|
||||||
|
item[0].collected_at,
|
||||||
|
item[0].id or 0,
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
selected_observation, selected_value = candidates[0]
|
||||||
|
unique_values = {_stable_payload(value) for _, value in candidates}
|
||||||
|
reason = "delivery_mode_priority" if len(unique_values) > 1 else "non_empty_priority"
|
||||||
|
return selected_value, selected_observation.source, reason
|
||||||
|
|
||||||
|
|
||||||
|
def _build_source_summary(observations: list[AISRawObservation]) -> dict[str, dict[str, Any]]:
|
||||||
|
summary: dict[str, dict[str, Any]] = {}
|
||||||
|
for observation in observations:
|
||||||
|
source_summary = summary.setdefault(
|
||||||
|
observation.source,
|
||||||
|
{
|
||||||
|
"observation_count": 0,
|
||||||
|
"latest_observed_at": None,
|
||||||
|
"delivery_mode": observation.delivery_mode,
|
||||||
|
"transport": observation.transport,
|
||||||
|
"message_types": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
source_summary["observation_count"] += 1
|
||||||
|
latest_observed_at = source_summary["latest_observed_at"]
|
||||||
|
if latest_observed_at is None or observation.observed_at > latest_observed_at:
|
||||||
|
source_summary["latest_observed_at"] = observation.observed_at
|
||||||
|
if observation.message_type and observation.message_type not in source_summary["message_types"]:
|
||||||
|
source_summary["message_types"].append(observation.message_type)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _build_aggregated_vessel(
|
||||||
|
entity_key: str,
|
||||||
|
observations: list[AISRawObservation],
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
strategy: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
strategy = strategy or DEFAULT_STRATEGY
|
||||||
|
position_observation, rejected_flags = _select_position_observation(
|
||||||
|
observations, now=now, strategy=strategy
|
||||||
|
)
|
||||||
|
if position_observation is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = position_observation.normalized_payload or {}
|
||||||
|
mmsi = int(entity_key)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"mmsi": mmsi,
|
||||||
|
"lat": float(payload["lat"]),
|
||||||
|
"lon": float(payload["lon"]),
|
||||||
|
"received_at": position_observation.observed_at,
|
||||||
|
"field_sources": {},
|
||||||
|
"selected_reasons": {},
|
||||||
|
"source_summary": _build_source_summary(observations),
|
||||||
|
"quality_flags": sorted(
|
||||||
|
set((position_observation.quality_flags or []) + rejected_flags)
|
||||||
|
),
|
||||||
|
"aggregation_strategy_version": int(strategy.get("version") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
for field in DYNAMIC_FIELDS:
|
||||||
|
value = _payload_value(payload, field)
|
||||||
|
if field in ("lat", "lon") or value is not None:
|
||||||
|
result[field] = value
|
||||||
|
result["field_sources"][field] = position_observation.source
|
||||||
|
result["selected_reasons"][field] = "newest_observation"
|
||||||
|
|
||||||
|
for field in CONFLICT_FIELDS:
|
||||||
|
selected_value, selected_source, reason = _select_static_field(
|
||||||
|
observations, field, strategy=strategy
|
||||||
|
)
|
||||||
|
if selected_value is None:
|
||||||
|
continue
|
||||||
|
result[field] = selected_value
|
||||||
|
result["field_sources"][field] = selected_source
|
||||||
|
result["selected_reasons"][field] = reason
|
||||||
|
|
||||||
|
result["name"] = result.get("name") or f"MMSI {mmsi}"
|
||||||
|
result["vessel_type_name"] = result.get("vessel_type_name") or normalize_vessel_type_name(
|
||||||
|
result.get("vessel_type")
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_conflict_records(
|
||||||
|
db: AsyncSession,
|
||||||
|
entity_key: str,
|
||||||
|
observations: list[AISRawObservation],
|
||||||
|
aggregated: dict[str, Any],
|
||||||
|
) -> int:
|
||||||
|
conflicts = build_field_conflict_candidates(observations)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for conflict in conflicts:
|
||||||
|
field = conflict["field"]
|
||||||
|
result = await db.execute(
|
||||||
|
select(AISConflictRecord)
|
||||||
|
.where(AISConflictRecord.target_schema == VESSEL_AIS_SCHEMA)
|
||||||
|
.where(AISConflictRecord.entity_key == entity_key)
|
||||||
|
.where(AISConflictRecord.field == field)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
record = AISConflictRecord(
|
||||||
|
target_schema=VESSEL_AIS_SCHEMA,
|
||||||
|
entity_key=entity_key,
|
||||||
|
field=field,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
record.candidates = conflict["candidates"]
|
||||||
|
record.selected_source = (aggregated.get("field_sources") or {}).get(field)
|
||||||
|
record.selected_value = aggregated.get(field)
|
||||||
|
record.selected_reason = (aggregated.get("selected_reasons") or {}).get(field)
|
||||||
|
record.resolved_by = "system"
|
||||||
|
record.status = "open"
|
||||||
|
record.updated_at = now
|
||||||
|
return len(conflicts)
|
||||||
|
|
||||||
|
|
||||||
|
def _group_observations(observations: Iterable[AISRawObservation]) -> dict[str, list[AISRawObservation]]:
|
||||||
|
grouped: dict[str, list[AISRawObservation]] = {}
|
||||||
|
for observation in observations:
|
||||||
|
grouped.setdefault(str(observation.entity_key), []).append(observation)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
async def record_vessel_ais_observation(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
normalized_payload: dict[str, Any],
|
||||||
|
raw_payload: dict[str, Any] | None = None,
|
||||||
|
delivery_mode: str,
|
||||||
|
transport: str,
|
||||||
|
message_type: str | None = "PositionReport",
|
||||||
|
source_message_id: str | None = None,
|
||||||
|
observed_at: datetime | None = None,
|
||||||
|
collected_at: datetime | None = None,
|
||||||
|
quality_flags: list[str] | None = None,
|
||||||
|
) -> AISRawObservation | None:
|
||||||
|
"""Insert one raw observation if the source-level fact has not already been stored."""
|
||||||
|
|
||||||
|
entity_key = str(normalized_payload["mmsi"])
|
||||||
|
collected_at = collected_at or datetime.now(UTC)
|
||||||
|
observed_at = (
|
||||||
|
_coerce_datetime(observed_at)
|
||||||
|
or _coerce_datetime(normalized_payload.get("received_at"))
|
||||||
|
or collected_at
|
||||||
|
)
|
||||||
|
normalized_json = _jsonable(normalized_payload)
|
||||||
|
raw_json = _jsonable(raw_payload or {})
|
||||||
|
|
||||||
|
observation_hash = build_observation_hash(
|
||||||
|
source=source,
|
||||||
|
entity_key=entity_key,
|
||||||
|
message_type=message_type,
|
||||||
|
observed_at=observed_at,
|
||||||
|
normalized_payload=normalized_json,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
)
|
||||||
|
existing_result = await db.execute(
|
||||||
|
select(AISRawObservation.id).where(AISRawObservation.observation_hash == observation_hash)
|
||||||
|
)
|
||||||
|
if existing_result.scalar_one_or_none() is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
observation = AISRawObservation(
|
||||||
|
target_schema=VESSEL_AIS_SCHEMA,
|
||||||
|
source=source,
|
||||||
|
entity_key=entity_key,
|
||||||
|
delivery_mode=delivery_mode,
|
||||||
|
transport=transport,
|
||||||
|
message_type=message_type,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
observation_hash=observation_hash,
|
||||||
|
observed_at=observed_at,
|
||||||
|
collected_at=collected_at,
|
||||||
|
normalized_payload=normalized_json,
|
||||||
|
raw_payload=raw_json,
|
||||||
|
quality_flags=quality_flags or [],
|
||||||
|
)
|
||||||
|
db.add(observation)
|
||||||
|
return observation
|
||||||
|
|
||||||
|
|
||||||
|
async def aggregate_vessel_observations(
|
||||||
|
db: AsyncSession,
|
||||||
|
observations: Iterable[AISRawObservation],
|
||||||
|
*,
|
||||||
|
write_conflicts: bool = False,
|
||||||
|
strategy: dict[str, Any] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
strategy = strategy if strategy is not None else await _safe_load_strategy(db)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
vessels = []
|
||||||
|
for entity_key, entity_observations in _group_observations(observations).items():
|
||||||
|
aggregated = _build_aggregated_vessel(
|
||||||
|
entity_key, entity_observations, now=now, strategy=strategy
|
||||||
|
)
|
||||||
|
if aggregated is None:
|
||||||
|
continue
|
||||||
|
if write_conflicts:
|
||||||
|
aggregated["conflict_count"] = await _upsert_conflict_records(
|
||||||
|
db,
|
||||||
|
entity_key,
|
||||||
|
entity_observations,
|
||||||
|
aggregated,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
aggregated["conflict_count"] = len(build_field_conflict_candidates(entity_observations))
|
||||||
|
vessels.append(aggregated)
|
||||||
|
|
||||||
|
vessels.sort(key=lambda item: item.get("received_at") or datetime.min.replace(tzinfo=UTC), reverse=True)
|
||||||
|
return vessels
|
||||||
|
|
||||||
|
|
||||||
|
async def _safe_load_strategy(db: AsyncSession) -> dict[str, Any]:
|
||||||
|
"""Tolerate fake test sessions where load_strategy may misbehave."""
|
||||||
|
try:
|
||||||
|
return await load_strategy(db)
|
||||||
|
except Exception:
|
||||||
|
return DEFAULT_STRATEGY
|
||||||
|
|
||||||
|
|
||||||
|
async def get_aggregated_vessels(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
bbox: tuple[float, float, float, float] | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
observed_since: datetime | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
observed_since = observed_since or (
|
||||||
|
datetime.now(UTC) - timedelta(hours=DEFAULT_AGGREGATION_WINDOW_HOURS)
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(AISRawObservation)
|
||||||
|
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||||
|
.where(AISRawObservation.observed_at >= observed_since)
|
||||||
|
.order_by(AISRawObservation.observed_at.desc(), AISRawObservation.id.desc())
|
||||||
|
)
|
||||||
|
if limit and limit > 0:
|
||||||
|
stmt = stmt.limit(max(limit * 20, limit))
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
if not hasattr(result, "scalars"):
|
||||||
|
return []
|
||||||
|
vessels = await aggregate_vessel_observations(db, result.scalars().all())
|
||||||
|
|
||||||
|
if bbox is not None:
|
||||||
|
lon_min, lat_min, lon_max, lat_max = bbox
|
||||||
|
vessels = [
|
||||||
|
vessel
|
||||||
|
for vessel in vessels
|
||||||
|
if lon_min <= float(vessel["lon"]) <= lon_max
|
||||||
|
and lat_min <= float(vessel["lat"]) <= lat_max
|
||||||
|
]
|
||||||
|
|
||||||
|
if limit and limit > 0:
|
||||||
|
return vessels[:limit]
|
||||||
|
return vessels
|
||||||
|
|
||||||
|
|
||||||
|
async def get_aggregated_vessel(db: AsyncSession, mmsi: int) -> dict[str, Any] | None:
|
||||||
|
observations = await get_vessel_raw_observations(db, mmsi, limit=1000)
|
||||||
|
vessels = await aggregate_vessel_observations(db, observations)
|
||||||
|
return vessels[0] if vessels else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_aggregated_vessel_track(
|
||||||
|
db: AsyncSession,
|
||||||
|
mmsi: int,
|
||||||
|
*,
|
||||||
|
cutoff: datetime,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AISRawObservation)
|
||||||
|
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||||
|
.where(AISRawObservation.entity_key == str(mmsi))
|
||||||
|
.where(AISRawObservation.observed_at >= cutoff)
|
||||||
|
.order_by(AISRawObservation.observed_at.asc(), AISRawObservation.id.asc())
|
||||||
|
)
|
||||||
|
if not hasattr(result, "scalars"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
points: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, float, float, str]] = set()
|
||||||
|
for observation in result.scalars().all():
|
||||||
|
payload = observation.normalized_payload or {}
|
||||||
|
if not _has_valid_position(payload):
|
||||||
|
continue
|
||||||
|
lat = float(payload["lat"])
|
||||||
|
lon = float(payload["lon"])
|
||||||
|
key = (
|
||||||
|
observation.observed_at.isoformat(),
|
||||||
|
round(lat, 5),
|
||||||
|
round(lon, 5),
|
||||||
|
observation.source,
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
points.append(
|
||||||
|
{
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"observed_at": observation.observed_at,
|
||||||
|
"source": observation.source,
|
||||||
|
"selected_reason": "track_timeline",
|
||||||
|
"quality_flags": observation.quality_flags or [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
async def update_ais_source_health(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
connection_state: str,
|
||||||
|
observed_count: int = 0,
|
||||||
|
last_seen_at: datetime | None = None,
|
||||||
|
last_success_at: datetime | None = None,
|
||||||
|
last_error: str | None = None,
|
||||||
|
lag_seconds: float | None = None,
|
||||||
|
) -> AISSourceHealth:
|
||||||
|
"""Upsert the health row for an AIS source."""
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
health = await db.get(AISSourceHealth, source)
|
||||||
|
if health is None:
|
||||||
|
health = AISSourceHealth(source=source)
|
||||||
|
db.add(health)
|
||||||
|
|
||||||
|
health.connection_state = connection_state
|
||||||
|
health.last_seen_at = last_seen_at or health.last_seen_at
|
||||||
|
health.last_success_at = last_success_at or health.last_success_at
|
||||||
|
health.last_error = last_error
|
||||||
|
health.message_rate = float(observed_count)
|
||||||
|
health.lag_seconds = lag_seconds
|
||||||
|
health.updated_at = now
|
||||||
|
return health
|
||||||
|
|
||||||
|
|
||||||
|
async def count_unique_raw_vessel_mmsi(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
observed_since: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Count unique raw vessel MMSI values for HUD counts; never aggregates."""
|
||||||
|
from sqlalchemy import func as sa_func
|
||||||
|
|
||||||
|
unique_mmsi_stmt = (
|
||||||
|
select(AISRawObservation.entity_key)
|
||||||
|
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
if observed_since is not None:
|
||||||
|
unique_mmsi_stmt = unique_mmsi_stmt.where(
|
||||||
|
AISRawObservation.observed_at >= observed_since,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(sa_func.count()).select_from(unique_mmsi_stmt.subquery()),
|
||||||
|
)
|
||||||
|
return int(result.scalar() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_vessel_raw_observations(
|
||||||
|
db: AsyncSession,
|
||||||
|
mmsi: int,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[AISRawObservation]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AISRawObservation)
|
||||||
|
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||||
|
.where(AISRawObservation.entity_key == str(mmsi))
|
||||||
|
.order_by(AISRawObservation.observed_at.desc(), AISRawObservation.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_vessel_conflict_records(
|
||||||
|
db: AsyncSession,
|
||||||
|
mmsi: int,
|
||||||
|
) -> list[AISConflictRecord]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AISConflictRecord)
|
||||||
|
.where(AISConflictRecord.target_schema == VESSEL_AIS_SCHEMA)
|
||||||
|
.where(AISConflictRecord.entity_key == str(mmsi))
|
||||||
|
.order_by(AISConflictRecord.updated_at.desc(), AISConflictRecord.id.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
109
backend/app/services/vessel_enrichment.py
Normal file
109
backend/app/services/vessel_enrichment.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""v5 vessel enrichment service.
|
||||||
|
|
||||||
|
Read-only side: `get_vessel_enrichment_bundle` is the only path the
|
||||||
|
aggregation/detail endpoints use. It never reaches out to third parties; it
|
||||||
|
just returns whatever the upsert side has already cached. Expired rows are
|
||||||
|
filtered out so old data never leaks back into the live UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.vessel_enrichment import VesselMediaEnrichment, VesselProfileEnrichment
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_datetime(value: Any) -> datetime | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
ts = float(value)
|
||||||
|
if ts > 10_000_000_000:
|
||||||
|
ts /= 1000
|
||||||
|
return datetime.fromtimestamp(ts, UTC)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_payload(record, *, now: datetime) -> dict[str, Any] | None:
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
expires_at = record.expires_at
|
||||||
|
if isinstance(expires_at, datetime):
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=UTC)
|
||||||
|
if expires_at < now:
|
||||||
|
return None
|
||||||
|
return record.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_vessel_enrichment_bundle(db: AsyncSession, mmsi: int) -> dict[str, Any]:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
profile = await db.get(VesselProfileEnrichment, mmsi)
|
||||||
|
media = await db.get(VesselMediaEnrichment, mmsi)
|
||||||
|
return {
|
||||||
|
"mmsi": mmsi,
|
||||||
|
"profile": _build_payload(profile, now=now),
|
||||||
|
"media": _build_payload(media, now=now),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_vessel_profile_enrichment(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
mmsi: int,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
record = await db.get(VesselProfileEnrichment, mmsi)
|
||||||
|
if record is None:
|
||||||
|
record = VesselProfileEnrichment(mmsi=mmsi)
|
||||||
|
db.add(record)
|
||||||
|
return _apply_upsert(record, payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_vessel_media_enrichment(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
mmsi: int,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
record = await db.get(VesselMediaEnrichment, mmsi)
|
||||||
|
if record is None:
|
||||||
|
record = VesselMediaEnrichment(mmsi=mmsi)
|
||||||
|
db.add(record)
|
||||||
|
return _apply_upsert(record, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_upsert(record, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("enrichment payload must be an object")
|
||||||
|
body = payload.get("payload")
|
||||||
|
if body is not None and not isinstance(body, dict):
|
||||||
|
raise ValueError("payload.payload must be an object")
|
||||||
|
if body is not None:
|
||||||
|
record.payload = body
|
||||||
|
if "source" in payload and isinstance(payload["source"], str) and payload["source"].strip():
|
||||||
|
record.source = payload["source"].strip()
|
||||||
|
fetched_at = _coerce_datetime(payload.get("fetched_at"))
|
||||||
|
record.fetched_at = fetched_at or datetime.now(UTC)
|
||||||
|
record.expires_at = _coerce_datetime(payload.get("expires_at"))
|
||||||
|
confidence = payload.get("confidence")
|
||||||
|
if confidence is not None:
|
||||||
|
try:
|
||||||
|
record.confidence = float(confidence)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
record.confidence = None
|
||||||
|
if "reference_url" in payload:
|
||||||
|
ref = payload.get("reference_url")
|
||||||
|
record.reference_url = str(ref) if ref else None
|
||||||
|
return record.to_dict()
|
||||||
31
backend/app/services/vessel_types.py
Normal file
31
backend/app/services/vessel_types.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Shared AIS vessel type helpers."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
VESSEL_TYPE_NAMES = {
|
||||||
|
30: "Fishing",
|
||||||
|
35: "Military",
|
||||||
|
60: "Passenger",
|
||||||
|
70: "Cargo",
|
||||||
|
80: "Tanker",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_vessel_type_name(vessel_type: Any) -> str:
|
||||||
|
"""Map AIS numeric vessel type codes to display buckets."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
type_code = int(float(vessel_type))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "Other"
|
||||||
|
if 70 <= type_code <= 79:
|
||||||
|
return "Cargo"
|
||||||
|
if 80 <= type_code <= 89:
|
||||||
|
return "Tanker"
|
||||||
|
if 60 <= type_code <= 69:
|
||||||
|
return "Passenger"
|
||||||
|
if type_code == 30:
|
||||||
|
return "Fishing"
|
||||||
|
if type_code == 35:
|
||||||
|
return "Military"
|
||||||
|
return VESSEL_TYPE_NAMES.get(type_code, "Other")
|
||||||
@@ -2,10 +2,45 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import AsyncGenerator
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def bgp_collector_location_cache():
|
||||||
|
"""Mirror app startup seeding for tests that call sync BGP helpers."""
|
||||||
|
from app.services.bgp_collector_locations import (
|
||||||
|
SEED_PATH,
|
||||||
|
set_bgp_collector_location_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(SEED_PATH.read_text(encoding="utf-8"))
|
||||||
|
cache = {}
|
||||||
|
for entry in payload.get("locations", []):
|
||||||
|
collector_id = next(
|
||||||
|
alias for alias in entry.get("aliases", []) if str(alias).startswith("rrc")
|
||||||
|
)
|
||||||
|
cache[collector_id] = {
|
||||||
|
"city": entry.get("city"),
|
||||||
|
"country": entry.get("country"),
|
||||||
|
"latitude": entry.get("latitude"),
|
||||||
|
"longitude": entry.get("longitude"),
|
||||||
|
"precision": entry.get("precision") or "city",
|
||||||
|
"source": "legacy_seed",
|
||||||
|
"needs_confirmation": True,
|
||||||
|
"matched_location_name": entry.get("site") or collector_id,
|
||||||
|
"verified_at": None,
|
||||||
|
"confidence": entry.get("confidence"),
|
||||||
|
"operator": entry.get("operator"),
|
||||||
|
"site": entry.get("site"),
|
||||||
|
"verification_status": "unverified",
|
||||||
|
"source_note": entry.get("source_note"),
|
||||||
|
}
|
||||||
|
set_bgp_collector_location_cache(cache)
|
||||||
|
yield
|
||||||
|
set_bgp_collector_location_cache({})
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for BGP observability helpers."""
|
"""Tests for BGP observability helpers."""
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
@@ -54,11 +55,34 @@ class _FakeResult:
|
|||||||
def scalars(self):
|
def scalars(self):
|
||||||
return _FakeScalarResult(self._rows)
|
return _FakeScalarResult(self._rows)
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
if self._rows and all(isinstance(row, BGPObservation) for row in self._rows):
|
||||||
|
return [
|
||||||
|
(row.prefix, row.origin_asn, row.collector, row.collector_geo)
|
||||||
|
for row in self._rows
|
||||||
|
]
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
def scalar(self):
|
||||||
|
if not self._rows:
|
||||||
|
return 0
|
||||||
|
first = self._rows[0]
|
||||||
|
if isinstance(first, (int, float, str)):
|
||||||
|
return first
|
||||||
|
if isinstance(first, tuple) and len(first) == 1:
|
||||||
|
return first[0]
|
||||||
|
return len(self._rows)
|
||||||
|
|
||||||
def fetchall(self):
|
def fetchall(self):
|
||||||
return self._rows
|
return self._rows
|
||||||
|
|
||||||
def fetchone(self):
|
def fetchone(self):
|
||||||
return self._rows[0] if self._rows else None
|
if not self._rows:
|
||||||
|
return None
|
||||||
|
first = self._rows[0]
|
||||||
|
if isinstance(first, CollectedData):
|
||||||
|
return {"extra_data": first.extra_data}
|
||||||
|
return first
|
||||||
|
|
||||||
|
|
||||||
class _FakeAsyncSession:
|
class _FakeAsyncSession:
|
||||||
@@ -988,7 +1012,7 @@ async def test_infer_related_infrastructure_links_nearby_cables():
|
|||||||
data_type="cable",
|
data_type="cable",
|
||||||
extra_data={"cable_id": 20},
|
extra_data={"cable_id": 20},
|
||||||
)
|
)
|
||||||
db = _FakeAsyncSession([[landing], [relation], [cable]])
|
db = _FakeAsyncSession([[landing, relation, cable]])
|
||||||
|
|
||||||
result = await infer_related_infrastructure(
|
result = await infer_related_infrastructure(
|
||||||
db,
|
db,
|
||||||
@@ -1012,27 +1036,37 @@ async def test_infer_related_infrastructure_links_nearby_cables():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_build_bgp_collector_coverage_summarizes_observations():
|
async def test_build_bgp_collector_coverage_summarizes_observations():
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
obs_one = BGPObservation(
|
aggregate = SimpleNamespace(
|
||||||
source="ris_live_bgp",
|
collector="rrc00",
|
||||||
|
observation_count=2,
|
||||||
|
prefix_count=2,
|
||||||
|
origin_asn_count=2,
|
||||||
|
peer_asn_count=2,
|
||||||
|
recent_15m_observation_count=2,
|
||||||
|
recent_24h_observation_count=2,
|
||||||
|
recent_7d_observation_count=2,
|
||||||
|
recent_15m_prefix_count=2,
|
||||||
|
recent_24h_prefix_count=2,
|
||||||
|
recent_7d_prefix_count=2,
|
||||||
|
latest_observed_at=now + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
latest = SimpleNamespace(
|
||||||
|
collector="rrc00",
|
||||||
|
latest_event_type="withdrawal",
|
||||||
|
country="Netherlands",
|
||||||
|
city="Amsterdam",
|
||||||
|
)
|
||||||
|
top_event = SimpleNamespace(
|
||||||
collector="rrc00",
|
collector="rrc00",
|
||||||
prefix="203.0.113.0/24",
|
|
||||||
origin_asn=64496,
|
|
||||||
peer_asn=3333,
|
|
||||||
event_type="announcement",
|
event_type="announcement",
|
||||||
observed_at=now,
|
count=1,
|
||||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
|
||||||
)
|
)
|
||||||
obs_two = BGPObservation(
|
scope = SimpleNamespace(
|
||||||
source="ris_live_bgp",
|
|
||||||
collector="rrc00",
|
collector="rrc00",
|
||||||
prefix="198.51.100.0/24",
|
country="Netherlands",
|
||||||
origin_asn=64497,
|
city="Amsterdam",
|
||||||
peer_asn=3334,
|
|
||||||
event_type="withdrawal",
|
|
||||||
observed_at=now + timedelta(minutes=5),
|
|
||||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
|
||||||
)
|
)
|
||||||
db = _FakeAsyncSession([[obs_one, obs_two]])
|
db = _FakeAsyncSession([[aggregate], [latest], [top_event], [scope]])
|
||||||
|
|
||||||
coverage = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
|
coverage = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
|
||||||
|
|
||||||
@@ -1363,18 +1397,39 @@ async def test_bgp_event_summary_api_returns_aggregates():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bgp_collectors_api_returns_coverage():
|
async def test_bgp_collectors_api_returns_coverage():
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
observation = BGPObservation(
|
aggregate = SimpleNamespace(
|
||||||
id=1,
|
|
||||||
source="ris_live_bgp",
|
|
||||||
collector="rrc00",
|
collector="rrc00",
|
||||||
peer_asn=3333,
|
observation_count=1,
|
||||||
prefix="203.0.113.0/24",
|
prefix_count=1,
|
||||||
event_type="announcement",
|
origin_asn_count=1,
|
||||||
origin_asn=64496,
|
peer_asn_count=1,
|
||||||
observed_at=now,
|
recent_15m_observation_count=1,
|
||||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
recent_24h_observation_count=1,
|
||||||
|
recent_7d_observation_count=1,
|
||||||
|
recent_15m_prefix_count=1,
|
||||||
|
recent_24h_prefix_count=1,
|
||||||
|
recent_7d_prefix_count=1,
|
||||||
|
latest_observed_at=now,
|
||||||
|
)
|
||||||
|
latest = SimpleNamespace(
|
||||||
|
collector="rrc00",
|
||||||
|
latest_event_type="announcement",
|
||||||
|
country="Netherlands",
|
||||||
|
city="Amsterdam",
|
||||||
|
)
|
||||||
|
top_event = SimpleNamespace(
|
||||||
|
collector="rrc00",
|
||||||
|
event_type="announcement",
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
scope = SimpleNamespace(
|
||||||
|
collector="rrc00",
|
||||||
|
country="Netherlands",
|
||||||
|
city="Amsterdam",
|
||||||
|
)
|
||||||
|
db = _FakeAsyncSession(
|
||||||
|
[[aggregate], [latest], [top_event], [scope], [aggregate], [latest], [top_event], [scope]]
|
||||||
)
|
)
|
||||||
db = _FakeAsyncSession([[observation], [observation]])
|
|
||||||
client = await _bgp_test_client(db)
|
client = await _bgp_test_client(db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
149
backend/tests/test_bgp_collector_locations.py
Normal file
149
backend/tests/test_bgp_collector_locations.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Tests for the BGP collector + event location services."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import bgp_collector_locations
|
||||||
|
from app.services.bgp_collector_locations import (
|
||||||
|
RIPE_RIS_COLLECTOR_COORDS,
|
||||||
|
collect_bgp_collector_location_candidates,
|
||||||
|
iter_known_collector_names,
|
||||||
|
resolve_bgp_collector_location,
|
||||||
|
)
|
||||||
|
from app.services.bgp_event_locations import (
|
||||||
|
resolve_bgp_event_geo_dict,
|
||||||
|
resolve_bgp_event_location,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_dict_view_preserves_backward_compatible_keys():
|
||||||
|
rrc00 = RIPE_RIS_COLLECTOR_COORDS["rrc00"]
|
||||||
|
assert rrc00["city"] == "Amsterdam"
|
||||||
|
assert rrc00["country"] == "Netherlands"
|
||||||
|
assert rrc00["latitude"] == pytest.approx(52.3676)
|
||||||
|
assert rrc00["longitude"] == pytest.approx(4.9041)
|
||||||
|
# New richer fields layered on top.
|
||||||
|
assert rrc00["precision"] == "city"
|
||||||
|
assert rrc00["source"] == "legacy_seed"
|
||||||
|
assert rrc00["needs_confirmation"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_legacy_collector_present():
|
||||||
|
expected = {
|
||||||
|
"rrc00", "rrc01", "rrc03", "rrc04", "rrc05", "rrc06", "rrc07",
|
||||||
|
"rrc10", "rrc11", "rrc12", "rrc13", "rrc14", "rrc15", "rrc16",
|
||||||
|
"rrc18", "rrc19", "rrc20", "rrc21", "rrc22", "rrc23", "rrc24",
|
||||||
|
"rrc25", "rrc26",
|
||||||
|
}
|
||||||
|
assert set(iter_known_collector_names()) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_bgp_collector_returns_stored_location():
|
||||||
|
result = resolve_bgp_collector_location("rrc12")
|
||||||
|
assert result.location is not None
|
||||||
|
assert result.location.city == "Frankfurt"
|
||||||
|
assert result.location.country == "Germany"
|
||||||
|
assert result.location.precision == "city"
|
||||||
|
assert result.location.source == "legacy_seed"
|
||||||
|
assert result.location.needs_confirmation is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_unknown_bgp_collector_returns_diagnostic(monkeypatch):
|
||||||
|
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", lambda q: None)
|
||||||
|
result = resolve_bgp_collector_location("rrc-doesnotexist")
|
||||||
|
assert result.location is None
|
||||||
|
assert result.diagnostic is not None
|
||||||
|
assert result.diagnostic.failure_reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_bgp_collector_candidates_uses_stored_context_without_registry(monkeypatch):
|
||||||
|
bgp_collector_locations._geocode_online.cache_clear()
|
||||||
|
|
||||||
|
def _fake_geocode(query):
|
||||||
|
assert "CIXP" in query or "Geneva" in query
|
||||||
|
return {
|
||||||
|
"lat": "46.2044",
|
||||||
|
"lon": "6.1432",
|
||||||
|
"display_name": "Geneva, Switzerland",
|
||||||
|
"address": {"city": "Geneva", "country": "Switzerland"},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", _fake_geocode)
|
||||||
|
candidates, attempted = collect_bgp_collector_location_candidates(
|
||||||
|
collector="rrc04",
|
||||||
|
)
|
||||||
|
assert attempted, "stored context should feed online query attempts"
|
||||||
|
assert candidates, "online geocoding should produce at least one candidate"
|
||||||
|
best = candidates[0]
|
||||||
|
assert best.source == "nominatim_online_geocode"
|
||||||
|
assert best.needs_confirmation is True
|
||||||
|
assert all(candidate.source != "local_registry" for candidate in candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_bgp_collector_candidates_uses_nominatim_when_registry_misses(monkeypatch):
|
||||||
|
bgp_collector_locations._geocode_online.cache_clear()
|
||||||
|
|
||||||
|
def _fake_geocode(query):
|
||||||
|
if "Lyon" not in query and "France-IX" not in query and "FR-IX" not in query:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"lat": "45.764",
|
||||||
|
"lon": "4.8357",
|
||||||
|
"display_name": "Lyon, Auvergne-Rhône-Alpes, France",
|
||||||
|
"address": {"city": "Lyon", "country": "France"},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", _fake_geocode)
|
||||||
|
candidates, attempted = collect_bgp_collector_location_candidates(
|
||||||
|
collector="rrc-mystery",
|
||||||
|
city="Lyon",
|
||||||
|
country="France",
|
||||||
|
)
|
||||||
|
assert attempted, "Nominatim plan should run"
|
||||||
|
online = [c for c in candidates if c.source == "nominatim_online_geocode"]
|
||||||
|
assert online, "online resolver must produce a candidate when registry misses"
|
||||||
|
assert online[0].needs_confirmation is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── BGP event resolver ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_resolver_inherits_from_owning_collector():
|
||||||
|
geo = resolve_bgp_event_geo_dict("rrc25")
|
||||||
|
assert geo["city"] == "Amsterdam"
|
||||||
|
assert geo["country"] == "Netherlands"
|
||||||
|
assert geo["source"] == "inherited_from_collector"
|
||||||
|
assert geo["precision"] == "city"
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_resolver_does_not_match_unrelated_collectors():
|
||||||
|
"""Regression: passing operator=RIPE NCC must NOT make every collector match."""
|
||||||
|
rrc12 = resolve_bgp_event_geo_dict("rrc12")
|
||||||
|
rrc25 = resolve_bgp_event_geo_dict("rrc25")
|
||||||
|
assert rrc12["city"] == "Frankfurt"
|
||||||
|
assert rrc25["city"] == "Amsterdam"
|
||||||
|
assert rrc12["latitude"] != rrc25["latitude"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_resolver_uses_source_coordinates_when_present():
|
||||||
|
geo = resolve_bgp_event_geo_dict(
|
||||||
|
"rrc12",
|
||||||
|
source_latitude=12.34,
|
||||||
|
source_longitude=56.78,
|
||||||
|
)
|
||||||
|
assert geo["latitude"] == pytest.approx(12.34)
|
||||||
|
assert geo["longitude"] == pytest.approx(56.78)
|
||||||
|
assert geo["precision"] == "precise"
|
||||||
|
assert geo["source"] == "source_coordinates"
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_resolver_returns_empty_for_unknown_collector_without_source_coords():
|
||||||
|
geo = resolve_bgp_event_geo_dict("rrc-doesnotexist")
|
||||||
|
assert geo == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_resolver_full_result_carries_diagnostic_on_miss():
|
||||||
|
result = resolve_bgp_event_location(collector="rrc-doesnotexist")
|
||||||
|
assert result.location is None
|
||||||
|
assert result.diagnostic is not None
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
"""Unit tests for data collectors"""
|
"""Unit tests for data collectors"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime
|
from unittest.mock import AsyncMock, patch
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
|
from app.core.datasource_defaults import DEFAULT_DATASOURCES
|
||||||
|
from app.services.credential_guides import DEFAULT_CREDENTIAL_GUIDES
|
||||||
from app.services.collectors.top500 import TOP500Collector
|
from app.services.collectors.top500 import TOP500Collector
|
||||||
from app.services.collectors.base import BaseCollector, HTTPCollector
|
from app.services.collectors.registry import collector_registry
|
||||||
|
from app.services.datasource_connectivity import SUPPORTED_CREDENTIAL_PROVIDERS
|
||||||
from app.models.task import CollectionTask
|
from app.models.task import CollectionTask
|
||||||
|
|
||||||
|
|
||||||
@@ -145,3 +147,30 @@ class TestHTTPCollector:
|
|||||||
assert hasattr(collector, "parse_response")
|
assert hasattr(collector, "parse_response")
|
||||||
assert callable(collector.fetch)
|
assert callable(collector.fetch)
|
||||||
assert callable(collector.parse_response)
|
assert callable(collector.parse_response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aisstream_collector_is_registered():
|
||||||
|
collector = collector_registry.get("aisstream_vessels")
|
||||||
|
|
||||||
|
assert collector is not None
|
||||||
|
assert collector.data_type == "vessel_ais"
|
||||||
|
|
||||||
|
|
||||||
|
def test_supported_credential_collectors_have_guides_and_connectivity_provider():
|
||||||
|
missing: list[str] = []
|
||||||
|
for source, info in DEFAULT_DATASOURCES.items():
|
||||||
|
if not info.get("requires_credentials"):
|
||||||
|
continue
|
||||||
|
if info.get("credential_status") != "supported":
|
||||||
|
continue
|
||||||
|
|
||||||
|
provider = info.get("credential_provider")
|
||||||
|
if not provider:
|
||||||
|
missing.append(f"{source}: missing credential_provider")
|
||||||
|
continue
|
||||||
|
if provider not in DEFAULT_CREDENTIAL_GUIDES:
|
||||||
|
missing.append(f"{source}: missing credential guide for {provider}")
|
||||||
|
if provider not in SUPPORTED_CREDENTIAL_PROVIDERS:
|
||||||
|
missing.append(f"{source}: missing connectivity provider for {provider}")
|
||||||
|
|
||||||
|
assert missing == []
|
||||||
|
|||||||
149
backend/tests/test_custom_datasource_runtime_live.py
Normal file
149
backend/tests/test_custom_datasource_runtime_live.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""End-to-end integration test for the custom WebSocket datasource runner.
|
||||||
|
|
||||||
|
Boots an in-process WebSocket server that mimics the bun mock AIS server
|
||||||
|
(`scripts/mock-ais-ws-server.ts`) and runs the real
|
||||||
|
`run_mapped_websocket_config` against it. Catches regressions where the
|
||||||
|
runner stops connecting, fails to extract the configured message path,
|
||||||
|
or quietly drops mapped records before broadcasting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
from app.models.datasource_config import DataSourceConfig
|
||||||
|
from app.services import custom_datasource_runtime
|
||||||
|
from app.services.custom_datasource_runtime import run_mapped_websocket_config
|
||||||
|
|
||||||
|
|
||||||
|
def _make_payload(seq: int) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"type": "vessel",
|
||||||
|
"sequence": seq,
|
||||||
|
"data": {
|
||||||
|
"mmsi": str(999_000_000 + seq),
|
||||||
|
"name": f"MOCK VESSEL {seq:03d}",
|
||||||
|
"lat": 36.20 + seq * 0.001,
|
||||||
|
"lon": 14.20 + seq * 0.001,
|
||||||
|
"sog": 12.0,
|
||||||
|
"cog": 90.0,
|
||||||
|
"heading": 90,
|
||||||
|
"vessel_type": 70,
|
||||||
|
"vessel_type_name": "Cargo",
|
||||||
|
"received_at": datetime.now(UTC).isoformat(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _mock_ais_server(emit_count: int):
|
||||||
|
received_subscribe: list[str] = []
|
||||||
|
|
||||||
|
async def handler(ws):
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(ws.recv(), timeout=0.5)
|
||||||
|
received_subscribe.append(msg)
|
||||||
|
except (asyncio.TimeoutError, websockets.ConnectionClosed):
|
||||||
|
pass
|
||||||
|
for seq in range(1, emit_count + 1):
|
||||||
|
await ws.send(_make_payload(seq))
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
# keep the socket open briefly so the runner observes the messages
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
except websockets.ConnectionClosed:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with websockets.serve(handler, "127.0.0.1", 0) as server:
|
||||||
|
port = next(iter(server.sockets)).getsockname()[1]
|
||||||
|
yield port, received_subscribe
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_websocket_runner_streams_from_live_mock(monkeypatch):
|
||||||
|
mapping = SimpleNamespace(
|
||||||
|
id=11,
|
||||||
|
version=3,
|
||||||
|
target_schema="vessel_ais",
|
||||||
|
mapping_json={
|
||||||
|
"source": {"items_path": "$"},
|
||||||
|
"fields": {
|
||||||
|
"mmsi": {"path": "$.mmsi", "type": "integer"},
|
||||||
|
"lat": {"path": "$.lat", "type": "float"},
|
||||||
|
"lon": {"path": "$.lon", "type": "float"},
|
||||||
|
"name": {"path": "$.name", "type": "string"},
|
||||||
|
"vessel_type": {"path": "$.vessel_type", "type": "integer", "default": None},
|
||||||
|
"vessel_type_name": {"path": "$.vessel_type_name", "type": "string", "default": None},
|
||||||
|
"sog": {"path": "$.sog", "type": "float", "default": None},
|
||||||
|
"cog": {"path": "$.cog", "type": "float", "default": None},
|
||||||
|
"heading": {"path": "$.heading", "type": "integer", "default": None},
|
||||||
|
"received_at": {"path": "$.received_at", "type": "datetime"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
async def execute(self, _stmt):
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
persist = AsyncMock(return_value=1)
|
||||||
|
monkeypatch.setattr(custom_datasource_runtime, "persist_mapped_records", persist)
|
||||||
|
|
||||||
|
async with _mock_ais_server(emit_count=3) as (port, received_subscribe):
|
||||||
|
result = await run_mapped_websocket_config(
|
||||||
|
FakeDB(),
|
||||||
|
DataSourceConfig(
|
||||||
|
id=99,
|
||||||
|
name="mock_ais_ws",
|
||||||
|
source_type="websocket",
|
||||||
|
endpoint=f"ws://127.0.0.1:{port}",
|
||||||
|
auth_type="none",
|
||||||
|
headers={},
|
||||||
|
config={
|
||||||
|
"ws_message_path": "$.data",
|
||||||
|
"ws_subscribe_message": {
|
||||||
|
"type": "subscribe",
|
||||||
|
"anchor": {"lat": 36.2, "lon": 14.2},
|
||||||
|
"spread_km": 50,
|
||||||
|
"rate_hz": 1,
|
||||||
|
},
|
||||||
|
"debug_max_messages": 2,
|
||||||
|
"delivery_mode": "realtime_stream",
|
||||||
|
"ws_reconnect": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
use_config_debug_max_messages=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert result["messages_seen"] == 2
|
||||||
|
assert result["written_count"] == 2
|
||||||
|
assert result["mapped_count"] == 2
|
||||||
|
assert result["target_schema"] == "vessel_ais"
|
||||||
|
# subscribe message must reach the server unchanged
|
||||||
|
assert received_subscribe, "runner did not forward ws_subscribe_message"
|
||||||
|
parsed = json.loads(received_subscribe[0])
|
||||||
|
assert parsed["type"] == "subscribe"
|
||||||
|
assert parsed["anchor"] == {"lat": 36.2, "lon": 14.2}
|
||||||
|
assert parsed["rate_hz"] == 1
|
||||||
|
# mapped records carry the real MMSIs from the mock stream
|
||||||
|
persisted_records = []
|
||||||
|
for call in persist.await_args_list:
|
||||||
|
persisted_records.extend(call.kwargs["records"])
|
||||||
|
assert {record["mmsi"] for record in persisted_records} == {999_000_001, 999_000_002}
|
||||||
|
assert all(record["vessel_type"] == 70 for record in persisted_records)
|
||||||
|
assert all(record["vessel_type_name"] == "Cargo" for record in persisted_records)
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from app.api.v1.datasource_config import get_ai_provider_client
|
from app.api.v1.datasource_config import get_ai_provider_client
|
||||||
|
from app.core.websocket import broadcaster as broadcaster_module
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.core.target_schema_registry import get_target_schema, list_target_schemas
|
from app.core.target_schema_registry import get_target_schema, list_target_schemas
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.datasource_config import DataSourceConfig
|
||||||
|
from app.services import custom_datasource_runtime
|
||||||
|
from app.services.custom_datasource_runtime import run_mapped_websocket_config
|
||||||
from app.services.datasource_mapping import execute_mapping, persist_mapped_records, redact_for_llm
|
from app.services.datasource_mapping import execute_mapping, persist_mapped_records, redact_for_llm
|
||||||
|
|
||||||
|
|
||||||
@@ -106,6 +111,130 @@ async def test_persist_mapped_records_writes_generic_records():
|
|||||||
assert db.added[0].extra_data["mapping_version"] == 3
|
assert db.added[0].extra_data["mapping_version"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_mapped_vessel_records_writes_raw_and_broadcasts(monkeypatch):
|
||||||
|
record_observation = AsyncMock(return_value=object())
|
||||||
|
update_health = AsyncMock()
|
||||||
|
broadcast_custom = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.vessel_ais_aggregation.record_vessel_ais_observation",
|
||||||
|
record_observation,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.vessel_ais_aggregation.update_ais_source_health",
|
||||||
|
update_health,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(broadcaster_module, "broadcast_custom", broadcast_custom)
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
def __init__(self):
|
||||||
|
self.committed = False
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
db = FakeDB()
|
||||||
|
|
||||||
|
count = await persist_mapped_records(
|
||||||
|
db,
|
||||||
|
datasource_name="mock_ais_ws",
|
||||||
|
datasource_config_id=42,
|
||||||
|
target_schema="vessel_ais",
|
||||||
|
records=[
|
||||||
|
{
|
||||||
|
"mmsi": 999000001,
|
||||||
|
"lat": 31.2,
|
||||||
|
"lon": 121.4,
|
||||||
|
"name": "MOCK VESSEL 001",
|
||||||
|
"received_at": "2026-05-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
mapping_version=1,
|
||||||
|
delivery_mode="realtime_stream",
|
||||||
|
transport="websocket",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
assert db.committed is True
|
||||||
|
record_observation.assert_awaited_once()
|
||||||
|
assert record_observation.await_args.kwargs["source"] == "mock_ais_ws"
|
||||||
|
assert record_observation.await_args.kwargs["delivery_mode"] == "realtime_stream"
|
||||||
|
assert record_observation.await_args.kwargs["transport"] == "websocket"
|
||||||
|
update_health.assert_awaited_once()
|
||||||
|
broadcast_custom.assert_awaited_once()
|
||||||
|
assert broadcast_custom.await_args.args[0] == "vessels"
|
||||||
|
assert broadcast_custom.await_args.args[1]["vessels"][0]["mmsi_display"] == "999000001"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_custom_websocket_runner_maps_and_persists_vessel_records(monkeypatch):
|
||||||
|
mapping = SimpleNamespace(
|
||||||
|
id=7,
|
||||||
|
version=2,
|
||||||
|
target_schema="vessel_ais",
|
||||||
|
mapping_json={
|
||||||
|
"source": {"items_path": "$"},
|
||||||
|
"fields": {
|
||||||
|
"mmsi": {"path": "$.mmsi", "type": "integer"},
|
||||||
|
"lat": {"path": "$.lat", "type": "float"},
|
||||||
|
"lon": {"path": "$.lon", "type": "float"},
|
||||||
|
"name": {"path": "$.name", "type": "string"},
|
||||||
|
"received_at": {"path": "$.received_at", "type": "datetime"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
async def execute(self, _stmt):
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
class FakeWebSocket:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def send(self, _message):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def recv(self):
|
||||||
|
return (
|
||||||
|
'{"type":"vessel","data":{"mmsi":"999000001","name":"MOCK VESSEL 001",'
|
||||||
|
'"lat":31.2,"lon":121.4,"received_at":"2026-05-01T00:00:00Z"}}'
|
||||||
|
)
|
||||||
|
|
||||||
|
persist = AsyncMock(return_value=1)
|
||||||
|
monkeypatch.setattr(custom_datasource_runtime, "_connect_websocket", AsyncMock(return_value=FakeWebSocket()))
|
||||||
|
monkeypatch.setattr(custom_datasource_runtime, "persist_mapped_records", persist)
|
||||||
|
|
||||||
|
result = await run_mapped_websocket_config(
|
||||||
|
FakeDB(),
|
||||||
|
DataSourceConfig(
|
||||||
|
id=42,
|
||||||
|
name="mock_ais_ws",
|
||||||
|
source_type="websocket",
|
||||||
|
endpoint="ws://localhost:8787/ais",
|
||||||
|
auth_type="none",
|
||||||
|
headers={},
|
||||||
|
config={"ws_message_path": "$.data", "debug_max_messages": 1},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert result["messages_seen"] == 1
|
||||||
|
assert result["written_count"] == 1
|
||||||
|
persist.assert_awaited_once()
|
||||||
|
assert persist.await_args.kwargs["datasource_name"] == "mock_ais_ws"
|
||||||
|
assert persist.await_args.kwargs["records"][0]["mmsi"] == 999000001
|
||||||
|
assert persist.await_args.kwargs["delivery_mode"] == "realtime_stream"
|
||||||
|
assert persist.await_args.kwargs["transport"] == "websocket"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mapping_preview_api_uses_deterministic_engine():
|
async def test_mapping_preview_api_uses_deterministic_engine():
|
||||||
def override_get_current_user():
|
def override_get_current_user():
|
||||||
|
|||||||
115
backend/tests/test_docs_gatekeeper.py
Normal file
115
backend/tests/test_docs_gatekeeper.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Docs Gatekeeper API tests."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.api.v1 import docs as docs_api
|
||||||
|
from app.main import app
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def make_user(role: str = "viewer", groups: list[str] | None = None) -> User:
|
||||||
|
user = User(
|
||||||
|
id=1,
|
||||||
|
username="docs-user",
|
||||||
|
email="docs@example.com",
|
||||||
|
password_hash="x",
|
||||||
|
role=role,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
user.gatekeeper_groups = groups or []
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_json(path: str, user: User | None = None):
|
||||||
|
if user is not None:
|
||||||
|
async def override_user():
|
||||||
|
return user
|
||||||
|
|
||||||
|
app.dependency_overrides[docs_api.get_optional_current_user] = override_user
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
return await client.get(path)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_catalog_only_for_anonymous_user():
|
||||||
|
response = await get_json("/api/v1/docs/catalog")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
items = response.json()["items"]
|
||||||
|
assert {item["access"] for item in items} == {"public"}
|
||||||
|
assert {item["slug"] for item in items if item["lang"] == "zh"} == {
|
||||||
|
"overview",
|
||||||
|
"quickstart",
|
||||||
|
"manual",
|
||||||
|
"location-pipeline-user",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anonymous_can_read_public_doc():
|
||||||
|
response = await get_json("/api/v1/docs/zh/quickstart")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["access"] == "public"
|
||||||
|
assert "快速开始" in response.json()["markdown"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anonymous_protected_doc_requires_authentication():
|
||||||
|
response = await get_json("/api/v1/docs/zh/backend-collectors")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_viewer_without_group_cannot_read_developer_doc():
|
||||||
|
response = await get_json(
|
||||||
|
"/api/v1/docs/zh/backend-collectors",
|
||||||
|
make_user(role="viewer"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_developer_group_can_read_developer_but_not_admin_doc():
|
||||||
|
user = make_user(role="viewer", groups=["docs_developer"])
|
||||||
|
|
||||||
|
developer_response = await get_json("/api/v1/docs/zh/backend-collectors", user)
|
||||||
|
admin_response = await get_json("/api/v1/docs/zh/backend-system-service-control", user)
|
||||||
|
|
||||||
|
assert developer_response.status_code == 200
|
||||||
|
assert developer_response.json()["access"] == "docs_developer"
|
||||||
|
assert admin_response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_and_super_admin_can_read_admin_docs():
|
||||||
|
admin_response = await get_json(
|
||||||
|
"/api/v1/docs/zh/backend-system-service-control",
|
||||||
|
make_user(role="admin"),
|
||||||
|
)
|
||||||
|
super_admin_response = await get_json(
|
||||||
|
"/api/v1/docs/zh/backend-system-service-control",
|
||||||
|
make_user(role="super_admin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert admin_response.status_code == 200
|
||||||
|
assert super_admin_response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_language_slug_and_path_traversal_do_not_read_files():
|
||||||
|
bad_lang = await get_json("/api/v1/docs/fr/quickstart")
|
||||||
|
bad_slug = await get_json("/api/v1/docs/zh/not-a-doc")
|
||||||
|
traversal = await get_json("/api/v1/docs/zh/..%2Fmanual")
|
||||||
|
|
||||||
|
assert bad_lang.status_code == 404
|
||||||
|
assert bad_slug.status_code == 404
|
||||||
|
assert traversal.status_code == 404
|
||||||
429
backend/tests/test_location_pipeline.py
Normal file
429
backend/tests/test_location_pipeline.py
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
"""Tests for the shared location resolution pipeline.
|
||||||
|
|
||||||
|
Validates the abstraction itself: the protocol contract, the orchestrator,
|
||||||
|
each built-in resolver, and the pluggability promise (a custom resolver can
|
||||||
|
be slotted in without touching consumers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.location import (
|
||||||
|
InheritFromAnotherEntityResolver,
|
||||||
|
LocationCandidate,
|
||||||
|
LocationPipeline,
|
||||||
|
LocationQuery,
|
||||||
|
NominatimResolver,
|
||||||
|
RegistryResolver,
|
||||||
|
ResolverOutput,
|
||||||
|
SourceCoordinatesResolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test fixtures ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_registry(tmp_path: Path) -> Path:
|
||||||
|
payload = {
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"canonical_name": "Test Site Alpha",
|
||||||
|
"aliases": ["alpha", "alpha-one", "Acme HQ"],
|
||||||
|
"operator": "Acme Networks",
|
||||||
|
"site": "Acme HQ",
|
||||||
|
"city": "Lyon",
|
||||||
|
"country": "France",
|
||||||
|
"latitude": 45.764,
|
||||||
|
"longitude": 4.8357,
|
||||||
|
"precision": "site",
|
||||||
|
"confidence": 0.92,
|
||||||
|
"source_note": "Test fixture",
|
||||||
|
"verified_at": "2026-05-08",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "Test Site Bravo",
|
||||||
|
"aliases": ["bravo"],
|
||||||
|
"operator": "Acme Networks",
|
||||||
|
"site": "Bravo POP",
|
||||||
|
"city": "Berlin",
|
||||||
|
"country": "Germany",
|
||||||
|
"latitude": 52.52,
|
||||||
|
"longitude": 13.405,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.85,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"city_fallbacks": [
|
||||||
|
{
|
||||||
|
"city": "Bhutan-Capital",
|
||||||
|
"country": "Bhutan",
|
||||||
|
"latitude": 27.4728,
|
||||||
|
"longitude": 89.639,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.5,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
path = tmp_path / "registry.json"
|
||||||
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ── SourceCoordinatesResolver ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_coordinates_resolver_passes_through_valid_coordinates():
|
||||||
|
resolver = SourceCoordinatesResolver()
|
||||||
|
query = LocationQuery(
|
||||||
|
name="Acme HQ",
|
||||||
|
source_latitude=45.0,
|
||||||
|
source_longitude=4.0,
|
||||||
|
country="France",
|
||||||
|
)
|
||||||
|
output = resolver.resolve(query)
|
||||||
|
assert len(output.candidates) == 1
|
||||||
|
candidate = output.candidates[0]
|
||||||
|
assert candidate.latitude == 45.0
|
||||||
|
assert candidate.longitude == 4.0
|
||||||
|
assert candidate.precision == "precise"
|
||||||
|
assert candidate.source == "source_coordinates"
|
||||||
|
assert candidate.needs_confirmation is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_coordinates_resolver_skips_zero_coordinates():
|
||||||
|
resolver = SourceCoordinatesResolver()
|
||||||
|
output = resolver.resolve(
|
||||||
|
LocationQuery(name="X", source_latitude=0.0, source_longitude=0.0)
|
||||||
|
)
|
||||||
|
assert output.candidates == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_coordinates_resolver_skips_when_missing():
|
||||||
|
resolver = SourceCoordinatesResolver()
|
||||||
|
output = resolver.resolve(LocationQuery(name="X"))
|
||||||
|
assert output.candidates == ()
|
||||||
|
|
||||||
|
|
||||||
|
# ── RegistryResolver ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_resolver_matches_alias(tmp_registry):
|
||||||
|
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||||
|
resolver.reload()
|
||||||
|
output = resolver.resolve(
|
||||||
|
LocationQuery(name="alpha", country="France")
|
||||||
|
)
|
||||||
|
candidates = list(output.candidates)
|
||||||
|
assert candidates, "should match registry entry"
|
||||||
|
assert any(c.matched_location_name == "Test Site Alpha" for c in candidates)
|
||||||
|
alpha = next(c for c in candidates if c.matched_location_name == "Test Site Alpha")
|
||||||
|
assert alpha.precision == "site"
|
||||||
|
assert alpha.confidence == pytest.approx(0.92)
|
||||||
|
assert alpha.needs_confirmation is True
|
||||||
|
assert alpha.location_verified_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_resolver_filters_country_mismatch(tmp_registry):
|
||||||
|
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||||
|
resolver.reload()
|
||||||
|
# alpha is in France; query says Spain → should reject
|
||||||
|
output = resolver.resolve(
|
||||||
|
LocationQuery(name="alpha", country="Spain")
|
||||||
|
)
|
||||||
|
assert all(
|
||||||
|
c.matched_location_name != "Test Site Alpha" for c in output.candidates
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_resolver_emits_city_fallback_candidate(tmp_registry):
|
||||||
|
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||||
|
resolver.reload()
|
||||||
|
output = resolver.resolve(
|
||||||
|
LocationQuery(city="Bhutan-Capital", country="Bhutan")
|
||||||
|
)
|
||||||
|
candidates = list(output.candidates)
|
||||||
|
assert candidates, "city fallback should fire"
|
||||||
|
assert any(c.source == "local_registry_city" for c in candidates)
|
||||||
|
|
||||||
|
|
||||||
|
# ── NominatimResolver ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_nominatim_resolver_calls_geocoder_with_plan_queries():
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_geocoder(query: str):
|
||||||
|
calls.append(query)
|
||||||
|
return {
|
||||||
|
"lat": "12.34",
|
||||||
|
"lon": "56.78",
|
||||||
|
"display_name": "Test City, Country",
|
||||||
|
"address": {"city": "Test City", "country": "Country"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def plan(query: LocationQuery):
|
||||||
|
return [
|
||||||
|
("primary query", ("name",)),
|
||||||
|
("secondary query", ("city",)),
|
||||||
|
]
|
||||||
|
|
||||||
|
resolver = NominatimResolver(
|
||||||
|
query_plan_builder=plan,
|
||||||
|
geocoder=fake_geocoder,
|
||||||
|
)
|
||||||
|
output = resolver.resolve(LocationQuery(name="X", country="Country"))
|
||||||
|
assert calls == ["primary query", "secondary query"]
|
||||||
|
assert output.attempted_queries == ("primary query", "secondary query")
|
||||||
|
assert len(output.candidates) == 2
|
||||||
|
assert all(c.precision == "city" for c in output.candidates)
|
||||||
|
assert all(c.needs_confirmation for c in output.candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nominatim_resolver_skips_when_geocoder_returns_none():
|
||||||
|
resolver = NominatimResolver(
|
||||||
|
query_plan_builder=lambda q: [("only", ("name",))],
|
||||||
|
geocoder=lambda q: None,
|
||||||
|
)
|
||||||
|
output = resolver.resolve(LocationQuery(name="X"))
|
||||||
|
assert output.candidates == ()
|
||||||
|
assert output.attempted_queries == ("only",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nominatim_resolver_swallows_exceptions_per_query():
|
||||||
|
def boom(query):
|
||||||
|
raise RuntimeError("network down")
|
||||||
|
|
||||||
|
resolver = NominatimResolver(
|
||||||
|
query_plan_builder=lambda q: [("a", ()), ("b", ())],
|
||||||
|
geocoder=boom,
|
||||||
|
)
|
||||||
|
output = resolver.resolve(LocationQuery(name="X"))
|
||||||
|
assert output.candidates == ()
|
||||||
|
assert output.attempted_queries == ("a", "b")
|
||||||
|
|
||||||
|
|
||||||
|
# ── InheritFromAnotherEntityResolver ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_inherit_resolver_returns_provided_candidate():
|
||||||
|
sentinel = LocationCandidate(
|
||||||
|
latitude=10.0,
|
||||||
|
longitude=20.0,
|
||||||
|
display_name="Inherited",
|
||||||
|
precision="city",
|
||||||
|
confidence=0.7,
|
||||||
|
query="inherit::test",
|
||||||
|
source="inherited",
|
||||||
|
source_note=None,
|
||||||
|
matched_fields=("collector",),
|
||||||
|
needs_confirmation=False,
|
||||||
|
)
|
||||||
|
resolver = InheritFromAnotherEntityResolver(
|
||||||
|
source_lookup=lambda q: sentinel
|
||||||
|
)
|
||||||
|
output = resolver.resolve(LocationQuery(name="X"))
|
||||||
|
assert output.candidates == (sentinel,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inherit_resolver_skips_when_lookup_returns_none():
|
||||||
|
resolver = InheritFromAnotherEntityResolver(source_lookup=lambda q: None)
|
||||||
|
assert resolver.resolve(LocationQuery(name="X")).candidates == ()
|
||||||
|
|
||||||
|
|
||||||
|
# ── LocationPipeline orchestration ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_aggregates_candidates_across_resolvers(tmp_registry):
|
||||||
|
pipeline = LocationPipeline(
|
||||||
|
[
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
RegistryResolver(registry_path=tmp_registry),
|
||||||
|
NominatimResolver(
|
||||||
|
query_plan_builder=lambda q: [("nominatim attempt", ("name",))],
|
||||||
|
geocoder=lambda q: {
|
||||||
|
"lat": "1.0",
|
||||||
|
"lon": "2.0",
|
||||||
|
"display_name": "Online City",
|
||||||
|
"address": {"city": "Online City", "country": "France"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
pipeline.resolvers[1].reload()
|
||||||
|
candidates, attempted = pipeline.collect_candidates(
|
||||||
|
LocationQuery(
|
||||||
|
name="alpha",
|
||||||
|
country="France",
|
||||||
|
source_latitude=44.0,
|
||||||
|
source_longitude=5.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sources = {c.source for c in candidates}
|
||||||
|
assert "source_coordinates" in sources
|
||||||
|
assert "local_registry" in sources
|
||||||
|
assert "nominatim_online_geocode" in sources
|
||||||
|
assert "nominatim attempt" in attempted
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_dedupes_by_source_and_coordinates():
|
||||||
|
same = LocationCandidate(
|
||||||
|
latitude=1.0,
|
||||||
|
longitude=2.0,
|
||||||
|
display_name="dup",
|
||||||
|
precision="city",
|
||||||
|
confidence=0.5,
|
||||||
|
query="x",
|
||||||
|
source="dup_source",
|
||||||
|
source_note=None,
|
||||||
|
matched_fields=(),
|
||||||
|
needs_confirmation=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _DupResolver:
|
||||||
|
name = "dup_source"
|
||||||
|
|
||||||
|
def resolve(self, query):
|
||||||
|
return ResolverOutput(candidates=(same, same))
|
||||||
|
|
||||||
|
pipeline = LocationPipeline([_DupResolver()])
|
||||||
|
candidates, _ = pipeline.collect_candidates(LocationQuery(name="X"))
|
||||||
|
assert len(candidates) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_short_aliases_do_not_match_inside_larger_tokens(tmp_path: Path):
|
||||||
|
registry_path = tmp_path / "registry.json"
|
||||||
|
registry_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"canonical_name": "Aurora",
|
||||||
|
"aliases": ["Aurora", "ANL"],
|
||||||
|
"site": "DOE/SC/Argonne National Laboratory",
|
||||||
|
"country": "United States",
|
||||||
|
"city": "Lemont",
|
||||||
|
"latitude": 41.713,
|
||||||
|
"longitude": -87.982,
|
||||||
|
"precision": "site",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical_name": "Venado",
|
||||||
|
"aliases": ["Venado"],
|
||||||
|
"site": "DOE/NNSA/LANL",
|
||||||
|
"country": "United States",
|
||||||
|
"city": "Los Alamos",
|
||||||
|
"latitude": 35.8443,
|
||||||
|
"longitude": -106.2872,
|
||||||
|
"precision": "site",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"city_fallbacks": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
resolver = RegistryResolver(registry_path=registry_path)
|
||||||
|
resolver.reload()
|
||||||
|
|
||||||
|
output = resolver.resolve(
|
||||||
|
LocationQuery(
|
||||||
|
name="Venado",
|
||||||
|
country="United States",
|
||||||
|
extra={"site": "DOE/NNSA/LANL"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(output.candidates) == 1
|
||||||
|
assert output.candidates[0].matched_location_name == "Venado"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_resolve_best_returns_highest_priority():
|
||||||
|
online = LocationCandidate(
|
||||||
|
latitude=10.0,
|
||||||
|
longitude=20.0,
|
||||||
|
display_name="online",
|
||||||
|
precision="city",
|
||||||
|
confidence=0.9,
|
||||||
|
query="x",
|
||||||
|
source="nominatim_online_geocode",
|
||||||
|
source_note=None,
|
||||||
|
matched_fields=(),
|
||||||
|
needs_confirmation=True,
|
||||||
|
)
|
||||||
|
source = LocationCandidate(
|
||||||
|
latitude=11.0,
|
||||||
|
longitude=21.0,
|
||||||
|
display_name="src",
|
||||||
|
precision="precise",
|
||||||
|
confidence=1.0,
|
||||||
|
query="x",
|
||||||
|
source="source_coordinates",
|
||||||
|
source_note=None,
|
||||||
|
matched_fields=(),
|
||||||
|
needs_confirmation=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _StubResolver:
|
||||||
|
def __init__(self, c, name):
|
||||||
|
self._c = c
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def resolve(self, query):
|
||||||
|
return ResolverOutput(candidates=(self._c,))
|
||||||
|
|
||||||
|
pipeline = LocationPipeline(
|
||||||
|
[
|
||||||
|
_StubResolver(online, "online"),
|
||||||
|
_StubResolver(source, "src"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = pipeline.resolve_best(LocationQuery(name="X"))
|
||||||
|
assert result.location is source, "source_coordinates should beat nominatim"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_returns_diagnostic_when_nothing_resolves():
|
||||||
|
pipeline = LocationPipeline([SourceCoordinatesResolver()])
|
||||||
|
result = pipeline.resolve_best(LocationQuery(name="X", country="Bhutan"))
|
||||||
|
assert result.location is None
|
||||||
|
assert result.diagnostic is not None
|
||||||
|
assert result.diagnostic.country == "Bhutan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pluggability_custom_resolver_works_without_changing_pipeline():
|
||||||
|
"""Validates the abstraction promise: a new algorithm = a new class."""
|
||||||
|
|
||||||
|
class _PeeringDBStubResolver:
|
||||||
|
name = "fake_peeringdb"
|
||||||
|
|
||||||
|
def resolve(self, query):
|
||||||
|
asn = (query.extra or {}).get("asn")
|
||||||
|
if asn != 174:
|
||||||
|
return ResolverOutput()
|
||||||
|
return ResolverOutput(
|
||||||
|
candidates=(
|
||||||
|
LocationCandidate(
|
||||||
|
latitude=1.0,
|
||||||
|
longitude=2.0,
|
||||||
|
display_name="Cogent HQ",
|
||||||
|
precision="site",
|
||||||
|
confidence=0.8,
|
||||||
|
query=f"peeringdb::{asn}",
|
||||||
|
source="peeringdb_stub",
|
||||||
|
source_note="Stub for testing",
|
||||||
|
matched_fields=("asn",),
|
||||||
|
needs_confirmation=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline = LocationPipeline([_PeeringDBStubResolver()])
|
||||||
|
candidates, _ = pipeline.collect_candidates(
|
||||||
|
LocationQuery(name="X", extra={"asn": 174})
|
||||||
|
)
|
||||||
|
assert len(candidates) == 1
|
||||||
|
assert candidates[0].source == "peeringdb_stub"
|
||||||
161
backend/tests/test_vessel_aggregation_strategy.py
Normal file
161
backend/tests/test_vessel_aggregation_strategy.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Tests for the v4 vessel_ais aggregation strategy."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.vessel import AISRawObservation
|
||||||
|
from app.services.vessel_aggregation_strategy import (
|
||||||
|
DEFAULT_STRATEGY,
|
||||||
|
StrategyValidationError,
|
||||||
|
validate_strategy,
|
||||||
|
)
|
||||||
|
from app.services.vessel_ais_aggregation import aggregate_vessel_observations
|
||||||
|
|
||||||
|
|
||||||
|
def _obs(*, source: str, mmsi: int, observed_at: datetime, **payload) -> AISRawObservation:
|
||||||
|
payload = {"mmsi": mmsi, "lat": 50.0, "lon": 10.0, **payload}
|
||||||
|
delivery_mode = "realtime_stream" if source == "aisstream_vessels" else "polling"
|
||||||
|
transport = "websocket" if source == "aisstream_vessels" else "http"
|
||||||
|
return AISRawObservation(
|
||||||
|
target_schema="vessel_ais",
|
||||||
|
source=source,
|
||||||
|
entity_key=str(mmsi),
|
||||||
|
delivery_mode=delivery_mode,
|
||||||
|
transport=transport,
|
||||||
|
message_type="PositionReport",
|
||||||
|
observation_hash=f"{source}:{mmsi}:{observed_at.isoformat()}",
|
||||||
|
observed_at=observed_at,
|
||||||
|
collected_at=observed_at,
|
||||||
|
normalized_payload=payload,
|
||||||
|
raw_payload=payload,
|
||||||
|
quality_flags=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_unknown_field():
|
||||||
|
with pytest.raises(StrategyValidationError, match="unknown vessel_ais field"):
|
||||||
|
validate_strategy({"vessel_ais": {"field_rules": {"definitely_not_a_field": {"mode": "newest"}}}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_dynamic_lock_without_flag():
|
||||||
|
with pytest.raises(StrategyValidationError, match="allow_dynamic_lock"):
|
||||||
|
validate_strategy(
|
||||||
|
{
|
||||||
|
"vessel_ais": {
|
||||||
|
"field_rules": {"lat": {"mode": "source_priority"}},
|
||||||
|
"allow_dynamic_lock": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_allows_dynamic_lock_with_flag():
|
||||||
|
normalized = validate_strategy(
|
||||||
|
{
|
||||||
|
"version": 0,
|
||||||
|
"vessel_ais": {
|
||||||
|
"field_rules": {"lat": {"mode": "source_priority", "source_priority": ["barentswatch_vessels"]}},
|
||||||
|
"allow_dynamic_lock": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert normalized["vessel_ais"]["field_rules"]["lat"]["mode"] == "source_priority"
|
||||||
|
assert normalized["version"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_increments_version():
|
||||||
|
first = validate_strategy({"version": 5, "vessel_ais": {}})
|
||||||
|
assert first["version"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_strategy_field_rule_promotes_specific_source(monkeypatch):
|
||||||
|
now = datetime(2026, 5, 4, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
obs_a = _obs(
|
||||||
|
source="aisstream_vessels",
|
||||||
|
mmsi=257123000,
|
||||||
|
observed_at=now,
|
||||||
|
name="AISSTREAM ONE",
|
||||||
|
vessel_type_name="Cargo",
|
||||||
|
)
|
||||||
|
obs_b = _obs(
|
||||||
|
source="barentswatch_vessels",
|
||||||
|
mmsi=257123000,
|
||||||
|
observed_at=now - timedelta(seconds=1),
|
||||||
|
name="BARENTSWATCH ONE",
|
||||||
|
vessel_type_name="Cargo",
|
||||||
|
)
|
||||||
|
|
||||||
|
strategy = {
|
||||||
|
"version": 7,
|
||||||
|
"vessel_ais": {
|
||||||
|
"source_priority": [],
|
||||||
|
"field_rules": {
|
||||||
|
"name": {"mode": "source_priority", "source_priority": ["barentswatch_vessels", "aisstream_vessels"]},
|
||||||
|
},
|
||||||
|
"freshness": {"realtime_stream_seconds": 0, "polling_seconds": 0},
|
||||||
|
"allow_dynamic_lock": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
db = AsyncMock()
|
||||||
|
vessels = await aggregate_vessel_observations(
|
||||||
|
db,
|
||||||
|
[obs_a, obs_b],
|
||||||
|
write_conflicts=False,
|
||||||
|
strategy=strategy,
|
||||||
|
)
|
||||||
|
assert len(vessels) == 1
|
||||||
|
vessel = vessels[0]
|
||||||
|
assert vessel["name"] == "BARENTSWATCH ONE"
|
||||||
|
assert vessel["field_sources"]["name"] == "barentswatch_vessels"
|
||||||
|
assert vessel["selected_reasons"]["name"] == "source_priority"
|
||||||
|
assert vessel["aggregation_strategy_version"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_strategy_freshness_falls_back_to_polling_when_realtime_stale():
|
||||||
|
now = datetime(2026, 5, 4, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
stale_realtime = _obs(
|
||||||
|
source="aisstream_vessels",
|
||||||
|
mmsi=257123000,
|
||||||
|
observed_at=now - timedelta(hours=1),
|
||||||
|
lat=58.0,
|
||||||
|
lon=10.0,
|
||||||
|
)
|
||||||
|
fresh_polling = _obs(
|
||||||
|
source="barentswatch_vessels",
|
||||||
|
mmsi=257123000,
|
||||||
|
observed_at=now - timedelta(seconds=30),
|
||||||
|
lat=60.0,
|
||||||
|
lon=11.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
strategy = {
|
||||||
|
"version": 1,
|
||||||
|
"vessel_ais": {
|
||||||
|
"source_priority": ["aisstream_vessels", "barentswatch_vessels"],
|
||||||
|
"field_rules": {},
|
||||||
|
"freshness": {"realtime_stream_seconds": 900, "polling_seconds": 7200},
|
||||||
|
"allow_dynamic_lock": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
db = AsyncMock()
|
||||||
|
vessels = await aggregate_vessel_observations(
|
||||||
|
db,
|
||||||
|
[stale_realtime, fresh_polling],
|
||||||
|
write_conflicts=False,
|
||||||
|
strategy=strategy,
|
||||||
|
)
|
||||||
|
assert vessels[0]["field_sources"]["lat"] == "barentswatch_vessels"
|
||||||
|
assert vessels[0]["lat"] == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_strategy_is_stable():
|
||||||
|
assert DEFAULT_STRATEGY["vessel_ais"]["allow_dynamic_lock"] is False
|
||||||
|
assert "freshness" in DEFAULT_STRATEGY["vessel_ais"]
|
||||||
155
backend/tests/test_vessel_enrichment.py
Normal file
155
backend/tests/test_vessel_enrichment.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""Tests for v5 enrichment + conflict promote-to-rule."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.vessel import AISConflictRecord, AISRawObservation
|
||||||
|
from app.models.vessel_enrichment import VesselMediaEnrichment, VesselProfileEnrichment
|
||||||
|
from app.services.vessel_ais_aggregation import aggregate_vessel_observations
|
||||||
|
from app.services.vessel_enrichment import (
|
||||||
|
_apply_upsert,
|
||||||
|
get_vessel_enrichment_bundle,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _StoreSession:
|
||||||
|
"""Minimal AsyncSession stand-in that tracks mmsi-keyed enrichment + a strategy."""
|
||||||
|
|
||||||
|
def __init__(self, *, profile=None, media=None, conflicts=None):
|
||||||
|
self.profile = profile
|
||||||
|
self.media = media
|
||||||
|
self.conflicts = list(conflicts or [])
|
||||||
|
self.added: list = []
|
||||||
|
self.committed = False
|
||||||
|
|
||||||
|
async def get(self, model, key):
|
||||||
|
if model is VesselProfileEnrichment:
|
||||||
|
return self.profile if self.profile and self.profile.mmsi == key else None
|
||||||
|
if model is VesselMediaEnrichment:
|
||||||
|
return self.media if self.media and self.media.mmsi == key else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enrichment_bundle_filters_expired_records():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
fresh = VesselProfileEnrichment(
|
||||||
|
mmsi=257123000,
|
||||||
|
source="local_cache",
|
||||||
|
payload={"vessel_subtype": "Container"},
|
||||||
|
fetched_at=now - timedelta(hours=1),
|
||||||
|
expires_at=now + timedelta(days=7),
|
||||||
|
confidence=0.9,
|
||||||
|
)
|
||||||
|
expired_media = VesselMediaEnrichment(
|
||||||
|
mmsi=257123000,
|
||||||
|
source="vesselfinder",
|
||||||
|
payload={"images": ["https://example.com/a.jpg"]},
|
||||||
|
fetched_at=now - timedelta(days=30),
|
||||||
|
expires_at=now - timedelta(days=1),
|
||||||
|
)
|
||||||
|
db = _StoreSession(profile=fresh, media=expired_media)
|
||||||
|
|
||||||
|
bundle = await get_vessel_enrichment_bundle(db, 257123000)
|
||||||
|
|
||||||
|
assert bundle["profile"]["payload"]["vessel_subtype"] == "Container"
|
||||||
|
assert bundle["media"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_upsert_preserves_payload_and_metadata():
|
||||||
|
record = VesselProfileEnrichment(mmsi=257123000)
|
||||||
|
out = _apply_upsert(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
"source": "vesselfinder",
|
||||||
|
"payload": {"vessel_subtype": "Container", "operator": "Maersk"},
|
||||||
|
"expires_at": "2026-12-31T00:00:00Z",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"reference_url": "https://www.vesselfinder.com/vessels/257123000",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert out["payload"]["operator"] == "Maersk"
|
||||||
|
assert out["confidence"] == 0.85
|
||||||
|
assert record.reference_url == "https://www.vesselfinder.com/vessels/257123000"
|
||||||
|
assert record.expires_at is not None
|
||||||
|
assert record.expires_at.year == 2026
|
||||||
|
|
||||||
|
|
||||||
|
def _obs(*, source: str, mmsi: int, observed_at, **payload) -> AISRawObservation:
|
||||||
|
payload = {"mmsi": mmsi, "lat": 60.0, "lon": 5.0, **payload}
|
||||||
|
delivery_mode = "realtime_stream" if source == "aisstream_vessels" else "polling"
|
||||||
|
transport = "websocket" if source == "aisstream_vessels" else "http"
|
||||||
|
return AISRawObservation(
|
||||||
|
target_schema="vessel_ais",
|
||||||
|
source=source,
|
||||||
|
entity_key=str(mmsi),
|
||||||
|
delivery_mode=delivery_mode,
|
||||||
|
transport=transport,
|
||||||
|
message_type="PositionReport",
|
||||||
|
observation_hash=f"{source}:{mmsi}:{observed_at.isoformat()}",
|
||||||
|
observed_at=observed_at,
|
||||||
|
collected_at=observed_at,
|
||||||
|
normalized_payload=payload,
|
||||||
|
raw_payload=payload,
|
||||||
|
quality_flags=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_promoted_rule_wins_during_aggregation():
|
||||||
|
"""Simulate the strategy that conflict-promote-to-rule writes."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
obs_a = _obs(
|
||||||
|
source="aisstream_vessels",
|
||||||
|
mmsi=257111000,
|
||||||
|
observed_at=now,
|
||||||
|
name="STREAM NAME",
|
||||||
|
vessel_type_name="Cargo",
|
||||||
|
)
|
||||||
|
obs_b = _obs(
|
||||||
|
source="barentswatch_vessels",
|
||||||
|
mmsi=257111000,
|
||||||
|
observed_at=now - timedelta(seconds=1),
|
||||||
|
name="REST NAME",
|
||||||
|
vessel_type_name="Cargo",
|
||||||
|
)
|
||||||
|
promoted_strategy = {
|
||||||
|
"version": 99,
|
||||||
|
"vessel_ais": {
|
||||||
|
"source_priority": [],
|
||||||
|
"field_rules": {
|
||||||
|
"name": {"mode": "source_priority", "source_priority": ["barentswatch_vessels"]}
|
||||||
|
},
|
||||||
|
"freshness": {"realtime_stream_seconds": 0, "polling_seconds": 0},
|
||||||
|
"allow_dynamic_lock": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
db = AsyncMock()
|
||||||
|
vessels = await aggregate_vessel_observations(
|
||||||
|
db,
|
||||||
|
[obs_a, obs_b],
|
||||||
|
write_conflicts=False,
|
||||||
|
strategy=promoted_strategy,
|
||||||
|
)
|
||||||
|
assert vessels[0]["name"] == "REST NAME"
|
||||||
|
assert vessels[0]["selected_reasons"]["name"] == "source_priority"
|
||||||
|
assert vessels[0]["aggregation_strategy_version"] == 99
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflict_record_holds_selected_source():
|
||||||
|
"""Sanity: the promote-to-rule API reads selected_source from this column."""
|
||||||
|
record = AISConflictRecord(
|
||||||
|
target_schema="vessel_ais",
|
||||||
|
entity_key="257111000",
|
||||||
|
field="name",
|
||||||
|
candidates={"a": "X", "b": "Y"},
|
||||||
|
selected_source="barentswatch_vessels",
|
||||||
|
selected_value="Y",
|
||||||
|
selected_reason="delivery_mode_priority",
|
||||||
|
)
|
||||||
|
serialized = record.to_dict()
|
||||||
|
assert serialized["selected_source"] == "barentswatch_vessels"
|
||||||
|
assert serialized["field"] == "name"
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.api.v1 import visualization
|
||||||
from app.api.v1.visualization import convert_vessels_to_geojson
|
from app.api.v1.visualization import convert_vessels_to_geojson
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.vessel import VesselPosition, VesselStatic
|
from app.models.vessel import AISRawObservation, VesselPosition, VesselStatic
|
||||||
from app.services import barentswatch
|
from app.services import barentswatch
|
||||||
|
from app.services.collectors.aisstream import AISStreamCollector
|
||||||
from app.services.collectors.vessel_ais import VesselAISCollector
|
from app.services.collectors.vessel_ais import VesselAISCollector
|
||||||
|
from app.services.vessel_ais_aggregation import (
|
||||||
|
aggregate_vessel_observations,
|
||||||
|
build_field_conflict_candidates,
|
||||||
|
build_observation_hash,
|
||||||
|
record_vessel_ais_observation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_vessel_collector_transforms_barentswatch_like_records():
|
def test_vessel_collector_transforms_barentswatch_like_records():
|
||||||
@@ -35,6 +44,380 @@ def test_vessel_collector_transforms_barentswatch_like_records():
|
|||||||
assert records[0]["lat"] == pytest.approx(59.91)
|
assert records[0]["lat"] == pytest.approx(59.91)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vessel_observation_hash_is_stable_for_same_payload():
|
||||||
|
observed_at = datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"mmsi": 257123000,
|
||||||
|
"lat": 59.91,
|
||||||
|
"lon": 10.73,
|
||||||
|
"received_at": observed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
first = build_observation_hash(
|
||||||
|
source="barentswatch_vessels",
|
||||||
|
entity_key="257123000",
|
||||||
|
message_type="PositionReport",
|
||||||
|
observed_at=observed_at,
|
||||||
|
normalized_payload=payload,
|
||||||
|
)
|
||||||
|
second = build_observation_hash(
|
||||||
|
source="barentswatch_vessels",
|
||||||
|
entity_key="257123000",
|
||||||
|
message_type="PositionReport",
|
||||||
|
observed_at=observed_at,
|
||||||
|
normalized_payload=dict(reversed(payload.items())),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert len(first) == 64
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_vessel_ais_observation_skips_existing_hash():
|
||||||
|
observed_at = datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return 123
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self):
|
||||||
|
self.added = []
|
||||||
|
|
||||||
|
async def execute(self, _stmt):
|
||||||
|
return _Result()
|
||||||
|
|
||||||
|
def add(self, item):
|
||||||
|
self.added.append(item)
|
||||||
|
|
||||||
|
db = _Session()
|
||||||
|
observation = await record_vessel_ais_observation(
|
||||||
|
db,
|
||||||
|
source="barentswatch_vessels",
|
||||||
|
normalized_payload={
|
||||||
|
"mmsi": 257123000,
|
||||||
|
"lat": 59.91,
|
||||||
|
"lon": 10.73,
|
||||||
|
"received_at": observed_at,
|
||||||
|
},
|
||||||
|
delivery_mode="polling",
|
||||||
|
transport="http",
|
||||||
|
observed_at=observed_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert observation is None
|
||||||
|
assert db.added == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_field_conflict_candidates_from_raw_observations():
|
||||||
|
observations = [
|
||||||
|
AISRawObservation(
|
||||||
|
source="barentswatch_vessels",
|
||||||
|
normalized_payload={"name": "OSLO TRADER", "flag": "NO"},
|
||||||
|
),
|
||||||
|
AISRawObservation(
|
||||||
|
source="aisstream_vessels",
|
||||||
|
normalized_payload={"name": "OSLO TRADER II", "flag": "NO"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
conflicts = build_field_conflict_candidates(observations)
|
||||||
|
|
||||||
|
assert conflicts == [
|
||||||
|
{
|
||||||
|
"field": "name",
|
||||||
|
"candidates": {
|
||||||
|
"aisstream_vessels": "OSLO TRADER II",
|
||||||
|
"barentswatch_vessels": "OSLO TRADER",
|
||||||
|
},
|
||||||
|
"status": "candidate",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_aggregate_vessel_observations_prefers_realtime_and_records_conflict():
|
||||||
|
observed_at = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self):
|
||||||
|
self.added = []
|
||||||
|
|
||||||
|
async def execute(self, _stmt):
|
||||||
|
return _Result()
|
||||||
|
|
||||||
|
def add(self, item):
|
||||||
|
self.added.append(item)
|
||||||
|
|
||||||
|
db = _Session()
|
||||||
|
observations = [
|
||||||
|
AISRawObservation(
|
||||||
|
id=1,
|
||||||
|
source="barentswatch_vessels",
|
||||||
|
entity_key="257123000",
|
||||||
|
delivery_mode="polling",
|
||||||
|
transport="http",
|
||||||
|
observed_at=observed_at,
|
||||||
|
collected_at=observed_at,
|
||||||
|
normalized_payload={
|
||||||
|
"mmsi": 257123000,
|
||||||
|
"name": "OSLO TRADER",
|
||||||
|
"lat": 59.91,
|
||||||
|
"lon": 10.73,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AISRawObservation(
|
||||||
|
id=2,
|
||||||
|
source="aisstream_vessels",
|
||||||
|
entity_key="257123000",
|
||||||
|
delivery_mode="realtime_stream",
|
||||||
|
transport="websocket",
|
||||||
|
observed_at=observed_at + timedelta(seconds=10),
|
||||||
|
collected_at=observed_at + timedelta(seconds=10),
|
||||||
|
normalized_payload={
|
||||||
|
"mmsi": 257123000,
|
||||||
|
"vessel_type": 79,
|
||||||
|
"lat": 59.92,
|
||||||
|
"lon": 10.74,
|
||||||
|
},
|
||||||
|
raw_payload={"MetaData": {"ShipName": "OSLO TRADER II "}},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
vessels = await aggregate_vessel_observations(db, observations)
|
||||||
|
|
||||||
|
assert vessels[0]["lat"] == pytest.approx(59.92)
|
||||||
|
assert vessels[0]["field_sources"]["lat"] == "aisstream_vessels"
|
||||||
|
assert vessels[0]["name"] == "OSLO TRADER II"
|
||||||
|
assert vessels[0]["vessel_type_name"] == "Cargo"
|
||||||
|
assert vessels[0]["source_summary"]["aisstream_vessels"]["observation_count"] == 1
|
||||||
|
assert vessels[0]["source_summary"]["barentswatch_vessels"]["delivery_mode"] == "polling"
|
||||||
|
assert vessels[0]["conflict_count"] == 0
|
||||||
|
assert db.added == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vessel_collector_writes_raw_observations_only(monkeypatch):
|
||||||
|
collector = VesselAISCollector()
|
||||||
|
collector.update_progress = AsyncMock()
|
||||||
|
record_observation = AsyncMock()
|
||||||
|
update_health = AsyncMock()
|
||||||
|
broadcast_custom = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.collectors.vessel_ais.record_vessel_ais_observation",
|
||||||
|
record_observation,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.collectors.vessel_ais.update_ais_source_health",
|
||||||
|
update_health,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.collectors.vessel_ais.broadcaster.broadcast_custom",
|
||||||
|
broadcast_custom,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self):
|
||||||
|
self.added = []
|
||||||
|
self.committed = False
|
||||||
|
|
||||||
|
async def get(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add(self, item):
|
||||||
|
self.added.append(item)
|
||||||
|
|
||||||
|
async def execute(self, _stmt):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
db = _Session()
|
||||||
|
observed_at = datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
saved = await collector._save_data(
|
||||||
|
db,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"mmsi": 257123000,
|
||||||
|
"name": "OSLO TRADER",
|
||||||
|
"lat": 59.91,
|
||||||
|
"lon": 10.73,
|
||||||
|
"received_at": observed_at,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert saved == 1
|
||||||
|
assert db.committed is True
|
||||||
|
# BarentsWatch must funnel through the unified AIS pipeline only — no legacy writes.
|
||||||
|
assert not any(isinstance(item, VesselStatic) for item in db.added)
|
||||||
|
assert not any(isinstance(item, VesselPosition) for item in db.added)
|
||||||
|
record_observation.assert_awaited_once()
|
||||||
|
assert record_observation.await_args.kwargs["source"] == "barentswatch_vessels"
|
||||||
|
assert record_observation.await_args.kwargs["normalized_payload"]["mmsi"] == 257123000
|
||||||
|
update_health.assert_awaited_once()
|
||||||
|
broadcast_custom.assert_awaited_once()
|
||||||
|
assert broadcast_custom.await_args.args[0] == "vessels"
|
||||||
|
assert broadcast_custom.await_args.args[1]["action"] == "upsert"
|
||||||
|
assert broadcast_custom.await_args.args[1]["vessels"][0]["mmsi_display"] == "257123000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_aisstream_collector_normalizes_position_report():
|
||||||
|
collector = AISStreamCollector()
|
||||||
|
|
||||||
|
records = collector.transform(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"MessageType": "PositionReport",
|
||||||
|
"MetaData": {
|
||||||
|
"MMSI": 257123000,
|
||||||
|
"ShipName": "OSLO TRADER ",
|
||||||
|
"time_utc": "2026-04-30T12:00:00Z",
|
||||||
|
},
|
||||||
|
"Message": {
|
||||||
|
"PositionReport": {
|
||||||
|
"Latitude": 59.91,
|
||||||
|
"Longitude": 10.73,
|
||||||
|
"Sog": 12.4,
|
||||||
|
"Cog": 214,
|
||||||
|
"TrueHeading": 215,
|
||||||
|
"NavigationalStatus": 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0]["mmsi"] == 257123000
|
||||||
|
assert records[0]["lat"] == pytest.approx(59.91)
|
||||||
|
assert records[0]["name"] == "OSLO TRADER"
|
||||||
|
assert records[0]["_message_type"] == "PositionReport"
|
||||||
|
|
||||||
|
|
||||||
|
def test_aisstream_collector_maps_ship_static_type_name():
|
||||||
|
collector = AISStreamCollector()
|
||||||
|
|
||||||
|
records = collector.transform(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"MessageType": "ShipStaticData",
|
||||||
|
"MetaData": {
|
||||||
|
"MMSI": 257123000,
|
||||||
|
"time_utc": "2026-04-30T12:00:00Z",
|
||||||
|
},
|
||||||
|
"Message": {
|
||||||
|
"ShipStaticData": {
|
||||||
|
"Name": "OSLO TRADER",
|
||||||
|
"Type": 79,
|
||||||
|
"CallSign": "LAAB",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0]["vessel_type"] == 79
|
||||||
|
assert records[0]["vessel_type_name"] == "Cargo"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_aisstream_collector_writes_only_raw_observations(monkeypatch):
|
||||||
|
collector = AISStreamCollector()
|
||||||
|
collector.update_progress = AsyncMock()
|
||||||
|
record_observation = AsyncMock(return_value=object())
|
||||||
|
update_health = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.collectors.aisstream.record_vessel_ais_observation",
|
||||||
|
record_observation,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.collectors.aisstream.update_ais_source_health",
|
||||||
|
update_health,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self):
|
||||||
|
self.added = []
|
||||||
|
self.committed = False
|
||||||
|
|
||||||
|
def add(self, item):
|
||||||
|
self.added.append(item)
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
db = _Session()
|
||||||
|
saved = await collector._save_data(
|
||||||
|
db,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"mmsi": 257123000,
|
||||||
|
"lat": 59.91,
|
||||||
|
"lon": 10.73,
|
||||||
|
"received_at": datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"_message_type": "PositionReport",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert saved == 1
|
||||||
|
assert db.added == []
|
||||||
|
assert db.committed is True
|
||||||
|
record_observation.assert_awaited_once()
|
||||||
|
assert record_observation.await_args.kwargs["source"] == "aisstream_vessels"
|
||||||
|
update_health.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_aisstream_stream_record_broadcasts_vessel_delta(monkeypatch):
|
||||||
|
collector = AISStreamCollector()
|
||||||
|
record_observation = AsyncMock(return_value=object())
|
||||||
|
update_health = AsyncMock()
|
||||||
|
broadcast_custom = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.collectors.aisstream.record_vessel_ais_observation",
|
||||||
|
record_observation,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.collectors.aisstream.update_ais_source_health",
|
||||||
|
update_health,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.collectors.aisstream.broadcaster.broadcast_custom",
|
||||||
|
broadcast_custom,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
async def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
created = await collector._save_stream_record(
|
||||||
|
_Session(),
|
||||||
|
{
|
||||||
|
"mmsi": 257123000,
|
||||||
|
"lat": 59.91,
|
||||||
|
"lon": 10.73,
|
||||||
|
"cog": 214,
|
||||||
|
"received_at": datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created is True
|
||||||
|
record_observation.assert_awaited_once()
|
||||||
|
broadcast_custom.assert_awaited_once()
|
||||||
|
assert broadcast_custom.await_args.args[0] == "vessels"
|
||||||
|
assert broadcast_custom.await_args.args[1]["action"] == "upsert"
|
||||||
|
assert broadcast_custom.await_args.args[1]["vessels"][0]["mmsi_display"] == "257123000"
|
||||||
|
|
||||||
|
|
||||||
def test_barentswatch_reads_credentials_from_zshrc(tmp_path):
|
def test_barentswatch_reads_credentials_from_zshrc(tmp_path):
|
||||||
zshrc = tmp_path / ".zshrc"
|
zshrc = tmp_path / ".zshrc"
|
||||||
zshrc.write_text(
|
zshrc.write_text(
|
||||||
@@ -106,6 +489,39 @@ def test_convert_vessels_to_geojson():
|
|||||||
assert payload["features"][0]["properties"]["vessel_type_name"] == "Cargo"
|
assert payload["features"][0]["properties"]["vessel_type_name"] == "Cargo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_vessels_to_geojson_dedupes_mmsi_rows():
|
||||||
|
first = VesselPosition(
|
||||||
|
mmsi=257123000,
|
||||||
|
lat=59.91,
|
||||||
|
lon=10.73,
|
||||||
|
received_at=datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
duplicate = VesselPosition(
|
||||||
|
mmsi=257123000,
|
||||||
|
lat=60.01,
|
||||||
|
lon=10.83,
|
||||||
|
received_at=datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
other = VesselPosition(
|
||||||
|
mmsi=257456000,
|
||||||
|
lat=60.3,
|
||||||
|
lon=5.3,
|
||||||
|
received_at=datetime(2026, 4, 28, 0, 59, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_vessels_to_geojson(
|
||||||
|
[
|
||||||
|
(first, VesselStatic(mmsi=257123000, name="OSLO TRADER")),
|
||||||
|
(duplicate, VesselStatic(mmsi=257123000, name="OSLO TRADER DUP")),
|
||||||
|
(other, VesselStatic(mmsi=257456000, name="BERGEN FERRY")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
mmsis = [feature["properties"]["mmsi"] for feature in payload["features"]]
|
||||||
|
assert mmsis == [257123000, 257456000]
|
||||||
|
assert payload["features"][0]["geometry"]["coordinates"] == [10.73, 59.91]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_vessels_geojson_endpoint_filters_type_and_bbox():
|
async def test_vessels_geojson_endpoint_filters_type_and_bbox():
|
||||||
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
||||||
@@ -137,7 +553,7 @@ async def test_vessels_geojson_endpoint_filters_type_and_bbox():
|
|||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/v1/visualization/geo/vessels",
|
"/api/v1/visualization/geo/vessels",
|
||||||
params={"bbox": "0,50,20,70", "type": "cargo"},
|
params={"bbox": "0,50,20,70", "type": "cargo", "limit": 0},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -147,3 +563,113 @@ async def test_vessels_geojson_endpoint_filters_type_and_bbox():
|
|||||||
assert data["stats"]["by_type"]["Cargo"] == 1
|
assert data["stats"]["by_type"]["Cargo"] == 1
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vessels_geojson_merges_raw_and_legacy_sources(monkeypatch):
|
||||||
|
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
visualization,
|
||||||
|
"get_aggregated_vessels",
|
||||||
|
AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"mmsi": 1,
|
||||||
|
"lat": 59.9,
|
||||||
|
"lon": 10.7,
|
||||||
|
"received_at": now,
|
||||||
|
"name": "AISSTREAM SHIP",
|
||||||
|
"vessel_type_name": "Cargo",
|
||||||
|
"source_summary": {"aisstream_vessels": {"message_types": ["PositionReport"]}},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
(
|
||||||
|
VesselPosition(mmsi=1, lat=60.0, lon=10.8, received_at=now),
|
||||||
|
VesselStatic(mmsi=1, name="LEGACY DUP", vessel_type_name="Cargo"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
VesselPosition(mmsi=2, lat=60.3, lon=5.3, received_at=now),
|
||||||
|
VesselStatic(mmsi=2, name="BARENTSWATCH ONLY", vessel_type_name="Passenger"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
def all(self):
|
||||||
|
return rows
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
async def execute(self, _query):
|
||||||
|
return _Result()
|
||||||
|
|
||||||
|
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/vessels")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = {feature["properties"]["mmsi"]: feature["properties"]["name"] for feature in data["features"]}
|
||||||
|
assert data["count"] == 2
|
||||||
|
assert names == {1: "AISSTREAM SHIP", 2: "BARENTSWATCH ONLY"}
|
||||||
|
assert data["diagnostics"]["legacy_backfilled_mmsi"] == 1
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vessel_name_fallbacks_reports_mmsi_display_names(monkeypatch):
|
||||||
|
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
visualization,
|
||||||
|
"get_aggregated_vessels",
|
||||||
|
AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"mmsi": 257123000,
|
||||||
|
"lat": 59.9,
|
||||||
|
"lon": 10.7,
|
||||||
|
"received_at": now,
|
||||||
|
"name": "MMSI 257123000",
|
||||||
|
"vessel_type_name": "Other",
|
||||||
|
"source_summary": {
|
||||||
|
"aisstream_vessels": {
|
||||||
|
"latest_observed_at": now,
|
||||||
|
"message_types": ["PositionReport"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
def all(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
async def execute(self, _query):
|
||||||
|
return _Result()
|
||||||
|
|
||||||
|
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/vessels/name-fallbacks")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["count"] == 1
|
||||||
|
assert data["items"][0]["mmsi"] == "257123000"
|
||||||
|
assert data["items"][0]["message_types"] == ["PositionReport"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import pytest
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from app.api.v1.visualization import convert_compute_centers_to_geojson
|
from app.api.v1.visualization import convert_compute_centers_to_geojson
|
||||||
|
import app.services.compute_center_locations as compute_center_locations
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.collected_data import CollectedData
|
from app.models.collected_data import CollectedData
|
||||||
@@ -89,6 +90,8 @@ def test_convert_compute_centers_to_geojson_unifies_sources():
|
|||||||
assert supercomputer_feature["properties"]["operator"] == "ORNL"
|
assert supercomputer_feature["properties"]["operator"] == "ORNL"
|
||||||
assert supercomputer_feature["properties"]["location_precision"] == "precise"
|
assert supercomputer_feature["properties"]["location_precision"] == "precise"
|
||||||
assert supercomputer_feature["properties"]["is_estimated"] is False
|
assert supercomputer_feature["properties"]["is_estimated"] is False
|
||||||
|
assert supercomputer_feature["properties"]["location_source"] == "source_coordinates"
|
||||||
|
assert supercomputer_feature["properties"]["location_confidence"] == 1.0
|
||||||
|
|
||||||
gpu_feature = payload["features"][1]
|
gpu_feature = payload["features"][1]
|
||||||
assert gpu_feature["properties"]["site_type"] == "gpu_cluster"
|
assert gpu_feature["properties"]["site_type"] == "gpu_cluster"
|
||||||
@@ -98,8 +101,110 @@ def test_convert_compute_centers_to_geojson_unifies_sources():
|
|||||||
assert gpu_feature["properties"]["location_precision"] == "precise"
|
assert gpu_feature["properties"]["location_precision"] == "precise"
|
||||||
|
|
||||||
|
|
||||||
def test_convert_compute_centers_to_geojson_uses_coordinate_hints():
|
def test_convert_compute_centers_to_geojson_accepts_source_coordinate_aliases():
|
||||||
hinted_record = _build_record(
|
record = _build_record(
|
||||||
|
record_id=3,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Alias Coordinates",
|
||||||
|
country="United States",
|
||||||
|
city="New York",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={
|
||||||
|
"latitude": "",
|
||||||
|
"longitude": "",
|
||||||
|
"location": {
|
||||||
|
"lat": 40.7128,
|
||||||
|
"lng": -74.0060,
|
||||||
|
},
|
||||||
|
"value": "1200",
|
||||||
|
"unit": "TFlop/s",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([record])
|
||||||
|
|
||||||
|
assert len(payload["features"]) == 1
|
||||||
|
feature = payload["features"][0]
|
||||||
|
assert feature["geometry"]["coordinates"] == [-74.006, 40.7128]
|
||||||
|
assert feature["properties"]["location_source"] == "source_coordinates"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_center_source_coordinates_win_over_stored_location():
|
||||||
|
compute_center_locations.set_compute_center_location_cache({
|
||||||
|
"top500:top500-31": {
|
||||||
|
"source": "top500",
|
||||||
|
"source_id": "top500-31",
|
||||||
|
"name": "Stored Wrong",
|
||||||
|
"latitude": 1.0,
|
||||||
|
"longitude": 2.0,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.5,
|
||||||
|
"needs_confirmation": True,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
record = _build_record(
|
||||||
|
record_id=31,
|
||||||
|
source="top500",
|
||||||
|
data_type="supercomputer",
|
||||||
|
name="Source Wins",
|
||||||
|
country="United States",
|
||||||
|
city="Oak Ridge",
|
||||||
|
latitude=35.93,
|
||||||
|
longitude=-84.31,
|
||||||
|
metadata={"organization": "ORNL"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([record])
|
||||||
|
|
||||||
|
assert payload["features"][0]["geometry"]["coordinates"] == [-84.31, 35.93]
|
||||||
|
assert payload["features"][0]["properties"]["location_source"] == "source_coordinates"
|
||||||
|
compute_center_locations.set_compute_center_location_cache({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_center_geojson_uses_stored_location_when_source_coords_missing():
|
||||||
|
compute_center_locations.set_compute_center_location_cache({
|
||||||
|
"epoch_ai_gpu:epoch_ai_gpu-32": {
|
||||||
|
"source": "epoch_ai_gpu",
|
||||||
|
"source_id": "epoch_ai_gpu-32",
|
||||||
|
"name": "Stored Cluster",
|
||||||
|
"city": "Memphis",
|
||||||
|
"country": "United States",
|
||||||
|
"latitude": 35.1495,
|
||||||
|
"longitude": -90.049,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.72,
|
||||||
|
"location_source": "manual_selection",
|
||||||
|
"source_note": "Saved by user",
|
||||||
|
"needs_confirmation": False,
|
||||||
|
"verified_at": "2026-05-08T00:00:00Z",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
record = _build_record(
|
||||||
|
record_id=32,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Stored Cluster",
|
||||||
|
country="United States",
|
||||||
|
city="",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={"value": "1200", "unit": "TFlop/s"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([record])
|
||||||
|
|
||||||
|
assert len(payload["features"]) == 1
|
||||||
|
feature = payload["features"][0]
|
||||||
|
assert feature["geometry"]["coordinates"] == [-90.049, 35.1495]
|
||||||
|
assert feature["properties"]["location_source"] == "stored_compute_center_location"
|
||||||
|
assert feature["properties"]["needs_confirmation"] is False
|
||||||
|
compute_center_locations.set_compute_center_location_cache({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_compute_centers_to_geojson_does_not_use_registry_aliases():
|
||||||
|
registry_record = _build_record(
|
||||||
record_id=3,
|
record_id=3,
|
||||||
source="top500",
|
source="top500",
|
||||||
data_type="supercomputer",
|
data_type="supercomputer",
|
||||||
@@ -114,23 +219,51 @@ def test_convert_compute_centers_to_geojson_uses_coordinate_hints():
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = convert_compute_centers_to_geojson([hinted_record])
|
payload = convert_compute_centers_to_geojson([registry_record])
|
||||||
|
|
||||||
assert len(payload["features"]) == 1
|
assert payload["features"] == []
|
||||||
coords = payload["features"][0]["geometry"]["coordinates"]
|
assert len(payload["unresolved"]) == 1
|
||||||
assert coords[0] == pytest.approx(-84.3107)
|
assert payload["unresolved"][0]["name"] == "Frontier"
|
||||||
assert coords[1] == pytest.approx(35.9319)
|
assert "source coords" in payload["unresolved"][0]["failure_reason"]
|
||||||
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():
|
def test_convert_compute_centers_to_geojson_does_not_use_city_fallback():
|
||||||
centroid_record = _build_record(
|
city_record = _build_record(
|
||||||
|
record_id=4,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Sample GPU Cluster",
|
||||||
|
country="United States",
|
||||||
|
city="San Francisco, CA",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={
|
||||||
|
"organization": "Sample Operator",
|
||||||
|
"value": "10000",
|
||||||
|
"unit": "TFlop/s",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([city_record])
|
||||||
|
|
||||||
|
assert payload["features"] == []
|
||||||
|
assert len(payload["unresolved"]) == 1
|
||||||
|
assert payload["unresolved"][0]["city"] == "San Francisco, CA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_compute_centers_to_geojson_does_not_online_geocode_on_startup(monkeypatch):
|
||||||
|
compute_center_locations._geocode_online.cache_clear()
|
||||||
|
|
||||||
|
def _explode(_query):
|
||||||
|
raise AssertionError("startup GeoJSON must not call online geocoding")
|
||||||
|
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", _explode)
|
||||||
|
country_record = _build_record(
|
||||||
record_id=4,
|
record_id=4,
|
||||||
source="epoch_ai_gpu",
|
source="epoch_ai_gpu",
|
||||||
data_type="gpu_cluster",
|
data_type="gpu_cluster",
|
||||||
name="Unknown Cluster",
|
name="Unknown Cluster",
|
||||||
country="United States",
|
country="France",
|
||||||
city="",
|
city="",
|
||||||
latitude=0.0,
|
latitude=0.0,
|
||||||
longitude=0.0,
|
longitude=0.0,
|
||||||
@@ -141,16 +274,228 @@ def test_convert_compute_centers_to_geojson_falls_back_to_country_centroid():
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = convert_compute_centers_to_geojson([centroid_record])
|
payload = convert_compute_centers_to_geojson([country_record])
|
||||||
|
|
||||||
assert len(payload["features"]) == 1
|
assert payload["features"] == []
|
||||||
props = payload["features"][0]["properties"]
|
assert len(payload["unresolved"]) == 1
|
||||||
coords = payload["features"][0]["geometry"]["coordinates"]
|
assert payload["unresolved"][0]["operator"] == "Unknown Operator"
|
||||||
assert coords[0] == pytest.approx(-98.5795)
|
|
||||||
assert coords[1] == pytest.approx(39.8283)
|
|
||||||
assert props["is_estimated"] is True
|
def test_convert_compute_centers_to_geojson_records_diagnostics_when_online_geocode_fails(monkeypatch):
|
||||||
assert props["location_precision"] == "estimated_country"
|
compute_center_locations._geocode_online.cache_clear()
|
||||||
assert props["geography_mode"] == "country_centroid"
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||||
|
country_record = _build_record(
|
||||||
|
record_id=5,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Unknown French Cluster",
|
||||||
|
country="France",
|
||||||
|
city="",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={
|
||||||
|
"organization": "Unknown Operator",
|
||||||
|
"value": "10000",
|
||||||
|
"unit": "TFlop/s",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([country_record])
|
||||||
|
|
||||||
|
assert payload["features"] == []
|
||||||
|
assert len(payload["unresolved"]) == 1
|
||||||
|
diagnostic = payload["unresolved"][0]
|
||||||
|
assert diagnostic["record_id"] == 5
|
||||||
|
assert diagnostic["source_id"] == "epoch_ai_gpu-5"
|
||||||
|
assert diagnostic["country"] == "France"
|
||||||
|
assert diagnostic["operator"] == "Unknown Operator"
|
||||||
|
assert diagnostic["failure_reason"]
|
||||||
|
assert diagnostic["attempted_queries"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_compute_centers_to_geojson_records_diagnostics_when_no_country(monkeypatch):
|
||||||
|
compute_center_locations._geocode_online.cache_clear()
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||||
|
unknown_record = _build_record(
|
||||||
|
record_id=6,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Unknown Offshore Cluster",
|
||||||
|
country="",
|
||||||
|
city="",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={
|
||||||
|
"organization": "Unknown Operator",
|
||||||
|
"value": "10000",
|
||||||
|
"unit": "TFlop/s",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([unknown_record])
|
||||||
|
|
||||||
|
assert payload["features"] == []
|
||||||
|
assert len(payload["unresolved"]) == 1
|
||||||
|
assert payload["unresolved"][0]["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_compute_centers_to_geojson_never_emits_zero_coordinates(monkeypatch):
|
||||||
|
compute_center_locations._geocode_online.cache_clear()
|
||||||
|
|
||||||
|
def _zero_geocode(query):
|
||||||
|
return {
|
||||||
|
"lat": "0",
|
||||||
|
"lon": "0",
|
||||||
|
"display_name": "Null Island",
|
||||||
|
"address": {"city": "", "country": ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", _zero_geocode)
|
||||||
|
record = _build_record(
|
||||||
|
record_id=7,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Null Island Cluster",
|
||||||
|
country="",
|
||||||
|
city="",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={"organization": "Null Inc"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([record])
|
||||||
|
|
||||||
|
for feature in payload["features"]:
|
||||||
|
coords = feature["geometry"]["coordinates"]
|
||||||
|
assert coords[0] not in (0, 0.0)
|
||||||
|
assert coords[1] not in (0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_compute_centers_to_geojson_rejects_country_or_unknown_precision(monkeypatch):
|
||||||
|
compute_center_locations._geocode_online.cache_clear()
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||||
|
record = _build_record(
|
||||||
|
record_id=8,
|
||||||
|
source="top500",
|
||||||
|
data_type="supercomputer",
|
||||||
|
name="Phantom System",
|
||||||
|
country="Liechtenstein",
|
||||||
|
city="",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={"organization": "Phantom Operator", "rmax": 100.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([record])
|
||||||
|
|
||||||
|
for feature in payload["features"]:
|
||||||
|
assert feature["properties"]["location_precision"] in {"precise", "site", "city"}
|
||||||
|
assert payload["features"] == []
|
||||||
|
assert payload["unresolved"], "phantom record must surface as diagnostic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_full_returns_diagnostic_for_unresolved(monkeypatch):
|
||||||
|
compute_center_locations._geocode_online.cache_clear()
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||||
|
record = _build_record(
|
||||||
|
record_id=11,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Phantom Cluster",
|
||||||
|
country="Bhutan",
|
||||||
|
city="",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={"organization": "Mystery Operator"},
|
||||||
|
)
|
||||||
|
result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data)
|
||||||
|
assert result.location is None
|
||||||
|
assert result.diagnostic is not None
|
||||||
|
assert result.diagnostic.failure_reason
|
||||||
|
assert result.diagnostic.country == "Bhutan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_location_candidates_ignores_registry_and_uses_online(monkeypatch):
|
||||||
|
compute_center_locations._geocode_online.cache_clear()
|
||||||
|
|
||||||
|
def _fake_ror(query):
|
||||||
|
assert query == "Oak Ridge National Laboratory"
|
||||||
|
return {
|
||||||
|
"id": "https://ror.org/01qz5mb56",
|
||||||
|
"names": [
|
||||||
|
{"types": ["ror_display"], "value": "Oak Ridge National Laboratory"}
|
||||||
|
],
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"geonames_id": 4646571,
|
||||||
|
"geonames_details": {
|
||||||
|
"name": "Oak Ridge",
|
||||||
|
"country_subdivision_name": "Tennessee",
|
||||||
|
"country_name": "United States",
|
||||||
|
"lat": 36.01036,
|
||||||
|
"lng": -84.26964,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", _fake_ror)
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||||
|
candidates, attempted = compute_center_locations.collect_location_candidates(
|
||||||
|
name="Frontier",
|
||||||
|
operator="Oak Ridge National Laboratory",
|
||||||
|
country="United States",
|
||||||
|
)
|
||||||
|
assert candidates, "online source-traced query must produce a candidate"
|
||||||
|
best = candidates[0]
|
||||||
|
assert best.source == "ror_organization_registry"
|
||||||
|
assert best.precision == "city"
|
||||||
|
assert best.needs_confirmation is True
|
||||||
|
assert attempted[0] == "ror:Oak Ridge National Laboratory"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_location_candidates_returns_online_when_registry_misses(monkeypatch):
|
||||||
|
compute_center_locations._geocode_online.cache_clear()
|
||||||
|
|
||||||
|
def _fake_geocode(query):
|
||||||
|
if "Lyon" not in query and "Mystery Operator" not in query and "Lyon, France" not in query:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"lat": "45.7640",
|
||||||
|
"lon": "4.8357",
|
||||||
|
"display_name": "Lyon, Auvergne-Rhône-Alpes, France",
|
||||||
|
"address": {"city": "Lyon", "state": "Auvergne-Rhône-Alpes", "country": "France"},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", _fake_geocode)
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None)
|
||||||
|
candidates, attempted = compute_center_locations.collect_location_candidates(
|
||||||
|
name="Mystery System",
|
||||||
|
operator="Mystery Operator",
|
||||||
|
city="Lyon",
|
||||||
|
country="France",
|
||||||
|
)
|
||||||
|
assert candidates, "online geocoding must produce a candidate"
|
||||||
|
online_candidates = [c for c in candidates if c.source == "nominatim_online_geocode"]
|
||||||
|
assert online_candidates, "must include at least one online candidate"
|
||||||
|
online = online_candidates[0]
|
||||||
|
assert online.precision == "city"
|
||||||
|
assert online.needs_confirmation is True
|
||||||
|
assert online.suggested_registry_entry is not None
|
||||||
|
assert attempted, "must record attempted query strings"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_location_candidates_failure_returns_attempted_queries(monkeypatch):
|
||||||
|
compute_center_locations._geocode_online.cache_clear()
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None)
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||||
|
candidates, attempted = compute_center_locations.collect_location_candidates(
|
||||||
|
name="Mystery Offshore Cluster",
|
||||||
|
operator="Mystery Operator",
|
||||||
|
country="Bhutan",
|
||||||
|
)
|
||||||
|
assert candidates == []
|
||||||
|
assert attempted, "even on failure we record attempted queries for diagnostics"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -292,6 +637,9 @@ async def test_visualization_geo_summary_returns_counts(monkeypatch):
|
|||||||
def scalar(self):
|
def scalar(self):
|
||||||
return self._scalar_value
|
return self._scalar_value
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
def scalars(self):
|
def scalars(self):
|
||||||
class _Scalars:
|
class _Scalars:
|
||||||
def __init__(self, rows):
|
def __init__(self, rows):
|
||||||
@@ -304,13 +652,18 @@ async def test_visualization_geo_summary_returns_counts(monkeypatch):
|
|||||||
|
|
||||||
class _FakeSession:
|
class _FakeSession:
|
||||||
async def execute(self, query):
|
async def execute(self, query):
|
||||||
query_text = str(query)
|
query_text = str(query).lower()
|
||||||
if "bgp_incidents" in query_text:
|
if "bgp_incidents" in query_text:
|
||||||
return _ScalarResult(scalar_value=2)
|
return _ScalarResult(scalar_value=2)
|
||||||
if "bgp_anomalies" in query_text:
|
if "bgp_anomalies" in query_text:
|
||||||
return _ScalarResult(scalar_value=3)
|
return _ScalarResult(scalar_value=3)
|
||||||
|
if "ais_raw_observations" in query_text or "vessel_position" in query_text:
|
||||||
|
return _ScalarResult(rows=[])
|
||||||
return _ScalarResult(rows=records)
|
return _ScalarResult(rows=records)
|
||||||
|
|
||||||
|
async def get(self, *_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
async def override_get_db():
|
async def override_get_db():
|
||||||
yield _FakeSession()
|
yield _FakeSession()
|
||||||
|
|
||||||
@@ -345,3 +698,304 @@ async def test_visualization_geo_summary_returns_counts(monkeypatch):
|
|||||||
assert stats["bgp_collector_count"] == 2
|
assert stats["bgp_collector_count"] == 2
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_collect_location_endpoint_returns_candidates_for_known_record(monkeypatch):
|
||||||
|
def _fake_ror(query):
|
||||||
|
assert query == "Oak Ridge National Laboratory"
|
||||||
|
return {
|
||||||
|
"id": "https://ror.org/01qz5mb56",
|
||||||
|
"names": [
|
||||||
|
{"types": ["ror_display"], "value": "Oak Ridge National Laboratory"}
|
||||||
|
],
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"geonames_id": 4646571,
|
||||||
|
"geonames_details": {
|
||||||
|
"name": "Oak Ridge",
|
||||||
|
"country_subdivision_name": "Tennessee",
|
||||||
|
"country_name": "United States",
|
||||||
|
"lat": 36.01036,
|
||||||
|
"lng": -84.26964,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", _fake_ror)
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||||
|
|
||||||
|
target_record = _build_record(
|
||||||
|
record_id=42,
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
|
||||||
|
class _ScalarResult:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def scalars(self):
|
||||||
|
class _Scalars:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return self._rows[0] if self._rows else None
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
return _Scalars(self._rows)
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
async def execute(self, _query):
|
||||||
|
return _ScalarResult([target_record])
|
||||||
|
|
||||||
|
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.post(
|
||||||
|
"/api/v1/visualization/compute-centers/top500-42/collect-location",
|
||||||
|
json={
|
||||||
|
"name": "Frontier",
|
||||||
|
"operator": "Oak Ridge National Laboratory",
|
||||||
|
"country": "United States",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert body["candidates"], "must include candidates"
|
||||||
|
best = body["best_candidate"]
|
||||||
|
assert best["precision"] in {"precise", "site", "city"}
|
||||||
|
assert best["source"] == "ror_organization_registry"
|
||||||
|
assert best["needs_confirmation"] is True
|
||||||
|
assert best["matched_fields"], "matched_fields must be populated"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_collect_location_endpoint_returns_failure_reason(monkeypatch):
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None)
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||||
|
|
||||||
|
class _ScalarResult:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def scalars(self):
|
||||||
|
class _Scalars:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return self._rows[0] if self._rows else None
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
return _Scalars(self._rows)
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
async def execute(self, _query):
|
||||||
|
return _ScalarResult([])
|
||||||
|
|
||||||
|
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.post(
|
||||||
|
"/api/v1/visualization/compute-centers/epoch-mystery-99/collect-location",
|
||||||
|
json={
|
||||||
|
"name": "Mystery Cluster",
|
||||||
|
"operator": "Mystery Operator",
|
||||||
|
"country": "Bhutan",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert body["failure_reason"]
|
||||||
|
assert body["candidates"] == []
|
||||||
|
assert body["attempted_queries"], "must include attempted queries"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_location_endpoint_upserts_and_geojson_can_render():
|
||||||
|
target_record = _build_record(
|
||||||
|
record_id=52,
|
||||||
|
source="epoch_ai_gpu",
|
||||||
|
data_type="gpu_cluster",
|
||||||
|
name="Saved Cluster",
|
||||||
|
country="United States",
|
||||||
|
city="",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={"value": "1200", "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 first(self):
|
||||||
|
return self._rows[0] if self._rows else None
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
return _Scalars(self._rows)
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self):
|
||||||
|
self.saved = []
|
||||||
|
|
||||||
|
async def execute(self, _query):
|
||||||
|
if self.saved:
|
||||||
|
return _ScalarResult(self.saved)
|
||||||
|
return _ScalarResult([target_record])
|
||||||
|
|
||||||
|
async def scalar(self, _query):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add(self, record):
|
||||||
|
self.saved.append(record)
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def refresh(self, _record):
|
||||||
|
return None
|
||||||
|
|
||||||
|
fake_session = _FakeSession()
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
yield fake_session
|
||||||
|
|
||||||
|
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.post(
|
||||||
|
"/api/v1/visualization/compute-centers/epoch_ai_gpu-52/location",
|
||||||
|
json={
|
||||||
|
"source": "epoch_ai_gpu",
|
||||||
|
"name": "Saved Cluster",
|
||||||
|
"latitude": 35.1495,
|
||||||
|
"longitude": -90.049,
|
||||||
|
"precision": "city",
|
||||||
|
"confidence": 0.72,
|
||||||
|
"location_source": "ror_organization_registry",
|
||||||
|
"source_note": "Selected by user",
|
||||||
|
"raw_payload": {"source": "ror_organization_registry"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert fake_session.saved
|
||||||
|
|
||||||
|
payload = convert_compute_centers_to_geojson([target_record])
|
||||||
|
assert len(payload["features"]) == 1
|
||||||
|
feature = payload["features"][0]
|
||||||
|
assert feature["geometry"]["coordinates"] == [-90.049, 35.1495]
|
||||||
|
assert feature["properties"]["location_source"] == "stored_compute_center_location"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
compute_center_locations.set_compute_center_location_cache({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolution_chain_orders_source_coords_first(monkeypatch):
|
||||||
|
def _explode(_query):
|
||||||
|
raise AssertionError("source coords must short-circuit before online geocoding")
|
||||||
|
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", _explode)
|
||||||
|
record = _build_record(
|
||||||
|
record_id=20,
|
||||||
|
source="top500",
|
||||||
|
data_type="supercomputer",
|
||||||
|
name="Frontier",
|
||||||
|
country="United States",
|
||||||
|
city="Oak Ridge",
|
||||||
|
latitude=35.93,
|
||||||
|
longitude=-84.31,
|
||||||
|
metadata={"organization": "ORNL"},
|
||||||
|
)
|
||||||
|
result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data)
|
||||||
|
assert result.is_resolved
|
||||||
|
assert result.location.location_precision == "precise"
|
||||||
|
assert result.location.location_source == "source_coordinates"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_country_centroid_or_major_compute_city_fallback(monkeypatch):
|
||||||
|
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||||
|
record = _build_record(
|
||||||
|
record_id=21,
|
||||||
|
source="top500",
|
||||||
|
data_type="supercomputer",
|
||||||
|
name="Phantom System",
|
||||||
|
country="France",
|
||||||
|
city="",
|
||||||
|
latitude=0.0,
|
||||||
|
longitude=0.0,
|
||||||
|
metadata={"organization": "Phantom Operator"},
|
||||||
|
)
|
||||||
|
result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data)
|
||||||
|
assert result.location is None, "must NOT fall back to country centroid or hashed major city"
|
||||||
|
assert result.diagnostic is not None
|
||||||
|
assert result.diagnostic.failure_reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_has_no_forbidden_precision_tokens():
|
||||||
|
"""Static guard: forbidden fallback strategies must not regress into the codebase.
|
||||||
|
|
||||||
|
Each forbidden token may appear at most once per target file, and only inside
|
||||||
|
the FORBIDDEN_PRECISIONS guard list (so we still reject them at runtime).
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
backend_root = Path(__file__).resolve().parents[1]
|
||||||
|
forbidden_tokens = (
|
||||||
|
"country_centroid",
|
||||||
|
"country_major_compute_city",
|
||||||
|
"estimated_country",
|
||||||
|
)
|
||||||
|
targets = [
|
||||||
|
backend_root / "app" / "services" / "compute_center_locations.py",
|
||||||
|
backend_root / "app" / "api" / "v1" / "visualization.py",
|
||||||
|
]
|
||||||
|
for target in targets:
|
||||||
|
text = target.read_text(encoding="utf-8")
|
||||||
|
for token in forbidden_tokens:
|
||||||
|
occurrences = text.count(token)
|
||||||
|
assert occurrences <= 1, (
|
||||||
|
f"{token} appears {occurrences} times in {target}; "
|
||||||
|
"should only appear in FORBIDDEN_PRECISIONS guard list."
|
||||||
|
)
|
||||||
|
if occurrences == 1:
|
||||||
|
assert "FORBIDDEN_PRECISIONS" in text, (
|
||||||
|
f"{token} appears in {target} outside the FORBIDDEN_PRECISIONS guard"
|
||||||
|
)
|
||||||
|
|||||||
46
backend/tests/test_websocket_manager.py
Normal file
46
backend/tests/test_websocket_manager.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.websocket.manager import ConnectionManager
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWebSocket:
|
||||||
|
def __init__(self):
|
||||||
|
self.accepted = False
|
||||||
|
self.sent = []
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def accept(self):
|
||||||
|
self.accepted = True
|
||||||
|
|
||||||
|
async def send_json(self, message):
|
||||||
|
self.sent.append(message)
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_channel_subscribers_receive_channel_broadcasts():
|
||||||
|
manager = ConnectionManager()
|
||||||
|
socket = FakeWebSocket()
|
||||||
|
|
||||||
|
await manager.connect(socket, "user-1")
|
||||||
|
manager.subscribe(socket, ["dashboard"])
|
||||||
|
await manager.broadcast({"type": "data_frame", "channel": "dashboard"}, channel="dashboard")
|
||||||
|
|
||||||
|
assert socket.accepted is True
|
||||||
|
assert socket.sent == [{"type": "data_frame", "channel": "dashboard"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disconnect_removes_channel_subscriptions():
|
||||||
|
manager = ConnectionManager()
|
||||||
|
socket = FakeWebSocket()
|
||||||
|
|
||||||
|
await manager.connect(socket, "user-1")
|
||||||
|
manager.subscribe(socket, ["dashboard"])
|
||||||
|
manager.disconnect(socket, "user-1")
|
||||||
|
await manager.broadcast({"type": "data_frame", "channel": "dashboard"}, channel="dashboard")
|
||||||
|
|
||||||
|
assert socket.sent == []
|
||||||
|
assert "dashboard" not in manager.channel_subscriptions
|
||||||
@@ -8,6 +8,62 @@ 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.49.0] — 2026-05-08
|
||||||
|
|
||||||
|
Released: 2026-05-08
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
- 新增统一地理位置解析 Pipeline,支持 SourceCoordinates / Nominatim / Registry / Inherit 多策略链式 resolver。
|
||||||
|
- 新增 BGP 采集站与算力中心地理定位服务(`bgp_collector_locations`、`compute_center_locations`、`bgp_event_locations`)。
|
||||||
|
- 新增 Docs Gatekeeper 带鉴权文档 API(`/api/v1/docs`),按用户权限动态返回文档目录与内容。
|
||||||
|
- 新增 Earth 全球新闻栏(`/api/v1/news/earth-feed`),根据地球视角坐标推断地区并聚合多源 RSS 信息流。
|
||||||
|
- Earth 新增 Mobile 算力中心国家高亮(`mobile-center-country-highlight.js`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.48.0] — 2026-05-07
|
||||||
|
|
||||||
|
Released: 2026-05-07
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- 自定义数据源新增 REST / WebSocket 映射运行时,并提供本地 AIS mock WebSocket,用于实时船只 upsert 链路验证。
|
||||||
|
- AIS 原始观测、聚合策略、字段来源、冲突记录与船舶 enrichment 继续完善,Earth 船只实时展示链路更接近生产数据形态。
|
||||||
|
- Earth 全球态势 summary 改为轻量 SQL 聚合,并在卫星 current 异常时回退到最近有效 TLE 批次,避免统计接口被大规模明细读取拖慢。
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 修复 `/geo/summary` 与 `/geo/satellites` 在大表下加载慢或超时的问题,并补充 `collected_data` 与 AIS raw 相关索引。
|
||||||
|
- WebSocket 管理器支持匿名连接、频道订阅清理和更稳的连接生命周期测试,前端 WebSocket candidates / fallback 更可靠。
|
||||||
|
- `planet.sh` 强化端口释放、端口诊断和前端启动流程,mock AIS server 提供 Bun 脚本入口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.47.0] — 2026-04-30
|
||||||
|
|
||||||
|
Released: 2026-04-30
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- 新增 AISStream WebSocket 船只采集器,并将 AIS 多源数据写入原始观测层,由聚合接口统一去重、合并和解释字段来源。
|
||||||
|
- 设置页新增 AISStream API Key、采集范围 preset、运行状态、连接验证和凭证教程入口,让全球 AIS 采集链路可配置、可观察。
|
||||||
|
- Earth 船只图层默认不再限制 5000 艘,并统一 marker 颜色、详情卡、hover 和搜索结果的船型归一化显示。
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 聚合接口新增 `field_sources`、`selected_reasons`、`source_summary`、`quality_flags` 和冲突记录调试接口,动态字段默认优先采用更新的实时流观测。
|
||||||
|
- AISStream 标准化支持 `MetaData.ShipName` 船名兜底,并将 AIS 数字船型映射为 Cargo / Tanker / Passenger / Fishing / Military。
|
||||||
|
- 将仓库 docs 技能改为通用文档工作流,Planet 专属白名单、双语、裸文件标题和凭证教程规则迁移到 `docs/documentation-coverage-rules.md`。
|
||||||
|
- 更新 AIS v4/v5 TODO 与计划文档,明确后续聚合策略配置、船舶资料 enrichment 和媒体缓存边界。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.46.3] — 2026-04-30
|
||||||
|
|
||||||
|
Released: 2026-04-30
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 优化 Starlink footprint 显示后的地球拖拽性能,避免旋转地球时每帧重建 footprint 大网格,同时保持现有视觉效果不变。
|
||||||
|
- 恢复点击线缆后的呼吸透明度动画,让 locked / hover 线缆重新使用既有 pulse 配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.46.2] — 2026-04-30
|
## [0.46.2] — 2026-04-30
|
||||||
|
|
||||||
Released: 2026-04-30
|
Released: 2026-04-30
|
||||||
|
|||||||
117
docs/documentation-coverage-rules.md
Normal file
117
docs/documentation-coverage-rules.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Documentation Coverage Rules
|
||||||
|
|
||||||
|
This file contains Planet-specific documentation coverage rules. Documentation skills and agents should read this file before deciding which docs to update. Keep tool-specific workflow in skills; keep product and repository rules here.
|
||||||
|
|
||||||
|
## Scope Rules
|
||||||
|
|
||||||
|
- User-visible workflow changes must update `docs/technical/zh/manual.md` and usually `docs/technical/zh/quickstart.md`.
|
||||||
|
- If an English counterpart exists for user-facing docs such as `manual.md` or `quickstart.md`, update `docs/technical/en/...` enough that it does not contradict the Chinese source.
|
||||||
|
- Control console page responsibility changes must update `docs/technical/zh/frontend-admin-frontend-context.md`.
|
||||||
|
- Earth frontend behavior changes must update `docs/technical/zh/earth-frontend-context.md`.
|
||||||
|
- Earth layer additions, `renderOrder`, altitude/radius offsets, depth strategy, pointer picking, legend modes, or layer panel/startup ordering must update `docs/technical/zh/earth-render-layer-order.md`.
|
||||||
|
- Earth layer visual style or legend symbol/color semantics should also update `docs/technical/zh/earth-layer-style-reference.md` when that reference is affected.
|
||||||
|
- Collector, datasource, credential, settings, connectivity, scheduler, or API changes must update the relevant backend docs, especially `docs/technical/zh/backend-collectors.md` and any datasource/settings-specific doc.
|
||||||
|
- When a change turns an old plan assumption into current behavior, update the relevant `docs/plans/*.md` with a status note instead of leaving contradictory instructions.
|
||||||
|
- Search docs for stale terms introduced by the change, for example old tab names, old route responsibilities, obsolete auth assumptions, or renamed UI labels.
|
||||||
|
|
||||||
|
## Public Docs Rules
|
||||||
|
|
||||||
|
- If adding a new technical document, add it to `docs/technical/zh/README.md` when it should be discoverable from the technical docs index.
|
||||||
|
- If a technical document should be visible in the public Docs page or linked from a technical README, register it in `frontend/src/pages/Docs/docs-content.ts` under `DOCS_METADATA`. Files under `docs/technical/{zh,en}/` are not automatically routable.
|
||||||
|
- For every public technical doc, keep the bilingual file pair in sync by filename: `docs/technical/zh/<name>.md` and `docs/technical/en/<name>.md`. If content is intentionally Chinese-only or English-only, state that intentionally in the final note.
|
||||||
|
- Public docs should use readable link text, not raw filenames such as `manual.md`.
|
||||||
|
|
||||||
|
## Credential Collector Rules
|
||||||
|
|
||||||
|
- Any built-in collector marked `requires_credentials: true` and `credential_status: supported` must have:
|
||||||
|
- a `credential_provider` in `backend/app/core/datasource_defaults.py`;
|
||||||
|
- a default credential guide in `backend/app/services/credential_guides.py`;
|
||||||
|
- a supported connectivity provider in `backend/app/services/datasource_connectivity.py`;
|
||||||
|
- settings UI guidance or a credential form in `frontend/src/pages/Settings/Settings.tsx`;
|
||||||
|
- a regression test that fails if the guide/provider is missing.
|
||||||
|
|
||||||
|
## Recommended Checks
|
||||||
|
|
||||||
|
Run the checks that match the affected docs.
|
||||||
|
|
||||||
|
### Duplicate Bilingual Docs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
same = []
|
||||||
|
for en in sorted(Path("docs/technical/en").glob("*.md")):
|
||||||
|
zh = Path("docs/technical/zh") / en.name
|
||||||
|
if zh.exists() and en.read_text() == zh.read_text():
|
||||||
|
same.append(en.name)
|
||||||
|
if same:
|
||||||
|
raise SystemExit("identical en/zh docs: " + ", ".join(same))
|
||||||
|
print("no identical en/zh docs")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Language-Less Technical Links
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "/home/ray/dev/linkong/planet/docs/technical/(?!zh|en)" docs/technical/zh --pcre2
|
||||||
|
```
|
||||||
|
|
||||||
|
This should return no matches.
|
||||||
|
|
||||||
|
### Public Docs Registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python - <<'PY'
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
metadata = Path("frontend/src/pages/Docs/docs-content.ts").read_text()
|
||||||
|
known = set(re.findall(r"'([^']+\.md)':\s*\{", metadata))
|
||||||
|
known.add("README.md")
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for readme in [Path("docs/technical/zh/README.md"), Path("docs/technical/en/README.md")]:
|
||||||
|
if not readme.exists():
|
||||||
|
continue
|
||||||
|
for href in re.findall(r"\]\(([^)]+\.md)\)", readme.read_text()):
|
||||||
|
path = Path(href)
|
||||||
|
if "docs/technical/" not in href:
|
||||||
|
continue
|
||||||
|
filename = path.name
|
||||||
|
if filename not in known:
|
||||||
|
missing.append(f"{readme}: {filename}")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise SystemExit("docs README links missing DOCS_METADATA: " + ", ".join(missing))
|
||||||
|
print("docs README links are whitelisted")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Public Bilingual Pairs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python - <<'PY'
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
metadata = Path("frontend/src/pages/Docs/docs-content.ts").read_text()
|
||||||
|
filenames = sorted(set(re.findall(r"'([^']+\.md)':\s*\{", metadata)) - {"README.md"})
|
||||||
|
missing = []
|
||||||
|
for filename in filenames:
|
||||||
|
for lang in ("zh", "en"):
|
||||||
|
path = Path("docs/technical") / lang / filename
|
||||||
|
if not path.exists():
|
||||||
|
missing.append(str(path))
|
||||||
|
if missing:
|
||||||
|
raise SystemExit("missing bilingual docs: " + ", ".join(missing))
|
||||||
|
print("public docs have zh/en file pairs")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Raw Filename Link Titles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "\[[^]]+\.md\]\(" docs/technical/zh docs/technical/en
|
||||||
|
```
|
||||||
|
|
||||||
|
This should return no matches for polished public docs.
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
- [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)
|
- [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)
|
||||||
- [earth-interactable-layer-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-interactable-layer-plan.md)
|
- [earth-interactable-layer-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-interactable-layer-plan.md)
|
||||||
- [frontend-public-docs-site-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-public-docs-site-plan.md)
|
- [frontend-public-docs-site-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-public-docs-site-plan.md)
|
||||||
|
- [Docs Gatekeeper 鉴权系统计划](/home/ray/dev/linkong/planet/docs/plans/docs-gatekeeper-auth-plan.md)
|
||||||
|
- [Location Resolver 共享管线计划](/home/ray/dev/linkong/planet/docs/plans/location-resolver-shared-pipeline-plan.md)
|
||||||
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
||||||
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)
|
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)
|
||||||
|
|
||||||
|
|||||||
384
docs/plans/custom-source-live-mock-plan.md
Normal file
384
docs/plans/custom-source-live-mock-plan.md
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
# Custom Source Live Mock 计划
|
||||||
|
|
||||||
|
**状态**:实施中
|
||||||
|
**创建日期**:2026-05-01
|
||||||
|
**任务名**:`Custom Source Live Mock`
|
||||||
|
**核心目标**:把自定义源升级为同时支持 REST 与 WebSocket 的可映射采集入口,并提供本地 AIS mock WebSocket 服务,用于验证 Earth 船只实时新增与 upsert 链路。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
真实 AIS 接口变化频率不可控,无法稳定验证 Earth 页面“不刷新也能看到新船只”的实时链路。当前系统已经有自定义源基础设施:
|
||||||
|
|
||||||
|
- `datasource_configs` 保存 endpoint、auth、headers、config。
|
||||||
|
- `datasource_mapping_templates` 保存目标 schema 的确定性映射模板。
|
||||||
|
- `run-mapped` 支持保存后的自定义 REST 源通过 active mapping 写入目标数据。
|
||||||
|
|
||||||
|
但现有能力主要面向 REST sample 和批量 mapping,缺少以下能力:
|
||||||
|
|
||||||
|
- 自定义源不能明确选择 `REST` 或 `WebSocket` 采集模式。
|
||||||
|
- WebSocket 长连接、订阅消息、重连、消息路径提取还没有通用 runtime。
|
||||||
|
- `vessel_ais` 自定义数据写入后需要进入 AIS raw observation 和 `vessels` WS channel,才能真实验证 Earth 实时 upsert。
|
||||||
|
- 删除自定义源时没有清晰的数据清理选项。
|
||||||
|
- 设置中心里“采集调度 / 凭证 / 自定义源”入口混杂,用户很难判断该在哪里配置。
|
||||||
|
|
||||||
|
## 已确认决策
|
||||||
|
|
||||||
|
| 项目 | 决策 |
|
||||||
|
|-----|------|
|
||||||
|
| 计划名称 | `Custom Source Live Mock` |
|
||||||
|
| 自定义源传输类型 | 支持 `REST` 与 `WebSocket` |
|
||||||
|
| 采集写入方式 | 先映射到目标 schema,再由 destination handler 写入 |
|
||||||
|
| AIS mock 目标 | 优先打通 `vessel_ais`,验证 Earth 船只实时新增和同 MMSI upsert |
|
||||||
|
| mock 服务 runtime | 使用 `bun` 启动本地 mock WS 服务 |
|
||||||
|
| 凭证配置 | 支持 headers、bearer、api key、basic,并保留 query/header API key 位置配置 |
|
||||||
|
| 删除策略 | 删除自定义源时允许选择是否删除该源写入的数据 |
|
||||||
|
| 合并语义 | 自定义源必须选择“合并到哪个内置数据”,作为内置源的补充数据进入同一聚合链路 |
|
||||||
|
| UI 方向 | 自定义源创建和维护放在“配置中心 > 采集器设置”的采集器下拉框内联入口;数据源页保留总览与运行控制 |
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
### 本阶段要做
|
||||||
|
|
||||||
|
- 自定义源可选择 `REST` 或 `WebSocket`。
|
||||||
|
- 自定义源支持请求头、凭证、query params、body、WS subscribe message。
|
||||||
|
- WebSocket 自定义源支持长连接、重连、消息解析、mapping、写入。
|
||||||
|
- `vessel_ais` 自定义源写入 AIS raw observations,并广播 `vessels` channel。
|
||||||
|
- 提供 mock AIS WS 服务,持续发送新增 MMSI 和位置变更。
|
||||||
|
- 删除自定义源时提供“是否删除该源数据”的选项。
|
||||||
|
- 梳理设置中心信息架构,明确后续 UI 重构方向。
|
||||||
|
|
||||||
|
### 暂不做
|
||||||
|
|
||||||
|
- 不新增任意动态数据库表。
|
||||||
|
- 不允许用户提交可执行脚本作为 mapping。
|
||||||
|
- 不让 LLM 进入正式采集链路。
|
||||||
|
- 不把 mock 数据直接写 legacy `vessel_position`,优先写 AIS raw observations,保持可追踪和可删除。
|
||||||
|
- 不在本阶段完成完整 `Earth Live Sync`,但要为后续 summary invalidation 留出 hook。
|
||||||
|
|
||||||
|
## 现状入口
|
||||||
|
|
||||||
|
| 能力 | 当前位置 |
|
||||||
|
|-----|----------|
|
||||||
|
| 自定义源配置模型 | `backend/app/models/datasource_config.py` |
|
||||||
|
| 自定义源 mapping 模型 | `backend/app/models/datasource_mapping.py` |
|
||||||
|
| 自定义源 API | `backend/app/api/v1/datasource_config.py` |
|
||||||
|
| 目标 schema registry | `backend/app/core/target_schema_registry.py` |
|
||||||
|
| mapping engine | `backend/app/services/datasource_mapping.py` |
|
||||||
|
| 数据源总览 UI | `frontend/src/pages/DataSources/DataSources.tsx` |
|
||||||
|
| 采集器设置 UI | `frontend/src/pages/Settings/Settings.tsx` |
|
||||||
|
|
||||||
|
## 目标架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Custom Source Config] --> B{source_type}
|
||||||
|
B -->|rest| C[Mapped REST Runner]
|
||||||
|
B -->|websocket| D[Mapped WS Runner]
|
||||||
|
C --> E[Mapping Engine]
|
||||||
|
D --> E
|
||||||
|
E --> F[Target Schema Validator]
|
||||||
|
F --> G{Destination Handler}
|
||||||
|
G -->|vessel_ais| H[AIS Raw Observations]
|
||||||
|
H --> I[AIS Aggregation]
|
||||||
|
H --> J[vessels WS Channel]
|
||||||
|
J --> K[Earth Vessel Upsert]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据配置设计
|
||||||
|
|
||||||
|
短期可以继续复用 `DataSourceConfig`,避免大迁移。语义约定如下:
|
||||||
|
|
||||||
|
| 字段 | 用途 |
|
||||||
|
|-----|------|
|
||||||
|
| `name` | 自定义源唯一名称,例如 `mock_ais_ws` |
|
||||||
|
| `source_type` | `rest` 或 `websocket` |
|
||||||
|
| `endpoint` | `http(s)://...` 或 `ws(s)://...` |
|
||||||
|
| `auth_type` | `none`、`bearer`、`api_key`、`basic` |
|
||||||
|
| `auth_config` | token、api_key、key name、basic username/password 等 |
|
||||||
|
| `headers` | 静态请求头 |
|
||||||
|
| `config` | method、params、body、timeout、retry、WS 订阅消息、重连策略、消息路径等 |
|
||||||
|
|
||||||
|
建议 `config` 结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"delivery_mode": "realtime_stream",
|
||||||
|
"merge_target_source": "barentswatch_vessels",
|
||||||
|
"target_schema": "vessel_ais",
|
||||||
|
"method": "GET",
|
||||||
|
"params": {},
|
||||||
|
"body": null,
|
||||||
|
"timeout": 30,
|
||||||
|
"retry": 3,
|
||||||
|
"ws_subscribe_message": {"type": "subscribe", "channel": "vessels"},
|
||||||
|
"ws_message_path": "$.data",
|
||||||
|
"ws_items_path": "$.vessels[*]",
|
||||||
|
"ws_reconnect": true,
|
||||||
|
"reconnect_delay_seconds": 3,
|
||||||
|
"debug_max_messages": null,
|
||||||
|
"delete_policy": "config_only"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后端实施计划
|
||||||
|
|
||||||
|
### Phase 1 — 自定义源类型与连接测试
|
||||||
|
|
||||||
|
- 允许 `source_type` 为 `rest` 或 `websocket`。
|
||||||
|
- REST 连接测试保留现有 HTTP 请求逻辑。
|
||||||
|
- WebSocket 连接测试新增:
|
||||||
|
- 校验 endpoint 必须是 `ws://` 或 `wss://`。
|
||||||
|
- 注入 headers 和 auth。
|
||||||
|
- 连接后可选发送 `ws_subscribe_message`。
|
||||||
|
- 读取一条消息或超时返回诊断。
|
||||||
|
|
||||||
|
### Phase 2 — Mapped REST Runner 补齐
|
||||||
|
|
||||||
|
现有 `run-mapped` 继续作为 REST 一次性采集入口,补齐:
|
||||||
|
|
||||||
|
- `GET/POST` method。
|
||||||
|
- query params。
|
||||||
|
- JSON body。
|
||||||
|
- headers 和 auth 注入。
|
||||||
|
- sample limit 与响应大小限制。
|
||||||
|
- `vessel_ais` destination handler。
|
||||||
|
|
||||||
|
### Phase 3 — Mapped WebSocket Runner
|
||||||
|
|
||||||
|
新增通用 WebSocket runner,读取 `DataSourceConfig + active mapping`:
|
||||||
|
|
||||||
|
- 建立长连接。
|
||||||
|
- 发送可选订阅消息。
|
||||||
|
- 循环接收消息。
|
||||||
|
- JSON parse。
|
||||||
|
- 按 `ws_message_path/ws_items_path` 提取 item 或 list。
|
||||||
|
- 使用 mapping engine 转换。
|
||||||
|
- 使用 target schema validator 校验。
|
||||||
|
- 调用 destination handler 写入。
|
||||||
|
- 更新采集任务状态:
|
||||||
|
- `connecting`
|
||||||
|
- `streaming`
|
||||||
|
- `reconnecting`
|
||||||
|
- `stopped`
|
||||||
|
- 维护运行指标:
|
||||||
|
- `messages_seen`
|
||||||
|
- `records_written`
|
||||||
|
- `unique_entities`
|
||||||
|
- `last_message_at`
|
||||||
|
- `last_error`
|
||||||
|
- 后台长连接不读取 `config.debug_max_messages`;该字段只用于显式的一次性调试运行,避免正式 WS 流被测试上限截断。
|
||||||
|
|
||||||
|
### Phase 4 — Destination Handler
|
||||||
|
|
||||||
|
为 target schema 建立明确写入处理器。
|
||||||
|
|
||||||
|
`vessel_ais` handler:
|
||||||
|
|
||||||
|
- 写入 `AISRawObservation`。
|
||||||
|
- `source = datasource.name`。
|
||||||
|
- `delivery_mode` 来自 config,默认 WS 为 `realtime_stream`、REST 为 `polling`。
|
||||||
|
- `transport` 来自 `source_type`。
|
||||||
|
- 生成幂等 observation hash。
|
||||||
|
- 更新 AIS source health。
|
||||||
|
- 广播 `vessels` channel,payload 使用当前 Earth 已支持的 upsert 格式。
|
||||||
|
|
||||||
|
`generic_records` handler:
|
||||||
|
|
||||||
|
- 写入通用 collected data 或后续 generic store。
|
||||||
|
- 不直接进入 Earth。
|
||||||
|
|
||||||
|
### Phase 5 — 删除与数据清理
|
||||||
|
|
||||||
|
删除自定义源时新增清理策略:
|
||||||
|
|
||||||
|
| 选项 | 行为 |
|
||||||
|
|-----|------|
|
||||||
|
| 只删除配置 | 删除 `datasource_configs`,保留 mapping 和历史数据需要另行处理 |
|
||||||
|
| 删除配置和 mapping | 删除配置及对应 `datasource_mapping_templates` |
|
||||||
|
| 删除配置、mapping 和该源数据 | 同时删除该源写入的数据 |
|
||||||
|
|
||||||
|
数据删除范围:
|
||||||
|
|
||||||
|
- `collected_data.source == datasource.name`
|
||||||
|
- `ais_raw_observations.source == datasource.name`
|
||||||
|
- `ais_source_health.source == datasource.name`
|
||||||
|
|
||||||
|
不建议直接删除 legacy `vessel_position`,因为当前 legacy 表不带 source,无法安全归因。自定义 AIS 源应优先只写 raw observations。
|
||||||
|
|
||||||
|
删除数据后应触发:
|
||||||
|
|
||||||
|
- `vessels` channel 的 reload/invalidation 事件,提示 Earth 重新拉船只聚合。
|
||||||
|
- 后续接入 `Earth Live Sync` 后,触发 `earth_summary` invalidation。
|
||||||
|
|
||||||
|
### Phase 6 — Mock AIS WebSocket 服务
|
||||||
|
|
||||||
|
新增脚本:
|
||||||
|
|
||||||
|
`scripts/mock-ais-ws-server.ts`
|
||||||
|
|
||||||
|
运行方式建议:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run mock:ais-ws
|
||||||
|
```
|
||||||
|
|
||||||
|
服务行为:
|
||||||
|
|
||||||
|
- 监听 `ws://localhost:8787/ais`。
|
||||||
|
- 接受任意客户端连接。
|
||||||
|
- 可记录收到的 subscribe message。
|
||||||
|
- 每 1-2 秒发送一条 AIS-like JSON。
|
||||||
|
- 每隔 N 条生成新 MMSI,验证船只数量增长。
|
||||||
|
- 已存在 MMSI 随时间改变 `lat/lon/cog/heading`,验证同 MMSI upsert。
|
||||||
|
- 支持固定 seed,保证测试可复现。
|
||||||
|
|
||||||
|
示例 payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "vessel",
|
||||||
|
"data": {
|
||||||
|
"mmsi": "999000001",
|
||||||
|
"name": "MOCK VESSEL 001",
|
||||||
|
"lat": 31.23,
|
||||||
|
"lon": 121.47,
|
||||||
|
"sog": 12.4,
|
||||||
|
"cog": 86,
|
||||||
|
"heading": 90,
|
||||||
|
"received_at": "2026-05-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端实施计划
|
||||||
|
|
||||||
|
### 信息架构调整
|
||||||
|
|
||||||
|
自定义源不作为割裂的新入口,而是作为内置采集器的补充源,直接纳入“配置中心 > 采集器设置”的采集器选择器:
|
||||||
|
|
||||||
|
- 采集器下拉框同时展示内置采集器和自定义补充源。
|
||||||
|
- 下拉框右侧提供加号按钮,用于添加自定义源。
|
||||||
|
- 新建自定义源时必须选择“合并到内置数据”,例如合并到 `barentswatch_vessels`。
|
||||||
|
- 选择自定义源后,右侧基础配置区域沿用正常采集器配置形态,支持连接测试、保存、endpoint、headers、auth、高级 JSON。
|
||||||
|
- 自定义源比内置源多一个“删除自定义源”按钮。
|
||||||
|
- 删除时弹出确认框,可勾选“同时删除该自定义源生成的所有数据”。
|
||||||
|
|
||||||
|
数据源页保留:
|
||||||
|
|
||||||
|
- 内置源总览。
|
||||||
|
- 内置源最近状态。
|
||||||
|
- 内置源手动触发。
|
||||||
|
- 不展示自定义源管理入口;自定义源创建、维护、删除统一在采集器设置中完成。
|
||||||
|
|
||||||
|
### 自定义源表单
|
||||||
|
|
||||||
|
新增或重构自定义源表单:
|
||||||
|
|
||||||
|
- 源名称。
|
||||||
|
- 类型:`REST` / `WebSocket`。
|
||||||
|
- 合并到内置数据:必选,用于声明该源补充哪个内置数据域。
|
||||||
|
- endpoint。
|
||||||
|
- method/body/params,仅 REST 显示。
|
||||||
|
- subscribe message/message path/items path,仅 WS 显示。
|
||||||
|
- auth type。
|
||||||
|
- headers。
|
||||||
|
- target schema。
|
||||||
|
- sample/test 按钮。
|
||||||
|
- mapping assistant/preview。
|
||||||
|
- 保存并运行。
|
||||||
|
|
||||||
|
### 删除确认
|
||||||
|
|
||||||
|
删除自定义源时弹出确认:
|
||||||
|
|
||||||
|
- 默认只删除配置。
|
||||||
|
- 可勾选删除 mapping。
|
||||||
|
- 可勾选删除该源写入的数据。
|
||||||
|
- 显示将删除的数据范围和不可恢复提示。
|
||||||
|
|
||||||
|
## 验证方案
|
||||||
|
|
||||||
|
### Mock WS 验证路径
|
||||||
|
|
||||||
|
1. 启动 mock 服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run mock:ais-ws
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 新建自定义源:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|-----|----|
|
||||||
|
| name | `mock_ais_ws` |
|
||||||
|
| source_type | `websocket` |
|
||||||
|
| endpoint | `ws://localhost:8787/ais` |
|
||||||
|
| merge_target_source | `barentswatch_vessels` |
|
||||||
|
| target_schema | `vessel_ais` |
|
||||||
|
| ws_message_path | `$.data` |
|
||||||
|
|
||||||
|
3. 保存 active mapping:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": {
|
||||||
|
"items_path": "$"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"mmsi": {"path": "$.mmsi", "type": "integer"},
|
||||||
|
"name": {"path": "$.name", "type": "string"},
|
||||||
|
"lat": {"path": "$.lat", "type": "float"},
|
||||||
|
"lon": {"path": "$.lon", "type": "float"},
|
||||||
|
"sog": {"path": "$.sog", "type": "float", "default": null},
|
||||||
|
"cog": {"path": "$.cog", "type": "float", "default": null},
|
||||||
|
"heading": {"path": "$.heading", "type": "integer", "default": null},
|
||||||
|
"received_at": {"path": "$.received_at", "type": "datetime", "default": null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 启动自定义源。
|
||||||
|
|
||||||
|
5. 打开 Earth 船只图层,不刷新页面观察:
|
||||||
|
|
||||||
|
- `vessels` WS channel 收到 `source = mock_ais_ws`。
|
||||||
|
- HUD 船只数在新 MMSI 到达时增加。
|
||||||
|
- 地球出现 `MOCK VESSEL`。
|
||||||
|
- 同 MMSI 后续消息更新位置和航向,不重复叠加。
|
||||||
|
|
||||||
|
### 自动化测试
|
||||||
|
|
||||||
|
后端测试:
|
||||||
|
|
||||||
|
- WebSocket 自定义源连接测试。
|
||||||
|
- WS message path 和 items path 提取。
|
||||||
|
- mapping 到 `vessel_ais`。
|
||||||
|
- 写入 AIS raw observation。
|
||||||
|
- 广播 `vessels` channel。
|
||||||
|
- 删除自定义源时按策略删除 mapping 和源数据。
|
||||||
|
|
||||||
|
前端测试:
|
||||||
|
|
||||||
|
- REST/WS 表单条件显示。
|
||||||
|
- 删除确认选项。
|
||||||
|
- mock 源配置保存 payload。
|
||||||
|
- mapping preview 展示错误和成功记录。
|
||||||
|
|
||||||
|
## 风险与约束
|
||||||
|
|
||||||
|
- WebSocket 自定义源是长连接,不能沿用一次性 REST 进度条。
|
||||||
|
- 如果 mock 源写 legacy vessel 表,删除会变得不安全,因此先只写 raw observations。
|
||||||
|
- 自定义 WS 可能消息量很大,必须有 backpressure、日志限流和任务取消能力。
|
||||||
|
- 任意外部 WS 不能信任 payload,必须经过 mapping 和 schema validation。
|
||||||
|
- headers/auth 不能进入 LLM mapping prompt。
|
||||||
|
|
||||||
|
## 交付顺序
|
||||||
|
|
||||||
|
1. Mock AIS WS 服务。
|
||||||
|
2. 后端自定义 WS runner。
|
||||||
|
3. `vessel_ais` destination handler 和 `vessels` broadcast。
|
||||||
|
4. 删除自定义源及数据清理。
|
||||||
|
5. 设置中心采集器下拉框内联自定义源 UI。
|
||||||
|
6. 配置中心信息架构重整。
|
||||||
|
7. 与 `Earth Live Sync` 对接 summary invalidation。
|
||||||
92
docs/plans/docs-gatekeeper-auth-plan.md
Normal file
92
docs/plans/docs-gatekeeper-auth-plan.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Docs Gatekeeper 鉴权系统计划
|
||||||
|
|
||||||
|
**状态**:已实现,当前行为见 [Docs Gatekeeper 开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/docs-gatekeeper-development.md)
|
||||||
|
**创建日期**:2026-05-08
|
||||||
|
**核心目标**:把 `/docs` 从前端公开打包 Markdown 改成后端受控读取,并通过用户 Gatekeeper 权限组划分公开文档、用户文档、开发文档和管理/运维文档。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前 Docs 页面通过前端 `import.meta.glob(...?raw)` 把 `docs/technical/{zh,en}` 中注册过的 Markdown 直接打进前端 bundle。即使在前端隐藏目录或增加路由守卫,受保护 Markdown 仍可能出现在构建产物中,无法形成真正鉴权。
|
||||||
|
|
||||||
|
本阶段需要把文档正文读取迁到后端,并让后端根据当前用户身份返回可见目录和正文。Earth 仍保持公开访问,其它控制台模块暂不改变既有鉴权。
|
||||||
|
|
||||||
|
## 鉴权模型
|
||||||
|
|
||||||
|
保留现有 `users.role`,新增 `gatekeeper_groups` 作为可叠加的权限组。`role` 继续用于控制台和系统操作;Gatekeeper 只负责 Docs 等内容权限。
|
||||||
|
|
||||||
|
默认权限:
|
||||||
|
|
||||||
|
| 身份 | 默认 Docs 能力 |
|
||||||
|
| --- | --- |
|
||||||
|
| 未登录访客 | `public` |
|
||||||
|
| 普通登录用户 | `public`,以及用户被分配的 Gatekeeper 组 |
|
||||||
|
| `admin` | `docs_admin`,并隐含 `docs_developer` / `docs_user` |
|
||||||
|
| `super_admin` | 全部 Docs 权限 |
|
||||||
|
|
||||||
|
Gatekeeper 组:
|
||||||
|
|
||||||
|
- `docs_user`:登录用户操作类文档。
|
||||||
|
- `docs_developer`:开发、前端、后端、Earth 实现文档。
|
||||||
|
- `docs_admin`:运维、服务控制、凭证、环境变量和敏感操作文档。
|
||||||
|
|
||||||
|
## 初步文档划分
|
||||||
|
|
||||||
|
`public`:
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `quickstart.md`
|
||||||
|
- `manual.md`
|
||||||
|
|
||||||
|
`docs_developer`:
|
||||||
|
|
||||||
|
- `earth-frontend-context.md`
|
||||||
|
- `earth-interactable-usage.md`
|
||||||
|
- `earth-layer-style-reference.md`
|
||||||
|
- `earth-render-layer-order.md`
|
||||||
|
- `earth-satellite-footprint-policy.md`
|
||||||
|
- `earth-bgp-context.md`
|
||||||
|
- `earth-news-live-streams-collector-format.md`
|
||||||
|
- `earth-toolbar-overlay-coordination.md`
|
||||||
|
- `frontend-admin-frontend-context.md`
|
||||||
|
- `frontend-layout-guidelines.md`
|
||||||
|
- `backend-collectors.md`
|
||||||
|
- `datasource-collector-settings-connectivity.md`
|
||||||
|
- `backend-datasources-api-performance.md`
|
||||||
|
- `agents-aiprovider.md`
|
||||||
|
|
||||||
|
`docs_admin`:
|
||||||
|
|
||||||
|
- `backend-system-service-control.md`
|
||||||
|
- `ops-docker-compose-buildx-upgrade.md`
|
||||||
|
- `ops-planet-sh-startup.md`
|
||||||
|
|
||||||
|
## 实施要点
|
||||||
|
|
||||||
|
后端新增:
|
||||||
|
|
||||||
|
- `GET /api/v1/docs/catalog`:返回当前用户可见文档目录;未登录只返回 `public`。
|
||||||
|
- `GET /api/v1/docs/{lang}/{slug}`:返回单篇 Markdown;未登录访问受保护文档返回 `401`,已登录无权限返回 `403`。
|
||||||
|
- 服务端维护文档 metadata 白名单,禁止任意路径读取。
|
||||||
|
|
||||||
|
用户管理新增:
|
||||||
|
|
||||||
|
- `users.gatekeeper_groups` JSON 字段。
|
||||||
|
- 用户列表、创建和编辑支持展示/配置 Gatekeeper 权限组。
|
||||||
|
- 只有 `super_admin` 能编辑 Gatekeeper 权限组。
|
||||||
|
|
||||||
|
前端 Docs 改造:
|
||||||
|
|
||||||
|
- 移除 Markdown raw import 作为正文来源。
|
||||||
|
- 从后端 catalog 构建目录和搜索记录。
|
||||||
|
- 从后端 content API 加载正文。
|
||||||
|
- 对 `401` 显示登录入口,对 `403` 显示无权限提示。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 未登录用户只能看到和读取 `public` 文档。
|
||||||
|
- 未登录直接访问受保护文档返回 `401` 并显示登录提示。
|
||||||
|
- 无 Gatekeeper 组的普通用户访问开发文档返回 `403`。
|
||||||
|
- `docs_developer` 用户能读开发文档,不能读管理/运维文档。
|
||||||
|
- `admin` 和 `super_admin` 能读管理/运维文档。
|
||||||
|
- 未知 slug、未知语言和路径穿越字符串不能读取文件。
|
||||||
|
- 前端构建产物不再包含受保护 Markdown raw import 生成的文档模块。
|
||||||
252
docs/plans/earth-mobile-center-country-highlight-plan.md
Normal file
252
docs/plans/earth-mobile-center-country-highlight-plan.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# Earth Mobile Center Country Highlight Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
移动端打开 Earth 国界图层后,用屏幕中心,也就是当前镜头正对的地球表面位置,自动识别所在国家,并高亮该国家国界。
|
||||||
|
|
||||||
|
桌面端仍保持现有 hover 行为。移动端不引入新的国界渲染体系,而是复用已有 `country-boundaries.js` 的 GeoJSON 命中和 hover 高亮能力。
|
||||||
|
|
||||||
|
## Criteria for success
|
||||||
|
|
||||||
|
1. 移动端 `layout-mode-mobile` 下,国界图层开启后,屏幕中心所在国家会自动高亮。
|
||||||
|
2. 移动端旋转、缩放、巡航或自动旋转地球时,高亮会跟随镜头中心更新。
|
||||||
|
3. 屏幕中心落在海洋或没有命中地球时,国家高亮会清除。
|
||||||
|
4. 国界图层关闭时,不执行中心国家识别,也不显示残留高亮。
|
||||||
|
5. 桌面端 pointer hover 行为保持不变。
|
||||||
|
6. 移动端抽屉、搜索、设置、媒体、详情等前景 UI 打开时,不因为用户操作 UI 产生明显误高亮或抖动。
|
||||||
|
7. 中心识别有节流或状态缓存,不把 GeoJSON point-in-polygon 检测放到无条件每帧高频执行。
|
||||||
|
8. 实现后能通过本地静态检查或前端构建,并用移动端 viewport 手动或 Playwright 验证核心场景。
|
||||||
|
|
||||||
|
## Existing pieces
|
||||||
|
|
||||||
|
当前项目已经具备大部分基础能力:
|
||||||
|
|
||||||
|
- [frontend/public/earth/js/country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js)
|
||||||
|
- `updateCountryBoundaryHover(coords)`:根据 `{ lat, lon }` 命中国家并更新高亮线。
|
||||||
|
- `clearCountryBoundaryHover()`:清除当前 hover 高亮。
|
||||||
|
- `getShowCountryBoundaries()`:判断国界线图层是否可见。
|
||||||
|
- [frontend/public/earth/js/utils.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/utils.js)
|
||||||
|
- `screenToEarthCoords(clientX, clientY, camera, earth, domElement)`:屏幕坐标 raycast 到地球表面。
|
||||||
|
- `vector3ToLatLon(vector)`:地球本地坐标转经纬度。
|
||||||
|
- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||||
|
- `COUNTRY_BOUNDARY_CONFIG` 已定义普通国界线和 hover 国界线样式。
|
||||||
|
- 移动端布局状态已经通过 `layout-mode-mobile` body class 区分。
|
||||||
|
|
||||||
|
因此本需求的核心不是新增图层,而是补一个移动端中心取点控制器。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 不改变桌面端 hover 交互。
|
||||||
|
- 不替换 `countries-admin0.min.geojson` 数据源。
|
||||||
|
- 不新增后端 API。
|
||||||
|
- 不把国家面填充做成新的 selected country 面状 shader。
|
||||||
|
- 不为移动端增加永久准星 UI,除非后续产品明确需要视觉准星。
|
||||||
|
|
||||||
|
## Implementation plan
|
||||||
|
|
||||||
|
### 1. Add a small mobile center hover controller
|
||||||
|
|
||||||
|
新增一个轻量函数,建议放在现有主循环附近或单独模块,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/public/earth/js/mobile-center-country-highlight.js
|
||||||
|
```
|
||||||
|
|
||||||
|
建议导出:
|
||||||
|
|
||||||
|
```js
|
||||||
|
updateMobileCenterCountryHighlight({
|
||||||
|
camera,
|
||||||
|
earth,
|
||||||
|
renderer,
|
||||||
|
now,
|
||||||
|
isBlocked,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearMobileCenterCountryHighlight();
|
||||||
|
```
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 判断是否处于移动端。
|
||||||
|
2. 判断国界图层是否开启。
|
||||||
|
3. 判断当前是否被移动端前景 UI 阻塞。
|
||||||
|
4. 对 renderer canvas 中心点做 raycast。
|
||||||
|
5. 命中地球后转经纬度。
|
||||||
|
6. 调用 `updateCountryBoundaryHover({ lat, lon })`。
|
||||||
|
7. 无命中或禁用时调用 `clearCountryBoundaryHover()`。
|
||||||
|
|
||||||
|
### 2. Use canvas center, not window center
|
||||||
|
|
||||||
|
中心点应基于 renderer canvas rect 计算:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
const clientX = rect.left + rect.width / 2;
|
||||||
|
const clientY = rect.top + rect.height / 2;
|
||||||
|
```
|
||||||
|
|
||||||
|
这样在移动端安全区、地址栏变化、viewport resize 或 canvas 非全屏时仍然准确。
|
||||||
|
|
||||||
|
### 3. Convert center point into country hover coords
|
||||||
|
|
||||||
|
复用已有工具:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const point = screenToEarthCoords(clientX, clientY, camera, earth, renderer.domElement);
|
||||||
|
if (!point) {
|
||||||
|
clearCountryBoundaryHover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = vector3ToLatLon(point);
|
||||||
|
updateCountryBoundaryHover(coords);
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:`screenToEarthCoords` 返回的是 earth local point,符合 `vector3ToLatLon` 的输入语义。
|
||||||
|
|
||||||
|
### 4. Gate updates by mobile and foreground UI state
|
||||||
|
|
||||||
|
建议新增一个本地判断函数:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function isMobileCenterCountryHighlightBlocked() {
|
||||||
|
return (
|
||||||
|
!document.body.classList.contains("layout-mode-mobile") ||
|
||||||
|
document.body.classList.contains("earth-search-open") ||
|
||||||
|
document.body.classList.contains("earth-settings-open") ||
|
||||||
|
document.body.classList.contains("earth-media-open") ||
|
||||||
|
document.body.classList.contains("earth-info-open")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果移动端抽屉只是半收起、且没有覆盖中心视野,可以继续允许中心高亮。若实际体验里抽屉展开会遮挡中心点,再把 drawer open 状态纳入阻塞条件。
|
||||||
|
|
||||||
|
### 5. Throttle and cache center updates
|
||||||
|
|
||||||
|
GeoJSON polygon 命中不应该无条件每帧执行。
|
||||||
|
|
||||||
|
第一版建议:
|
||||||
|
|
||||||
|
- `throttleMs = 120`
|
||||||
|
- 缓存上次经纬度,中心点变化小于 `0.05` 度时跳过。
|
||||||
|
- 禁用、切回桌面、图层关闭、UI 阻塞时立即清除一次高亮。
|
||||||
|
|
||||||
|
伪代码:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (now - lastUpdateAt < 120) return;
|
||||||
|
if (Math.abs(coords.lat - lastLat) < 0.05 && Math.abs(coords.lon - lastLon) < 0.05) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Wire into the Earth animation loop
|
||||||
|
|
||||||
|
在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 的动画循环中调用:
|
||||||
|
|
||||||
|
```js
|
||||||
|
updateMobileCenterCountryHighlight({
|
||||||
|
camera,
|
||||||
|
earth,
|
||||||
|
renderer,
|
||||||
|
now: performance.now(),
|
||||||
|
isBlocked: isMobileCenterCountryHighlightBlocked(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
这样自动旋转、手势旋转、缩放和巡航都会自然更新。
|
||||||
|
|
||||||
|
### 7. Keep desktop hover unchanged
|
||||||
|
|
||||||
|
桌面 pointer hover 仍然走当前逻辑。
|
||||||
|
|
||||||
|
移动端中心高亮只在 `layout-mode-mobile` 下生效,不应该监听 pointer move,也不应该抢占 desktop hover 状态。
|
||||||
|
|
||||||
|
### 8. Optional visual tuning
|
||||||
|
|
||||||
|
第一版复用:
|
||||||
|
|
||||||
|
- `COUNTRY_BOUNDARY_CONFIG.hoverLineColor`
|
||||||
|
- `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity`
|
||||||
|
- `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity`
|
||||||
|
|
||||||
|
如果移动端体验太强,可以后续加独立配置:
|
||||||
|
|
||||||
|
```js
|
||||||
|
mobileCenterHoverLineOpacity
|
||||||
|
mobileCenterHoverGlowOpacity
|
||||||
|
```
|
||||||
|
|
||||||
|
但第一版不建议过早分叉样式。
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Static checks
|
||||||
|
|
||||||
|
1. `npm` 前端构建或现有 lint/typecheck 命令通过。
|
||||||
|
2. `rg` 确认新增函数只在移动端路径调用,不影响桌面 pointer hover。
|
||||||
|
3. `git diff --stat` 和目标文件 diff 确认改动范围集中。
|
||||||
|
|
||||||
|
### Manual mobile checks
|
||||||
|
|
||||||
|
使用移动端 viewport,例如 390x844:
|
||||||
|
|
||||||
|
1. 打开 Earth。
|
||||||
|
2. 开启国界图层。
|
||||||
|
3. 转动地球到中国、美国、澳大利亚等大块陆地区域,确认中心国家国界高亮。
|
||||||
|
4. 转动到太平洋或印度洋,确认高亮消失。
|
||||||
|
5. 缩放地球,确认高亮仍跟随中心点。
|
||||||
|
6. 打开移动端搜索、设置、媒体或详情面板,确认没有明显误高亮或抖动。
|
||||||
|
7. 切回桌面 viewport,确认 hover 仍由鼠标位置控制。
|
||||||
|
|
||||||
|
### Playwright smoke check
|
||||||
|
|
||||||
|
如果已有 Playwright 流程,建议补一个移动端 smoke:
|
||||||
|
|
||||||
|
1. 设置 viewport 为手机尺寸。
|
||||||
|
2. 打开 Earth 页面。
|
||||||
|
3. 开启国界图层。
|
||||||
|
4. 等待国界数据加载。
|
||||||
|
5. 截图确认中心附近国家边界有 hover 高亮线。
|
||||||
|
|
||||||
|
这个 smoke 不必断言具体国家名称,因为当前功能核心是视觉高亮;更稳定的自动化可以后续通过暴露 debug state 实现。
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
### Polygon hit cost too高
|
||||||
|
|
||||||
|
风险:移动端设备上频繁 `featureContains` 可能带来卡顿。
|
||||||
|
|
||||||
|
缓解:
|
||||||
|
|
||||||
|
- 使用 `120ms` 节流。
|
||||||
|
- 经纬度变化小于阈值时跳过。
|
||||||
|
- 后续如仍慢,再为 GeoJSON features 预计算 bbox,先 bbox 粗筛再 point-in-polygon。
|
||||||
|
|
||||||
|
### UI blocking state 不完整
|
||||||
|
|
||||||
|
风险:某些移动端前景 UI 没有对应 body class,中心点被遮挡但高亮仍更新。
|
||||||
|
|
||||||
|
缓解:
|
||||||
|
|
||||||
|
- 第一版覆盖现有主要 class。
|
||||||
|
- 验证时记录遗漏项,补充到 `isMobileCenterCountryHighlightBlocked()`。
|
||||||
|
|
||||||
|
### Desktop hover 被移动端状态污染
|
||||||
|
|
||||||
|
风险:移动端中心高亮和桌面 hover 共用 `_hoveredFeature` 状态。
|
||||||
|
|
||||||
|
缓解:
|
||||||
|
|
||||||
|
- 只在 `layout-mode-mobile` 下运行中心高亮。
|
||||||
|
- 切出 mobile 或图层关闭时调用一次 `clearCountryBoundaryHover()`。
|
||||||
|
- 不改 `updateCountryBoundaryHover()` 的语义。
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
1. 设计落地:完成本 plan,明确目标和验收标准。
|
||||||
|
2. 最小实现:新增移动端中心取点 controller,并接入 animation loop。
|
||||||
|
3. 性能保护:加入节流、经纬度阈值和禁用态清理。
|
||||||
|
4. 验证:本地构建通过,移动端 viewport 手动检查通过。
|
||||||
|
5. 调优:根据截图或真机体验微调阻塞条件和节流阈值。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# AIS 多源采集、冲突记录与聚合接口计划
|
# AIS 多源采集、冲突记录与聚合接口计划
|
||||||
|
|
||||||
**状态**:规划中
|
**状态**:v0-v3 已实现,v3.1-v3.4 为 v4/v5 前置稳定化任务,v4 / v5 已落最小可用子集
|
||||||
**创建日期**:2026-04-30
|
**创建日期**:2026-04-30
|
||||||
**核心原则**:采集器只写原始观测;去重、合并、冲突解释放在聚合接口中完成
|
**核心原则**:采集器只写原始观测;去重、合并、冲突解释放在聚合接口中完成
|
||||||
|
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
| 冲突处理 | 先记录冲突事实和当前选择原因,后续再开放用户规则配置 |
|
| 冲突处理 | 先记录冲突事实和当前选择原因,后续再开放用户规则配置 |
|
||||||
| 默认可信度 | 同类 AIS 数据源优先按 `delivery_mode` 评估:`realtime_stream` 优于 `batch_stream`,再优于 `polling` 和 `snapshot` |
|
| 默认可信度 | 同类 AIS 数据源优先按 `delivery_mode` 评估:`realtime_stream` 优于 `batch_stream`,再优于 `polling` 和 `snapshot` |
|
||||||
| 过期保护 | 实时流源断流超过 freshness 窗口后,不能仅凭“实时源”身份压过更新的轮询数据 |
|
| 过期保护 | 实时流源断流超过 freshness 窗口后,不能仅凭“实时源”身份压过更新的轮询数据 |
|
||||||
|
| 源健康状态 | 聚合时必须参考采集器健康状态,不能只看配置中的理论优先级 |
|
||||||
|
| 媒体富化 | 船只图片等媒体信息不进入 AIS 实时聚合主链路,后续单独做 enrichment |
|
||||||
|
| v4/v5 顺序 | 在聚合完整性、AISStream 实时链路、采集状态语义和基础身份信息显示修好之前,不进入策略配置和 enrichment UI |
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|
||||||
@@ -24,7 +27,7 @@
|
|||||||
- WebSocket 或其他实时流通常更接近实时,但也可能断流或批量延迟。
|
- WebSocket 或其他实时流通常更接近实时,但也可能断流或批量延迟。
|
||||||
- 如果每个 collector 自己做去重合并,规则会分散、不可审计,也很难让用户后续配置“某个字段信任哪个来源”。
|
- 如果每个 collector 自己做去重合并,规则会分散、不可审计,也很难让用户后续配置“某个字段信任哪个来源”。
|
||||||
|
|
||||||
因此 v1 不应让采集器直接覆盖最终船只表。更稳的方式是先保留观测事实,再由聚合接口统一给出当前展示视图。
|
因此第一阶段不应让采集器直接覆盖最终船只表。更稳的方式是先保留观测事实,再由聚合接口统一给出当前展示视图。
|
||||||
|
|
||||||
## 目标架构
|
## 目标架构
|
||||||
|
|
||||||
@@ -53,11 +56,36 @@ flowchart LR
|
|||||||
| `transport` | `websocket`、`sse`、`http`、`file` 等 |
|
| `transport` | `websocket`、`sse`、`http`、`file` 等 |
|
||||||
| `observed_at` | 上游数据时间,优先使用 AIS 消息时间 |
|
| `observed_at` | 上游数据时间,优先使用 AIS 消息时间 |
|
||||||
| `collected_at` | 本系统接收或采集时间 |
|
| `collected_at` | 本系统接收或采集时间 |
|
||||||
|
| `source_message_id` | 上游消息 ID 或可推导 ID,没有则为空 |
|
||||||
|
| `observation_hash` | 幂等去重指纹,用于防止同一来源重复写入同一条观测 |
|
||||||
| `normalized_payload` | 标准化后的 AIS JSON |
|
| `normalized_payload` | 标准化后的 AIS JSON |
|
||||||
| `raw_payload` | 可选,保存原始或裁剪后的上游记录 |
|
| `raw_payload` | 可选,保存原始或裁剪后的上游记录 |
|
||||||
|
| `quality_flags` | 观测级质量标记,例如 `stale`、`position_jump`、`future_timestamp` |
|
||||||
|
|
||||||
`delivery_mode` 和 `transport` 不应混为一谈。WebSocket 是传输方式;streaming 是交付模式。聚合可信度主要看 `delivery_mode`,`transport` 只作为辅助信息。
|
`delivery_mode` 和 `transport` 不应混为一谈。WebSocket 是传输方式;streaming 是交付模式。聚合可信度主要看 `delivery_mode`,`transport` 只作为辅助信息。
|
||||||
|
|
||||||
|
原始观测层需要做存储级幂等去重,但这里的去重不是业务合并。推荐使用 `source + entity_key + message_type + observed_at + payload_hash` 或上游稳定消息 ID 作为唯一约束,避免 WebSocket 重连、HTTP 重试或批量回放导致同一事实重复入库。
|
||||||
|
|
||||||
|
### 源健康状态
|
||||||
|
|
||||||
|
每个采集器应维护独立的健康状态,供聚合服务读取:
|
||||||
|
|
||||||
|
| 字段 | 用途 |
|
||||||
|
|-----|------|
|
||||||
|
| `source` | 采集器标识 |
|
||||||
|
| `connection_state` | `connected`、`reconnecting`、`disconnected`、`disabled` 等 |
|
||||||
|
| `last_seen_at` | 最近收到上游消息或响应的时间 |
|
||||||
|
| `last_success_at` | 最近成功写入观测的时间 |
|
||||||
|
| `last_error` | 最近错误摘要 |
|
||||||
|
| `message_rate` | 最近窗口内的消息速率 |
|
||||||
|
| `lag_seconds` | 上游观测时间与本系统接收时间的延迟 |
|
||||||
|
|
||||||
|
聚合优先级不能只看 `source_priority`。例如 `aisstream_vessels` 默认优先于 `barentswatch_vessels`,但如果它处于 `disconnected` 或 `lag_seconds` 超过 freshness 窗口,则动态字段应回退到更新的可用来源。
|
||||||
|
|
||||||
|
### 身份键边界
|
||||||
|
|
||||||
|
v1 可以继续用 MMSI 作为 `entity_key`,因为它是 AIS 动态消息里最稳定、最容易获得的主键。但文档和模型都要为后续扩展留出口:MMSI 可能复用、填错或缺少静态信息,后续身份解析应结合 `mmsi + imo + callsign + name + dimensions` 判断是否需要拆分或合并实体。
|
||||||
|
|
||||||
### 冲突记录层
|
### 冲突记录层
|
||||||
|
|
||||||
聚合服务发现同一个实体、同一个字段存在多个非空不同值时,写入冲突记录。冲突记录不代表错误,只代表“有多个可用候选值”。
|
聚合服务发现同一个实体、同一个字段存在多个非空不同值时,写入冲突记录。冲突记录不代表错误,只代表“有多个可用候选值”。
|
||||||
@@ -90,7 +118,8 @@ flowchart LR
|
|||||||
| 动态位置 | `lat`、`lon`、`sog`、`cog`、`heading`、`nav_status` | 优先最新 `observed_at`,同时间再按来源优先级 |
|
| 动态位置 | `lat`、`lon`、`sog`、`cog`、`heading`、`nav_status` | 优先最新 `observed_at`,同时间再按来源优先级 |
|
||||||
| 静态身份 | `name`、`callsign`、`imo`、`flag` | 非空优先,再按字段策略或来源优先级 |
|
| 静态身份 | `name`、`callsign`、`imo`、`flag` | 非空优先,再按字段策略或来源优先级 |
|
||||||
| 静态规格 | `vessel_type`、`vessel_type_name`、`length`、`width`、`draught` | 非空优先;冲突时记录候选值 |
|
| 静态规格 | `vessel_type`、`vessel_type_name`、`length`、`width`、`draught` | 非空优先;冲突时记录候选值 |
|
||||||
| 元信息 | `field_sources`、`conflict_count`、`selected_reasons` | 聚合接口生成,便于调试和后续 UI 展示 |
|
| 轨迹点 | `track_points` | 按时间线合并;同一时间窗口内相近点去重;保留点级 `source` |
|
||||||
|
| 元信息 | `field_sources`、`conflict_count`、`selected_reasons`、`quality_flags` | 聚合接口生成,便于调试和后续 UI 展示 |
|
||||||
|
|
||||||
### 默认优先级
|
### 默认优先级
|
||||||
|
|
||||||
@@ -124,6 +153,27 @@ freshness:
|
|||||||
|
|
||||||
如果 `aisstream_vessels` 最近 15 分钟没有该 MMSI 的新观测,而 BarentsWatch 轮询源有更新位置,则位置类字段应采用 BarentsWatch 的更新观测,并记录选择原因 `newest_observation` 或 `freshness_fallback`。
|
如果 `aisstream_vessels` 最近 15 分钟没有该 MMSI 的新观测,而 BarentsWatch 轮询源有更新位置,则位置类字段应采用 BarentsWatch 的更新观测,并记录选择原因 `newest_observation` 或 `freshness_fallback`。
|
||||||
|
|
||||||
|
### 异常位置保护
|
||||||
|
|
||||||
|
多源 AIS 接入后,聚合服务必须过滤或降权明显异常的位置观测:
|
||||||
|
|
||||||
|
- 经纬度必须在合法范围内。
|
||||||
|
- `observed_at` 不能明显来自未来。
|
||||||
|
- 同一 MMSI 短时间内跨越不合理距离时,标记 `position_jump`,默认不直接采用该点。
|
||||||
|
- 当异常点来自当前优先源时,应记录 `selected_reason = anomaly_rejected`,再回退到其他可用来源。
|
||||||
|
|
||||||
|
异常保护不应静默丢弃事实。原始观测仍应保留,聚合结果通过 `quality_flags` 和冲突记录解释为什么没有采用它。
|
||||||
|
|
||||||
|
### 轨迹聚合
|
||||||
|
|
||||||
|
轨迹接口不能简单拼接所有来源,否则前端会出现折返、抖动和重复点。默认规则:
|
||||||
|
|
||||||
|
- 以 `observed_at` 排序,生成统一时间线。
|
||||||
|
- 同一来源的完全重复点通过 `observation_hash` 去重。
|
||||||
|
- 多来源在短时间窗口内上报的相近位置视为同一轨迹点,优先选择 freshness 和 source priority 更高的一条。
|
||||||
|
- 每个轨迹点保留 `source`、`selected_reason` 和必要的 `quality_flags`。
|
||||||
|
- 对被判定为 `position_jump` 的点,默认不进入展示轨迹,但可通过调试参数查看。
|
||||||
|
|
||||||
## 聚合接口
|
## 聚合接口
|
||||||
|
|
||||||
现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic`。
|
现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic`。
|
||||||
@@ -155,6 +205,7 @@ GeoJSON properties 建议增加:
|
|||||||
"lat": "newest_observation",
|
"lat": "newest_observation",
|
||||||
"vessel_type": "non_empty_priority"
|
"vessel_type": "non_empty_priority"
|
||||||
},
|
},
|
||||||
|
"quality_flags": [],
|
||||||
"conflict_count": 2
|
"conflict_count": 2
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -231,15 +282,171 @@ aisstream_vessels:
|
|||||||
- ShipStaticData
|
- ShipStaticData
|
||||||
```
|
```
|
||||||
|
|
||||||
## 实施顺序
|
默认不建议直接订阅全球范围。AISStream 采集器应支持以下订阅策略:
|
||||||
|
|
||||||
1. 新增原始观测模型和冲突记录模型。
|
- 使用配置的固定 `bounding_boxes`。
|
||||||
2. 实现 AIS 聚合服务,先从现有 `vessel_position` / `vessel_static` 兼容读取,再逐步切换到原始观测层。
|
- 后续支持按 Earth 当前视口或关注区域动态调整订阅范围。
|
||||||
3. 将 `/geo/vessels` 和 `/vessels/{mmsi}` 改为走聚合服务。
|
- 支持限制 `message_types`,避免静态信息、位置报告和扩展消息全量涌入。
|
||||||
4. 改造 BarentsWatch 保存逻辑,让它写入原始观测,同时保留现有表作为兼容缓存。
|
- 断线后使用指数退避重连,并把连接状态写入源健康状态。
|
||||||
5. 实现 AISStream WebSocket collector。
|
- 重连后可能收到重复或回放消息,因此必须依赖原始观测层的幂等去重。
|
||||||
6. 接入系统设置中的聚合策略配置。
|
|
||||||
7. 做冲突治理 UI。
|
### 媒体富化边界
|
||||||
|
|
||||||
|
VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图片、船籍详情、公司信息等后续应作为独立 enrichment 链路:
|
||||||
|
|
||||||
|
- 通过 MMSI、IMO、船名等字段异步查询。
|
||||||
|
- 使用独立缓存和授权配置。
|
||||||
|
- 不阻塞 `vessel_ais` 实时观测入库。
|
||||||
|
- 聚合接口只暴露已经缓存好的媒体引用,不在请求链路中现场抓取。
|
||||||
|
|
||||||
|
## 版本拆分
|
||||||
|
|
||||||
|
计划先按 v0-v3 建立基础能力,再用 v3.1-v3.4 修复当前稳定性缺口,最后进入 v4/v5:
|
||||||
|
|
||||||
|
### v0 — 聚合基础设施(已实现)
|
||||||
|
|
||||||
|
目标是不改变前端展示行为,先把数据底座铺好。
|
||||||
|
|
||||||
|
1. 新增原始观测模型、冲突记录模型和源健康状态模型。
|
||||||
|
2. 为现有 BarentsWatch collector 写入原始观测,同时保留现有 `vessel_position` / `vessel_static` 兼容写入。
|
||||||
|
3. 实现存储级 `observation_hash` 幂等去重。
|
||||||
|
4. 补基础管理命令或调试接口,用于查看某个 MMSI 的原始观测和冲突候选。
|
||||||
|
|
||||||
|
### v1 — 聚合读接口(已实现)
|
||||||
|
|
||||||
|
目标是让展示接口开始消费聚合结果,但前端形状保持兼容。
|
||||||
|
|
||||||
|
1. 实现 AIS 聚合服务,先兼容读取现有表,再逐步切换到原始观测层。
|
||||||
|
2. 将 `/geo/vessels` 和 `/vessels/{mmsi}` 改为走聚合服务。
|
||||||
|
3. 将 `/vessels/{mmsi}/track` 改为走轨迹聚合逻辑。
|
||||||
|
4. 返回 `field_sources`、`selected_reasons`、`quality_flags`、`conflict_count`。
|
||||||
|
5. 加入 freshness fallback 和异常位置保护。
|
||||||
|
|
||||||
|
### v2 — AISStream WebSocket collector(已实现)
|
||||||
|
|
||||||
|
目标是接入第二个真实 AIS 来源,并验证多源冲突和回退逻辑。
|
||||||
|
|
||||||
|
1. 实现 `aisstream_vessels` collector。
|
||||||
|
2. 支持 API key、订阅范围、消息类型、重连和限流配置。
|
||||||
|
3. 将 AISStream 写入原始观测层,不直接 upsert 最终展示表。
|
||||||
|
4. 接入源健康状态和 message rate 统计。
|
||||||
|
5. 提供 AISStream API Key 获取教程、设置页入口和连接验证支持。
|
||||||
|
6. 为重复消息、断流回退、WS 优先级写集成测试。
|
||||||
|
|
||||||
|
### v3 — AISStream 可用性与配置体验(已实现)
|
||||||
|
|
||||||
|
目标是让 AISStream 从“能采集”变成日常可观察、可调试、可配置的数据源。
|
||||||
|
|
||||||
|
1. 设置页展示 AISStream 运行状态:连接状态、最近收到、最近成功、本轮消息数、延迟和最近错误。
|
||||||
|
2. AISStream 设置页提供常用采集范围 preset,并保留自定义 Bounding Boxes JSON。
|
||||||
|
3. 聚合结果返回 `source_summary`,展示每艘船的来源、观测数量、最新观测时间、传输模式和消息类型。
|
||||||
|
4. 保留 `field_sources` 和 `selected_reasons`,用于解释动态字段来自实时流、静态字段来自可用非空来源。
|
||||||
|
5. 船名标准化会读取 AISStream `MetaData.ShipName`;船型展示会从 `vessel_type_name` 和 AIS 数字 `vessel_type` 共同归一化,保证 marker 颜色、详情卡、hover 和搜索结果一致。
|
||||||
|
6. `/geo/vessels` 不再默认限制 5000 艘;不传 `limit` 或传 `limit=0` 表示全量返回,前端默认也不再二次裁剪到 5000。
|
||||||
|
|
||||||
|
### v3.1 — 聚合完整性修复(v4 前置)
|
||||||
|
|
||||||
|
目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。
|
||||||
|
|
||||||
|
当前风险是 `/geo/vessels` 只要 raw observation 聚合返回非空,就直接使用 raw 聚合结果,不再补读兼容层 `vessel_position + vessel_static`。如果 raw observation 中只存在 AISStream 的几百艘船,或 BarentsWatch 历史数据没有完整回填到 raw 层,最终 Earth 就会只显示 AISStream 子集。
|
||||||
|
|
||||||
|
1. `/geo/vessels` 必须合并 raw observation 聚合结果和 legacy latest position 结果。
|
||||||
|
2. raw 与 legacy 同一 MMSI 同时存在时只显示一艘,优先使用 raw 聚合结果及其 `field_sources` / `selected_reasons`。
|
||||||
|
3. raw 中不存在的 BarentsWatch-only MMSI 必须从 `vessel_position + vessel_static` 补齐。
|
||||||
|
4. `bbox`、`type`、`limit` 过滤必须作用在合并后的最终集合上;不传 `limit` 或 `limit=0` 仍表示全量返回。
|
||||||
|
5. 增加诊断统计,至少能看到 raw AISStream unique MMSI、raw BarentsWatch unique MMSI、legacy unique MMSI、final merged unique MMSI 和被 legacy 补齐的数量。
|
||||||
|
6. 为 raw 只有 AISStream 子集、legacy 有更多 BarentsWatch 船只的场景补回归测试。
|
||||||
|
|
||||||
|
### v3.2 — AISStream 真实时链路(v4 前置)
|
||||||
|
|
||||||
|
目标是把 AISStream 从“一次 collector 收一批消息后结束”改成真正的 WebSocket 长连接实时数据源,并把实时变化推送到 Earth。
|
||||||
|
|
||||||
|
当前 `aisstream_vessels` 只在 collector `fetch()` 中连接 `wss://stream.aisstream.io/v0/stream`,默认收 `max_messages = 500` 条后结束。这不符合 WebSocket 流式数据源的运行语义,也不能保证新船、位置变化和航向变化实时出现在前端。
|
||||||
|
|
||||||
|
1. 为 AISStream 增加 streaming service / long-running runner,不再依赖单次 `fetch -> transform -> save -> completed` 表达实时采集。
|
||||||
|
2. 外部 AISStream WebSocket 保持长连接,断线后指数退避重连,并持续更新 `AISSourceHealth`。
|
||||||
|
3. 每条或小批量 AIS 消息标准化后写入 `ais_raw_observations`,按时间或数量短周期 commit,避免长事务堆积。
|
||||||
|
4. 将新增船只、位置变化、航向变化和静态字段补充转换成 vessel delta。
|
||||||
|
5. 通过应用内部 `/ws` 的 `vessels` channel 广播 delta,复用 `DataBroadcaster.broadcast_custom("vessels", payload)`。
|
||||||
|
6. Earth 前端订阅 `vessels` channel,`vessels.js` 支持按 MMSI upsert marker,而不是每次全量 reload。
|
||||||
|
7. 船只改变航向时,前端必须更新 course bin / marker bucket,避免 marker 方向滞后。
|
||||||
|
8. freshness 超时或 AISStream 健康异常时,动态字段可回退到 BarentsWatch 最新可用观测。
|
||||||
|
|
||||||
|
### v3.3 — Streaming 采集状态语义(v4 前置)
|
||||||
|
|
||||||
|
目标是让采集页面正确表达 AISStream 这类长连接数据源,不再使用一次性 REST collector 的完成型进度条。
|
||||||
|
|
||||||
|
REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100 -> completed`。AISStream 的自然状态应是 `connecting -> streaming -> reconnecting -> stopped/failed`,没有固定总量,也不应在收到一批消息后显示“采集完成”。
|
||||||
|
|
||||||
|
1. AISStream 采集状态使用 indeterminate / streaming 状态,而不是百分比完成进度条。
|
||||||
|
2. 设置页运行状态卡展示连接状态、已运行时长、本轮消息数、新增观测数、unique MMSI、message rate、最近消息时间、延迟和最近错误。
|
||||||
|
3. `phase_message` 使用“正在接收 AISStream 实时消息”“重连中”“已停止”等长连接语义。
|
||||||
|
4. 停止、重连和配置变更要有明确操作入口;配置变化后必须安全重订阅。
|
||||||
|
5. 后端任务状态不能因为没有 `total_records` 就长期显示 `0%` 或误判失败。
|
||||||
|
6. WebSocket 健康状态和 collector task 状态要分离:上游短暂断线是 `reconnecting`,不是普通采集任务完成或失败。
|
||||||
|
|
||||||
|
### v3.4 — 船只身份字段和名称聚合修复(v4 前置)
|
||||||
|
|
||||||
|
目标是把 MMSI、IMO、callsign 这类身份编号按字符串显示,并把仍然使用 MMSI 作为船名的记录视为信息聚合未完成,而不是正常船名。
|
||||||
|
|
||||||
|
1. 前端详情卡、hover、搜索结果和日志中的 `mmsi`、`imo`、`callsign` 必须作为 identifier 字段展示,禁止走 `toLocaleString()` 或数字千分位格式。
|
||||||
|
2. GeoJSON 可增加 `mmsi_display` / `imo_display` 等字符串字段,但前端仍必须对 identifier key 做兜底格式保护。
|
||||||
|
3. 聚合服务生成船名时,不能把 `MMSI 257123000` 当成真实 `name` 的成功结果;它只能作为 display fallback。
|
||||||
|
4. 增加诊断查询,列出所有当前仍以 MMSI 号码或 `MMSI <number>` 作为船只名称的记录,包括:
|
||||||
|
- `vessel_static.name` 为空或等于 MMSI fallback 的 MMSI;
|
||||||
|
- raw observation 中没有任何非空 `name` / `MetaData.ShipName` / `ShipStaticData.Name` 的 MMSI;
|
||||||
|
- 聚合结果最终 `name` 仍为 fallback 的 MMSI;
|
||||||
|
- 每个 MMSI 的可用来源、最近观测时间、message types 和缺失原因。
|
||||||
|
5. 对这些 fallback-name 船只建立待修复集合,优先通过 AISStream `ShipStaticData`、BarentsWatch 静态字段和后续 enrichment 缓存补齐。
|
||||||
|
6. 船只详情面板需要区分“真实船名”和“显示兜底”:真实船名缺失时展示 `MMSI <id>` 可以继续作为标题,但字段来源应标注为 `fallback`,避免误以为聚合成功。
|
||||||
|
7. 为 MMSI 千分位格式、fallback-name 诊断和名称来源解释补回归测试。
|
||||||
|
|
||||||
|
### v4 — 策略配置(v0 可用)
|
||||||
|
|
||||||
|
目标是开放系统级配置,但仍以安全默认值兜底。
|
||||||
|
|
||||||
|
已落地的最小子集:
|
||||||
|
|
||||||
|
1. 策略持久化在 `system_settings.category = 'vessel_aggregation_strategy'`,保存时自动版本递增。
|
||||||
|
2. `app/services/vessel_aggregation_strategy.py` 暴露 `load_strategy / save_strategy / reset_strategy / validate_strategy`,并维护 `DEFAULT_STRATEGY` 兜底。
|
||||||
|
3. 校验规则:
|
||||||
|
- 未知 `field_rules.<name>` → `400 unknown vessel_ais field`;
|
||||||
|
- 未知 mode → `400 mode must be one of ...`;
|
||||||
|
- 动态字段(`lat/lon/sog/cog/heading/nav_status`)使用非 `newest` mode 时必须显式 `allow_dynamic_lock=true`,否则拒绝;
|
||||||
|
- `freshness.realtime_stream_seconds` / `polling_seconds` 必须为非负整数;
|
||||||
|
- `mode=locked` 必须带非空 `locked_source`。
|
||||||
|
4. 聚合服务 `vessel_ais_aggregation.py` 在 `_select_position_observation` 中按 `freshness` 把过期实时流降级到 stale 候选;在 `_select_static_field` 中按 `field_rules.mode = source_priority / locked / newest / non_empty` 选源。
|
||||||
|
5. 聚合输出每条 vessel 携带 `aggregation_strategy_version`,并在 `/geo/vessels` GeoJSON properties + `/vessels/{mmsi}` 详情中暴露。
|
||||||
|
6. API:
|
||||||
|
- `GET /api/v1/vessel-aggregation/strategy`
|
||||||
|
- `PUT /api/v1/vessel-aggregation/strategy`(校验失败 400)
|
||||||
|
- `DELETE /api/v1/vessel-aggregation/strategy`(恢复默认并 bump version)
|
||||||
|
|
||||||
|
未做项(留给 v4 后续):
|
||||||
|
|
||||||
|
- 系统设置 UI 中的策略编辑器尚未做,目前直接调 API;
|
||||||
|
- `transport_priority`、`quality_flags` 级别的策略尚未引入;
|
||||||
|
- `source_priority` 中的未知 source 不强校验,留给后续 warn-only 提示。
|
||||||
|
|
||||||
|
### v5 — 船舶资料 enrichment 与冲突治理(v0 可用)
|
||||||
|
|
||||||
|
目标是把 AIS 实时流里不稳定或低频出现的静态信息,补成可缓存、可审计的船舶资料层,同时把冲突解释变成可操作能力。
|
||||||
|
|
||||||
|
已落地的最小子集:
|
||||||
|
|
||||||
|
1. 新增模型 `app/models/vessel_enrichment.py::VesselProfileEnrichment` + `VesselMediaEnrichment`:以 `mmsi` 为主键,记录 `source / payload / fetched_at / expires_at / confidence / reference_url`;通过 `Base.metadata.create_all` 在 `init_db` 中建表。
|
||||||
|
2. 服务 `app/services/vessel_enrichment.py` 提供 `upsert_vessel_profile_enrichment` / `upsert_vessel_media_enrichment` / `get_vessel_enrichment_bundle`;读路径只读缓存,过期记录(`expires_at < now`)直接过滤为 `None`,永不联网。
|
||||||
|
3. 聚合接口在 `/api/v1/visualization/vessels/{mmsi}` 响应中追加 `enrichment.profile` 与 `enrichment.media` 字段(含 `source / fetched_at / expires_at / confidence / reference_url`);命中失败时返回 `null`,不阻塞 AIS 实时链路。
|
||||||
|
4. 冲突治理 API:
|
||||||
|
- `POST /api/v1/vessel-aggregation/conflicts/{mmsi}/{field}/promote-to-rule` 读取最近 `AISConflictRecord.selected_source`,写入 `field_rules[field] = {mode: source_priority, source_priority: [<source>]}` 并 bump version;
|
||||||
|
- `DELETE` 对应路径移除该 field 的覆盖,恢复默认。
|
||||||
|
5. 前端 Earth `info-card.js` 渲染 `船舶资料` 区块:profile.payload 标量字段平铺、媒体 `images` 数组缩略图、来源 / 更新时间 / 置信度元数据;缓存命中失败回退到 `资料缓存中`;常规字段在 `field_sources` 命中时附带来源 tag。
|
||||||
|
|
||||||
|
未做项(留给 v5 后续):
|
||||||
|
|
||||||
|
- 没有真正的异步 enrichment 抓取作业;当前依赖外部脚本/管理 API 写入缓存;
|
||||||
|
- 冲突治理 UI 还没接入设置中心,目前只暴露 API;
|
||||||
|
- enrichment 命中状态尚未广播到 `vessels` channel,详情面板首次打开时按需请求即可。
|
||||||
|
|
||||||
## 测试计划
|
## 测试计划
|
||||||
|
|
||||||
@@ -247,8 +454,18 @@ aisstream_vessels:
|
|||||||
- 多来源同一 MMSI 的位置字段优先选择最新观测。
|
- 多来源同一 MMSI 的位置字段优先选择最新观测。
|
||||||
- 实时流和轮询源同时间冲突时,实时流优先。
|
- 实时流和轮询源同时间冲突时,实时流优先。
|
||||||
- 实时流过期后,更新的轮询源可以接管动态字段。
|
- 实时流过期后,更新的轮询源可以接管动态字段。
|
||||||
|
- 实时流源健康状态异常时,动态字段可以回退到更新的可用来源。
|
||||||
- 静态字段不会被空值覆盖。
|
- 静态字段不会被空值覆盖。
|
||||||
- 静态字段冲突会写入冲突记录。
|
- 静态字段冲突会写入冲突记录。
|
||||||
|
- 明显异常位置不会进入默认展示轨迹,并会留下 `quality_flags`。
|
||||||
|
- 同一时间窗口内多来源相近轨迹点只展示一个点。
|
||||||
|
- AISStream 重连或回放导致的重复消息不会重复进入聚合结果。
|
||||||
|
- raw observation 聚合结果和 legacy latest position 结果会按 MMSI 合并,BarentsWatch-only 船只不会因为 AISStream 子集存在而消失。
|
||||||
|
- 不传 `limit` 或传 `limit=0` 时,`/geo/vessels` 全量返回合并后的船只集合。
|
||||||
|
- AISStream 长连接收到新船、位置变化和航向变化后,会通过内部 `/ws` 的 `vessels` channel 推送增量。
|
||||||
|
- AISStream streaming 状态不会显示成固定百分比完成进度条,也不会在收到一批消息后误报采集完成。
|
||||||
|
- `mmsi`、`imo`、`callsign` 等身份编号在前端不显示千分位符。
|
||||||
|
- 聚合结果中仍以 MMSI fallback 作为船名的记录可以被诊断查询完整列出,并带来源和缺失原因。
|
||||||
- 字段级配置可以覆盖默认来源优先级。
|
- 字段级配置可以覆盖默认来源优先级。
|
||||||
- 聚合接口在没有冲突表时仍可返回兼容 GeoJSON。
|
- 聚合接口在没有冲突表时仍可返回兼容 GeoJSON。
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ CREATE UNIQUE INDEX ON vessel_latest(mmsi);
|
|||||||
GET /api/v1/visualization/geo/vessels
|
GET /api/v1/visualization/geo/vessels
|
||||||
?bbox=lon_min,lat_min,lon_max,lat_max # 视口裁剪
|
?bbox=lon_min,lat_min,lon_max,lat_max # 视口裁剪
|
||||||
?type=cargo,tanker,passenger # 船型过滤
|
?type=cargo,tanker,passenger # 船型过滤
|
||||||
?limit=5000
|
?limit=0 # 可选;不传或 0 表示不裁剪数量
|
||||||
→ GeoJSON FeatureCollection(Point)
|
→ GeoJSON FeatureCollection(Point)
|
||||||
|
|
||||||
GET /api/v1/visualization/vessels/{mmsi} # 单船详情
|
GET /api/v1/visualization/vessels/{mmsi} # 单船详情
|
||||||
@@ -163,6 +163,8 @@ GeoJSON Feature 格式:
|
|||||||
- 后端 BarentsWatch collector 继续以 HTTP polling 方式采集
|
- 后端 BarentsWatch collector 继续以 HTTP polling 方式采集
|
||||||
- AISStream 等实时源以独立 WebSocket collector 写入原始观测层
|
- AISStream 等实时源以独立 WebSocket collector 写入原始观测层
|
||||||
- 展示接口从聚合服务读取当前船只视图,而不是由单个 collector 决定最终展示值
|
- 展示接口从聚合服务读取当前船只视图,而不是由单个 collector 决定最终展示值
|
||||||
|
- 前端默认不再给 `/geo/vessels` 传 `limit=5000`,`VESSEL_CONFIG.maxRenderedMarkers = 0` 表示不做前端数量裁剪;后续如性能不足再引入显式 LOD 上限
|
||||||
|
- marker 颜色、详情卡、hover 和搜索结果必须共享 `vessel_type_display` 船型归一化结果,避免 AIS 数字类型码已驱动颜色但卡片仍显示 `Other`
|
||||||
- 前端是否升级为 WebSocket delta push 是独立优化,不影响后端采集器可以使用 WebSocket 接上游实时源
|
- 前端是否升级为 WebSocket delta push 是独立优化,不影响后端采集器可以使用 WebSocket 接上游实时源
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -196,8 +198,8 @@ GeoJSON Feature 格式:
|
|||||||
|
|
||||||
| 相机距离 | 渲染策略 |
|
| 相机距离 | 渲染策略 |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| > 400 | 仅渲染 top 1000 艘(按数据新鲜度 + 船型优先级) |
|
| > 400 | 默认渲染当前接口返回的全部船只;如性能不足,再引入可配置 LOD 上限 |
|
||||||
| 200–400 | 渲染 top 5000 艘 |
|
| 200–400 | 默认渲染当前接口返回的全部船只;如性能不足,再引入可配置 LOD 上限 |
|
||||||
| < 200 | 渲染当前视口 bbox 内全部船只 |
|
| < 200 | 渲染当前视口 bbox 内全部船只 |
|
||||||
|
|
||||||
前端根据相机位置动态计算 bbox,附加到 API 请求中。
|
前端根据相机位置动态计算 bbox,附加到 API 请求中。
|
||||||
|
|||||||
127
docs/plans/location-resolver-shared-pipeline-plan.md
Normal file
127
docs/plans/location-resolver-shared-pipeline-plan.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Location Resolver Shared Pipeline Plan
|
||||||
|
|
||||||
|
**状态**:已实现,当前用户流程见 [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md),开发接口见 [通用位置估算管线开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-development.md)。
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
把"给定一条记录,决定它的 lat/lon"这件事抽象成一条统一的可插拔管线,让算力中心、BGP 观测站、BGP 事件——以及未来任何需要位置估算的实体——共用同一套接口。新算法(peeringdb 设施查询、IXP 表、用户认领的精确点位等)通过实现一个 Resolver 类即可挂入,不需要改任何上层调用方。
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
### 实施前现状
|
||||||
|
|
||||||
|
- **算力中心** (`backend/app/services/compute_center_locations.py`) 早期曾使用源坐标 → 本地 JSON 注册表 → 城市兜底 → Nominatim 在线地理编码。后续为避免硬编码位置污染事实链路,算力中心本地注册表已移除;主地图只使用源坐标,手动候选采集使用 ROR 和 Nominatim。
|
||||||
|
- **BGP 观测站** (`collectors/bgp_common.py:RIPE_RIS_COLLECTOR_COORDS`) 是一张写死的字典,26 个 RIPE RIS collector 的城市级坐标。新增 collector / 升级到设施级精度都得改 Python。
|
||||||
|
- **BGP 事件**继承所属 collector 的城市级坐标(`BGPObservation.collector_geo`)。
|
||||||
|
- 用户原本以为 BGP 观测站位置是通过 iptoasn 推断的——其实 iptoasn 只用于前缀级国家归属(`bgp_enrichment.py`),不影响 marker 坐标。
|
||||||
|
|
||||||
|
### 痛点
|
||||||
|
|
||||||
|
1. 算力中心那条 4 层链路写死在算力中心模块里,BGP 想用得复制一遍。
|
||||||
|
2. 三类实体各走各的坐标策略,缺统一抽象。
|
||||||
|
3. 未来要插更精的算法(peeringdb / IXP / 用户认领),现在没有挂入点。
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 接口契约
|
||||||
|
|
||||||
|
`backend/app/services/location/`:
|
||||||
|
|
||||||
|
- `models.py` —— `LocationQuery`(输入)、`LocationCandidate`(候选)、`ResolverOutput`(单 resolver 输出)、`ResolutionResult`/`ResolutionDiagnostic`(管线最终结果)
|
||||||
|
- `pipeline.py` —— `LocationResolver` Protocol、`LocationPipeline` 编排器
|
||||||
|
- `resolvers/source_coordinates.py` —— 记录自带 lat/lon 时直通
|
||||||
|
- `resolvers/registry.py` —— 本地 JSON 注册表(locations + city_fallbacks),按别名得分
|
||||||
|
- `resolvers/nominatim.py` —— 通用 Nominatim 客户端(rate-limited + LRU 缓存)+ 可注入 query plan
|
||||||
|
- `resolvers/inherit.py` —— 从外部回调取候选(事件继承 collector 用)
|
||||||
|
- `text.py` —— 文本规范化共享工具
|
||||||
|
|
||||||
|
核心 Protocol:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LocationResolver(Protocol):
|
||||||
|
name: str
|
||||||
|
def resolve(self, query: LocationQuery) -> ResolverOutput: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`LocationPipeline.collect_candidates()` 跑全部 resolver,聚合所有候选,按 `(source_rank, precision_rank, -confidence)` 排序去重;`resolve_best()` 选 top 候选。
|
||||||
|
|
||||||
|
### 各领域管线
|
||||||
|
|
||||||
|
```python
|
||||||
|
# compute_center_locations.py(重构后,公共 API 不变)
|
||||||
|
COMPUTE_CENTER_PIPELINE = LocationPipeline([
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
])
|
||||||
|
|
||||||
|
COMPUTE_CENTER_COLLECTION_PIPELINE = LocationPipeline([
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
ROROrganizationResolver(),
|
||||||
|
NominatimResolver(query_plan_builder=_compute_center_query_plan,
|
||||||
|
geocoder=lambda q: _geocode_online(q)),
|
||||||
|
])
|
||||||
|
|
||||||
|
# bgp_collector_locations.py(新)
|
||||||
|
BGP_COLLECTOR_PIPELINE = LocationPipeline([
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
StoredCollectorLocationResolver(),
|
||||||
|
])
|
||||||
|
|
||||||
|
BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline([
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
NominatimResolver(query_plan_builder=_bgp_collector_query_plan,
|
||||||
|
geocoder=lambda q: _geocode_online(q)),
|
||||||
|
])
|
||||||
|
|
||||||
|
# bgp_event_locations.py(新)
|
||||||
|
BGP_EVENT_PIPELINE = LocationPipeline([
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
InheritFromAnotherEntityResolver(source_lookup=_inherit_from_owning_collector),
|
||||||
|
# 占位:将来插 ASNFacilityResolver / PrefixGeoResolver
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
|
||||||
|
1. **算力中心公共 API 完全不变**:`resolve_compute_center_location()`、`collect_location_candidates()`、`ComputeCenterLocation` dataclass、`_geocode_online` 模块级符号都保留,前端 / 上层调用方零改动;现有 19 个回归测试全绿。
|
||||||
|
2. **`_geocode_online` 用 lambda 晚绑定**:`NominatimResolver(geocoder=lambda q: _geocode_online(q))` 能让测试 `monkeypatch.setattr(module, "_geocode_online", fake)` 继续生效。
|
||||||
|
3. **`RIPE_RIS_COLLECTOR_COORDS` 自动从 DB-backed cache 重建**:启动时 seed/refresh `bgp_collector_locations` 维表,再原地刷新旧 `{rrcXX → {city, country, lat, lon}}` 字典。下游消费者(`bgp_collectors.py`、序列化、detector)不动即可获得新元数据。
|
||||||
|
4. **修复隐藏 bug**:BGP collector 不再通过 registry/operator 模糊匹配晋升候选,避免 `operator="RIPE NCC"` 让每个事件都落到 `rrc00`。
|
||||||
|
5. **事件继承走严格名字查询**:事件继承不跑 collector 的完整 pipeline,改成直接查 DB-backed cache。"改进位置"用户触发流程只跑源坐标和在线地理编码候选。
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- `backend/app/services/location/__init__.py`
|
||||||
|
- `backend/app/services/location/models.py`
|
||||||
|
- `backend/app/services/location/pipeline.py`
|
||||||
|
- `backend/app/services/location/text.py`
|
||||||
|
- `backend/app/services/location/resolvers/__init__.py`
|
||||||
|
- `backend/app/services/location/resolvers/source_coordinates.py`
|
||||||
|
- `backend/app/services/location/resolvers/registry.py`
|
||||||
|
- `backend/app/services/location/resolvers/nominatim.py`
|
||||||
|
- `backend/app/services/location/resolvers/inherit.py`
|
||||||
|
- `backend/app/services/bgp_collector_locations.py`
|
||||||
|
- `backend/app/services/bgp_event_locations.py`
|
||||||
|
- `backend/app/models/bgp_collector_location.py`
|
||||||
|
- `backend/tests/test_location_pipeline.py`(16 用例)
|
||||||
|
- `backend/tests/test_bgp_collector_locations.py`(11 用例)
|
||||||
|
|
||||||
|
### 修改
|
||||||
|
- `backend/app/services/compute_center_locations.py` —— 改为薄包装
|
||||||
|
- `backend/app/services/collectors/bgp_common.py` —— 删除写死字典,改调 `resolve_bgp_event_geo_dict()`
|
||||||
|
- `backend/app/api/v1/bgp.py` —— 新增 `POST /api/v1/bgp/collectors/{collector_id}/collect-location`
|
||||||
|
- `frontend/public/earth/js/info-card.js` —— `renderComputeCenterCollectSection` → `renderLocationCollectSection`,BGP collector 走通用化路径
|
||||||
|
- `frontend/public/earth/js/compute-centers.js` —— 新增通用 `collectLocationCandidates(endpoint, payload)`
|
||||||
|
- `frontend/public/earth/js/main.js` —— `previewComputeCenterCandidate` → `previewLocationCandidate`,事件名改为 `earth:preview-location-candidate`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `uv run pytest backend/tests/test_visualization_compute_centers.py` —— 19 个用例全绿(公共 API 未改)
|
||||||
|
- `uv run pytest backend/tests/test_location_pipeline.py backend/tests/test_bgp_collector_locations.py` —— 16 + 11 用例全绿
|
||||||
|
- 抽象可插拔性测试:`test_pluggability_custom_resolver_works_without_changing_pipeline` —— 临时实现 `_PeeringDBStubResolver` 直接接入 `LocationPipeline`,验证管线不需要改一行就能识别新 source
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- 持久化用户认领的精确坐标(写回 JSON 注册表)—— `suggested_registry_entry` 字段已就绪,工作流单独立项
|
||||||
|
- 真正实现 `ASNFacilityResolver` / `PrefixGeoResolver` —— 接口已留好,具体算法(peeringdb / IXP 表 / iptoasn 升级)单独立项
|
||||||
|
- 算力中心 / 观测站 marker 合并避让 —— 上一轮已用 `SURFACE_AVOIDANCE_PROFILES.city` + halo 收敛解决
|
||||||
@@ -25,8 +25,12 @@ What belongs here:
|
|||||||
|
|
||||||
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md): The shortest path to getting Planet running from scratch
|
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md): The shortest path to getting Planet running from scratch
|
||||||
- [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md): Complete usage guide for the console, `planet.sh`, Earth, and Docs
|
- [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md): Complete usage guide for the console, `planet.sh`, Earth, and Docs
|
||||||
|
- [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md): Collect and preview coordinate candidates for compute centers and BGP collectors on Earth
|
||||||
- [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md): Data source catalog, collector settings, connectivity validation, and BarentsWatch credentials
|
- [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md): Data source catalog, collector settings, connectivity validation, and BarentsWatch credentials
|
||||||
|
- [Shared Location Resolution Pipeline Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-development.md): Backend location resolver / pipeline interfaces, registries, and extension points
|
||||||
|
- [Docs Gatekeeper Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/docs-gatekeeper-development.md): Backend Docs catalog, Markdown content loading, and Gatekeeper permission groups
|
||||||
- [Earth Interactable Usage](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-usage.md): API, lifecycle, and integration examples for Earth surface icon Interactable
|
- [Earth Interactable Usage](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-usage.md): API, lifecycle, and integration examples for Earth surface icon Interactable
|
||||||
|
- [Earth Toolbar and Overlay Coordination](/home/ray/dev/linkong/planet/docs/technical/en/earth-toolbar-overlay-coordination.md): Closing matrix and integration rules for toolbar buttons, search, settings, news, and layer overlays
|
||||||
|
|
||||||
What does not belong here:
|
What does not belong here:
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ async def run(self, db):
|
|||||||
| HuggingFace Spaces | space | Demo applications | 1 day |
|
| HuggingFace Spaces | space | Demo applications | 1 day |
|
||||||
| PeeringDB | ixp/network/facility | Internet exchange points / networks / facilities | 1-2 days |
|
| PeeringDB | ixp/network/facility | Internet exchange points / networks / facilities | 1-2 days |
|
||||||
| TeleGeography | submarine_cable | Submarine cable information | 7 days |
|
| TeleGeography | submarine_cable | Submarine cable information | 7 days |
|
||||||
|
| BarentsWatch AIS | vessel | AIS vessel positions, speed, heading, MMSI, and related fields | Collector settings |
|
||||||
|
| AISStream Vessels | vessel_ais | AIS WebSocket realtime stream, written to the raw observation layer and displayed through aggregation | Collector settings |
|
||||||
|
|
||||||
|
AIS vessel collectors use a different persistence path from regular `CollectedData` collectors. BarentsWatch, AISStream, and custom `vessel_ais` sources write into the AIS raw observation layer first, then the aggregation service merges those observations into the GeoJSON and detail payloads used by the Earth vessel layer. This preserves source, transport, field conflicts, and observation time instead of letting one realtime source overwrite the final display table.
|
||||||
|
|
||||||
|
TOP500 and Epoch AI compute sources do not always provide usable coordinates. The unified Earth compute-center endpoint uses only valid source-provided coordinates or `compute_center_locations` dimension-table coordinates during the main map startup path; records without coordinates are returned as `unresolved` instead of being rendered from a local registry, country centroid, or guessed city. When users manually collect candidates, the backend queries ROR and Nominatim/OpenStreetMap from source fields; accepted candidates are saved into `compute_center_locations` and rendered from that table on the next layer refresh.
|
||||||
|
|
||||||
## IV. Data Format (stored in CollectedData table)
|
## IV. Data Format (stored in CollectedData table)
|
||||||
|
|
||||||
@@ -211,13 +217,136 @@ backend/app/services/collectors/
|
|||||||
├── epoch_ai.py # Epoch AI collector
|
├── epoch_ai.py # Epoch AI collector
|
||||||
├── huggingface.py # HuggingFace collector
|
├── huggingface.py # HuggingFace collector
|
||||||
├── peeringdb.py # PeeringDB collector
|
├── peeringdb.py # PeeringDB collector
|
||||||
└── telegeraphy.py # TeleGeography submarine cable collector
|
├── telegeraphy.py # TeleGeography submarine cable collector
|
||||||
|
├── vessel_ais.py # BarentsWatch AIS vessel collector
|
||||||
|
└── aisstream.py # AISStream WebSocket vessel collector
|
||||||
|
|
||||||
|
backend/app/services/
|
||||||
|
├── custom_datasource_runtime.py # Custom REST / WebSocket mapping runtime
|
||||||
|
├── datasource_mapping.py # Deterministic field mapping and target writes
|
||||||
|
├── vessel_ais_aggregation.py # AIS raw observation writes and aggregate reads
|
||||||
|
├── vessel_aggregation_strategy.py # Multi-source field selection, freshness fallback, and conflict records
|
||||||
|
└── vessel_enrichment.py # Vessel profile enrichment cache
|
||||||
|
|
||||||
backend/app/models/
|
backend/app/models/
|
||||||
└── collected_data.py # Unified data model
|
├── collected_data.py # Unified data model
|
||||||
|
└── vessel_enrichment.py # Vessel enrichment cache
|
||||||
```
|
```
|
||||||
|
|
||||||
## IX. Data Usage
|
## IX. Credentialed Collectors
|
||||||
|
|
||||||
|
Some collectors require external service credentials:
|
||||||
|
|
||||||
|
| Collector | Credential provider | Credential sources |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `barentswatch_vessels` | `barentswatch` | Console collector settings, environment variables, `~/.zshrc` |
|
||||||
|
| `aisstream_vessels` | `aisstream` | Console collector settings, environment variables, `~/.zshrc` for connectivity checks; save it in collector settings or inject it into the backend environment for collection |
|
||||||
|
| `spacetrack_tle` | `spacetrack` | Environment variables, `~/.zshrc` |
|
||||||
|
|
||||||
|
### BarentsWatch AIS
|
||||||
|
|
||||||
|
BarentsWatch AIS credential resolution is centralized in:
|
||||||
|
|
||||||
|
- [barentswatch.py](/home/ray/dev/linkong/planet/backend/app/services/barentswatch.py)
|
||||||
|
|
||||||
|
`VesselAISCollector` only collects and transforms AIS data. It no longer reads environment variables or builds token requests directly. It uses:
|
||||||
|
|
||||||
|
- `resolve_barentswatch_config()`
|
||||||
|
- `fetch_barentswatch_access_token()`
|
||||||
|
|
||||||
|
Resolution priority:
|
||||||
|
|
||||||
|
1. `DataSourceConfig.auth_config`
|
||||||
|
2. `DataSourceConfig.config`
|
||||||
|
3. Environment variables
|
||||||
|
4. `~/.zshrc`
|
||||||
|
|
||||||
|
Supported variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BARENTSWATCH_CLIENT_ID="..."
|
||||||
|
export BARENTSWATCH_CLIENT_SECRET="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Historical misspellings are also supported:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BARRENTSWATCH_CLIENT_ID="..."
|
||||||
|
export BARRENTSWATCH_CLIENT_SECRET="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Connectivity validation requests `https://id.barentswatch.no/connect/token` for an access token with `scope=ais`, then requests the AIS endpoint with `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
### AISStream Realtime Vessels
|
||||||
|
|
||||||
|
AISStream uses the `wss://stream.aisstream.io/v0/stream` WebSocket endpoint. Its default runtime is a long-lived realtime collector rather than the traditional REST pattern of one request, progress to 100%, then completion.
|
||||||
|
|
||||||
|
Runtime configuration:
|
||||||
|
|
||||||
|
- `api_key`: read first from `DataSourceConfig.auth_config.api_key` or `config.api_key`; it can also come from the backend process environment variable `AISSTREAM_API_KEY`.
|
||||||
|
- `bounding_boxes`: AISStream subscription bounds. The default example is global `[[[-90, -180], [90, 180]]]`; demos and production runs should usually start with a smaller area.
|
||||||
|
- `message_types`: defaults to `PositionReport` and `ShipStaticData`.
|
||||||
|
- `streaming_enabled`: enables long-lived streaming by default; disabling it falls back to batch-style `fetch -> transform -> save`.
|
||||||
|
- `streaming_max_messages`: test-only stop limit. Non-zero values stop the stream after the requested number of messages.
|
||||||
|
- `reconnect_delay_seconds` and `receive_timeout_seconds`: control reconnect delay and idle receive waits.
|
||||||
|
|
||||||
|
State semantics:
|
||||||
|
|
||||||
|
- `connecting`: connecting to AISStream.
|
||||||
|
- `streaming`: receiving realtime messages; `records_processed` means messages seen, usually without a fixed total or percentage.
|
||||||
|
- `reconnecting`: upstream or network interruption; the collector records `AISSourceHealth` and waits before reconnecting.
|
||||||
|
- `stopped` / `cancelled`: stopped by a test limit or user action.
|
||||||
|
|
||||||
|
AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Settings -> Collector Settings -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it.
|
||||||
|
|
||||||
|
### AIS Raw Observations And Aggregation
|
||||||
|
|
||||||
|
AIS observations do not directly replace final vessel records. They are first saved as raw observations:
|
||||||
|
|
||||||
|
- `source` records the origin, such as `barentswatch_vessels`, `aisstream_vessels`, or a custom source name.
|
||||||
|
- `delivery_mode` captures realtime quality; `realtime_stream` outranks `polling`.
|
||||||
|
- `transport` records `websocket` or `http`.
|
||||||
|
- Dynamic fields such as position, speed, and course are selected by freshness and source priority.
|
||||||
|
- Static fields prefer non-empty values; conflicting candidates are recorded for detail and diagnostics views.
|
||||||
|
|
||||||
|
Earth still reads vessel data from:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/visualization/geo/vessels
|
||||||
|
GET /api/v1/visualization/vessels/{mmsi}
|
||||||
|
GET /api/v1/visualization/vessels/{mmsi}/track
|
||||||
|
GET /api/v1/visualization/vessels/{mmsi}/conflicts
|
||||||
|
GET /api/v1/visualization/vessels/aggregation/diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
`/geo/vessels` merges raw observation aggregation with the legacy BarentsWatch latest-position tables so adding AISStream does not hide historical BarentsWatch-only vessels.
|
||||||
|
|
||||||
|
## X. Collector Settings And Connectivity Validation
|
||||||
|
|
||||||
|
The console "Collector Settings" page owns endpoint, headers, timeouts, retries, and credentials for all built-in collectors. Connectivity is derived by the backend checksum rather than by frontend button styling:
|
||||||
|
|
||||||
|
- endpoint
|
||||||
|
- auth type
|
||||||
|
- headers
|
||||||
|
- config
|
||||||
|
- credential provider
|
||||||
|
- credential fingerprint
|
||||||
|
|
||||||
|
Related APIs:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/datasources/configs/all
|
||||||
|
POST /api/v1/datasources/configs/builtin/connection-status
|
||||||
|
POST /api/v1/datasources/configs/builtin/connect
|
||||||
|
POST /api/v1/settings/integrations/barentswatch/connect
|
||||||
|
GET /api/v1/settings/credential-guides/{provider}
|
||||||
|
POST /api/v1/settings/credential-guides/{provider}/generate
|
||||||
|
POST /api/v1/settings/credential-guides/{provider}/reset
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md) for the full flow.
|
||||||
|
|
||||||
|
## XI. Data Usage
|
||||||
|
|
||||||
Collected data ultimately:
|
Collected data ultimately:
|
||||||
|
|
||||||
@@ -225,7 +354,7 @@ Collected data ultimately:
|
|||||||
2. **Situational analysis** — global compute distribution statistics and growth trends
|
2. **Situational analysis** — global compute distribution statistics and growth trends
|
||||||
3. **Alert system** — detects changes to important nodes
|
3. **Alert system** — detects changes to important nodes
|
||||||
|
|
||||||
## X. Collector Registration
|
## XII. Collector Registration
|
||||||
|
|
||||||
Collectors are automatically registered at application startup:
|
Collectors are automatically registered at application startup:
|
||||||
|
|
||||||
@@ -247,7 +376,7 @@ collector_registry.register(TeleGeographyCableSystemCollector())
|
|||||||
|
|
||||||
**Core file**: `backend/app/services/collectors/registry.py`
|
**Core file**: `backend/app/services/collectors/registry.py`
|
||||||
|
|
||||||
## XI. Triggering Collection
|
## XIII. Triggering Collection
|
||||||
|
|
||||||
### Method 1: Scheduled
|
### Method 1: Scheduled
|
||||||
|
|
||||||
|
|||||||
99
docs/technical/en/backend-datasources-api-performance.md
Normal file
99
docs/technical/en/backend-datasources-api-performance.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# DataSources List API Performance Optimization
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
`GET /api/v1/datasources` is the core API for the Data Sources page. Slow responses directly block page rendering.
|
||||||
|
|
||||||
|
## Query Path Before Optimization
|
||||||
|
|
||||||
|
`_load_datasource_list_context` used to run these queries sequentially:
|
||||||
|
|
||||||
|
| Order | Function | Query | Bottleneck |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | `_load_latest_running_tasks` | `collection_tasks` window query; stale check depends on this result | Must be serial |
|
||||||
|
| 2 | `_load_latest_completed_tasks` | `collection_tasks` window query for latest completed tasks | Serial wait |
|
||||||
|
| 3 | `_load_datasource_data_counts` | `COUNT(*) GROUP BY source` on `collected_data` | Slow full-table scan |
|
||||||
|
| 4 | `_load_datasource_endpoint_overrides` | Simple `datasource_configs` SELECT | Serial wait |
|
||||||
|
|
||||||
|
## Phase 1: Parallelization
|
||||||
|
|
||||||
|
The independent queries 2, 3, and 4 were moved to `asyncio.gather` with separate sessions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _fetch_completed():
|
||||||
|
async with async_session_factory() as s:
|
||||||
|
return await _load_latest_completed_tasks(s, datasource_ids)
|
||||||
|
|
||||||
|
async def _fetch_counts():
|
||||||
|
async with async_session_factory() as s:
|
||||||
|
return await _load_datasource_data_counts(s, sources)
|
||||||
|
|
||||||
|
async def _fetch_overrides():
|
||||||
|
async with async_session_factory() as s:
|
||||||
|
return await _load_datasource_endpoint_overrides(s, sources)
|
||||||
|
|
||||||
|
completed_tasks, data_counts, endpoint_overrides = await asyncio.gather(
|
||||||
|
_fetch_completed(), _fetch_counts(), _fetch_overrides(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
SQLAlchemy `AsyncSession` does not support concurrent use from multiple coroutines, so every parallel branch needs its own session.
|
||||||
|
|
||||||
|
## Phase 2: Remove Heavy Queries
|
||||||
|
|
||||||
|
### Remove `_load_datasource_data_counts`
|
||||||
|
|
||||||
|
`data_count` was only used by the frontend to show an edge-case `(0 records)` hint in the latest collection column. It was not worth keeping a `COUNT(*) GROUP BY` full-table scan.
|
||||||
|
|
||||||
|
- Frontend `(0 records)` display logic was removed.
|
||||||
|
- `data_count` was removed from the `BuiltInDataSource` interface.
|
||||||
|
|
||||||
|
### Remove `_load_latest_completed_tasks`
|
||||||
|
|
||||||
|
`last_status` and `last_run_at` are already written to the `DataSource` model when collectors finish, so the list endpoint no longer needs to join `collection_tasks`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Before: completed_tasks query required
|
||||||
|
last_run_at = datasource.last_run_at or (last_task.completed_at if last_task else None)
|
||||||
|
last_status = datasource.last_status or (last_task.status if last_task else None)
|
||||||
|
|
||||||
|
# After: read model fields directly
|
||||||
|
last_run_at = datasource.last_run_at
|
||||||
|
last_status = datasource.last_status
|
||||||
|
```
|
||||||
|
|
||||||
|
`last_records_processed` was removed as well because it came from completed task rows and is not displayed in the list.
|
||||||
|
|
||||||
|
## Query Path After Optimization
|
||||||
|
|
||||||
|
```text
|
||||||
|
datasources SELECT -> required primary data
|
||||||
|
_load_latest_running_tasks -> required for running state and stale check
|
||||||
|
_load_datasource_endpoint_overrides -> required for endpoint overrides and collector settings display
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint now runs three queries instead of five. The last two run sequentially because running tasks are needed for stale checks and endpoint overrides are lightweight.
|
||||||
|
|
||||||
|
## Frontend `triggerDatasource` Double Refresh Fix
|
||||||
|
|
||||||
|
`triggerDatasource` previously called `fetchData()` twice:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
} else {
|
||||||
|
window.setTimeout(() => { fetchData() }, 800)
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
|
||||||
|
// After: mutually exclusive
|
||||||
|
if (res.data.task_id) {
|
||||||
|
fetchData()
|
||||||
|
} else {
|
||||||
|
window.setTimeout(fetchData, 800)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- [datasources.py](/home/ray/dev/linkong/planet/backend/app/api/v1/datasources.py): `_load_datasource_list_context`, `list_datasources`
|
||||||
|
- [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx): `BuiltInDataSource`, `triggerDatasource`
|
||||||
@@ -62,7 +62,7 @@ File:
|
|||||||
Current behavior:
|
Current behavior:
|
||||||
|
|
||||||
- The `collector_credentials` tab is displayed as "Collector Settings".
|
- The `collector_credentials` tab is displayed as "Collector Settings".
|
||||||
- A select lists all built-in collectors.
|
- A select lists built-in collectors and supports maintaining custom supplemental sources that merge into built-in data.
|
||||||
- The only button beside the select is a plug icon for health checks.
|
- The only button beside the select is a plug icon for health checks.
|
||||||
- Status tags below the select show:
|
- Status tags below the select show:
|
||||||
- `Credentials required` / `No credentials required`
|
- `Credentials required` / `No credentials required`
|
||||||
@@ -72,6 +72,8 @@ Current behavior:
|
|||||||
- Whether the endpoint is overridden
|
- Whether the endpoint is overridden
|
||||||
- Collectors that require credentials place the credential card above base configuration.
|
- Collectors that require credentials place the credential card above base configuration.
|
||||||
- Collectors without credentials only show base configuration.
|
- Collectors without credentials only show base configuration.
|
||||||
|
- The AISStream collector uses WebSocket semantics: connecting, streaming, reconnecting, or stopped. It does not use a fixed completion percentage.
|
||||||
|
- Custom source editing lives in collector settings. The data source catalog keeps overview, run controls, and read-only drawers.
|
||||||
|
|
||||||
The connection button uses an inline Tabler-style plug icon with `plug-connected` semantics, avoiding the older refresh icon for a connection action.
|
The connection button uses an inline Tabler-style plug icon with `plug-connected` semantics, avoiding the older refresh icon for a connection action.
|
||||||
|
|
||||||
@@ -260,6 +262,124 @@ AIS request rules:
|
|||||||
|
|
||||||
`VesselAISCollector` no longer reads environment variables directly. It goes through `resolve_barentswatch_config()` and `fetch_barentswatch_access_token()` so settings, connectivity validation, and collection do not fork into three credential flows.
|
`VesselAISCollector` no longer reads environment variables directly. It goes through `resolve_barentswatch_config()` and `fetch_barentswatch_access_token()` so settings, connectivity validation, and collection do not fork into three credential flows.
|
||||||
|
|
||||||
|
## AISStream Collector Chain
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- [aisstream.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/aisstream.py)
|
||||||
|
- [vessel_ais_aggregation.py](/home/ray/dev/linkong/planet/backend/app/services/vessel_ais_aggregation.py)
|
||||||
|
|
||||||
|
AISStream uses a WebSocket realtime stream. The collector writes only to the `ais_raw_observations` raw observation layer; it does not directly overwrite the final vessel display table. The aggregation API handles multi-source deduplication, field selection, and conflict records.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
|
||||||
|
- `api_key`: stored in `DataSourceConfig.auth_config`, or provided through `AISSTREAM_API_KEY`.
|
||||||
|
- `endpoint`: defaults to `wss://stream.aisstream.io/v0/stream`.
|
||||||
|
- `message_types`: defaults to `PositionReport` and `ShipStaticData`.
|
||||||
|
- `bounding_boxes`: AISStream format is `[[[lat_min, lon_min], [lat_max, lon_max]]]`; the settings page provides global, Norway / North Sea, Europe coast, East Asia, and North America coast presets.
|
||||||
|
- `max_messages` and `receive_timeout_seconds`: control the batch-style WebSocket collection window.
|
||||||
|
|
||||||
|
Normalization:
|
||||||
|
|
||||||
|
- `PositionReport` mainly provides position, speed, course, heading, and navigation status.
|
||||||
|
- Vessel names can be filled from `MetaData.ShipName` even when the message body has no `name`.
|
||||||
|
- Vessel type usually comes from lower-frequency `ShipStaticData.Type`; the backend maps AIS numeric type codes to Cargo / Tanker / Passenger / Fishing / Military.
|
||||||
|
- If a vessel has not yet produced a static message, its aggregated type can still be `Other`; v5 vessel profile enrichment is planned to fill that gap.
|
||||||
|
|
||||||
|
Connectivity validation reads saved configuration, environment variables, and `AISSTREAM_API_KEY` from `~/.zshrc`. For actual collection, prefer saving the API key in collector settings. If the key only lives in `~/.zshrc`, confirm that the backend process inherited it; otherwise validation may pass while the collector runtime cannot read the key.
|
||||||
|
|
||||||
|
## Custom REST / WebSocket Mapping Runtime
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- [custom_datasource_runtime.py](/home/ray/dev/linkong/planet/backend/app/services/custom_datasource_runtime.py)
|
||||||
|
- [datasource_mapping.py](/home/ray/dev/linkong/planet/backend/app/services/datasource_mapping.py)
|
||||||
|
|
||||||
|
Custom sources are supplemental inputs for existing target schemas, not isolated data islands. The most complete target today is `vessel_ais`: a custom REST or WebSocket source is mapped deterministically, written into AIS raw observations, and then pushed to Earth through the `vessels` WebSocket channel.
|
||||||
|
|
||||||
|
### Configuration Semantics
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `source_type`: `rest` / `http` / `websocket` / `ws`.
|
||||||
|
- `endpoint`: REST uses `http(s)://`; WebSocket uses `ws(s)://`.
|
||||||
|
- `auth_type`: `none`, `bearer`, `api_key`, or `basic`.
|
||||||
|
- `headers`: static request headers.
|
||||||
|
- `auth_config`: token, API key, or basic username/password; API keys can be sent by header or query.
|
||||||
|
- `config.target_schema`: for example `vessel_ais`.
|
||||||
|
- `config.delivery_mode`: REST defaults to `polling`; WebSocket defaults to `realtime_stream`.
|
||||||
|
- `config.merge_target_source`: records which built-in source this custom source supplements, such as `barentswatch_vessels`.
|
||||||
|
|
||||||
|
The REST runner supports:
|
||||||
|
|
||||||
|
- `GET` / `POST`
|
||||||
|
- query params
|
||||||
|
- JSON body
|
||||||
|
- headers and auth injection
|
||||||
|
- active mapping writes into the target schema
|
||||||
|
|
||||||
|
The WebSocket runner supports:
|
||||||
|
|
||||||
|
- endpoint format validation
|
||||||
|
- headers and auth injection
|
||||||
|
- optional `ws_subscribe_message`
|
||||||
|
- `ws_message_path` / `ws_items_path` extraction
|
||||||
|
- reconnects
|
||||||
|
- `debug_max_messages` debug limits
|
||||||
|
- background stream start / stop / status
|
||||||
|
|
||||||
|
Related APIs:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/datasources/custom/sample
|
||||||
|
GET /api/v1/datasources/target-schemas
|
||||||
|
POST /api/v1/datasources/{config_id}/run-mapped
|
||||||
|
POST /api/v1/datasources/{config_id}/stop-mapped
|
||||||
|
GET /api/v1/datasources/{config_id}/mapped-status
|
||||||
|
DELETE /api/v1/datasources/configs/{config_id}?delete_mappings=true&delete_source_data=true
|
||||||
|
```
|
||||||
|
|
||||||
|
`run-mapped?background=true` only matters for WebSocket sources and starts a background stream. REST sources remain one-shot collection runs.
|
||||||
|
|
||||||
|
### Delete And Data Cleanup
|
||||||
|
|
||||||
|
Deleting a custom source has three levels:
|
||||||
|
|
||||||
|
- Delete configuration only: preserve mapping and historical data.
|
||||||
|
- Delete configuration and mapping: also delete mapping templates for that config.
|
||||||
|
- Delete configuration, mapping, and source data: delete that source's `collected_data`, `ais_raw_observations`, and `ais_source_health`.
|
||||||
|
|
||||||
|
When deleted `vessel_ais` source data affects Earth, the backend broadcasts `reload_required` on the `vessels` channel so Earth reloads aggregated vessels. Legacy `vessel_position` rows are not deleted by custom source because that table cannot safely attribute rows back to a custom source.
|
||||||
|
|
||||||
|
### Local AIS Mock WebSocket
|
||||||
|
|
||||||
|
File:
|
||||||
|
|
||||||
|
- [mock-ais-ws-server.ts](/home/ray/dev/linkong/planet/scripts/mock-ais-ws-server.ts)
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run mock:ais-ws
|
||||||
|
```
|
||||||
|
|
||||||
|
The mock service continuously sends AIS-like JSON to validate the chain: WebSocket custom source -> mapping -> AIS raw observation -> `vessels` channel -> Earth vessel upsert. Typical config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source_type": "websocket",
|
||||||
|
"endpoint": "ws://localhost:8787",
|
||||||
|
"config": {
|
||||||
|
"target_schema": "vessel_ais",
|
||||||
|
"delivery_mode": "realtime_stream",
|
||||||
|
"merge_target_source": "barentswatch_vessels",
|
||||||
|
"ws_message_path": "$.data",
|
||||||
|
"ws_items_path": "$.vessels[*]",
|
||||||
|
"ws_reconnect": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Credential Guide
|
## Credential Guide
|
||||||
|
|
||||||
File:
|
File:
|
||||||
@@ -277,6 +397,7 @@ POST /api/v1/settings/credential-guides/{provider}/reset
|
|||||||
Currently supported:
|
Currently supported:
|
||||||
|
|
||||||
- `barentswatch`
|
- `barentswatch`
|
||||||
|
- `aisstream`
|
||||||
|
|
||||||
The default guide includes the official BarentsWatch tutorial:
|
The default guide includes the official BarentsWatch tutorial:
|
||||||
|
|
||||||
@@ -320,6 +441,7 @@ Added coverage:
|
|||||||
Credential providers currently supported:
|
Credential providers currently supported:
|
||||||
|
|
||||||
- `barentswatch`
|
- `barentswatch`
|
||||||
|
- `aisstream`
|
||||||
- `spacetrack`
|
- `spacetrack`
|
||||||
|
|
||||||
Other collectors with `requires_credentials=true` return that their credential chain has not been wired yet, and the frontend shows `Unavailable`.
|
Other collectors with `requires_credentials=true` return that their credential chain has not been wired yet, and the frontend shows `Unavailable`.
|
||||||
|
|||||||
116
docs/technical/en/docs-gatekeeper-development.md
Normal file
116
docs/technical/en/docs-gatekeeper-development.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Docs Gatekeeper Development Guide
|
||||||
|
|
||||||
|
Docs Gatekeeper moves `/docs` from "bundle all Markdown into the frontend" to "return catalog and content from the backend according to permissions." Its goal is to keep public manuals, user docs, developer docs, and admin/ops docs in one searchable Docs page while making every protected Markdown body pass through a server-side whitelist and authorization check.
|
||||||
|
|
||||||
|
For the user workflow, see the Docs section in [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md).
|
||||||
|
|
||||||
|
## Authorization Model
|
||||||
|
|
||||||
|
Docs uses two permission layers:
|
||||||
|
|
||||||
|
- `users.role`: preserved for console/system permissions.
|
||||||
|
- `users.gatekeeper_groups`: Docs content permission groups.
|
||||||
|
|
||||||
|
Groups:
|
||||||
|
|
||||||
|
| Group | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `docs_user` | User-operation docs |
|
||||||
|
| `docs_developer` | Earth, frontend, backend, collector, and AI Provider development docs |
|
||||||
|
| `docs_admin` | Service control, operations, environment, and sensitive-operation docs |
|
||||||
|
|
||||||
|
Inheritance:
|
||||||
|
|
||||||
|
- Anonymous users can only read `public`.
|
||||||
|
- `docs_developer` includes `docs_user`.
|
||||||
|
- `docs_admin` includes `docs_developer` and `docs_user`.
|
||||||
|
- `admin` and `super_admin` receive all Docs permissions by default.
|
||||||
|
|
||||||
|
## Backend Entry Points
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- [docs.py](/home/ray/dev/linkong/planet/backend/app/api/v1/docs.py)
|
||||||
|
- [docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/app/services/docs_gatekeeper.py)
|
||||||
|
- [user.py](/home/ray/dev/linkong/planet/backend/app/models/user.py)
|
||||||
|
- [users.py](/home/ray/dev/linkong/planet/backend/app/api/v1/users.py)
|
||||||
|
|
||||||
|
APIs:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/docs/catalog
|
||||||
|
GET /api/v1/docs/{lang}/{slug}
|
||||||
|
```
|
||||||
|
|
||||||
|
`catalog` returns only documents visible to the current user. The content endpoint validates language, slug, and file existence through the metadata whitelist before checking access:
|
||||||
|
|
||||||
|
- Anonymous protected-doc request: `401`.
|
||||||
|
- Authenticated but insufficient permissions: `403`.
|
||||||
|
- Unknown language, unknown slug, or missing file: `404`.
|
||||||
|
|
||||||
|
Markdown bodies can only come from whitelisted files under `docs/technical/{zh,en}/`; arbitrary path reads are not allowed.
|
||||||
|
|
||||||
|
## Metadata Source
|
||||||
|
|
||||||
|
Server-side metadata lives in [docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/app/services/docs_gatekeeper.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
DocsMetadata(
|
||||||
|
"manual.md",
|
||||||
|
"manual",
|
||||||
|
"public",
|
||||||
|
"Manual",
|
||||||
|
2,
|
||||||
|
"Planet 使用手册",
|
||||||
|
"Planet Manual",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
When adding a public technical doc:
|
||||||
|
|
||||||
|
- Add both Chinese and English Markdown files.
|
||||||
|
- Add filename, slug, access, group, order, and titles to server `DOCS_METADATA`.
|
||||||
|
- Add matching metadata to frontend [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) so navigation titles and sorting stay aligned.
|
||||||
|
- Update `docs/technical/zh/README.md` and `docs/technical/en/README.md` when the document should be discoverable from the README.
|
||||||
|
|
||||||
|
## User Management
|
||||||
|
|
||||||
|
The `users` table has `gatekeeper_groups JSONB DEFAULT '[]'`. Startup [session.py](/home/ray/dev/linkong/planet/backend/app/db/session.py) applies `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for existing local databases.
|
||||||
|
|
||||||
|
The user API:
|
||||||
|
|
||||||
|
- Writes `gatekeeper_groups` during user creation.
|
||||||
|
- Validates group names on update: only `docs_user`, `docs_developer`, and `docs_admin` are accepted.
|
||||||
|
- Allows only `super_admin` to modify Gatekeeper groups.
|
||||||
|
|
||||||
|
Frontend [Users.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Users/Users.tsx) displays group tags and provides a multi-select in the edit form. Non-`super_admin` users see the field disabled, and submission removes `gatekeeper_groups` before sending.
|
||||||
|
|
||||||
|
## Frontend Docs Loading
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- [Docs.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/Docs.tsx)
|
||||||
|
- [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts)
|
||||||
|
- [docs-search.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-search.ts)
|
||||||
|
|
||||||
|
Key changes:
|
||||||
|
|
||||||
|
- Remove `import.meta.glob(...?raw)` as the Markdown content source.
|
||||||
|
- Load `/api/v1/docs/catalog` to build the visible navigation.
|
||||||
|
- Load `/api/v1/docs/{lang}/{slug}` for document bodies.
|
||||||
|
- Index search only across currently visible docs, loading Markdown from the backend as needed.
|
||||||
|
- Show login state for `401`, permission state for `403`, and unavailable-doc state for `404`.
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
Relevant tests:
|
||||||
|
|
||||||
|
- [test_docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/tests/test_docs_gatekeeper.py)
|
||||||
|
|
||||||
|
Tests should cover:
|
||||||
|
|
||||||
|
- Anonymous users only see public docs.
|
||||||
|
- Protected content returns `401` or `403` appropriately.
|
||||||
|
- `docs_developer` can read developer docs but not admin docs.
|
||||||
|
- `admin` and `super_admin` can read admin docs.
|
||||||
|
- Unknown slugs, unknown languages, and path traversal strings cannot read files.
|
||||||
@@ -96,9 +96,10 @@ Responsibilities:
|
|||||||
|
|
||||||
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||||
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
||||||
|
- [vessels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/vessels.js)
|
||||||
- [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js)
|
- [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js)
|
||||||
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
|
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
|
||||||
- [compute-centers.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/compute-centers.js)
|
- [compute-centers.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/compute-centers.js) renders supercomputer and GPU-cluster markers. The backend renders compute centers only from source-provided coordinates or `compute_center_locations` dimension-table coordinates during startup; manual candidate collection can query ROR and Nominatim/OpenStreetMap, and the layer keeps the `?` badge for unconfirmed positions while the details card shows precision, confidence, source notes, and verification date.
|
||||||
- [country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js)
|
- [country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js)
|
||||||
|
|
||||||
Each module is responsible for its own:
|
Each module is responsible for its own:
|
||||||
@@ -108,6 +109,16 @@ Each module is responsible for its own:
|
|||||||
- State tracking (loaded, visible, hover, locked)
|
- State tracking (loaded, visible, hover, locked)
|
||||||
- Self-cleanup (dispose on scene destroy)
|
- Self-cleanup (dispose on scene destroy)
|
||||||
|
|
||||||
|
The compute-center layer row has a notification badge for GeoJSON `unresolved` records. The badge means "no trustworthy coordinates, cannot render on the globe"; it is different from the `?` marker drawn on already positioned but unconfirmed compute centers. Clicking the badge opens a fixed info card beside the layer panel. Row-level `采集` fetches candidates only. Header-level `一键采用` processes the queue top-to-bottom, saves the highest-confidence valid candidate, removes successful rows, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch ends, `earth:compute-center-location-saved` refreshes the real layer.
|
||||||
|
|
||||||
|
### AIS Vessel Layer
|
||||||
|
|
||||||
|
The vessel layer fetches `/api/v1/visualization/geo/vessels` and renders the aggregated AIS GeoJSON through `createInteractableLayer()`. By default it does not send a `limit` parameter, and `VESSEL_CONFIG.maxRenderedMarkers = 0` means the frontend does not clip the result to 5000 vessels. A positive `options.limit` or positive `maxRenderedMarkers` can still be used as an explicit temporary cap.
|
||||||
|
|
||||||
|
Vessel color and vessel type text must use the same normalized classification. `vessels.js` derives `type` from both `vessel_type_name` and the AIS numeric `vessel_type` code; that `type` drives marker color. It also derives `vessel_type_display`, which `main.js` uses for the info card, hover summary, and search result subtitle. Do not make the info card read only the raw `vessel_type_name`, because AISStream can provide a numeric type while the raw name is still `Other`.
|
||||||
|
|
||||||
|
AISStream `PositionReport` messages commonly carry live position and `MetaData.ShipName`, while vessel type usually comes from lower-frequency `ShipStaticData.Type`. The backend normalizes `MetaData.ShipName` into the vessel name and maps numeric type codes into Cargo / Tanker / Passenger / Fishing / Military where available. Missing type detail should wait for a static AIS message or the planned vessel profile enrichment; the frontend should not invent a more specific type.
|
||||||
|
|
||||||
### 7. HUD Panels and Search
|
### 7. HUD Panels and Search
|
||||||
|
|
||||||
- [hud-panels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/hud-panels.js)
|
- [hud-panels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/hud-panels.js)
|
||||||
@@ -145,6 +156,8 @@ Layer toggle buttons use `data-status-target` attributes to link button state to
|
|||||||
|
|
||||||
This is the canonical way to synchronize button visual state with actual layer state. Do not maintain separate boolean flags for button display.
|
This is the canonical way to synchronize button visual state with actual layer state. Do not maintain separate boolean flags for button display.
|
||||||
|
|
||||||
|
Terrain should not block startup when it is not the restored visible layer. After deferred layer visibility settings are applied, `controls.js` schedules `scheduleTerrainPrefetch()` only when HD texture is enabled, terrain is not ready, and no prefetch is already running. The prefetch uses `setTimeout` plus `requestIdleCallback` so cloud, HD texture, and startup layer work keep first-screen priority.
|
||||||
|
|
||||||
## Current Settings Persistence
|
## Current Settings Persistence
|
||||||
|
|
||||||
Earth settings are stored in `localStorage`. The key is typically a namespaced string defined in `constants.js`. `controls.js` handles read, write, and reset.
|
Earth settings are stored in `localStorage`. The key is typically a namespaced string defined in `constants.js`. `controls.js` handles read, write, and reset.
|
||||||
@@ -162,6 +175,8 @@ Settings that affect visual layers (terrain opacity, day/night mode, satellite d
|
|||||||
|
|
||||||
When HD texture is off, terrain is temporarily hidden and its state is remembered. When HD texture comes back on, terrain restores its prior visibility.
|
When HD texture is off, terrain is temporarily hidden and its state is remembered. When HD texture comes back on, terrain restores its prior visibility.
|
||||||
|
|
||||||
|
Terrain tile fetching is batched. `terrain.js` deduplicates required Terrarium tile keys and sends chunks sized by `TERRAIN_CONFIG.batchRequestSize` to `/api/v1/visualization/terrain/terrarium/batch`. The backend proxies S3 Terrarium tiles with an in-memory LRU cache, per-batch deduplication, and bounded concurrency. The single tile endpoint remains for fallback paths and browser cache semantics.
|
||||||
|
|
||||||
## Current High-Frequency Risk Points
|
## Current High-Frequency Risk Points
|
||||||
|
|
||||||
### 1. Visual State and Business State Out of Sync
|
### 1. Visual State and Business State Out of Sync
|
||||||
|
|||||||
@@ -182,11 +182,14 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
|
|||||||
| Vessel renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | Normal marker and interactive overlay |
|
| Vessel renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | Normal marker and interactive overlay |
|
||||||
| Vessel track renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | Below vessel markers |
|
| Vessel track renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | Below vessel markers |
|
||||||
| Vessel point pixel size | local `VESSEL_POINT_SIZE` | `34` | Shared size for normal markers and hover / locked overlays |
|
| Vessel point pixel size | local `VESSEL_POINT_SIZE` | `34` | Shared size for normal markers and hover / locked overlays |
|
||||||
|
| Default vessel render cap | `VESSEL_CONFIG.maxRenderedMarkers` | `0` | `0` means the frontend does not clip by default; positive values send `limit` and clip markers |
|
||||||
| Vessel texture canvas size | local `VESSEL_ATLAS_CELL_SIZE` | `128` | Canvas point texture |
|
| Vessel texture canvas size | local `VESSEL_ATLAS_CELL_SIZE` | `128` | Canvas point texture |
|
||||||
| Course bucket count | local `VESSEL_COURSE_BINS` | `32` | Moving vessels are bucketed by COG to reduce draw calls while preserving direction |
|
| Course bucket count | local `VESSEL_COURSE_BINS` | `32` | Moving vessels are bucketed by COG to reduce draw calls while preserving direction |
|
||||||
| Vessel hover picking throttle | local `VESSEL_HOVER_PICK_INTERVAL_MS` | `100` | `main.js` hover picking |
|
| Vessel hover picking throttle | local `VESSEL_HOVER_PICK_INTERVAL_MS` | `100` | `main.js` hover picking |
|
||||||
| Vessel screen hit radius | local `VESSEL_POINTER_RADIUS_PX` | `22` | `main.js` screen-space picking |
|
| Vessel screen hit radius | local `VESSEL_POINTER_RADIUS_PX` | `22` | `main.js` screen-space picking |
|
||||||
|
|
||||||
|
AIS vessel markers use batched `THREE.Points`, not one `THREE.Sprite` per vessel. Moving vessels stay triangular, anchored or slow vessels stay circular, and hover / locked states add a same-size glow overlay. Vessel type color and info-card type text must come from the same normalized result: `vessels.js` reads both backend `vessel_type_name` and AIS numeric `vessel_type`, derives the color-driving `type`, then exposes `vessel_type_display` for the info card, hover summary, and search results.
|
||||||
|
|
||||||
## Compute Centers
|
## Compute Centers
|
||||||
|
|
||||||
| Name | Variable | Current Value | Location / Notes |
|
| Name | Variable | Current Value | Location / Notes |
|
||||||
|
|||||||
86
docs/technical/en/earth-toolbar-overlay-coordination.md
Normal file
86
docs/technical/en/earth-toolbar-overlay-coordination.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Earth Toolbar And Overlay Coordination
|
||||||
|
|
||||||
|
This document describes the current coordination rules between the Earth toolbar buttons and the search panel, settings modal, news/live panel, and layer panel. Use this matrix when changing interactions, adding buttons, or adjusting panels so one action does not close an unrelated overlay.
|
||||||
|
|
||||||
|
Related entries:
|
||||||
|
|
||||||
|
- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
|
||||||
|
- [Frontend Layout Guidelines](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
|
||||||
|
|
||||||
|
## Toolbar Button Directory
|
||||||
|
|
||||||
|
The toolbar is marked by `.earth-toolbar-btn` in [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html):
|
||||||
|
|
||||||
|
| ID | Title | Type | Overlay / action |
|
||||||
|
|----|-------|------|------------------|
|
||||||
|
| `layer-action` | Layers | Overlay toggle | HUD panel `layer-toggles` on desktop / mobile drawer `layers` card |
|
||||||
|
| `search-action` | Search | Overlay toggle | Search panel on desktop / mobile drawer `search` card |
|
||||||
|
| `rotate-toggle` | Auto rotate | Standalone toggle | No overlay |
|
||||||
|
| `toggle-tv` | News live | Overlay toggle | Media panel `media-panel` with TV and News tabs |
|
||||||
|
| `reload-data` | Reload data | Standalone action | No overlay |
|
||||||
|
| `zoom-trigger` | Zoom control | Floating menu | Zoom floating menu |
|
||||||
|
| `settings-trigger` | Settings | Overlay toggle | Settings modal on desktop / mobile drawer `settings` card |
|
||||||
|
| `reset-view` | Reset view | Standalone action | No overlay |
|
||||||
|
| `layout-toggle` | Maximize layout | Standalone toggle | No overlay |
|
||||||
|
|
||||||
|
## Shared Coordination Entry Point
|
||||||
|
|
||||||
|
[controls.js::closeTransientMobileOverlays](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) is the shared coordinator for deciding what should close when an overlay opens.
|
||||||
|
|
||||||
|
Every path that opens a fullscreen-style overlay calls `closeTransientMobileOverlays({ except })`, where `except` names the overlay that should stay open:
|
||||||
|
|
||||||
|
```js
|
||||||
|
closeTransientMobileOverlays({ except: "search" });
|
||||||
|
closeTransientMobileOverlays({ except: "settings" });
|
||||||
|
closeTransientMobileOverlays({ except: "media" });
|
||||||
|
closeTransientMobileOverlays({ except: "layer-toggles" });
|
||||||
|
```
|
||||||
|
|
||||||
|
Current `except` values are `"search"`, `"settings"`, `"media"`, `"layer-toggles"`, or omitted to close all transient overlays.
|
||||||
|
|
||||||
|
## Close Matrix
|
||||||
|
|
||||||
|
`close` means the overlay closes; `keep` means it remains open.
|
||||||
|
|
||||||
|
| Action | Search | Settings | Mobile layers drawer | News/live |
|
||||||
|
|--------|:------:|:--------:|:--------------------:|:---------:|
|
||||||
|
| Open search (`except: "search"`) | self | close | close | keep |
|
||||||
|
| Open settings (`except: "settings"`) | close | self | close | keep |
|
||||||
|
| Open news/live (`except: "media"`) | close | close | close | self |
|
||||||
|
| Open mobile layers (`except: "layer-toggles"`) | close | close | self | close |
|
||||||
|
| Close all (`except: null`) | close | close | close | close |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Clicking toolbar Settings closes search and the mobile layer drawer, but keeps news/live open.
|
||||||
|
- Clicking toolbar Layers on mobile opens the `layers` drawer and closes search, settings, and news.
|
||||||
|
- Clicking News Live closes search, settings, and the layer drawer, then toggles the media panel.
|
||||||
|
|
||||||
|
## Design Rules
|
||||||
|
|
||||||
|
1. **Floating menus such as `zoom-trigger` are not overlays.** They use `bindFloatingMenu` and are managed separately by `closeFloatingMenus()`. Opening any overlay first closes floating menus.
|
||||||
|
2. **Desktop `layer-toggles` is a persistent HUD panel.** `closeTransientMobileOverlays` only closes it when `activeMobileDrawerId === "layer-toggles"`, so desktop search, settings, and news do not disturb the layer panel.
|
||||||
|
3. **News/live is independent from settings.** Users often adjust collector settings while watching news, so opening settings does not close the media panel. This became an invariant after the May 2026 coordination patch.
|
||||||
|
4. **Search and news are both primary information overlays.** Search opens without closing news, and news opens without closing search. If product direction changes, update both sides in `closeTransientMobileOverlays` so the matrix stays symmetric.
|
||||||
|
5. **Mobile drawers are fullscreen-focus states.** Any mobile drawer, whether layers, search, or settings, uses `setMobileDrawerState` and closes other overlays.
|
||||||
|
6. **Escape has a fixed close order.** See [controls.js::setupKeyboardControls](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js): search, settings, mobile drawer, floating menu, toolbar hub, locked object.
|
||||||
|
|
||||||
|
## Adding A Button Or Overlay
|
||||||
|
|
||||||
|
1. Add the button in the `.earth-toolbar` container in [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), using the existing `floating-btn liquid-glass-surface earth-toolbar-btn` class pattern.
|
||||||
|
2. Decide whether it is a standalone action, a floating menu, or a mutually coordinated overlay.
|
||||||
|
3. For a coordinated overlay, call `closeTransientMobileOverlays({ except: "<your-key>" })` when opening it.
|
||||||
|
4. Add the reciprocal close branch inside `closeTransientMobileOverlays`, so other overlays can close yours.
|
||||||
|
5. If the new overlay should coexist with an existing overlay, exclude that peer on both sides of the matrix.
|
||||||
|
6. Add an Escape close path in `setupKeyboardControls`.
|
||||||
|
7. On mobile, use `setMobileDrawerState({ open: true, card: "<your-card>" })` for drawer-style panels.
|
||||||
|
|
||||||
|
## Current Implementation Locations
|
||||||
|
|
||||||
|
- Coordinator: [controls.js::closeTransientMobileOverlays](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||||
|
- Settings overlay: [controls.js::openSettingsModal / closeSettingsModal](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||||
|
- Search overlay: [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js), imported from the search module
|
||||||
|
- News/live overlay: [tv.js::setTVPanelVisible](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), with the News tab in [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
|
||||||
|
- Mobile layer drawer: [controls.js::setMobileDrawerState](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||||
|
- Floating menu: [controls.js::bindFloatingMenu](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||||
|
- Toolbar DOM: [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
|
||||||
@@ -155,6 +155,7 @@ Purpose:
|
|||||||
- Renders Markdown content for `/docs`
|
- Renders Markdown content for `/docs`
|
||||||
- Supports headings, lists, blockquotes, code blocks, tables, and basic inline formatting
|
- Supports headings, lists, blockquotes, code blocks, tables, and basic inline formatting
|
||||||
- Code blocks and tables reuse `Scrollbar` so horizontal content does not blow out the docs page
|
- Code blocks and tables reuse `Scrollbar` so horizontal content does not blow out the docs page
|
||||||
|
- Docs content is returned by backend `/api/v1/docs/...` endpoints according to Gatekeeper permissions; the frontend only renders content visible to the current user
|
||||||
|
|
||||||
Current constraints:
|
Current constraints:
|
||||||
|
|
||||||
@@ -190,9 +191,10 @@ Responsibilities:
|
|||||||
|
|
||||||
- Token
|
- Token
|
||||||
- Current user
|
- Current user
|
||||||
|
- Gatekeeper groups
|
||||||
- Login / logout
|
- Login / logout
|
||||||
|
|
||||||
`App.tsx` uses it to decide whether to redirect to the login page.
|
`App.tsx` uses it to decide whether to redirect to the login page. `/docs` remains a public route, but the backend decides the visible catalog and content from the token; anonymous visitors only receive public docs.
|
||||||
|
|
||||||
### 2. Business Data Gateway
|
### 2. Business Data Gateway
|
||||||
|
|
||||||
|
|||||||
200
docs/technical/en/location-pipeline-development.md
Normal file
200
docs/technical/en/location-pipeline-development.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# Shared Location Resolution Pipeline Development Guide
|
||||||
|
|
||||||
|
`backend/app/services/location/` is the shared abstraction for any "given a record, decide its lat/lon" workflow. Compute centers, BGP collectors, and BGP events now run on this pipeline. Future entities such as satellite ground stations, user-claimed points, and IXP facilities should plug in here instead of creating another geocoding path.
|
||||||
|
|
||||||
|
For the user workflow, see [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md).
|
||||||
|
|
||||||
|
## Design Goals
|
||||||
|
|
||||||
|
Historically compute centers had their own four-tier chain, BGP collectors used a hard-coded dictionary, and BGP events inherited collector coordinates. These implementations did not share code, and new algorithms had no stable insertion point.
|
||||||
|
|
||||||
|
The refactored rules:
|
||||||
|
|
||||||
|
- Share the `LocationResolver` protocol and `LocationPipeline` orchestrator.
|
||||||
|
- Domain modules only build `LocationQuery` and choose resolver order.
|
||||||
|
- New algorithms join by adding resolver classes, without changing ingestion, API, or frontend envelopes.
|
||||||
|
- Earth renders only city-level or better locations.
|
||||||
|
- Local JSON registries are not runtime candidate sources for compute centers or BGP collectors; persisted location facts live in database dimension tables.
|
||||||
|
|
||||||
|
## Core Interfaces
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocationQuery:
|
||||||
|
name: str | None
|
||||||
|
aliases: tuple[str, ...]
|
||||||
|
city: str | None
|
||||||
|
country: str | None
|
||||||
|
region: str | None
|
||||||
|
source_latitude: float | None
|
||||||
|
source_longitude: float | None
|
||||||
|
extra: Mapping[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocationCandidate:
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
display_name: str
|
||||||
|
precision: str
|
||||||
|
confidence: float
|
||||||
|
source: str
|
||||||
|
needs_confirmation: bool
|
||||||
|
matched_fields: tuple[str, ...]
|
||||||
|
suggested_registry_entry: dict | None
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LocationResolver(Protocol):
|
||||||
|
name: str
|
||||||
|
def resolve(self, query: LocationQuery) -> ResolverOutput: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`LocationPipeline.collect_candidates()` returns sorted candidates plus `attempted_queries`; `resolve_best()` returns the best candidate with diagnostics. The default sort key ranks source, precision, and confidence, then deduplicates candidates with the same source and rounded coordinates.
|
||||||
|
|
||||||
|
## Built-In Resolvers
|
||||||
|
|
||||||
|
| Resolver | File | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SourceCoordinatesResolver` | `resolvers/source_coordinates.py` | Emits `precision="precise"` when the record already has lat/lon |
|
||||||
|
| `RegistryResolver` | `resolvers/registry.py` | Legacy generic resolver; current compute-center and BGP runtime paths do not use it to generate candidates |
|
||||||
|
| `NominatimResolver` | `resolvers/nominatim.py` | Runs a domain query plan against Nominatim with LRU cache and rate limiting |
|
||||||
|
| `InheritFromAnotherEntityResolver` | `resolvers/inherit.py` | Wraps an externally resolved entity location as a candidate |
|
||||||
|
|
||||||
|
`RegistryResolver` remains available for future controlled import scenarios, but it should not be reconnected as a hard-coded hint source for compute centers or BGP. Matching common fields such as `operator` or `city` was the main reason multiple entities could collapse onto the same point.
|
||||||
|
|
||||||
|
## Current Domain Pipelines
|
||||||
|
|
||||||
|
### Compute Centers
|
||||||
|
|
||||||
|
Entry points:
|
||||||
|
|
||||||
|
- [compute_center_locations.py](/home/ray/dev/linkong/planet/backend/app/services/compute_center_locations.py)
|
||||||
|
|
||||||
|
Resolver order:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SourceCoordinatesResolver()
|
||||||
|
StoredComputeCenterLocationResolver()
|
||||||
|
```
|
||||||
|
|
||||||
|
The main map startup path is source coordinates first, then the database-backed current-location table. The table is `compute_center_locations`, keyed by `(source, source_id)`, and stores manually accepted locations or true coordinates migrated from source records. `init_db()` only migrates source records that already contain real coordinates; it does not import old hard-coded hints and does not run ROR, Nominatim, or LLM geocoding during startup.
|
||||||
|
|
||||||
|
Candidate collection is intentionally separate from rendering. `collect_location_candidates()` builds ROR and Nominatim/OpenStreetMap queries from source fields, but it does not emit the current `compute_center_locations` row as a candidate. After a user accepts a candidate, the save endpoint upserts it into the dimension table; the next map refresh renders it through `StoredComputeCenterLocationResolver`.
|
||||||
|
|
||||||
|
`resolve_compute_center_location()`, `resolve_compute_center_location_full()`, and `collect_location_candidates()` remain the domain API. `visualization.py` consumes that API and no longer owns coordinate hints, country-centroid fallbacks, or Nominatim details.
|
||||||
|
|
||||||
|
GeoJSON output includes only `RENDERABLE_PRECISIONS`. Unresolved records are returned in `unresolved` with `failure_reason`, `attempted_queries`, `source_id`, `record_id`, and related diagnostics.
|
||||||
|
|
||||||
|
### BGP Collectors
|
||||||
|
|
||||||
|
Entry points:
|
||||||
|
|
||||||
|
- [bgp_collector_locations.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collector_locations.py)
|
||||||
|
- [bgp_collector_location.py](/home/ray/dev/linkong/planet/backend/app/models/bgp_collector_location.py)
|
||||||
|
|
||||||
|
Resolver order:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SourceCoordinatesResolver()
|
||||||
|
StoredCollectorLocationResolver()
|
||||||
|
NominatimResolver(_bgp_collector_query_plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
The 23 RIPE RIS collector coordinates moved from the old table into the `bgp_collector_locations` dimension table with `source=legacy_seed` and `needs_confirmation=true`. The legacy dictionary is still maintained from the DB-backed cache for compatibility; manual candidate collection uses stored site/city/country as context but does not emit stored rows as candidates.
|
||||||
|
|
||||||
|
### BGP Events
|
||||||
|
|
||||||
|
Entry point:
|
||||||
|
|
||||||
|
- [bgp_event_locations.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_event_locations.py)
|
||||||
|
|
||||||
|
Resolver order:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SourceCoordinatesResolver()
|
||||||
|
InheritFromAnotherEntityResolver(_inherit_from_owning_collector)
|
||||||
|
```
|
||||||
|
|
||||||
|
Event inheritance uses a strict owning-collector lookup and does not run the full fuzzy collector registry. Future ASN facility, PrefixGeo, or PeeringDB resolvers can be inserted after inheritance.
|
||||||
|
|
||||||
|
## API Envelope
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/visualization/compute-centers/{source_id}/collect-location
|
||||||
|
POST /api/v1/visualization/compute-centers/{source_id}/location
|
||||||
|
POST /api/v1/bgp/collectors/{collector_id}/collect-location
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `collect-location` endpoints return the same envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"candidates": [],
|
||||||
|
"best_candidate": {},
|
||||||
|
"attempted_queries": [],
|
||||||
|
"context": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /api/v1/visualization/compute-centers/{source_id}/location` upserts the candidate selected by the frontend into `compute_center_locations`. Manual saves default to `needs_confirmation=false`, `verification_status="verified"`, and a `verified_at` timestamp. Future automated staging can pass `needs_confirmation=true` explicitly.
|
||||||
|
|
||||||
|
The frontend [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) renders the shared candidate list and preview events. The compute-center layer button shows an `unresolved` badge; clicking it opens the unresolved queue. Row-level `采集` only fetches candidates. Header-level `一键采用` walks the queue top-to-bottom, picks the highest-confidence candidate with valid coordinates, saves it, removes the row, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch finishes, `earth:compute-center-location-saved` refreshes the real layer.
|
||||||
|
|
||||||
|
If the remaining records have no city-level candidates, the batch must not invent coordinates. The UI keeps those rows and shows the backend `failure_reason` plus attempted queries.
|
||||||
|
|
||||||
|
## Adding A Resolver
|
||||||
|
|
||||||
|
A resolver only needs `name` and `resolve()`, returning `ResolverOutput`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PeeringDBFacilityResolver:
|
||||||
|
name = "peeringdb_facility"
|
||||||
|
|
||||||
|
def __init__(self, client):
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def resolve(self, query):
|
||||||
|
asn = query.extra.get("origin_asn")
|
||||||
|
if not asn:
|
||||||
|
return ResolverOutput()
|
||||||
|
return ResolverOutput(candidates=tuple(
|
||||||
|
LocationCandidate(
|
||||||
|
latitude=f.latitude,
|
||||||
|
longitude=f.longitude,
|
||||||
|
display_name=f.name,
|
||||||
|
precision="site",
|
||||||
|
confidence=0.78,
|
||||||
|
query=f"peeringdb::{asn}",
|
||||||
|
source=self.name,
|
||||||
|
source_note=f"PeeringDB facility for AS{asn}",
|
||||||
|
matched_fields=("origin_asn",),
|
||||||
|
needs_confirmation=False,
|
||||||
|
city=f.city,
|
||||||
|
country=f.country,
|
||||||
|
)
|
||||||
|
for f in self._client.facilities_for_asn(asn)
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire it in:
|
||||||
|
|
||||||
|
```python
|
||||||
|
BGP_EVENT_PIPELINE = LocationPipeline([
|
||||||
|
SourceCoordinatesResolver(),
|
||||||
|
InheritFromAnotherEntityResolver(source_lookup=...),
|
||||||
|
PeeringDBFacilityResolver(client=peeringdb_client),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
Relevant tests:
|
||||||
|
|
||||||
|
- [test_location_pipeline.py](/home/ray/dev/linkong/planet/backend/tests/test_location_pipeline.py)
|
||||||
|
- [test_bgp_collector_locations.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp_collector_locations.py)
|
||||||
|
- [test_visualization_compute_centers.py](/home/ray/dev/linkong/planet/backend/tests/test_visualization_compute_centers.py)
|
||||||
|
|
||||||
|
Coverage focuses on resolver pluggability, registry alias guards, BGP collector legacy dictionary compatibility, compute-center public API compatibility, and non-renderable locations being returned as `unresolved`.
|
||||||
127
docs/technical/en/location-pipeline-user.md
Normal file
127
docs/technical/en/location-pipeline-user.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Earth Location Candidate Collection User Guide
|
||||||
|
|
||||||
|
Location candidate collection helps fill or verify coordinates for compute centers and BGP collectors on Earth. Users do not type coordinates by hand; the backend ranks source coordinates, open organization-registry results, and online geocoding results into a previewable candidate list.
|
||||||
|
|
||||||
|
## Supported Entities
|
||||||
|
|
||||||
|
Currently supported:
|
||||||
|
|
||||||
|
- Compute centers: TOP500 supercomputers and Epoch AI GPU clusters.
|
||||||
|
- BGP collectors: RIPE RIS `rrcXX` collectors.
|
||||||
|
|
||||||
|
BGP events inherit the location of their owning collector. Events do not have a separate collection button yet; future ASN facility, prefix geography, or PeeringDB resolvers should use the same pipeline.
|
||||||
|
|
||||||
|
## What Users See
|
||||||
|
|
||||||
|
Clicking a compute center or BGP collector on Earth opens a detail card with location fields:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| Location precision | Precise coordinates, site-level, city-level, or unconfirmed |
|
||||||
|
| Location source | Source coordinates, ROR organization registry, Nominatim online search, or stored BGP collector locations |
|
||||||
|
| Location confidence | Relative confidence reported by the backend resolver |
|
||||||
|
| Verification status | Confirmed, estimated, or online result pending confirmation |
|
||||||
|
| Resolution reason | Why the location was selected |
|
||||||
|
| Matched location name | Canonical name from an open source, online result, or stored collector location |
|
||||||
|
| Verified at | Verification date for confirmed locations; online candidates are usually empty |
|
||||||
|
|
||||||
|
Compute-center GeoJSON no longer renders country centroids, unknown locations, or `[0, 0]` placeholders. Records that cannot reach city-level precision are returned in the endpoint's `unresolved` list and can be improved through candidate collection.
|
||||||
|
|
||||||
|
A compute center with a `?` marker on Earth is not unresolved. It already has coordinates, but the coordinates still need confirmation, either because `needs_confirmation=true` or because the source is online geocoding. Truly unresolved records have no trustworthy coordinates and are therefore absent from the globe.
|
||||||
|
|
||||||
|
## Collect Candidates
|
||||||
|
|
||||||
|
1. Open `http://localhost:3000/earth`.
|
||||||
|
2. Enable the `Compute centers` or `BGP observation` layer.
|
||||||
|
3. Click an object to open its detail card.
|
||||||
|
4. Click `自动采集坐标候选` or `重新自动采集坐标`.
|
||||||
|
5. Wait for up to five candidates to appear.
|
||||||
|
6. Click `预览` on a candidate row; Earth flies to that latitude and longitude.
|
||||||
|
|
||||||
|
Candidate rows show:
|
||||||
|
|
||||||
|
- Candidate name.
|
||||||
|
- Precision: precise, site, or city.
|
||||||
|
- Resolver source.
|
||||||
|
- Confidence.
|
||||||
|
- Coordinates.
|
||||||
|
|
||||||
|
Clicking `保存` on a candidate row writes the selected compute-center candidate into the location dimension table. After the save succeeds, the compute-center layer refreshes; if the record was previously in the unresolved queue, the unresolved count decreases.
|
||||||
|
|
||||||
|
## Unresolved Queue And Adopt All
|
||||||
|
|
||||||
|
The notification badge on the compute-center layer row shows the current unresolved count. Clicking it opens a fixed queue beside the layer panel:
|
||||||
|
|
||||||
|
1. The queue contains only compute centers without trustworthy coordinates.
|
||||||
|
2. Row-level `采集` calls the candidate endpoint and shows up to five previewable candidates.
|
||||||
|
3. Header-level `一键采用` walks the list from top to bottom, chooses the highest-confidence candidate with valid coordinates, and saves it.
|
||||||
|
4. Each successful save immediately removes that row, renumbers the remaining rows, and updates the badge count.
|
||||||
|
5. When the batch completes, the frontend refreshes the compute-center layer so UI state and backend state converge.
|
||||||
|
|
||||||
|
If a record has no saveable candidate, the system does not invent a country centroid, vendor headquarters, or hard-coded hint. The row stays in the queue with the backend failure reason and attempted queries so an operator can supply better evidence later.
|
||||||
|
|
||||||
|
## Backend APIs
|
||||||
|
|
||||||
|
The frontend buttons call:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/visualization/compute-centers/{source_id}/collect-location
|
||||||
|
POST /api/v1/visualization/compute-centers/{source_id}/location
|
||||||
|
POST /api/v1/bgp/collectors/{collector_id}/collect-location
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `collect-location` endpoints use the same response shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"candidates": [],
|
||||||
|
"best_candidate": {},
|
||||||
|
"attempted_queries": [],
|
||||||
|
"context": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When no candidate reaches city-level precision, `success` is `false` and the response includes `failure_reason` plus the attempted queries. This helps distinguish missing source fields, open-source gaps, and online geocoding misses.
|
||||||
|
|
||||||
|
## Registry Maintenance
|
||||||
|
|
||||||
|
Compute centers and BGP collectors no longer maintain local candidate registries. Compute-center accepted locations are stored in the `compute_center_locations` database dimension table keyed by `(source, source_id)`. BGP collector current locations are stored in the `bgp_collector_locations` database dimension table; the old RIPE RIS city-level coordinates are used only as initialization seed data and still require confirmation.
|
||||||
|
|
||||||
|
For compute centers, prefer maintaining:
|
||||||
|
|
||||||
|
- `source` / `source_id`: for example `top500` + `top500_50`.
|
||||||
|
- `name` / `operator` / `site`.
|
||||||
|
- `city` / `country`.
|
||||||
|
- `latitude` / `longitude`.
|
||||||
|
- `precision`: `precise`, `site`, or `city`.
|
||||||
|
- `confidence`: confidence from 0 to 1.
|
||||||
|
- `location_source` / `source_url` / `source_note` / `raw_payload`: evidence source.
|
||||||
|
- `needs_confirmation` / `verification_status` / `verified_at`: manual verification status and date.
|
||||||
|
|
||||||
|
For BGP collectors, prefer maintaining:
|
||||||
|
|
||||||
|
- `collector_id`: for example `rrc12`.
|
||||||
|
- `site` / `operator`: site and operator.
|
||||||
|
- `city` / `country` / `region`.
|
||||||
|
- `latitude` / `longitude`.
|
||||||
|
- `precision`: `precise`, `site`, or `city`.
|
||||||
|
- `confidence`: confidence from 0 to 1.
|
||||||
|
- `source` / `source_url` / `raw_payload`: evidence source.
|
||||||
|
- `verification_status` / `verified_at`: manual verification status and date.
|
||||||
|
|
||||||
|
If only the city is known, use city-level precision. Do not enter a precise-looking coordinate that has not been verified.
|
||||||
|
|
||||||
|
## Common Questions
|
||||||
|
|
||||||
|
### Why are some compute centers missing on Earth?
|
||||||
|
|
||||||
|
Earth only renders coordinates that reach city-level precision or better. If source data, verified storage, and online geocoding all fail, the record is returned as `unresolved` instead of being rendered at a misleading country center or `[0, 0]`.
|
||||||
|
|
||||||
|
### Why do online results need confirmation?
|
||||||
|
|
||||||
|
Nominatim/OpenStreetMap results may match same-name cities, organizations, or campuses. They are useful for previewing candidates, but should be manually confirmed before being persisted as verified locations.
|
||||||
|
|
||||||
|
### Why do BGP events no longer all land in Amsterdam?
|
||||||
|
|
||||||
|
The old behavior could match common fields like `operator="RIPE NCC"` and incorrectly promote `rrc00`. BGP event inheritance now uses a strict owning-collector lookup in the DB-backed cache instead of registry fuzzy matching.
|
||||||
@@ -5,7 +5,7 @@ This manual is for daily use, demos, development integration, and local operatio
|
|||||||
- `planet.sh`: local start, stop, restart, health check, and log access
|
- `planet.sh`: local start, stop, restart, health check, and log access
|
||||||
- Earth: public 3D situational awareness page
|
- Earth: public 3D situational awareness page
|
||||||
- Console: admin backend (login required)
|
- Console: admin backend (login required)
|
||||||
- Docs: public developer documentation and manual
|
- Docs: backend Gatekeeper-controlled documentation; basic usage docs are public, while developer and operations docs require permission groups
|
||||||
|
|
||||||
For the shortest path to getting started, see [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md).
|
For the shortest path to getting started, see [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md).
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ After a default startup, the common URLs are:
|
|||||||
| Name | URL | Login Required | Description |
|
| Name | URL | Login Required | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Earth | `http://localhost:3000/earth` | No | 3D globe, layers, BGP, satellites, cables, news situational awareness |
|
| Earth | `http://localhost:3000/earth` | No | 3D globe, layers, BGP, satellites, cables, news situational awareness |
|
||||||
| Docs | `http://localhost:3000/docs` | No | Developer docs, technical reference, usage manual |
|
| Docs | `http://localhost:3000/docs` | Partly | Usage docs are public; developer, backend, and operations docs require Gatekeeper groups |
|
||||||
| Console | `http://localhost:3000/admin` | Yes | Data, config, alerts, logs, and situational observation |
|
| Console | `http://localhost:3000/admin` | Yes | Data, config, alerts, logs, and situational observation |
|
||||||
| AI Playground | `http://localhost:3000/playground` | Yes | AI Provider status and debugging |
|
| AI Playground | `http://localhost:3000/playground` | Yes | AI Provider status and debugging |
|
||||||
| Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation |
|
| Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation |
|
||||||
@@ -176,7 +176,26 @@ Useful for:
|
|||||||
- Demos on phone or tablet
|
- Demos on phone or tablet
|
||||||
- Another machine on the same LAN accessing the same dev instance
|
- Another machine on the same LAN accessing the same dev instance
|
||||||
|
|
||||||
After starting, check your firewall and WSL network forwarding if access fails.
|
`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but access from a phone or another computer through `http://<Windows LAN IP>:3000` still depends on Windows port forwarding and firewall rules.
|
||||||
|
|
||||||
|
Use this order to diagnose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From WSL or the shell running Planet
|
||||||
|
curl http://localhost:3000
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
ss -ltnp | grep -E ':3000|:8000'
|
||||||
|
```
|
||||||
|
|
||||||
|
If this shows `0.0.0.0:3000` and `0.0.0.0:8000`, but the LAN IP still fails, configure Windows from an elevated PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
## Earth
|
## Earth
|
||||||
|
|
||||||
@@ -259,6 +278,12 @@ Earth search finds current globe objects, such as:
|
|||||||
|
|
||||||
Search results can be used to quickly locate objects and open their details.
|
Search results can be used to quickly locate objects and open their details.
|
||||||
|
|
||||||
|
### Location Candidate Collection
|
||||||
|
|
||||||
|
Compute-center and BGP collector detail cards can collect candidate coordinates automatically. After clicking an object, use `自动采集坐标候选` or `重新自动采集坐标`; the backend ranks source coordinates, open organization lookups, and Nominatim online search results. Stored BGP collector locations are used as query context only and are not emitted as candidates.
|
||||||
|
|
||||||
|
Candidates can be previewed directly on Earth. Compute-center candidates can be saved into the `compute_center_locations` dimension table from the detail card, then the layer refreshes immediately. The notification badge on the compute-center layer row shows unresolved records that cannot be rendered; clicking it opens the queue, where users can collect individual candidates or use `一键采用` to save the highest-confidence candidate top-to-bottom. Records without candidates stay in the queue and are not replaced by country centroids or hard-coded hints. See [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md) for the full workflow.
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
|
|
||||||
The settings panel contains:
|
The settings panel contains:
|
||||||
@@ -492,33 +517,41 @@ Then open `/logs` for more structured runtime information.
|
|||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
Public documentation site:
|
Documentation site:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://localhost:3000/docs
|
http://localhost:3000/docs
|
||||||
```
|
```
|
||||||
|
|
||||||
Current public content comes from:
|
Docs content is read through backend APIs by permission. The frontend no longer bundles all Markdown files directly. Source files still live in:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/technical/zh/ (Chinese)
|
docs/technical/zh/ (Chinese)
|
||||||
docs/technical/en/ (English)
|
docs/technical/en/ (English)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Anonymous visitors only see `public` docs such as the overview, quickstart, and manual. Logged-in users can see more technical docs when assigned Gatekeeper groups:
|
||||||
|
|
||||||
|
- `docs_user`: user-operation docs.
|
||||||
|
- `docs_developer`: Earth, frontend, backend, collector, and AI Provider development docs.
|
||||||
|
- `docs_admin`: service control, operations, environment variable, and sensitive-operation docs.
|
||||||
|
|
||||||
|
`admin` receives admin-doc access by default, and `super_admin` can read all Docs content. Gatekeeper groups are configured in the console Users page.
|
||||||
|
|
||||||
Docs supports:
|
Docs supports:
|
||||||
|
|
||||||
- Category navigation
|
- Category navigation
|
||||||
- Markdown rendering
|
- Markdown rendering
|
||||||
- Tables and code blocks
|
- Tables and code blocks
|
||||||
- In-document table of contents
|
- In-document table of contents
|
||||||
- Local search
|
- Search across currently visible docs
|
||||||
- Internal links between technical documents
|
- Internal links between technical documents
|
||||||
|
|
||||||
When adding a new technical document, check:
|
When adding a new technical document, check:
|
||||||
|
|
||||||
- Does it have a clear top-level heading
|
- Does it have a clear top-level heading
|
||||||
- Does it need to be added to the `/docs` manual category and ordering
|
- Does it need to be added to backend Docs metadata for category and ordering
|
||||||
- Does it contain information that should not be publicly displayed
|
- Should it be classified as `public`, `docs_user`, `docs_developer`, or `docs_admin`
|
||||||
|
|
||||||
## Development Command Conventions
|
## Development Command Conventions
|
||||||
|
|
||||||
@@ -589,5 +622,6 @@ When something goes wrong, follow this sequence:
|
|||||||
- [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
|
- [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
|
||||||
- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
|
- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
|
||||||
- [Earth Layer Style Reference](/home/ray/dev/linkong/planet/docs/technical/en/earth-layer-style-reference.md)
|
- [Earth Layer Style Reference](/home/ray/dev/linkong/planet/docs/technical/en/earth-layer-style-reference.md)
|
||||||
|
- [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md)
|
||||||
- [System Service Control](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md)
|
- [System Service Control](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md)
|
||||||
- [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)
|
- [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)
|
||||||
|
|||||||
205
docs/technical/en/ops-planet-sh-startup.md
Normal file
205
docs/technical/en/ops-planet-sh-startup.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# `planet.sh` Startup Performance Optimization
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
`planet.sh` manages start, stop, restart, health checks, and logs for all local services. The previous implementation had several startup issues:
|
||||||
|
|
||||||
|
1. AI Provider rebuilt every time, even when code had not changed.
|
||||||
|
2. Port cleanup could wait up to 45 seconds.
|
||||||
|
3. Port bind detection used a Python subprocess, adding about 300 ms per call.
|
||||||
|
4. Plain `restart` and `restart -b` behaved differently.
|
||||||
|
|
||||||
|
## Issue 1: AI Provider Rebuilt Every Time
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
The build stamp file lived under `/tmp/`. After WSL or Linux restart, `/tmp` is cleared, so the `stamp_non_empty` condition failed and the script decided to rebuild:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All three conditions had to be true to skip rebuild
|
||||||
|
image_exists AND stamp_non_empty AND fingerprint_match
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
The stamp file moved to a persistent cache path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256"
|
||||||
|
```
|
||||||
|
|
||||||
|
Writing the stamp creates the directory first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
write_ai_provider_build_stamp() {
|
||||||
|
mkdir -p "$(dirname "$AI_PROVIDER_BUILD_STAMP_FILE")"
|
||||||
|
compute_ai_provider_build_fingerprint > "$AI_PROVIDER_BUILD_STAMP_FILE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Faster Fingerprint
|
||||||
|
|
||||||
|
The previous implementation tarred the whole `aiprovider/` directory before hashing, which could take seconds in large trees. The new version uses `find + stat` and reads only file metadata:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
compute_ai_provider_build_fingerprint() {
|
||||||
|
find aiprovider \
|
||||||
|
-type f \
|
||||||
|
! -path '*/__pycache__/*' \
|
||||||
|
! -name '.env' \
|
||||||
|
! -name '.env.*' \
|
||||||
|
! -name '*.pyc' \
|
||||||
|
! -name '*.pyo' \
|
||||||
|
| LC_ALL=C sort \
|
||||||
|
| xargs -r stat --format="%Y %s %n" 2>/dev/null
|
||||||
|
sha256sum docker-compose.yml docker-compose.simple.yml 2>/dev/null
|
||||||
|
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py" 2>/dev/null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is roughly 10 times faster for many-small-file workloads while preserving the same practical rebuild signal. `.env` and `.env.*` are excluded because runtime model, key, and Base URL changes should not force an image rebuild.
|
||||||
|
|
||||||
|
### Docker Build Context
|
||||||
|
|
||||||
|
AI Provider only needs root `pyproject.toml`, `uv.lock`, and `aiprovider/` source code. Sending the entire repository as Docker build context wastes time on frontend assets, PDFs, historical data, and Unreal files.
|
||||||
|
|
||||||
|
The root `.dockerignore` now narrows the context:
|
||||||
|
|
||||||
|
```dockerignore
|
||||||
|
**
|
||||||
|
|
||||||
|
!pyproject.toml
|
||||||
|
!uv.lock
|
||||||
|
!aiprovider/
|
||||||
|
!aiprovider/**
|
||||||
|
|
||||||
|
aiprovider/.env
|
||||||
|
aiprovider/.env.*
|
||||||
|
!aiprovider/.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
The Dockerfile copies only AI Provider inputs:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
COPY pyproject.toml uv.lock /app/
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
COPY aiprovider /app/aiprovider
|
||||||
|
```
|
||||||
|
|
||||||
|
`uv sync` uses a BuildKit cache mount. The first build may still depend on network speed, but later builds reuse `/root/.cache/uv`.
|
||||||
|
|
||||||
|
### Runtime Configuration
|
||||||
|
|
||||||
|
Before starting AI Provider, `planet.sh` generates a temporary env-file and passes it to Compose or the manual `docker run` fallback. Configuration priority:
|
||||||
|
|
||||||
|
1. `aiprovider/.env`
|
||||||
|
2. simple `export AI_...=...` or `AI_...=...` lines from `~/.zshrc`
|
||||||
|
|
||||||
|
The default parser is static and only covers AI Provider, image, and proxy variables. It avoids executing interactive shell initialization. Complex shell expansion can be enabled explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
|
||||||
|
```
|
||||||
|
|
||||||
|
To ignore personal shell config during debugging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skip-Rebuild Behavior
|
||||||
|
|
||||||
|
When the fingerprint matches, the script skips `docker compose build` and starts the existing container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker start planet_aiprovider
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker stop` stops the container without deleting the image. `cleanup_exit_containers` removes exited containers but not images, so the next `docker start` can reuse the existing image.
|
||||||
|
|
||||||
|
## Issue 2: Slow Port Cleanup
|
||||||
|
|
||||||
|
### Cause
|
||||||
|
|
||||||
|
`wait_for_port_release` could wait up to 45 seconds by default: 15 attempts times 3 seconds.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Background process cleanup now uses a 3-second timeout: TERM, 1.5 seconds, KILL, 1.5 seconds.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT_RELEASE_ATTEMPTS=15
|
||||||
|
PORT_RELEASE_INTERVAL=0.2
|
||||||
|
|
||||||
|
wait_for_port_release "$port" 15 0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
`wait_for_port_release` accepts optional parameters so different situations can choose different timeouts.
|
||||||
|
|
||||||
|
## Issue 3: Port Detection Used Python
|
||||||
|
|
||||||
|
### Cause
|
||||||
|
|
||||||
|
`can_bind_port` used `python3 -c "import socket..."`; each call cost about 300 ms.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Prefer system tools and keep Python as a fallback:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
can_bind_port() {
|
||||||
|
local port="$1"
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
! ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE ":${port}$"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
|
[ -z "$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null)" ]
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
python3 - "$port" <<'PY'
|
||||||
|
import sys, socket
|
||||||
|
p = int(sys.argv[1])
|
||||||
|
s = socket.socket()
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
try:
|
||||||
|
s.bind(("", p)); s.close(); sys.exit(0)
|
||||||
|
except OSError:
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend startup now has an additional pre-start cleanup retry layer:
|
||||||
|
|
||||||
|
- `PORT_PRESTART_RETRIES`: defaults to 3 attempts.
|
||||||
|
- `PORT_PRESTART_RETRY_INTERVAL`: defaults to 2 seconds.
|
||||||
|
|
||||||
|
`kill_port_if_requested()` only kills processes when the current environment can identify listening PIDs. If no PID is visible but the port still cannot bind, it logs diagnostics and lets the service startup flow make the final decision. `start_frontend_with_retry()` only enters the pre-cleanup retry path when a listener PID is visible, so the script no longer spends its retry budget repeatedly killing nothing while a host-side or external network namespace is still releasing the port. Seeing "no listener found but port still unavailable" on the first restart usually means the external environment is still releasing the port, not that a local process cleanup loop is useful.
|
||||||
|
|
||||||
|
## Issue 4: `restart` Behavior
|
||||||
|
|
||||||
|
Before the stamp path fix:
|
||||||
|
|
||||||
|
- `restart -b`: stop all services, check fingerprint, rebuild only when needed, then start.
|
||||||
|
- plain `restart`: stop all services, then often rebuild AI Provider because `/tmp` lost the stamp.
|
||||||
|
|
||||||
|
After moving the stamp file, plain `restart` uses the same `stop + start` behavior and the same fingerprint check as `restart -b`.
|
||||||
|
|
||||||
|
## Other Cleanup
|
||||||
|
|
||||||
|
Two redundant `sleep 3` waits were removed because health checks already cover the same readiness:
|
||||||
|
|
||||||
|
- `start_backend_service`: post-database-health-check sleep.
|
||||||
|
- `restart_database_service`: post-restart sleep.
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- [planet.sh](/home/ray/dev/linkong/planet/planet.sh)
|
||||||
|
- [.dockerignore](/home/ray/dev/linkong/planet/.dockerignore)
|
||||||
|
- [aiprovider/Dockerfile](/home/ray/dev/linkong/planet/aiprovider/Dockerfile)
|
||||||
|
- [docker-compose.yml](/home/ray/dev/linkong/planet/docker-compose.yml)
|
||||||
|
- [docker-compose.simple.yml](/home/ray/dev/linkong/planet/docker-compose.simple.yml)
|
||||||
|
- [compute_aiprovider_dependency_fingerprint.py](/home/ray/dev/linkong/planet/scripts/compute_aiprovider_dependency_fingerprint.py)
|
||||||
@@ -30,6 +30,16 @@ Personal AI Provider configuration can also live in `~/.zshrc`. `planet.sh` read
|
|||||||
./planet.sh restart -a
|
./planet.sh restart -a
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Collector credentials such as AISStream and BarentsWatch can also start in `~/.zshrc` for connectivity validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AISSTREAM_API_KEY="..."
|
||||||
|
export BARENTSWATCH_CLIENT_ID="..."
|
||||||
|
export BARENTSWATCH_CLIENT_SECRET="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
For actual collection, prefer saving credentials in `Settings -> Collector Settings`, especially for AISStream's long-lived WebSocket collector. That keeps connectivity validation, backend collection tasks, and Earth realtime vessel aggregation on the same configuration source.
|
||||||
|
|
||||||
## 1. Start Services
|
## 1. Start Services
|
||||||
|
|
||||||
From the repository root:
|
From the repository root:
|
||||||
@@ -44,7 +54,7 @@ After startup, the key URLs are:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Earth | `http://localhost:3000/earth` | Public 3D Earth visualization |
|
| Earth | `http://localhost:3000/earth` | Public 3D Earth visualization |
|
||||||
| Console | `http://localhost:3000/admin` | Admin console (login required) |
|
| Console | `http://localhost:3000/admin` | Admin console (login required) |
|
||||||
| Docs | `http://localhost:3000/docs` | Public developer docs and manual |
|
| Docs | `http://localhost:3000/docs` | Usage docs are public; developer and operations docs require Gatekeeper groups |
|
||||||
| AI Playground | `http://localhost:3000/playground` | AI debugging (login required) |
|
| AI Playground | `http://localhost:3000/playground` | AI debugging (login required) |
|
||||||
| Backend API Docs | `http://localhost:8000/docs` | FastAPI / OpenAPI interface docs |
|
| Backend API Docs | `http://localhost:8000/docs` | FastAPI / OpenAPI interface docs |
|
||||||
|
|
||||||
@@ -64,6 +74,8 @@ The console requires login. For first-time use:
|
|||||||
|
|
||||||
Follow the prompts to enter username, password, and role.
|
Follow the prompts to enter username, password, and role.
|
||||||
|
|
||||||
|
To read developer or operations docs, log in as `super_admin` and assign Gatekeeper groups from the Users page. Use `docs_developer` for development docs and `docs_admin` for service-control and operations docs.
|
||||||
|
|
||||||
## 3. Open Earth
|
## 3. Open Earth
|
||||||
|
|
||||||
Visit:
|
Visit:
|
||||||
@@ -79,6 +91,7 @@ Once in, verify:
|
|||||||
- The globe renders correctly
|
- The globe renders correctly
|
||||||
- The right-side layer panel can toggle layers on/off
|
- The right-side layer panel can toggle layers on/off
|
||||||
- Search can find cables, satellites, compute centers, BGP events
|
- Search can find cables, satellites, compute centers, BGP events
|
||||||
|
- Compute-center and BGP collector detail cards can collect and preview coordinate candidates; the compute-center unresolved badge can open the queue and save candidates
|
||||||
- Mouse drag, wheel zoom, and zoom percent feedback work correctly
|
- Mouse drag, wheel zoom, and zoom percent feedback work correctly
|
||||||
- Settings panel can switch cruise mode, day/night mode, satellite display style
|
- Settings panel can switch cruise mode, day/night mode, satellite display style
|
||||||
|
|
||||||
@@ -176,6 +189,14 @@ To allow a Windows browser, phone, or another device on the same network:
|
|||||||
|
|
||||||
This makes the frontend and backend listen on a LAN-accessible address.
|
This makes the frontend and backend listen on a LAN-accessible address.
|
||||||
|
|
||||||
|
Note: `--allow-lan` only makes Planet listen on `0.0.0.0`; it does not automatically expose WSL services through the Windows LAN IP. A common pattern is:
|
||||||
|
|
||||||
|
- `localhost:3000` / `localhost:8000` works inside WSL
|
||||||
|
- `localhost:3000` / `localhost:8000` works on Windows
|
||||||
|
- `http://<Windows LAN IP>:3000` fails from a phone or another computer
|
||||||
|
|
||||||
|
That usually means Windows still needs port forwarding or firewall rules.
|
||||||
|
|
||||||
If access fails, check from the shell running Planet:
|
If access fails, check from the shell running Planet:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -184,6 +205,16 @@ curl http://localhost:8000/health
|
|||||||
ss -ltnp | grep -E ':3000|:8000'
|
ss -ltnp | grep -E ':3000|:8000'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If WSL is listening on `0.0.0.0:3000` and `0.0.0.0:8000` but the LAN IP still fails, configure Windows forwarding and firewall rules from an elevated PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
## 9. Stop Services
|
## 9. Stop Services
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -23,8 +23,12 @@
|
|||||||
|
|
||||||
- [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md):从零启动 Planet 的最短路径
|
- [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md):从零启动 Planet 的最短路径
|
||||||
- [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md):控制台、`planet.sh`、Earth 和 Docs 的完整使用手册
|
- [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md):控制台、`planet.sh`、Earth 和 Docs 的完整使用手册
|
||||||
|
- [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md):在 Earth 上为算力中心和 BGP 观测站采集、预览坐标候选
|
||||||
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md):数据源目录、采集器设置、连接验证、BarentsWatch 凭证链路
|
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md):数据源目录、采集器设置、连接验证、BarentsWatch 凭证链路
|
||||||
|
- [通用位置估算管线开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-development.md):后端 location resolver / pipeline 的接口、注册表和扩展方式
|
||||||
|
- [Docs Gatekeeper 开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/docs-gatekeeper-development.md):后端 Docs 目录、正文读取和 Gatekeeper 权限组实现
|
||||||
- [Earth 可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md):Earth 地表可交互图标 `Interactable` 的接口、生命周期和接入示例
|
- [Earth 可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md):Earth 地表可交互图标 `Interactable` 的接口、生命周期和接入示例
|
||||||
|
- [Earth 工具栏与浮层协同](/home/ray/dev/linkong/planet/docs/technical/zh/earth-toolbar-overlay-coordination.md):工具栏按钮与搜索 / 设置 / 新闻 / 图层浮层之间的关闭矩阵和接入规则
|
||||||
|
|
||||||
不适合放入这里的内容:
|
不适合放入这里的内容:
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ async def run(self, db):
|
|||||||
| TeleGeography | submarine_cable | 海底光缆信息 | 7天 |
|
| TeleGeography | submarine_cable | 海底光缆信息 | 7天 |
|
||||||
| Space-Track TLE | satellite_tle | 卫星轨道 TLE 数据 | 依采集器配置 |
|
| Space-Track TLE | satellite_tle | 卫星轨道 TLE 数据 | 依采集器配置 |
|
||||||
| BarentsWatch AIS | vessel | 船只位置、航速、航向、MMSI 等 AIS 数据 | 依采集器配置 |
|
| BarentsWatch AIS | vessel | 船只位置、航速、航向、MMSI 等 AIS 数据 | 依采集器配置 |
|
||||||
|
| AISStream Vessels | vessel_ais | AIS WebSocket 实时流,写入原始观测层并由聚合接口展示 | 依采集器配置 |
|
||||||
|
|
||||||
|
AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同。BarentsWatch、AISStream 和自定义 `vessel_ais` 源都会进入 AIS 原始观测层,随后由聚合服务合并成 Earth 船只图层使用的 GeoJSON 和详情数据。这样做可以保留来源、传输方式、字段冲突和观测时间,避免某个实时源直接覆盖最终展示表。
|
||||||
|
|
||||||
|
TOP500 和 Epoch AI 算力数据的公开源不总是提供可用经纬度。Earth 统一算力中心接口在主地图启动链路中只使用源数据自带坐标或 `compute_center_locations` 维表坐标;缺少坐标的记录会进入 `unresolved`,不会通过本地注册表、国家质心或猜测城市自动渲染。用户手动采集候选时,后端会用源字段调用 ROR 组织注册 API 和 Nominatim/OpenStreetMap 在线搜索;候选经前端保存后写入 `compute_center_locations`,后续地图刷新再从维表渲染。
|
||||||
|
|
||||||
## 四、数据格式 (统一存储到 CollectedData 表)
|
## 四、数据格式 (统一存储到 CollectedData 表)
|
||||||
|
|
||||||
@@ -238,10 +243,19 @@ backend/app/services/collectors/
|
|||||||
├── huggingface.py # HuggingFace采集器
|
├── huggingface.py # HuggingFace采集器
|
||||||
├── peeringdb.py # PeeringDB采集器
|
├── peeringdb.py # PeeringDB采集器
|
||||||
├── telegeraphy.py # TeleGeography海底光缆采集器
|
├── telegeraphy.py # TeleGeography海底光缆采集器
|
||||||
└── vessel_ais.py # BarentsWatch AIS 船只采集器
|
├── vessel_ais.py # BarentsWatch AIS 船只采集器
|
||||||
|
└── aisstream.py # AISStream WebSocket 船只采集器
|
||||||
|
|
||||||
|
backend/app/services/
|
||||||
|
├── custom_datasource_runtime.py # 自定义 REST / WebSocket 映射运行时
|
||||||
|
├── datasource_mapping.py # 确定性字段映射与目标写入
|
||||||
|
├── vessel_ais_aggregation.py # AIS 原始观测写入与聚合读取
|
||||||
|
├── vessel_aggregation_strategy.py # 多源字段选择、freshness fallback 和冲突记录
|
||||||
|
└── vessel_enrichment.py # 船舶资料富化缓存
|
||||||
|
|
||||||
backend/app/models/
|
backend/app/models/
|
||||||
└── collected_data.py # 统一数据模型
|
├── collected_data.py # 统一数据模型
|
||||||
|
└── vessel_enrichment.py # 船舶富化结果缓存
|
||||||
```
|
```
|
||||||
|
|
||||||
## 九、凭证型采集器
|
## 九、凭证型采集器
|
||||||
@@ -251,6 +265,7 @@ backend/app/models/
|
|||||||
| 采集器 | credential provider | 凭证来源 |
|
| 采集器 | credential provider | 凭证来源 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `barentswatch_vessels` | `barentswatch` | 控制台采集器设置、环境变量、`~/.zshrc` |
|
| `barentswatch_vessels` | `barentswatch` | 控制台采集器设置、环境变量、`~/.zshrc` |
|
||||||
|
| `aisstream_vessels` | `aisstream` | 控制台采集器设置、环境变量、`~/.zshrc`(连接验证可读;正式采集建议保存到采集器设置或注入后端环境) |
|
||||||
| `spacetrack_tle` | `spacetrack` | 环境变量、`~/.zshrc` |
|
| `spacetrack_tle` | `spacetrack` | 环境变量、`~/.zshrc` |
|
||||||
|
|
||||||
### BarentsWatch AIS
|
### BarentsWatch AIS
|
||||||
@@ -289,6 +304,50 @@ export BARRENTSWATCH_CLIENT_SECRET="..."
|
|||||||
|
|
||||||
连接验证会先请求 `https://id.barentswatch.no/connect/token` 获取 `scope=ais` 的 access token,再用 `Authorization: Bearer <token>` 请求 AIS endpoint。
|
连接验证会先请求 `https://id.barentswatch.no/connect/token` 获取 `scope=ais` 的 access token,再用 `Authorization: Bearer <token>` 请求 AIS endpoint。
|
||||||
|
|
||||||
|
### AISStream 实时船舶
|
||||||
|
|
||||||
|
AISStream 使用 `wss://stream.aisstream.io/v0/stream` WebSocket endpoint。默认运行方式是长连接实时采集,而不是传统 REST collector 的“请求一次、进度到 100%、完成”模型。
|
||||||
|
|
||||||
|
运行时配置:
|
||||||
|
|
||||||
|
- `api_key`:优先从 `DataSourceConfig.auth_config.api_key` 或 `config.api_key` 读取;也可由后端进程环境变量 `AISSTREAM_API_KEY` 提供。
|
||||||
|
- `bounding_boxes`:AISStream 订阅范围,默认示例为全球 `[[[-90, -180], [90, 180]]]`,生产或演示建议先缩小区域。
|
||||||
|
- `message_types`:默认 `PositionReport` 和 `ShipStaticData`。
|
||||||
|
- `streaming_enabled`:默认启用长连接;关闭后回退到批次式 `fetch -> transform -> save`。
|
||||||
|
- `streaming_max_messages`:测试用上限,非 0 时收到指定消息数后停止。
|
||||||
|
- `reconnect_delay_seconds`、`receive_timeout_seconds`:控制断线重连和空闲等待。
|
||||||
|
|
||||||
|
状态语义:
|
||||||
|
|
||||||
|
- `connecting`:正在连接 AISStream。
|
||||||
|
- `streaming`:持续接收实时消息,`records_processed` 表示已见消息数,通常没有固定总量和百分比。
|
||||||
|
- `reconnecting`:上游断开或网络异常,采集器记录 `AISSourceHealth` 后等待重连。
|
||||||
|
- `stopped` / `cancelled`:任务被测试上限或用户停止。
|
||||||
|
|
||||||
|
AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“设置 -> 采集器设置 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。
|
||||||
|
|
||||||
|
### AIS 原始观测与聚合
|
||||||
|
|
||||||
|
AIS 观测写入后不会直接替换最终船只记录,而是先保存为 raw observation:
|
||||||
|
|
||||||
|
- `source` 记录来源,例如 `barentswatch_vessels`、`aisstream_vessels` 或自定义源名称。
|
||||||
|
- `delivery_mode` 表达实时性,`realtime_stream` 优先于 `polling`。
|
||||||
|
- `transport` 记录 `websocket` 或 `http`。
|
||||||
|
- 位置、速度、航向等动态字段会按 freshness 和来源优先级选择。
|
||||||
|
- 静态字段优先保留非空值;冲突候选会记录到详情接口,便于排查多源差异。
|
||||||
|
|
||||||
|
Earth 使用的接口仍是:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/visualization/geo/vessels
|
||||||
|
GET /api/v1/visualization/vessels/{mmsi}
|
||||||
|
GET /api/v1/visualization/vessels/{mmsi}/track
|
||||||
|
GET /api/v1/visualization/vessels/{mmsi}/conflicts
|
||||||
|
GET /api/v1/visualization/vessels/aggregation/diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
`/geo/vessels` 会合并 raw observation 聚合结果和 legacy BarentsWatch latest position 结果,避免只接入 AISStream 后把历史 BarentsWatch 船只遮蔽掉。
|
||||||
|
|
||||||
## 十、采集器设置与连接验证
|
## 十、采集器设置与连接验证
|
||||||
|
|
||||||
控制台的“采集器设置”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态,而是由后端计算 checksum:
|
控制台的“采集器设置”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态,而是由后端计算 checksum:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
当前行为:
|
当前行为:
|
||||||
|
|
||||||
- `collector_credentials` tab 展示为“采集器设置”。
|
- `collector_credentials` tab 展示为“采集器设置”。
|
||||||
- 下拉框列出所有内置采集器。
|
- 下拉框列出内置采集器,并支持维护合并到内置数据的自定义补充源。
|
||||||
- 下拉框右侧只有一个插头图标按钮,用于健康检查。
|
- 下拉框右侧只有一个插头图标按钮,用于健康检查。
|
||||||
- 下拉框下方用状态标签展示:
|
- 下拉框下方用状态标签展示:
|
||||||
- `需要凭证` / `无需凭证`
|
- `需要凭证` / `无需凭证`
|
||||||
@@ -72,6 +72,8 @@
|
|||||||
- 是否覆盖 endpoint
|
- 是否覆盖 endpoint
|
||||||
- 需要凭证的采集器把凭证卡片放在基础配置上方。
|
- 需要凭证的采集器把凭证卡片放在基础配置上方。
|
||||||
- 不需要凭证的采集器只显示基础配置。
|
- 不需要凭证的采集器只显示基础配置。
|
||||||
|
- AISStream 采集器使用 WebSocket 语义,状态会显示为连接中、实时接收、重连或停止,不使用固定百分比表达完成度。
|
||||||
|
- 自定义源入口放在采集器设置内,不在数据源目录里重复提供编辑入口;数据源目录只保留总览、运行和只读抽屉。
|
||||||
|
|
||||||
连接按钮使用内联 Tabler 风格插头图标,来源语义对应 `plug-connected`,避免继续使用刷新图标表达连接动作。
|
连接按钮使用内联 Tabler 风格插头图标,来源语义对应 `plug-connected`,避免继续使用刷新图标表达连接动作。
|
||||||
|
|
||||||
@@ -262,6 +264,124 @@ AIS 请求规则:
|
|||||||
|
|
||||||
`VesselAISCollector` 不再自己读取环境变量,而是统一走 `resolve_barentswatch_config()` 和 `fetch_barentswatch_access_token()`,避免设置页、连接验证和采集器三套凭证逻辑分叉。
|
`VesselAISCollector` 不再自己读取环境变量,而是统一走 `resolve_barentswatch_config()` 和 `fetch_barentswatch_access_token()`,避免设置页、连接验证和采集器三套凭证逻辑分叉。
|
||||||
|
|
||||||
|
## AISStream 采集器链路
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- [aisstream.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/aisstream.py)
|
||||||
|
- [vessel_ais_aggregation.py](/home/ray/dev/linkong/planet/backend/app/services/vessel_ais_aggregation.py)
|
||||||
|
|
||||||
|
AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations` 原始观测层,不直接覆盖最终船只展示表。聚合接口负责多源去重、字段选择和冲突记录。
|
||||||
|
|
||||||
|
配置项:
|
||||||
|
|
||||||
|
- `api_key`:保存在 `DataSourceConfig.auth_config`,也可用环境变量 `AISSTREAM_API_KEY`。
|
||||||
|
- `endpoint`:默认 `wss://stream.aisstream.io/v0/stream`。
|
||||||
|
- `message_types`:默认 `PositionReport` 和 `ShipStaticData`。
|
||||||
|
- `bounding_boxes`:AISStream 格式为 `[[[lat_min, lon_min], [lat_max, lon_max]]]`,设置页提供全球、挪威 / 北海、欧洲近海、东亚、北美东西海岸 preset。
|
||||||
|
- `max_messages` 和 `receive_timeout_seconds`:控制单次批次式 WebSocket 采集窗口。
|
||||||
|
|
||||||
|
标准化规则:
|
||||||
|
|
||||||
|
- `PositionReport` 主要提供位置、速度、航向和状态。
|
||||||
|
- 船名可以从 `MetaData.ShipName` 补入,即使消息体本身没有 `name`。
|
||||||
|
- 船型通常来自低频 `ShipStaticData.Type`;后端会把 AIS 数字类型码映射为 Cargo / Tanker / Passenger / Fishing / Military。
|
||||||
|
- 如果某艘船尚未收到静态消息,聚合结果的船型仍可能是 `Other`,后续由 v5 船舶资料 enrichment 补齐。
|
||||||
|
|
||||||
|
连接验证会读取保存配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,推荐把 API Key 保存到采集器设置;如果只写在 `~/.zshrc`,需要确认后端进程实际继承了该变量,否则连接验证可能可用但 collector 运行时拿不到 key。
|
||||||
|
|
||||||
|
## 自定义 REST / WebSocket 映射运行时
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- [custom_datasource_runtime.py](/home/ray/dev/linkong/planet/backend/app/services/custom_datasource_runtime.py)
|
||||||
|
- [datasource_mapping.py](/home/ray/dev/linkong/planet/backend/app/services/datasource_mapping.py)
|
||||||
|
|
||||||
|
自定义源现在不是独立的新数据孤岛,而是作为内置数据源的补充源写入目标 schema。当前最完整的目标是 `vessel_ais`:自定义 REST 或 WebSocket 源经过确定性 mapping 后写入 AIS raw observations,再通过 `vessels` WebSocket channel 推送给 Earth。
|
||||||
|
|
||||||
|
### 配置语义
|
||||||
|
|
||||||
|
关键字段:
|
||||||
|
|
||||||
|
- `source_type`:`rest` / `http` / `websocket` / `ws`。
|
||||||
|
- `endpoint`:REST 使用 `http(s)://`,WebSocket 使用 `ws(s)://`。
|
||||||
|
- `auth_type`:`none`、`bearer`、`api_key`、`basic`。
|
||||||
|
- `headers`:静态请求头。
|
||||||
|
- `auth_config`:token、API key、basic 用户名密码,API key 支持 header 或 query。
|
||||||
|
- `config.target_schema`:例如 `vessel_ais`。
|
||||||
|
- `config.delivery_mode`:REST 默认 `polling`,WebSocket 默认 `realtime_stream`。
|
||||||
|
- `config.merge_target_source`:记录该自定义源补充哪个内置数据,例如 `barentswatch_vessels`。
|
||||||
|
|
||||||
|
REST runner 支持:
|
||||||
|
|
||||||
|
- `GET` / `POST`
|
||||||
|
- query params
|
||||||
|
- JSON body
|
||||||
|
- headers 和 auth 注入
|
||||||
|
- active mapping 写入目标 schema
|
||||||
|
|
||||||
|
WebSocket runner 支持:
|
||||||
|
|
||||||
|
- endpoint 格式校验
|
||||||
|
- headers 和 auth 注入
|
||||||
|
- 可选 `ws_subscribe_message`
|
||||||
|
- `ws_message_path` / `ws_items_path` 提取消息主体或数组
|
||||||
|
- 断线重连
|
||||||
|
- `debug_max_messages` 调试上限
|
||||||
|
- 后台 stream start / stop / status
|
||||||
|
|
||||||
|
相关 API:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/datasources/custom/sample
|
||||||
|
GET /api/v1/datasources/target-schemas
|
||||||
|
POST /api/v1/datasources/{config_id}/run-mapped
|
||||||
|
POST /api/v1/datasources/{config_id}/stop-mapped
|
||||||
|
GET /api/v1/datasources/{config_id}/mapped-status
|
||||||
|
DELETE /api/v1/datasources/configs/{config_id}?delete_mappings=true&delete_source_data=true
|
||||||
|
```
|
||||||
|
|
||||||
|
`run-mapped?background=true` 只对 WebSocket 源有意义,会启动后台 stream。REST 源仍是一次性采集。
|
||||||
|
|
||||||
|
### 删除与数据清理
|
||||||
|
|
||||||
|
删除自定义源时有三种层级:
|
||||||
|
|
||||||
|
- 只删除配置:保留 mapping 和历史数据。
|
||||||
|
- 删除配置和 mapping:同时删除该配置的 mapping 模板。
|
||||||
|
- 删除配置、mapping 和该源数据:删除该源写入的 `collected_data`、`ais_raw_observations` 和 `ais_source_health`。
|
||||||
|
|
||||||
|
如果删除的是 `vessel_ais` 自定义源数据,后端会向 `vessels` channel 广播 `reload_required`,提示 Earth 重新拉取船只聚合结果。legacy `vessel_position` 不按自定义源直接删除,因为它没有可靠的 source 归因。
|
||||||
|
|
||||||
|
### 本地 AIS mock WebSocket
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- [mock-ais-ws-server.ts](/home/ray/dev/linkong/planet/scripts/mock-ais-ws-server.ts)
|
||||||
|
|
||||||
|
运行方式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run mock:ais-ws
|
||||||
|
```
|
||||||
|
|
||||||
|
mock 服务持续发送 AIS-like JSON,用于验证“WebSocket 自定义源 -> mapping -> AIS raw observation -> `vessels` channel -> Earth 船只 upsert”链路。典型配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source_type": "websocket",
|
||||||
|
"endpoint": "ws://localhost:8787",
|
||||||
|
"config": {
|
||||||
|
"target_schema": "vessel_ais",
|
||||||
|
"delivery_mode": "realtime_stream",
|
||||||
|
"merge_target_source": "barentswatch_vessels",
|
||||||
|
"ws_message_path": "$.data",
|
||||||
|
"ws_items_path": "$.vessels[*]",
|
||||||
|
"ws_reconnect": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 凭证教程
|
## 凭证教程
|
||||||
|
|
||||||
文件:
|
文件:
|
||||||
@@ -279,6 +399,7 @@ POST /api/v1/settings/credential-guides/{provider}/reset
|
|||||||
当前支持:
|
当前支持:
|
||||||
|
|
||||||
- `barentswatch`
|
- `barentswatch`
|
||||||
|
- `aisstream`
|
||||||
|
|
||||||
默认教程包含 BarentsWatch 官方 tutorial 地址:
|
默认教程包含 BarentsWatch 官方 tutorial 地址:
|
||||||
|
|
||||||
@@ -322,6 +443,7 @@ BarentsWatch `client_secret` 保存时有特殊处理:
|
|||||||
当前已经支持的凭证 provider:
|
当前已经支持的凭证 provider:
|
||||||
|
|
||||||
- `barentswatch`
|
- `barentswatch`
|
||||||
|
- `aisstream`
|
||||||
- `spacetrack`
|
- `spacetrack`
|
||||||
|
|
||||||
其他 `requires_credentials=true` 的采集器如果还没有 provider,会返回“凭证链路尚未接入”,前端显示 `不可用`。
|
其他 `requires_credentials=true` 的采集器如果还没有 provider,会返回“凭证链路尚未接入”,前端显示 `不可用`。
|
||||||
|
|||||||
116
docs/technical/zh/docs-gatekeeper-development.md
Normal file
116
docs/technical/zh/docs-gatekeeper-development.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Docs Gatekeeper 开发说明
|
||||||
|
|
||||||
|
Docs Gatekeeper 把 `/docs` 从“前端构建时打包所有 Markdown”改成“后端按权限返回目录和正文”。它的目标是让公开使用手册、用户文档、开发文档和管理/运维文档在同一个 Docs 页面内可检索,但正文读取必须经过服务端白名单和用户权限检查。
|
||||||
|
|
||||||
|
用户侧说明见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) 的 Docs 章节。
|
||||||
|
|
||||||
|
## 鉴权模型
|
||||||
|
|
||||||
|
Docs 使用两层权限:
|
||||||
|
|
||||||
|
- `users.role`:保留给控制台系统权限。
|
||||||
|
- `users.gatekeeper_groups`:Docs 内容权限组。
|
||||||
|
|
||||||
|
权限组:
|
||||||
|
|
||||||
|
| 组 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `docs_user` | 用户操作类文档 |
|
||||||
|
| `docs_developer` | Earth、前端、后端、采集器和 AI Provider 开发文档 |
|
||||||
|
| `docs_admin` | 服务控制、运维、环境变量和敏感操作文档 |
|
||||||
|
|
||||||
|
继承规则:
|
||||||
|
|
||||||
|
- 未登录用户只能读 `public`。
|
||||||
|
- `docs_developer` 隐含 `docs_user`。
|
||||||
|
- `docs_admin` 隐含 `docs_developer` 和 `docs_user`。
|
||||||
|
- `admin` 和 `super_admin` 默认拥有全部 Docs 权限。
|
||||||
|
|
||||||
|
## 后端入口
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- [docs.py](/home/ray/dev/linkong/planet/backend/app/api/v1/docs.py)
|
||||||
|
- [docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/app/services/docs_gatekeeper.py)
|
||||||
|
- [user.py](/home/ray/dev/linkong/planet/backend/app/models/user.py)
|
||||||
|
- [users.py](/home/ray/dev/linkong/planet/backend/app/api/v1/users.py)
|
||||||
|
|
||||||
|
API:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/docs/catalog
|
||||||
|
GET /api/v1/docs/{lang}/{slug}
|
||||||
|
```
|
||||||
|
|
||||||
|
`catalog` 只返回当前用户可见文档。正文接口会先校验语言、slug 和文件是否在 metadata 白名单里,再判断权限:
|
||||||
|
|
||||||
|
- 未登录访问受保护文档:`401`。
|
||||||
|
- 已登录但权限不足:`403`。
|
||||||
|
- 未知语言、未知 slug 或文件不存在:`404`。
|
||||||
|
|
||||||
|
正文文件只能来自 `docs/technical/{zh,en}/` 下的白名单文件,不能通过路径拼接读取任意文件。
|
||||||
|
|
||||||
|
## Metadata 来源
|
||||||
|
|
||||||
|
当前服务端 metadata 维护在 [docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/app/services/docs_gatekeeper.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
DocsMetadata(
|
||||||
|
"manual.md",
|
||||||
|
"manual",
|
||||||
|
"public",
|
||||||
|
"Manual",
|
||||||
|
2,
|
||||||
|
"Planet 使用手册",
|
||||||
|
"Planet Manual",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
新增公开文档时,需要同步:
|
||||||
|
|
||||||
|
- 新增中英文 Markdown 文件。
|
||||||
|
- 在服务端 `DOCS_METADATA` 添加 filename、slug、access、group、order、标题。
|
||||||
|
- 在前端 [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) 添加同名 metadata,保持导航标题和排序一致。
|
||||||
|
- 如果需要从 README 发现,更新 `docs/technical/zh/README.md` 和 `docs/technical/en/README.md`。
|
||||||
|
|
||||||
|
## 用户管理
|
||||||
|
|
||||||
|
`users` 表新增 `gatekeeper_groups JSONB DEFAULT '[]'`。启动时 [session.py](/home/ray/dev/linkong/planet/backend/app/db/session.py) 会用 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 补列,适配已有本地数据库。
|
||||||
|
|
||||||
|
用户 API 负责:
|
||||||
|
|
||||||
|
- 创建用户时写入 `gatekeeper_groups`。
|
||||||
|
- 更新用户时校验组名只能是 `docs_user`、`docs_developer`、`docs_admin`。
|
||||||
|
- 只有 `super_admin` 能修改 Gatekeeper 权限组。
|
||||||
|
|
||||||
|
前端 [Users.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Users/Users.tsx) 展示权限组标签,并在编辑表单中提供多选框。非 `super_admin` 打开的表单会禁用该字段,并在提交前移除 `gatekeeper_groups`。
|
||||||
|
|
||||||
|
## 前端 Docs 加载
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- [Docs.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/Docs.tsx)
|
||||||
|
- [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts)
|
||||||
|
- [docs-search.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-search.ts)
|
||||||
|
|
||||||
|
关键变化:
|
||||||
|
|
||||||
|
- 移除 `import.meta.glob(...?raw)` 作为正文来源。
|
||||||
|
- 页面加载时请求 `/api/v1/docs/catalog` 构建当前可见目录。
|
||||||
|
- 打开正文时请求 `/api/v1/docs/{lang}/{slug}`。
|
||||||
|
- 搜索只索引当前用户可见文档,并按需从后端读取 Markdown。
|
||||||
|
- `401` 显示登录提示,`403` 显示权限提示,`404` 显示文档不可用。
|
||||||
|
|
||||||
|
## 测试覆盖
|
||||||
|
|
||||||
|
相关测试:
|
||||||
|
|
||||||
|
- [test_docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/tests/test_docs_gatekeeper.py)
|
||||||
|
|
||||||
|
测试应覆盖:
|
||||||
|
|
||||||
|
- 匿名用户只能看到 public 文档。
|
||||||
|
- 受保护正文的 `401` / `403`。
|
||||||
|
- `docs_developer` 可读开发文档但不能读管理文档。
|
||||||
|
- `admin` 和 `super_admin` 可读管理文档。
|
||||||
|
- 未知 slug、未知语言和路径穿越字符串不能读取文件。
|
||||||
@@ -254,10 +254,10 @@ AIS 船只图层入口:
|
|||||||
船只图层当前负责:
|
船只图层当前负责:
|
||||||
|
|
||||||
- 请求 `/api/v1/visualization/geo/vessels`
|
- 请求 `/api/v1/visualization/geo/vessels`
|
||||||
- 将 BarentsWatch AIS GeoJSON 转为地球局部坐标 marker 数据
|
- 将聚合后的 AIS GeoJSON 转为地球局部坐标 marker 数据;请求默认不传 `limit`,后端和前端都不再默认裁剪到 5000 艘
|
||||||
- 通过 `createInteractableLayer()` 注册 Interactable 图标层
|
- 通过 `createInteractableLayer()` 注册 Interactable 图标层
|
||||||
- 用按航向分桶的 `THREE.Points` 批量渲染普通船只 marker
|
- 用按航向分桶的 `THREE.Points` 批量渲染普通船只 marker
|
||||||
- 按船型映射颜色
|
- 按船型映射颜色;`vessels.js` 会用 `vessel_type_name` 和 AIS `vessel_type` 数字共同归一化船型
|
||||||
- 根据航行/停泊状态绘制三角形或圆点纹理
|
- 根据航行/停泊状态绘制三角形或圆点纹理
|
||||||
- 用单点 `THREE.Points` overlay 承载 hover / locked glow
|
- 用单点 `THREE.Points` overlay 承载 hover / locked glow
|
||||||
- 支持 hover、lock、轨迹加载和视觉聚焦
|
- 支持 hover、lock、轨迹加载和视觉聚焦
|
||||||
@@ -273,6 +273,10 @@ AIS 船只图层入口:
|
|||||||
|
|
||||||
方向标准以 AIS `course / cog` 为准:从正北开始顺时针。普通态和交互态都通过同一套 canvas 旋转规则生成纹理,避免 hover 后箭头方向和原 marker 不一致。
|
方向标准以 AIS `course / cog` 为准:从正北开始顺时针。普通态和交互态都通过同一套 canvas 旋转规则生成纹理,避免 hover 后箭头方向和原 marker 不一致。
|
||||||
|
|
||||||
|
船型展示也必须复用同一套归一化结果。`buildVesselMarkerData()` 会把后端的 `vessel_type_name` 和 AIS 数字类型码归一化为 `type`,用于 marker 颜色;同时生成 `vessel_type_display`,供详情卡、hover 简述和搜索结果显示。不要让详情卡直接只读原始 `vessel_type_name`,否则会出现 marker 已按 Cargo/Tanker 等颜色显示、卡片仍写 `Other` 的不一致。
|
||||||
|
|
||||||
|
AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但船型通常来自低频 `ShipStaticData.Type`。后端会把 `MetaData.ShipName` 补进船名,并将类型码映射为 Cargo / Tanker / Passenger / Fishing / Military;仍缺失的船型需要等待静态 AIS 消息或后续船舶资料 enrichment,不能在前端凭颜色之外的信息臆造细分类。
|
||||||
|
|
||||||
船只 hover / click 也不再对渲染对象做 `raycaster.intersectObjects()`。`main.js` 只负责传入当前 Earth、camera、pointer 和命中半径,实际命中计算由 `interactable.js` 的图标层接口完成:
|
船只 hover / click 也不再对渲染对象做 `raycaster.intersectObjects()`。`main.js` 只负责传入当前 Earth、camera、pointer 和命中半径,实际命中计算由 `interactable.js` 的图标层接口完成:
|
||||||
|
|
||||||
1. 拖动地球或惯性旋转时跳过 hover picking。
|
1. 拖动地球或惯性旋转时跳过 hover picking。
|
||||||
@@ -299,7 +303,9 @@ AIS 船只图层入口:
|
|||||||
|
|
||||||
登陆点是当前明确保留的例外:它曾接入 `Interactable`,但 pin 类 SVG 在地球边缘会被 `THREE.Points` 的深度测试裁切成碎片;关闭 depthTest 又会破坏背面遮挡语义。因此登陆点退回 `cables.js` 内的专用 `THREE.Sprite` 路径,并改为 canvas 生成的黄色扁平球纹理。它的 `altitudeOffset` 和 `renderOrder` 与海缆线一致,避免漂在海缆之上;Sprite 本体关闭 `depthTest` 保持球完整,背面可见性由 `isFacingCamera()` 的球体遮挡判断控制。
|
登陆点是当前明确保留的例外:它曾接入 `Interactable`,但 pin 类 SVG 在地球边缘会被 `THREE.Points` 的深度测试裁切成碎片;关闭 depthTest 又会破坏背面遮挡语义。因此登陆点退回 `cables.js` 内的专用 `THREE.Sprite` 路径,并改为 canvas 生成的黄色扁平球纹理。它的 `altitudeOffset` 和 `renderOrder` 与海缆线一致,避免漂在海缆之上;Sprite 本体关闭 `depthTest` 保持球完整,背面可见性由 `isFacingCamera()` 的球体遮挡判断控制。
|
||||||
|
|
||||||
图标资源可以继续用 canvas draw,也可以放到 `frontend/public/earth/assets/icons/` 后由 `Interactable` 预加载。asset 路径不会在每帧读取;图层加载阶段通过 `preloadAssets()` 只加载一次 SVG / 图片,之后按 `icon source + state + bucket + color` 生成 `CanvasTexture` 并复用。当前算力中心已经从 `assets/icons/compute-supercomputer.svg`、`assets/icons/compute-gpu-cluster.svg` 和备用 `assets/icons/compute-hdd-network.svg` 读取图标,再在 canvas 上叠加估算位置的 `?` badge。
|
图标资源可以继续用 canvas draw,也可以放到 `frontend/public/earth/assets/icons/` 后由 `Interactable` 预加载。asset 路径不会在每帧读取;图层加载阶段通过 `preloadAssets()` 只加载一次 SVG / 图片,之后按 `icon source + state + bucket + color` 生成 `CanvasTexture` 并复用。当前算力中心已经从 `assets/icons/compute-supercomputer.svg`、`assets/icons/compute-gpu-cluster.svg` 和备用 `assets/icons/compute-hdd-network.svg` 读取图标,再在 canvas 上叠加未确认位置的 `?` badge。算力中心后端在启动链路只渲染源数据自带坐标或 `compute_center_locations` 维表坐标;手动候选采集会调用 ROR 和 Nominatim/OpenStreetMap,并在 GeoJSON 或候选响应中返回位置精度、置信度、来源说明和核验时间;前端详情卡展示这些字段。
|
||||||
|
|
||||||
|
算力中心图层行左上角的通知气泡显示 GeoJSON `unresolved` 数量。这个数字表示“完全没有可信坐标、不能渲染到地球上”的记录,不等同于地图上带 `?` 的已定位待确认点。点击气泡会在图层面板右侧打开固定信息卡,信息卡内容区内部滚动,不随鼠标 hover 消失。列表中的单条 `采集` 只展示候选;顶部 `一键采用` 会按当前列表顺序逐条采集、保存最高置信候选,成功一条就移除一条、重新编号,并通过 `earth:compute-center-unresolved-count-change` 同步气泡数量。批量结束后再触发 `earth:compute-center-location-saved` 刷新真实图层。
|
||||||
|
|
||||||
asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文件应尽量保持原始 viewBox 和路径,不要为了在地球上显示成 60x60 而手写 `transform`;`drawAssetIcon()` 会把资源等比 contain 到指定尺寸并居中绘制到 atlas canvas。
|
asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文件应尽量保持原始 viewBox 和路径,不要为了在地球上显示成 60x60 而手写 `transform`;`drawAssetIcon()` 会把资源等比 contain 到指定尺寸并居中绘制到 atlas canvas。
|
||||||
|
|
||||||
@@ -348,6 +354,10 @@ const scale = THREE.MathUtils.clamp(
|
|||||||
- 按钮:`#toggle-terrain`
|
- 按钮:`#toggle-terrain`
|
||||||
- 状态节点:`#terrain-status`
|
- 状态节点:`#terrain-status`
|
||||||
|
|
||||||
|
地形不是默认可见图层时,启动期不会立即阻塞加载地形瓦片。`controls.js` 会在图层可见性恢复完成后才调度 `scheduleTerrainPrefetch()`,并且只在高清材质可用、地形尚未 ready、预取未开始时执行。预取使用 `setTimeout` + `requestIdleCallback`,避免和首屏云图、高清材质、图层启动队列抢主线程。
|
||||||
|
|
||||||
|
地形瓦片请求也不再逐个散发大量单 tile 请求。`terrain.js` 会把需要的 Terrarium tile 去重后按 `TERRAIN_CONFIG.batchRequestSize` 分批请求 `/api/v1/visualization/terrain/terrarium/batch`;后端用 LRU 内存缓存、批次去重和并发限制代理 S3 Terrarium tile。单 tile endpoint 仍保留给回退路径和浏览器缓存语义。
|
||||||
|
|
||||||
以后别的异步图层也可以沿用这套约定。
|
以后别的异步图层也可以沿用这套约定。
|
||||||
|
|
||||||
## 当前设置持久化
|
## 当前设置持久化
|
||||||
|
|||||||
@@ -194,6 +194,7 @@
|
|||||||
| 船只 renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | 普通 marker 和交互 overlay |
|
| 船只 renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | 普通 marker 和交互 overlay |
|
||||||
| 船只轨迹 renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | 低于船只 marker |
|
| 船只轨迹 renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | 低于船只 marker |
|
||||||
| 船只点像素尺寸 | local `VESSEL_POINT_SIZE` | `34` | 普通 marker 与 hover / locked overlay 共享尺寸 |
|
| 船只点像素尺寸 | local `VESSEL_POINT_SIZE` | `34` | 普通 marker 与 hover / locked overlay 共享尺寸 |
|
||||||
|
| 船只默认渲染上限 | `VESSEL_CONFIG.maxRenderedMarkers` | `0` | `0` 表示不在前端默认裁剪;正数才会给接口传 `limit` 并裁剪 marker |
|
||||||
| 船只纹理画布尺寸 | local `VESSEL_ATLAS_CELL_SIZE` | `128` | canvas 点纹理 |
|
| 船只纹理画布尺寸 | local `VESSEL_ATLAS_CELL_SIZE` | `128` | canvas 点纹理 |
|
||||||
| 航向分桶数 | local `VESSEL_COURSE_BINS` | `32` | moving 船只按 COG 分桶,降低 draw call 同时保留方向 |
|
| 航向分桶数 | local `VESSEL_COURSE_BINS` | `32` | moving 船只按 COG 分桶,降低 draw call 同时保留方向 |
|
||||||
| 船只 hover 拾取节流 | local `VESSEL_HOVER_PICK_INTERVAL_MS` | `100` | `main.js` hover picking |
|
| 船只 hover 拾取节流 | local `VESSEL_HOVER_PICK_INTERVAL_MS` | `100` | `main.js` hover picking |
|
||||||
@@ -206,6 +207,8 @@
|
|||||||
|
|
||||||
AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。航行船只保持三角形,停泊或低速船只保持圆点;普通态不带 glow,hover / locked 时在同一屏幕尺寸上叠加带 glow 的单点 overlay。AIS 航向按 `course / cog` 从正北顺时针解释,普通态和交互态必须使用同一套 canvas 旋转规则。
|
AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。航行船只保持三角形,停泊或低速船只保持圆点;普通态不带 glow,hover / locked 时在同一屏幕尺寸上叠加带 glow 的单点 overlay。AIS 航向按 `course / cog` 从正北顺时针解释,普通态和交互态必须使用同一套 canvas 旋转规则。
|
||||||
|
|
||||||
|
船型颜色和详情卡船型文本必须来自同一套归一化结果:`vessels.js` 同时读取后端 `vessel_type_name` 和 AIS 数字 `vessel_type`,先得到颜色用的 `type`,再生成 `vessel_type_display` 给详情卡、hover 和搜索使用。
|
||||||
|
|
||||||
## 算力中心
|
## 算力中心
|
||||||
|
|
||||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||||
|
|||||||
94
docs/technical/zh/earth-toolbar-overlay-coordination.md
Normal file
94
docs/technical/zh/earth-toolbar-overlay-coordination.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Earth 工具栏与浮层协同
|
||||||
|
|
||||||
|
本文件描述 Earth 大屏右侧工具栏按钮,以及搜索面板、设置弹窗、新闻直播面板、图层面板这几个浮层之间当前的协同规则。改交互、加按钮、调整面板时按这个表对齐,避免出现「点 A 把不该关的 B 也关了」之类的协同冲突。
|
||||||
|
|
||||||
|
相关入口:
|
||||||
|
|
||||||
|
- [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
|
||||||
|
- [前端布局指南](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-layout-guidelines.md)
|
||||||
|
|
||||||
|
## 工具栏按钮目录
|
||||||
|
|
||||||
|
工具栏在 [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) 中以 `.earth-toolbar-btn` 标识,按钮列表:
|
||||||
|
|
||||||
|
| ID | 标题 | 类型 | 触发的浮层/动作 |
|
||||||
|
|----|------|------|------------------|
|
||||||
|
| `layer-action` | 图层 | 浮层切换 | HUD 面板 `layer-toggles`(桌面)/ 移动端抽屉 `layers` 卡 |
|
||||||
|
| `search-action` | 搜索 | 浮层切换 | 搜索面板(桌面)/ 移动端抽屉 `search` 卡 |
|
||||||
|
| `rotate-toggle` | 自动旋转 | 独立开关 | 不打开任何浮层 |
|
||||||
|
| `toggle-tv` | 新闻直播 | 浮层切换 | 媒体面板 `media-panel`(含 TV/News 两个 tab) |
|
||||||
|
| `reload-data` | 重新加载数据 | 独立动作 | 不打开任何浮层 |
|
||||||
|
| `zoom-trigger` | 缩放控制 | 浮动菜单 | 缩放 floating menu |
|
||||||
|
| `settings-trigger` | 设置 | 浮层切换 | 设置弹窗(桌面)/ 移动端抽屉 `settings` 卡 |
|
||||||
|
| `reset-view` | 重置视角 | 独立动作 | 不打开任何浮层 |
|
||||||
|
| `layout-toggle` | 最大化布局 | 独立开关 | 不打开任何浮层 |
|
||||||
|
|
||||||
|
## 浮层协同的统一入口
|
||||||
|
|
||||||
|
[controls.js::closeTransientMobileOverlays](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 是「打开 X 时该关谁」的统一协调函数。
|
||||||
|
|
||||||
|
调用约定:每个会进入 fullscreen-style 浮层的开启路径调用 `closeTransientMobileOverlays({ except })`,告诉协调函数「除了我这一类,其他互斥浮层一律关掉」。
|
||||||
|
|
||||||
|
```js
|
||||||
|
closeTransientMobileOverlays({ except: "search" }); // 搜索打开
|
||||||
|
closeTransientMobileOverlays({ except: "settings" }); // 设置打开
|
||||||
|
closeTransientMobileOverlays({ except: "media" }); // 新闻直播打开
|
||||||
|
closeTransientMobileOverlays({ except: "layer-toggles" }); // 图层抽屉(移动端)
|
||||||
|
```
|
||||||
|
|
||||||
|
`except` 当前可取的值:`"search"`、`"settings"`、`"media"`、`"layer-toggles"`,或省略表示「全部关闭」。
|
||||||
|
|
||||||
|
## 关闭矩阵
|
||||||
|
|
||||||
|
下表描述「打开 X」时其它浮层的命运。`✓` = 关闭,`—` = 保留。
|
||||||
|
|
||||||
|
| 触发动作 → | 关搜索 | 关设置 | 关图层抽屉(移动端) | 关新闻/直播 |
|
||||||
|
|-----------|:------:|:------:|:--------------------:|:-----------:|
|
||||||
|
| 打开搜索 (`except: "search"`) | (自身)| ✓ | ✓ | — |
|
||||||
|
| 打开设置 (`except: "settings"`) | ✓ | (自身)| ✓ | — |
|
||||||
|
| 打开新闻/直播 (`except: "media"`) | ✓ | ✓ | ✓ | (自身)|
|
||||||
|
| 打开图层抽屉 (`except: "layer-toggles"`) | ✓ | ✓ | (自身)| ✓ |
|
||||||
|
| 全部关闭 (`except: null`) | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
|
||||||
|
读法举例:
|
||||||
|
|
||||||
|
- 点工具栏「设置」,搜索面板和图层抽屉会被关掉,新闻/直播面板保持原状。
|
||||||
|
- 点工具栏「图层」(移动端打开 `layers` 抽屉),搜索 / 设置 / 新闻 全关。
|
||||||
|
- 点工具栏「新闻直播」,搜索 / 设置 / 图层抽屉全关,新闻面板自身切换为打开。
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
下面是当前矩阵背后的几条不变量。新增浮层或调整规则时按它们对齐:
|
||||||
|
|
||||||
|
1. **`zoom-trigger` 等浮动菜单不属于浮层。** 它们走 `bindFloatingMenu`,由 `closeFloatingMenus()` 单独管理;任何浮层打开都会先调一次 `closeFloatingMenus()`。
|
||||||
|
2. **桌面 `layer-toggles` 是常驻 HUD 面板,不是浮层。** `closeTransientMobileOverlays` 中只有 `activeMobileDrawerId === "layer-toggles"`(移动端抽屉态)才会被关掉。所以桌面打开搜索/设置/新闻不会动图层面板,符合「桌面屏幕大、可共存」的预期。
|
||||||
|
3. **新闻/直播面板独立于设置。** 用户切到设置改采集器时,常常想边看新闻边改配置,所以打开设置时不关新闻面板。这条是 2026-05 的协同补丁后建立的不变量;改设置打开路径时不要再去主动关 `media-panel`。
|
||||||
|
4. **搜索和新闻面板视为「主信息浮层」,互相独立。** 搜索打开不关新闻、新闻打开不关搜索:两者面向不同任务(搜索定位 / 浏览态势新闻),允许同屏共存。如果未来 UX 上希望它们互斥,要在 `closeTransientMobileOverlays` 中**同时**改两边的规则,避免单边修改导致非对称的关闭逻辑。
|
||||||
|
5. **移动端抽屉是 fullscreen 级别的状态。** 一旦进入移动端抽屉,无论是 `layers` / `search` / `settings` 哪一类,都会通过 `setMobileDrawerState` 关闭其它浮层。这是 mobile 单一焦点 UX 的要求。
|
||||||
|
6. **`Escape` 键有固定的关闭顺序。** 见 [controls.js::setupKeyboardControls](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js):搜索 → 设置 → 移动端抽屉 → 浮动菜单 → 工具栏 hub → 锁定对象。新增浮层要决定它在这个顺序中的位置。
|
||||||
|
|
||||||
|
## 新加按钮 / 浮层时怎么接
|
||||||
|
|
||||||
|
按下面的清单走,规则就不会乱:
|
||||||
|
|
||||||
|
1. 按钮加在 [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) 的 `.earth-toolbar` 容器里,class 跟齐 `floating-btn liquid-glass-surface earth-toolbar-btn`。
|
||||||
|
2. 决定它属于哪一类:
|
||||||
|
- **独立动作**(reload / reset / rotate / layout):直接 `bindListener`,不调任何 `closeTransientMobileOverlays`。
|
||||||
|
- **浮动菜单**(zoom 这种 dropdown):用 `bindFloatingMenu`,不进协同矩阵。
|
||||||
|
- **互斥浮层**:进矩阵。
|
||||||
|
3. 互斥浮层要做两件事:
|
||||||
|
- 在打开路径调用 `closeTransientMobileOverlays({ except: "<your-key>" })`,让其他浮层主动让位。
|
||||||
|
- 在 `closeTransientMobileOverlays` 函数体内补一条 `if (except !== "<your-key>" && isYourPanelVisible()) closeYourPanel();` 让别的浮层打开时关掉自己。
|
||||||
|
4. 如果新浮层和某个现有浮层(例如新闻面板)应当共存,参考第 3 条规则:在自己的关闭判断里 `&& except !== "<peer-key>"` 把对方排除掉。**不要**只单边改一处,否则关闭逻辑会非对称。
|
||||||
|
5. 新浮层应该有 `Escape` 关闭路径,加在 `setupKeyboardControls` 中合适的位置。
|
||||||
|
6. 移动端如果应进入抽屉态,使用 `setMobileDrawerState({ open: true, card: "<your-card>" })` 而不是直接 toggle 面板。
|
||||||
|
|
||||||
|
## 当前实现位置
|
||||||
|
|
||||||
|
- 协调入口:[controls.js::closeTransientMobileOverlays](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||||
|
- 设置浮层:[controls.js::openSettingsModal / closeSettingsModal](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||||
|
- 搜索浮层:[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)(导入自 search 模块)
|
||||||
|
- 新闻/直播浮层:[tv.js::setTVPanelVisible](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)、新闻 tab 在 [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
|
||||||
|
- 图层抽屉(移动端):[controls.js::setMobileDrawerState](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||||
|
- 浮动菜单:[controls.js::bindFloatingMenu](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||||
|
- 工具栏 DOM:[index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user