// dsh-console 本地接口后端入口。
//
// 默认只监听 127.0.0.1:5390(可用 DSH_CONSOLE_BACKEND_HOST / _PORT 覆盖,自测隔离用)。
// 接口分两层:
// 只读(GET,纯只读):runtime / profiles / logs / processes / instances / dsh-releases
// 写(POST /api/local/action + GET/PUT /api/local/settings + GET /api/local/console)
// 写侧的守卫、动作与落盘全在 actions 包里;这里只负责把两条路分开。
//
// 同一个端口既答 API 也服务前端构建产物 dist/(A 形态,终局形态)。
// 中间件顺序只有一条规矩 —— API 在前,静态在后:
// 最外层 localapi.api_gate → /api/local* 到此为止,不往下走
// 次外层 dist_static → dist 目录服务 + SPA 回退(index.html)
// 最内层 路由 → 兜底 404
// 静态中间件不能放到闸门外层:它的 history_fallback 会把 404 改写成 index.html,
// 未实现接口的错误壳就没了。
///|
let default_listen_host : String = "127.0.0.1"
///|
let default_listen_port : UInt16 = 5390
///|
/// 十进制串转端口号;非数字或超范围给 None。
fn parse_port(text : String) -> UInt16? {
if !@json.is_all_digits(text) {
return None
}
let value = @json.parse_dec(text)
if value < 1 || value > 65535 {
return None
}
Some(value.to_uint16())
}
///|
/// 监听地址:$DSH_CONSOLE_BACKEND_HOST 优先,默认 127.0.0.1。
/// 只做本机自测时的端口隔离用;默认值就是文档里写的那一条。
async fn resolve_listen() -> (String, UInt16) noraise {
let host = match @json.env_value("DSH_CONSOLE_BACKEND_HOST") {
Some(v) => v
None => default_listen_host
}
let port = match @json.env_value("DSH_CONSOLE_BACKEND_PORT") {
Some(v) =>
match parse_port(v) {
Some(p) => p
None => default_listen_port
}
None => default_listen_port
}
(host, port)
}
///|
/// runtime 读取函数,转成路由 handler 需要的签名。
async fn read_runtime() -> @json.JsonVal raise @localapi.ReadError {
@runtime.read_runtime()
}
///|
/// profiles 读取函数。
async fn read_profiles() -> @json.JsonVal raise @localapi.ReadError {
@profiles.read_profiles()
}
///|
/// logs 读取函数。tail 从查询串取;缺省取设置里的 logs.defaultTail;
/// 非正整数 / 超上限都在 logs 包里收敛。
async fn read_logs(
req : @moonback.Request,
) -> @json.JsonVal raise @localapi.ReadError {
let source = match @localapi.query_param(req, "source") {
Some(s) => s
None =>
raise @localapi.ReadError(
code="missing-source",
message="缺少 source 参数,只支持 host / audit / instance:<实例 id>",
)
}
let spec = @logs.parse_tail_with_settings(@localapi.query_param(req, "tail"))
@logs.read_logs(source, spec)
}
///|
/// processes 读取函数。
async fn read_processes() -> @json.JsonVal raise @localapi.ReadError {
@processes.read_processes_data()
}
///|
/// instances 读取函数。
async fn read_instances() -> @json.JsonVal raise @localapi.ReadError {
@instances.read_instances()
}
///|
/// dsh 版本来源读取函数:**只读进程内缓存,秒回**(design/ops-maintenance-jobs.md §10.1)。
/// 真正问三个源(GitHub / npm / 国内镜像,各自 6 秒硬超时)的是启动时那一趟后台拉取,
/// 以及缓存空 / 过期时由这条接口顺手叫起的后台刷新 —— 都不在这条请求里等。
/// 三个源全没答上来也算读到了(读数里 sources 全是 ok=false)——
/// 读不到是一个事实,不是接口错误;这里按路由表的签名套一层。
async fn read_releases() -> @json.JsonVal raise @localapi.ReadError {
// cached_releases 只读缓存,不 await:这一条请求因此不会被三个源的网络拖住。
let value = @releases.cached_releases()
// 这条接口从不失败:读不到也是读数里的一个事实。ReadError 是路由表要求的形状,
// 这一格是「形状万一被破坏」时唯一能如实报出来的出口(正常路径走不到)。
if value is @json.JNull {
raise @localapi.ReadError(
code="read-failed",
message="版本来源读数不完整",
)
}
value
}
///|
/// 已实现的路由表(无查询参数的这几条)。
fn routes() -> Array[@localapi.Api] {
[
@localapi.api("/api/local/runtime", read_runtime),
@localapi.api("/api/local/profiles", read_profiles),
@localapi.api("/api/local/processes", read_processes),
@localapi.api("/api/local/instances", read_instances),
// 版本来源这条不会失败:三个源全没答上来也是 200(读不到是读数里的一个事实,
// 不是接口错误 —— 见 releases.mbt 的头注释)
@localapi.api("/api/local/dsh-releases", read_releases),
]
}
///|
/// 带查询串的路由表(logs 要读 source / tail)。
fn query_routes() -> Array[@localapi.QueryApi] {
[@localapi.query_api("/api/local/logs", read_logs)]
}
///|
/// 单个请求的分发:写侧三条路径归 actions,其余按只读接口走。
async fn handle(req : @moonback.Request, res : @moonback.Responder) -> Unit {
let read_at = @json.iso_from_epoch_ms(@async.now())
if @actions.handles_path(req.path) {
@actions.dispatch(req, res, read_at)
return
}
@localapi.dispatch(req, res, routes(), query_routes(), read_at)
}
///|
async fn main {
// 静态根在起服务之前解析一次:解析结果要写进启动日志,不能藏。
let (dist_root, dist_source) = @dist_static.resolve_dist_root()
let app = @moonback.App(ctx => {
// 接口闸门在最外层:/api/local* 不进静态中间件,404/405 的接口语义原样保留。
// 闸门直接调 handle,与路由表指向同一个 handler;405 仍由 dispatch 发(非 GET)。
ctx.add_middleware(@localapi.api_gate(handle))
// 静态中间件在闸门里层:实体文件命中就答,命中不了回落到 index.html(SPA 回退),
// 再命中不了才落到最内层的路由(404)。根解析失败时给的是 503 + 原因说明。
ctx.add_middleware(@dist_static.static_middleware(dist_root, dist_source))
})
defer app.close()
let (host, port) = resolve_listen()
let server = @moonback.listen(host~, port~)
// 读路径的原文去处(§109):单独一个文件,与审计同目录;不配也能跑,只是那几处原文丢弃。
@store.configure_read_error_log()
// 启动日志只留三行(§79):这是什么、空一行、该访问的地址。
// 静态根、API 清单、gzip、SPA 回退这些细节都在 backend/README.md,不在这里刷屏。
@stdio.stdout.write(
"dsh-console " + @store.build_sha + "\n\nhttp://\{server.addr}\n",
)
// serve 跑在控制台主任务组里:detach=false 的实例就挂在这个组上,
// 控制台退出(含 SIGTERM/SIGINT 的取消)时由运行时的结构化并发按 stopGraceMs 收掉它们。
// 不能改成「serve 之后写收尾代码」:收到全局取消信号后运行时会重新抬起信号交给默认处理,
// serve 之后的语句一句都跑不到(实测退出码 143)。
@async.with_task_group(group => {
@actions.set_console_group(group)
// 版本列表在这里就发起第一次后台拉取(§10.1):spawn 完立刻往下走,
// serve 不被它拖住 —— 三个源最坏各 6 秒,那是后台的事。
@releases.start_prewarm(group)
app.serve(server)
})
}