外观
控制台 ↔ 控制服务器接口
控制台消费的全部端点,以及那些读代码看不出来的字段口径。
基址 VITE_CONTROL_API ?? https://api.natlan.io。客户端实现是 apps/control/src/lib/api.ts,类型镜像在 apps/control/src/lib/types.ts。
BGP 抖动检测(/ui/fleet/*flap* 与 /admin/fleet/mitigations/*)单列一篇: flaps.md。
1. 通用约定
| 项 | 约定 |
|---|---|
| 鉴权 | Authorization: Bearer <token>,账号会话令牌与 admin token 走同一校验入口,所有端点无差别接受两种 |
| 401 | 令牌失效。前端丢弃令牌,路由守卫弹回登录页 |
| 403 | Admin API 被锁定 |
| 错误体 | FastAPI 风格 {"detail": …},detail 可能是字符串或 {message, errors[]} |
| 时间戳 | ISO 8601(UTC) |
| 时序 series | 一律按时间升序(旧 → 新)。唯一例外是配合 before_id 游标的瘦列表(status-events、flap-alerts、audit),它们是新 → 旧 |
| 字段命名 | snake_case |
| 请求超时 | 前端一律 15 秒(上传 120 秒)。最重的两个端点 GET /ui/dashboard 与 GET /ui/nodes/{id}/routing/dashboard 必须在冷缓存下也稳定低于此值 |
两层前缀的分工:
/control/v1/ui/*—— BFF 面。服务端预先聚合、预先计算,前端只渲染。/control/v1/admin/*—— 通用管理面。资源 CRUD 与运维动作。
兼容性方针:单操作者、前后端同车发布,因此契约不做旧后端兼容适配。新字段 一律必有(未知取 null),前端直读,不存在 feature flag 门控的回退分支。 /ui/session 的 features[] 仍然保留,用于后端上新能力时的灰度。
2. 账号与会话
POST /control/v1/auth/login
无 Authorization 头。
jsonc
// 请求
{ "username": "…", "password": "…", "captcha_token": "…" }
// 200
{ "token": "<不透明随机令牌>", "expires_at": "2026-08-14T04:00:00Z" }token 必需;expires_at 可选(前端当前忽略)。
错误语义是契约,前端按状态码分支:
| 状态码 | 含义 | detail(稳定字符串) |
|---|---|---|
400 | 人机验证失败 / 缺失 | turnstile verification failed |
401 | 凭据错误(统一文案,防用户枚举) | invalid credentials |
403 | Admin API 被锁定 | 沿用通用语义 |
429 | 触发登录限速 | too many attempts |
turnstile verification failed是历史锁定的措辞。人机验证 2026-07 已从 Cloudflare Turnstile 迁到自托管 Cap,这个字符串保持不变——前端与更早的缓存产物 都按它匹配。
路由不存在时前端把 404/405 解释为「旧后端不支持账号登录」并引导改走 API Token。 因此不要上线一个会 500 的半成品路由。
人机验证是服务端硬校验:前端只渲染控件并透传一次性 token,服务端持 CAP_SECRET_KEY 调 POST https://challenges.natlan.io/1018704021/siteverify 校验。票一次性,前端不做 预校验(会烧掉 token),每次失败自动 reset 控件重新取票。
POST /control/v1/auth/password
{old_password, new_password},需 Bearer,仅 kind=user 的令牌可调。 改密吊销该用户全部存活令牌。
GET /control/v1/ui/session
令牌探针 + 服务端元信息。极轻量。
jsonc
{
"authenticated": true,
"scope": "admin",
"server_version": "1.4.0",
"agent_target_version": "0.9.3",
"heartbeat_interval_seconds": 30,
"liveness_thresholds": { "online_seconds": 75, "stale_seconds": 300 },
"features": [],
"identity": { "username": "…", "kind": "user" | "static-token" }
}liveness_thresholds 让前端在需要本地补算时(倒计时 UI 之类)与服务端策略对齐, 而不是自带一份硬编码阈值。
3. 服务端归位的两项策略
这两件事曾经在前端做,现在是服务端职责。前端不得再自行推导。
liveness
「心跳 ≤75s = online / ≤300s = stale / 否则 offline」这套判定归服务端。所有携带 agent 活性字段的响应行都直接带:
jsonc
"liveness": "online" | "stale" | "offline"覆盖范围:dashboard 的 fleet overview 行、/ui/nodes 行、node overview、 agent-releases 的 nodes[]。聚合计数由服务端给 ({online, stale, offline, agents_behind})。
阈值本身经 /ui/session 下发。后端改心跳间隔时,前端不需要跟着改。
geo
site code → 坐标 / 城市 / 国家 / DN42 region 的注册表在后端。所有带节点行的响应 都带:
jsonc
"geo": {
"lat": 50.11, "lon": 8.68,
"city": "Frankfurt", // 英文名,前端负责 i18n 显示
"country": "DE", // ISO 3166-1 alpha-2
"region": 41 // DN42 origin-region community;未知为 null
}
// 整体可为 null(site 无法解析且无 region 兜底)解析优先级:节点自带的 region 字段覆盖注册表推导值。
新增一个机房因此不需要发一版前端。
4. 仪表盘
GET /control/v1/ui/dashboard
首屏一次取全,取代五个细粒度端点的连发。高频轮询,服务端有约 3 秒 TTL 缓存。
| 参数 | 说明 |
|---|---|
range | 6h | 24h | 7d | 30d。服务端按 range 选桶宽,返回点数上限约 200 |
compare | 1 时返回 points_previous:紧邻的上一个等长窗口,同粒度同桶数,前端按索引对齐叠加 |
origins_top | 起源 AS 榜深度(≤200,常用 100) |
traffic_limit | 流量点数 |
as_traffic | 1 时返回 traffic_by_as(默认不返回,控响应体积) |
jsonc
{
"generated_at": "…", // 本次聚合的服务器时间(命中缓存则为缓存生成时间)
"overview": { /* FleetOverview,每行带 liveness + geo;summary 带四项计数 */ },
"traffic": { "points": [ … ], "points_previous": [ … ] },
"traffic_breakdown": { /* 每节点 / 每对端当前速率 */ },
"traffic_by_as": { "series": [ … ], "table": [ … ] },
"traffic_mix": { "internal_bytes": …, "external_bytes": …, "rx_bytes": …, "tx_bytes": … },
"peering_issues": [ /* 裸数组,不是 {issues:[…]} */ ],
"routing": { "captured_at": "…", "summary": { … }, "trend": [ … ], "origins": [ … ] }
}口径要点:
agent_summary是FleetOverview的顶层字段,未并入summary——health 的 stale 与 liveness 的 stale 会撞键。形状{online, stale, offline, agents_behind}。peering_issues是裸数组,其余四块与对应细粒度端点逐字段一致。routing.trend[]每点有size(= 两者之和,兼容值)、size_v4、size_v6;summary有as_count(RIB 中不同起源 AS 总数)、as_count_v4/v6、rpki_v4/v6。announced/withdrawn字段保留但仪表盘已不展示。routing.origins[]每项有name(registry as-name,未配 registry token 则全 null) 与count_v4/count_v6。traffic_by_as的「AS」= peering 的remote_asn;份额 = 窗口累计字节占外部流量 的比例;关联不到 peering 的接口(内部 meshwg-*/dn42-*)归入 internal,不进榜。 名称一律用注册表 as-name(权威),不是 peering 的本地remote_label。traffic_mix的 internal / external 按接口名约定区分,与traffic_by_as同源。- 「流量相对变化」(逐桶百分比)由前端从
points_previous算出,无对应字段。
时间戳字段(generated_at / captured_at)不是装饰:dashboard 有缓存、routing 采集 有分钟级延迟,「这个数字是几点的」直接决定可信度,各 Widget 底部都在渲染它。
5. 节点
GET /control/v1/ui/nodes
列表页数据源。行 = NodeOut 的标量字段(不含 base_template 与 inventory 这两坨大 JSON),加服务端 join 的活性与富化字段:
| 字段 | 类型 | 口径 |
|---|---|---|
health | NodeHealthValue | — |
liveness | online|stale|offline | 见第 3 节 |
agent_version / agent_up_to_date / last_heartbeat_at | agent 活性 | |
drift_count | int | 最新 report 的漂移项数;未上报 → 0 |
peers_up / peers_total | int | null | 最新路由快照中 Established 的 BGP 会话数 / 总数(eBGP+iBGP);无快照 → 两者均 null |
rx_bytes_per_sec / tx_bytes_per_sec | float | null | 当前吞吐,与 traffic_breakdown.nodes 同源同口径 |
geo | NodeGeo | null | 见第 3 节 |
前端「当前吞吐」渲染 rx+tx 合计;liveness=offline 的节点前端自行置灰显示 —, 后端不特判。
GET /control/v1/ui/nodes/metrics
机群指标矩阵,驱动可观测性 tab 的四张图与节点卡片的通栏 sparkline。
参数 range ∈ 1h | 6h | 24h | 7d(默认 6h)。
jsonc
{
"generated_at": "…", "range": "6h",
"timestamps": [ "…", "…" ], // 全体节点共享的桶网格
"nodes": [ {
"node_id": "hkg2-edge",
"cpu_percent": [12.4, null, 13.1],
"rss_mb": [128.0, 129.5, null],
"rx_bytes_per_sec": [ … ], "tx_bytes_per_sec": [ … ],
"reconcile_failures": [0, 1, null]
} ]
}固定网格约定(趋势类端点通用):
timestamps是共享网格,各序列与其按索引对齐、等长;- 服务端按 range 选桶宽,点数 ≤ 200。实际桶宽:
1h → 5min/12 点、6h → 10min/36、24h → 30min/48、7d → 3h/56; - 空桶 =
null,不是0。缺数据是缺口,画图要断线。reconcile_failures的0表示「有上报且无失败」,null表示「该桶无上报」; - 全量矩阵返回,不做服务端 top-N——排名与站点过滤都在前端。
1h档是 5min 桶而非更细的 2min:cpu / rss 与流量归档的原生粒度就是 5 分钟 (快照节奏),2min 桶会让三分之二的桶变成 null 缺口。
GET /control/v1/ui/nodes/{id}/overview
详情页首屏的唯一请求。除 NodeOverview 原有字段外无条件内嵌:
| 内嵌 | 说明 |
|---|---|
node | 完整 NodeOut,含 base_template(编辑弹窗要用)与 wireguard_public_key |
dns_group | {id, name},null = 未分配。免掉 DNS tab 拉全量组列表解析名字 |
trends | agent 自观测 / drift / apply 三组序列,见下 |
liveness / geo | 见第 3 节 |
节点不存在才 404;存在但从未上报返回 health: "unknown" 骨架 + 完整 node ——前端靠这个区分「未上报」与「出错」。
peerings 不并入(Peerings tab 懒加载自取,避免 overview 轮询变重)。
内嵌的 trends:
jsonc
{
"self_metrics": { "current": { … } | null, "series": [ {"at":"…","cpu_percent":…,"rss_mb":…,
"last_routing_collect_seconds":…,"last_reresolve_seconds":…} ] },
"drift": { "current": 0, "series": [ {"at":"…","count":0} ] },
"apply": { "total": 50, "succeeded": 48, "last_status": "succeeded",
"last_at": "…", "series": [ {"at":"…","status":"succeeded"} ] }
}从未上报 → 三段均 current: null / 空 series。apply 的成功率由服务端算好。
GET /control/v1/ui/nodes/{id}/status-events
瘦列表,kind= 过滤,before_id 游标,新 → 旧。行只带 {id, kind, generation, status, created_at, drift_count}——drift_count 让列表行直接 显示而不必下载 payload(snapshot 单条可达几十 KB)。
完整事件(含 payload)按需取 GET /control/v1/ui/status-events/{event_id}。
GET /control/v1/ui/nodes/{id}/traffic
节点流量,参数同 dashboard 的 range / compare。
GET|PUT /control/v1/ui/nodes/{id}/route-tuning
路由调优的专用读写,存在的意义是消除 base_template 的读-改-写竞态。
jsonc
// GET
{ "node_id": "de-fra1",
"cold_potato_med": 50,
"route_local_pref": [ { "prefix": "172.20.62.160/27", "local_pref": 200 } ],
"sessions": [ { "id": 12, "name": "peer_mp", "remote_asn": 4242420000, "link_latency": 3 } ],
"updated_at": "…" }
// PUT { "cold_potato_med": 50, "route_local_pref": [ … ] } 两字段均可选,缺省不动
// → 200,返回 GET 同款完整视图后端做字段级合并:只写 base_template.bird.cold_potato_med 与 .route_local_pref 两个 key,base_template 其余部分原样保留。前端因此不需要理解 base_template 的 内部结构,也不会覆盖他人对其他部分的并发修改。
会话级同理:PUT /control/v1/ui/bgp-sessions/{sid}/link-latency (body {link_latency: 3},null = 清除)在 session spec 上做单字段合并。
GET /control/v1/ui/nodes/{id}/peer-defaults
互联向导的默认值。这些推导曾是前端启发式,现在归后端——约定一变前端就猜错。
jsonc
{ "node_id": "de-fra1",
"wireguard": { "private_key_ref": "…", "link_local": "fe80::ade0",
"used_listen_ports": [51820, 51821] } }推不出的字段为 null。
GET /control/v1/ui/nodes/{id}/routing/dashboard 与 /admin/nodes/{id}/routing/prefixes
节点路由三视图的数据源。prefixes 的查询参数:
| 参数 | 说明 |
|---|---|
family / scope / q | 协议族 / 范围 / 自由文本 |
rpki | valid | invalid | not_found,可与上面自由组合 |
path_asn | 匹配 as_path 包含该 AS 的路由(含起源跳),与 q 独立可组合 |
响应顶层:
rpki_counts: {valid, invalid, not_found}—— 当前 family/scope/q 条件下 (不含 rpki 自身)的分布,驱动 chips 上的计数徽标。rpki_observed=false的快照 返回null,前端隐藏 chips。as_names: {"<asn>": "<name>"}—— 仅当前页出现的 origin ASN,未知的省略。
RPKI 状态值在数据里用下划线
not_found。历史上前端有一处用连字符not-found判断,导致该类别永远落到 muted 而非 warn。命名以数据为准。
GET /control/v1/ui/nodes/{id}/internal-topology
iBGP / OSPF 不是 bgp_sessions 记录,而是从 bird.internal_topology 合成的。 本视图暴露那份配置,外加一个从路由派生的活性提示(rib_routes / in_rib)。
6. 写操作
POST /control/v1/admin/nodes/{id}/peerings/provision
一次事务创建 peering + interface + N 条 BGP 会话。
jsonc
// 请求
{ "peering": { … }, "interface_spec": { … }, "interface_enabled": true,
"bgp_specs": [ { … }, { … } ] }
// 200
{ "peering": PeeringOut, "interface": InterfaceOut,
"sessions": [ SessionOut, … ], "bgp_session": SessionOut }- 全部对象在一个事务里创建,任一失败整体回滚,不存在「部分成功」的半配置状态。
- 响应保留旧的
bgp_session(首条)作兼容,新增sessions[]。 bgp_spec与bgp_specs二选一,同给422。
DNS 写操作返回父级摘要
zone 与 record 的增删改都会影响父级的计数,因此写响应直接带回父级最新状态, 免掉一次双重刷新:
jsonc
// zone 的 POST / PATCH / DELETE
{ "zone": DnsGroupZoneOut | null, // DELETE 时为 null
"group": DnsGroupOut } // zone_count 已更新
// record 的 POST / PATCH / DELETE
{ "record": DnsRecordOut | null,
"zone": DnsGroupZoneOut } // record_count 已更新⚠️ zone / record 的 DELETE 是 200 带 body,不是 204。
GET /control/v1/ui/audit
游标分页 + 服务端搜索:limit / before_id / q(匹配 actor / method / path)。 行结构 {id, actor, method, path, status_code, detail, created_at}。
所有管理动作(含抖动处置)自动进审计,前端不需要额外记录。
流式端点
POST /control/v1/ui/probes → GET /control/v1/ui/probes/{id}/stream (ping / mtr / traceroute),日志同构:/ui/logs + /ui/logs/{id}/stream。
正常结束路径必须发 done 帧再关流。 前端把「流关闭但没收到 done」显示为 红色的「连接已断开」。异常路径(agent 掉线、超时)不发 done 是正确的——那正是这个 提示要捕捉的场景。
7. 错误契约
后端的错误 detail 措辞是稳定接口。 前端 apps/control/src/lib/api.ts 的 DETAIL_PATTERNS 用正则把已知句式翻译成四种语言;改措辞不会报错,但翻译会静默失效、 回退成英文原文。
已在匹配的句式家族:
| 句式 | 示例 |
|---|---|
unknown <kind> <id> | unknown node de-fra1、unknown remote node …、unknown bgp session … |
<what> already exists … | bgp session 'peer_mp' already exists on node X |
wireguard listen_port <n> is already used by interface '<name>' | 端口冲突 |
wireguard listen_port must be inside node runtime.wireguard_port_range <a-b> | 端口范围 |
node <id> has no connected agent / … agent disconnected before dispatch | 下发失败 |
no desired state for node <id> / node <id> has no published desired state | 无期望状态 |
node <id> has no generation <n> / no previous generation to diff against | 世代 |
peering <n> not on node <id> | 归属错配 |
node <id> is live; … / node <id> has no live agent connection… | 生命周期 |
materialization failed schema validation | 物化失败 |
invalid <what> | 通用校验 |
turnstile verification failed | 人机验证(历史锁定措辞) |
{"message": "invalid <Spec>", "errors": [...]} | pydantic 校验,前端内联首条字段错误 |
新增错误请复用现有句式(unknown <kind> <id> / <what> already exists … / node <id> has no …),前端零成本自动覆盖。未匹配的句式原样透传——未翻译好过翻错。
长期方向:detail 里加机器可读 code ({"code": "node_not_found", "params": {...}, "message": "…"}),前端改为按 code 查表,整张正则表即可退役、措辞从此随意改。前端已有承接结构,后端加字段即可,不破坏兼容。
8. 轮询预算
| 端点 | 周期 |
|---|---|
/ui/dashboard | 35s(全站统一 tick) |
/ui/nodes、/ui/nodes/{id}/overview | 35s |
| 抖动类端点 | 60s,见 flaps.md |
| 流式端点 | 不轮询 |
全站共用一个定时器,标签页隐藏时暂停。详见 internals/interaction-contracts.md。