跳转至

crates\nfarms-core\src\observability.rs

源码路径:crates\nfarms-core\src\observability.rs · 2295 行

//! 可观测性三件套 —— VictoriaMetrics(指标)/ Loki(日志)/ Grafana(看板)。
//!
//! 三个都是**外部 exe**,和 bililive-go 一样由 `fetch-libs.ps1` 放进 `libs\`,
//! 程序不打包它们。本模块只做六件事,不重写它们:
//!
//! 1. 找到它们、拼出启动命令行([`spec`] / [`missing`] / [`start`])
//! 2. 等它们真的能服务([`wait_ready`])
//! 3. 往指标库/日志库喂数据([`push_metric`] / [`push_log`])
//! 4. 生成 Grafana 的数据源与看板([`provision_datasources`] / [`provision_dashboards`])
//! 5. 缺配置时补上([`ensure_configs`])
//! 6. 把应用 logo 装进 Grafana 并让 `[branding]` 指向它([`install_branding`])
//!
//! ## 端口、exe 名、就绪耗时全是**实测值**
//!
//! | 服务 | exe(`libs\` 下) | 端口 | 健康检查 | 实测就绪 |
//! |---|---|---|---|---|
//! | VictoriaMetrics | `victoria-metrics-windows-amd64-prod.exe` | 9091 | `/health` | 1 秒 |
//! | Loki | `loki-windows-amd64.exe` | 9092 | `/ready` | **17 秒** |
//! | Grafana | `grafana\bin\grafana.exe` | 9093 | `/api/health` | 7 秒 |
//!
//! Loki 那 17 秒是这个模块里最要紧的一个数:就绪预算按 [`Spec::ready_hint_secs`]
//! 算而不是拍一个「5 秒应该够了吧」,否则 loki 每次都被判成起不来,而它其实正在
//! 正常启动(见 `ready_budget`)。
//!
//! ## 运行产物放 `data\`,不放 `libs\`
//!
//! `libs\` 是**第三方组件**的地盘,`data\` 才是运行产物:
//! `data\vm\`(TSDB)、`data\loki\loki-config.yaml`、`data\grafana\custom.ini`、
//! `data\grafana\provisioning\datasources\nfarms.yml`、
//! `data\grafana\provisioning\dashboards\nfarms.yml` 和 `data\grafana\dashboards\nfarms.json`。
//! 分开的理由很实际 ——
//! 升级/重装三件套时整个 `libs\` 可以被覆盖掉,而 `data\` 里的库和配置要留着。
//!
//! **唯一的例外是 [`install_branding`]**:品牌 logo 必须落在 Grafana 自己的
//! `public\` 里(它只从 homepath 提供静态文件),所以那一步会往 `libs\grafana\`
//! 写一个文件。理由和代价都写在那条函数的注释里 —— 总之**重装 libs 之后要重新
//! install 一次**。
//!
//! ## 三条踩过的坑
//!
//! * **`CREATE_NO_WINDOW`**:三件套都是控制台程序,从 GUI 里 `spawn` 不加这个
//!   会先闪一个黑框(先例:`chat.rs::draw` / `wireshark.rs`)。
//! * **`timeout_global` 必须显式设**:ureq 默认 `None` = 永不超时,对端「连上了
//!   但不回话」时调用方永久卡死(先例:`weather.rs` / `chat.rs` 都为此修过)。
//! * **`.tmp` + `rename` 落盘**:直接 `fs::write` 先截断原文件,写到一半崩了
//!   就只剩半份配置(先例:`settings.rs::save` / `store.rs::save`)。
//!
//! ## 进程回收:Job Object(实测漏过 91 个孤儿进程)
//!
//! Grafana 13 起的时候会派生**一堆插件后端子进程**(实测 13 个
//! `gpx_*_windows_amd64.exe`,每个约 30 MB),而 `Child::kill()` 只打**直接**
//! 子进程 —— 那 13 个会变成孤儿,启动器关掉之后还在跑。实测在机器上累计查到
//! **91 个**(来自 6 轮启动,父进程全部已消失)。
//!
//! 所以 [`start`] 起出来的子进程会挂进 Job Object
//! (`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`),用的是 `live.rs`(bililive-go)
//! 那份实现 —— `crate::job_win`,**不另抄一份**(同一段 `unsafe` 有两份就是
//! 两个会各自漂移的来源;那边连"结构体 136≠144 只回一个参数错"这个坑都踩过了)。
//!
//! * 三件服务**各有一个 Job**,不是三件共用一个:关句柄 = 结束 Job 里**所有**
//!   进程,共用的话「停止 Grafana」会把 Loki / VictoriaMetrics 一起带走。
//! * Job 成员资格默认被子进程继承,所以 Grafana 派生出来的插件后端也在里面 ——
//!   关句柄收走的是**整棵树**。
//! * 句柄存在本模块的进程级槽里(见 `JOBS`),于是它和**启动器进程同寿命**:
//!   启动器正常退出、崩溃、被任务管理器结束,内核都会关掉句柄,整棵树跟着走。
//!
//! **界面层(`nfarms-gui/src/lib.rs`)只需要多一行**,写在 `obs_stop` 里:
//!
//! ```ignore
//! // 停之前先关这一件的 Job:内核连带结束它派生的所有子孙进程
//! observability::close_job(OBS_SERVICES[idx]);
//! let _ = child.kill();   // 原来打头的是这一行;关掉 Job 之后它基本是空操作
//! ```
//!
//! 挂 Job 失败(比如启动器自己已经在某个**不允许嵌套**的 Job 里,CI 上很常见)
//! **不挡服务启动**:退回"不回收"的旧行为,并把原因留在 [`take_job_warning`]
//! 里 —— "这一件将来没人回收"正是需要留下痕迹的那类事。

use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
#[cfg(windows)]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

// Job Object 的封装在 `live.rs` 那边就存在了 —— 这里只是**用**它,见模块头。
#[cfg(windows)]
use crate::job_win;

// ---------------------------------------------------------------- 固定的量

/// `CREATE_NO_WINDOW`(0x0800_0000):子进程不弹控制台窗口。
///
/// 只有 Windows 有这个东西。非 Windows(工作区里的 `nfarms-mobile` 要编
/// Android)上它**不参与编译**,而不是留着当一个「看起来设了其实没设」的空操作。
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;

/// 指标库(VictoriaMetrics)默认端口 —— 实测值。
const METRICS_PORT: u16 = 9091;
/// 日志库(Loki)默认端口。
const LOGS_PORT: u16 = 9092;
/// 看板(Grafana)默认端口。
const DASHBOARD_PORT: u16 = 9093;

/// 单次健康检查请求的上限。
///
/// 和「总共等多久」是两件事:这个管**一次** HTTP 往返,那个管整个就绪窗口。
/// 取 2 秒是为了让「端口连得上但一直不回话」这种半死状态尽快轮空一次、回到
/// 轮询节奏 —— 不设的话一次连接就能把整个就绪预算吃掉,然后报「超时」,
/// 而真实原因是一次莫名其妙的挂起(这就是 `weather.rs` 那次的形态)。
const HEALTH_REQUEST_TIMEOUT_SECS: u64 = 2;

/// 健康检查的轮询间隔。
///
/// 250 毫秒:VM 实测 1 秒就绪,粗了会让「1 秒的服务」白等半秒;细了只是空转
/// 回环请求。**不做指数退避** —— 三件套的就绪窗口最长也就十几秒,退避省下的
/// 那点请求量换不来「明明好了却还在等」的响应延迟。
const POLL_INTERVAL: Duration = Duration::from_millis(250);

/// 推数据(指标/日志)的 HTTP 上限。
///
/// 这两个都是本机回环、且只在服务已经就绪后才调,10 秒足够;但**必须显式设**:
/// ureq 的 `timeout_global` 默认 `None`,等于永不超时,对端一挂调用方就永久卡住。
const PUSH_TIMEOUT_SECS: u64 = 10;

/// 三个服务,顺序和界面上的一致(指标 → 日志 → 看板 = 数据的流向)。
const ALL: [Service; 3] = [Service::Metrics, Service::Logs, Service::Dashboard];

/// 应用 logo 的字节 —— **就是 exe 上那个图标本身**。
///
/// 和 `crates\nfarms-gui\src\logo.rs` 里 `include_bytes!` 的是同一个文件:
/// 「用软件的 logo」在这里的字面意思就是「和窗口/任务栏上那个图标逐字节相同」,
/// 而不是照着它另画一个近似的。
///
/// `include_bytes!` 的相对路径是按**本文件所在目录**算的:
/// `crates\nfarms-core\src\` 往上三级 = 仓库根,再进 `assets\`。
const NFARMS_ICO: &[u8] = include_bytes!("../../../assets/nfarms.ico");

/// logo 在 Grafana `public\` 下的**相对路径**。
///
/// 这一份字符串有两个用处:决定文件落盘到哪([`branding_logo_path`]),以及
/// `[branding]` 里那三个键写什么。两处必须是同一个值 —— 只在一边写死的话,
/// 以后改名字会出现「文件搬过去了、配置还指着老路径」,而现象是登录页上一张
/// 破图,不报任何错。
const LOGO_REL_PATH: &str = "public/img/nfarms-logo.ico";

/// 追加到 `custom.ini` 末尾的那一段 `[branding]`。
///
/// 三个键都用**不带前导斜杠**的相对路径 —— 和 Grafana 自己
/// `conf\defaults.ini` 里 `public/img/grafana_icon.svg` 的写法一致。带前导
/// 斜杠看着更"绝对",但 Grafana 前端会把它拼在 `appSubUrl` 后面,空 subUrl 时
/// 就成了 `//public/img/...` —— 那是个**协议相对 URL**,浏览器会去连一台叫
/// `public` 的主机,图直接不显示(而且控制台里看不出是路径写法的锅)。
///
/// 开头的 `\n` 是故意的:它保证这一段和上面原有的内容之间空一行。
const BRANDING_SECTION: &str = "\n[branding]\n\
     app_title = NFarms HUB\n\
     login_logo = public/img/nfarms-logo.ico\n\
     menu_logo = public/img/nfarms-logo.ico\n\
     favicon = public/img/nfarms-logo.ico\n";

/// Grafana 看板本体(uid `nfarms-overview`)。
///
/// 写成一段**静态 JSON** 而不是用 `serde_json::json!` 现场搭:这份东西的归宿是
/// 「能直接贴进 Grafana 的导入框」,保持它和 Grafana 导出的样子一致(缩进、键序、
/// 面板顺序)比在 Rust 里拼装更重要 —— 拼出来的那份只有编译一次才知道长什么样。
/// 代价是拼错了编译期看不出来,所以测试里把它 `serde_json::from_str` 解一遍。
///
/// ## 只画**真的存在**的指标
///
/// 程序目前只推 `nfarms_tokens_total{kind="prompt"|"completion"}` 这一条指标
/// (见 `crates\nfarms-gui\src\lib.rs` 的 `token_metric_line`),**没有任何系统
/// 指标**。所以这里一个 CPU/内存/磁盘的面板都没有 —— 空面板会显示 "No data",
/// 用户分不清那是「还没数据」还是「这东西坏了」,不如不放。
///
/// ## 面板坐标
///
/// 24 列网格,4 个面板两行两列,`h` 全是 8:
/// `(0,0,12×8)` `(12,0,12×8)` / `(0,8,12×8)` `(12,8,12×8)` —— 互不重叠。
const DASHBOARD_JSON: &str = r#"{
  "uid": "nfarms-overview",
  "title": "NFarms HUB",
  "tags": ["nfarms"],
  "timezone": "browser",
  "schemaVersion": 41,
  "version": 1,
  "refresh": "30s",
  "editable": true,
  "time": {
    "from": "now-6h",
    "to": "now"
  },
  "timepicker": {},
  "templating": {
    "list": []
  },
  "annotations": {
    "list": []
  },
  "panels": [
    {
      "id": 1,
      "type": "stat",
      "title": "Token 累计(按 kind)",
      "description": "程序启动到现在推给 VictoriaMetrics 的 token 总数。程序只推 nfarms_tokens_total{kind=\"prompt\"} 和 {kind=\"completion\"} 两条,所以这里也只有这两个数。",
      "datasource": { "type": "prometheus", "uid": "nfarms-metrics" },
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
      "fieldConfig": {
        "defaults": {
          "unit": "short",
          "decimals": 0,
          "color": { "mode": "palette-classic" }
        },
        "overrides": []
      },
      "options": {
        "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
        "colorMode": "value",
        "graphMode": "area",
        "justifyMode": "auto",
        "textMode": "auto",
        "orientation": "auto"
      },
      "targets": [
        {
          "refId": "A",
          "expr": "sum(nfarms_tokens_total{kind=\"prompt\"})",
          "legendFormat": "输入 prompt",
          "instant": true,
          "range": false,
          "datasource": { "type": "prometheus", "uid": "nfarms-metrics" }
        },
        {
          "refId": "B",
          "expr": "sum(nfarms_tokens_total{kind=\"completion\"})",
          "legendFormat": "输出 completion",
          "instant": true,
          "range": false,
          "datasource": { "type": "prometheus", "uid": "nfarms-metrics" }
        }
      ]
    },
    {
      "id": 2,
      "type": "timeseries",
      "title": "Token 增量速率(每分钟)",
      "description": "rate(nfarms_tokens_total[5m]) × 60 = 每分钟新增的 token 数。线断掉的那段 = 没在推送,不是归零。",
      "datasource": { "type": "prometheus", "uid": "nfarms-metrics" },
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
      "fieldConfig": {
        "defaults": {
          "unit": "short",
          "decimals": 1,
          "custom": {
            "drawStyle": "line",
            "lineInterpolation": "smooth",
            "lineWidth": 2,
            "fillOpacity": 10,
            "showPoints": "never",
            "spanNulls": false
          }
        },
        "overrides": []
      },
      "options": {
        "legend": {
          "displayMode": "list",
          "placement": "bottom",
          "showLegend": true,
          "calcs": ["lastNotNull", "max"]
        },
        "tooltip": { "mode": "multi", "sort": "desc" }
      },
      "targets": [
        {
          "refId": "A",
          "expr": "sum by (kind) (rate(nfarms_tokens_total[5m])) * 60",
          "legendFormat": "{{kind}}",
          "datasource": { "type": "prometheus", "uid": "nfarms-metrics" }
        }
      ]
    },
    {
      "id": 3,
      "type": "timeseries",
      "title": "输入 Token 累计(prompt)",
      "description": "喂给模型的输入 token 累计曲线(只增不减)。看速率看右上那张,这张只看总量。",
      "datasource": { "type": "prometheus", "uid": "nfarms-metrics" },
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
      "fieldConfig": {
        "defaults": {
          "unit": "short",
          "decimals": 0,
          "custom": {
            "drawStyle": "line",
            "lineInterpolation": "linear",
            "lineWidth": 2,
            "fillOpacity": 10,
            "showPoints": "never",
            "spanNulls": false
          }
        },
        "overrides": []
      },
      "options": {
        "legend": {
          "displayMode": "list",
          "placement": "bottom",
          "showLegend": true,
          "calcs": ["lastNotNull"]
        },
        "tooltip": { "mode": "single", "sort": "none" }
      },
      "targets": [
        {
          "refId": "A",
          "expr": "sum(nfarms_tokens_total{kind=\"prompt\"})",
          "legendFormat": "输入 prompt",
          "datasource": { "type": "prometheus", "uid": "nfarms-metrics" }
        }
      ]
    },
    {
      "id": 4,
      "type": "timeseries",
      "title": "输出 Token 累计(completion)",
      "description": "模型吐出来的 token 累计曲线。和输入那张分开画:两者差着数量级时合在一张图上小的那条会贴地。",
      "datasource": { "type": "prometheus", "uid": "nfarms-metrics" },
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
      "fieldConfig": {
        "defaults": {
          "unit": "short",
          "decimals": 0,
          "custom": {
            "drawStyle": "line",
            "lineInterpolation": "linear",
            "lineWidth": 2,
            "fillOpacity": 10,
            "showPoints": "never",
            "spanNulls": false
          }
        },
        "overrides": []
      },
      "options": {
        "legend": {
          "displayMode": "list",
          "placement": "bottom",
          "showLegend": true,
          "calcs": ["lastNotNull"]
        },
        "tooltip": { "mode": "single", "sort": "none" }
      },
      "targets": [
        {
          "refId": "A",
          "expr": "sum(nfarms_tokens_total{kind=\"completion\"})",
          "legendFormat": "输出 completion",
          "datasource": { "type": "prometheus", "uid": "nfarms-metrics" }
        }
      ]
    }
  ]
}
"#;

// ---------------------------------------------------------------- 公开类型

/// 可观测性三件套里的一件。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Service {
    /// VictoriaMetrics —— 指标(时序库)
    Metrics,
    /// Loki —— 日志
    Logs,
    /// Grafana —— 看板(把上面两个接起来给人看)
    Dashboard,
}

/// 一件服务的启动描述。
///
/// 字段全部公开、且都是**值**(不借任何东西),这样调用方可以直接把它丢进
/// 后台 Job 的闭包里 —— 起进程和等就绪是两件事、节奏也不同(起进程立刻返回,
/// 等就绪要几十秒),分成两步才好在界面上分别反馈。
#[derive(Debug, Clone)]
pub struct Spec {
    /// 哪一件
    pub service: Service,
    /// 给人看的名字
    pub name: &'static str,
    /// 可执行文件的**绝对路径**
    pub exe: PathBuf,
    /// 启动参数(不带程序名)
    pub args: Vec<String>,
    /// 健康检查地址
    pub health_url: String,
    /// 端口(和 `args` / 配置里的端口是同一个值)
    pub port: u16,
    /// 实测就绪耗时的上界(秒),用来算等待预算
    pub ready_hint_secs: u64,
}

// ---------------------------------------------------------------- 探测

fn service_name(service: Service) -> &'static str {
    match service {
        // 名字用组件自己的拼法:界面上要和它们的日志/文档对得上
        Service::Metrics => "VictoriaMetrics",
        Service::Logs => "Loki",
        Service::Dashboard => "Grafana",
    }
}

/// exe 在 `libs\` 下的位置。
///
/// 单独一个函数(而不是在 [`spec`] 里拼)是为了让 [`missing`] 用得**同一份**
/// 路径规则 —— 两处各拼一遍的话,以后 Grafana 换了目录层级会出现
/// 「missing 说齐了、spec 却返回 None」这种自相矛盾的状态。
fn exe_path(service: Service, libs: &Path) -> PathBuf {
    match service {
        Service::Metrics => libs.join("victoria-metrics-windows-amd64-prod.exe"),
        Service::Logs => libs.join("loki-windows-amd64.exe"),
        // Grafana 是**目录式**分发的:exe 在 `grafana\bin\` 下,同级还有
        // conf\ / public\ 等一堆资源,所以 homepath 必须是这个目录(见 spec)
        Service::Dashboard => libs.join("grafana").join("bin").join("grafana.exe"),
    }
}

/// 缺哪些服务。返回的是**服务名**(与 [`Spec::name`] 同一份字符串),
/// 空 = 三件套齐了。
///
/// 返回名字而不是路径:调用方几乎总是拿它拼一句给用户看的话
/// (「缺 VictoriaMetrics 和 Loki,跑一下 fetch-libs.ps1」),而 exe 的绝对路径
/// 又长又和用户的动作无关。真要路径,`spec()` 里有。
pub fn missing(libs: &Path) -> Vec<&'static str> {
    ALL.iter()
        .filter(|s| !exe_path(**s, libs).is_file())
        .map(|s| service_name(*s))
        .collect()
}

/// 默认端口:9091 / 9092 / 9093(实测值,和 `defaults.ini` / 现有配置一致)。
pub fn default_port(service: Service) -> u16 {
    match service {
        Service::Metrics => METRICS_PORT,
        Service::Logs => LOGS_PORT,
        Service::Dashboard => DASHBOARD_PORT,
    }
}

/// 拼出一件服务的启动描述;**exe 不在就返回 `None`,不 panic**。
///
/// 「找不到」是**常态**而不是异常:`libs\` 里没有三件套的机器上,界面要照常
/// 能用(那一页显示「未安装」)。做成 `panic` 或 `Result` 的话,每个调用点都得
/// 先自己判一次存在性 —— 而存在性判定要用同一份路径规则,早晚会有人漏判。
///
/// # `port` 与配置文件的关系(易错点)
///
/// `port` 一处进 `args`/`health_url`:`spec` 只把它写进 **VM 的
/// `-httpListenAddr`** 和健康检查地址。Loki 的监听端口在
/// `loki-config.yaml` 的 `server.http_listen_port`、Grafana 的在
/// `custom.ini` 的 `[server] http_port` 里 —— 也就是说 **`spec` 不会去改配置
/// 文件**,它只是「相信调用方给的 port 和配置文件里的一致」。
/// 调用方要保证这一点:用同一个 `port` 调 [`ensure_configs`] 生成配置、调
/// [`spec`] 拼命令行,两边不一致就会出现「健康检查打在 A 端口、服务听着 B 端口」
/// 这种永远等不到就绪的状态。
pub fn spec(service: Service, libs: &Path, data: &Path, port: u16) -> Option<Spec> {
    let exe = exe_path(service, libs);
    // 用 `is_file` 而不是 `exists`:同名目录会让 spawn 报一个很难懂的
    // 「拒绝访问」,在这里就判掉更省事
    if !exe.is_file() {
        return None;
    }

    let (args, health_url, ready_hint_secs) = match service {
        Service::Metrics => {
            let storage = data.join("vm");
            (
                vec![
                    "-storageDataPath".to_string(),
                    storage.display().to_string(),
                    "-httpListenAddr".to_string(),
                    format!(":{port}"),
                ],
                format!("http://127.0.0.1:{port}/health"),
                // 实测 1 秒(空库启动,不建索引)
                1,
            )
        }
        Service::Logs => {
            let config = loki_config_path(data);
            (
                vec![
                    "-config.file".to_string(),
                    config.display().to_string(),
                ],
                format!("http://127.0.0.1:{port}/ready"),
                // **实测 17 秒**(要建 TSDB shipper 目录、开 ring、加载 schema)
                17,
            )
        }
        Service::Dashboard => {
            let home = libs.join("grafana");
            let config = grafana_ini_path(data);
            (
                vec![
                    "server".to_string(),
                    "--homepath".to_string(),
                    home.display().to_string(),
                    "--config".to_string(),
                    config.display().to_string(),
                ],
                format!("http://127.0.0.1:{port}/api/health"),
                // 实测 7 秒(要迁移 SQLite、加载插件与默认看板)
                7,
            )
        }
    };

    Some(Spec {
        service,
        name: service_name(service),
        exe,
        args,
        health_url,
        port,
        ready_hint_secs,
    })
}

// ---------------------------------------------------------------- 起进程

/// 起一个服务,**不等待**它就绪(就绪交给 [`wait_ready`],那一步要几十秒,
/// 调用方必须放进后台 Job)。
///
/// 这是冻结清单之外的一个补充:清单里没有「起进程」这一步,但启动三件套的
/// 命令行只有本模块知道([`Spec`]),而且**这条 `Command` 必须带
/// `CREATE_NO_WINDOW`** —— 少了它,GUI 里每次点「启动」都会先闪一个控制台黑框。
/// 调用方仍可以自己拿 `spec.exe` / `spec.args` 去 `Command`;但那就得自己记得
/// 加那个标志位,正是这个模块存在的意义。
///
/// # 三件套的 stdout/stderr 接到 `NULL`,不接管道
///
/// **接 `piped` 而不读 = 死锁**:管道缓冲区(几 KB)一满,子进程的 `write` 就
/// 永久阻塞,而 GUI 这边根本不读 —— Loki / Grafana 启动时话很多,几秒钟就能
/// 把缓冲区写满,于是「服务明明在跑却永远不 ready」。真要留日志,给它们自己
/// 的日志开关,别在这里改成管道。
///
/// 也不去改 `current_dir`:这里拼出来的就是实测过能跑的那条命令行,
/// 额外设工作目录只会引入「和实测不一样」的变量(参数里的路径全是绝对的)。
///
/// 起出来的子进程会被挂进**这一件服务自己的** Job Object(见模块头那段
/// "进程回收"),挂失败不挡启动,只留一句 [`take_job_warning`]。
pub fn start(spec: &Spec) -> Result<Child, String> {
    if !spec.exe.is_file() {
        return Err(format!(
            "{} 的可执行文件不在:{}(跑一下 libs\\scripts\\fetch-libs.ps1)",
            spec.name,
            spec.exe.display()
        ));
    }
    let mut cmd = Command::new(&spec.exe);
    cmd.args(&spec.args)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        cmd.creation_flags(CREATE_NO_WINDOW);
    }
    let child = cmd
        .spawn()
        .map_err(|e| format!("起 {} 失败({}):{e}", spec.name, spec.exe.display()))?;
    // 挂 Job 只能在 `spawn` **之后**:Job 认的是进程,不是命令行
    #[cfg(windows)]
    attach_to_job(spec, &child);
    Ok(child)
}

// ---------------------------------------------------------------- 进程回收

/// 三件服务各自的 Job Object 句柄,**`0` = 还没建或建失败**。
///
/// 为什么是进程级 `static`,而不是像 `live.rs` 那样挂在某个结构体上:
/// [`start`] 的签名是 `Result<Child, String>`(界面直接把它当 `Child` 存进
/// `obs_proc`),没有任何地方能安放一个"和三件服务同寿命"的所有者。
/// 放在这里等于**句柄和启动器进程一样长命** —— 而"启动器被结束/崩溃时谁来收"
/// 恰好只能靠内核关句柄这一下,这正是要覆盖的场景。
///
/// 下标由 [`job_slot`] 定,**顺序必须和 `ALL` 一致**(有测试盯着)。
#[cfg(windows)]
static JOBS: [AtomicUsize; 3] = [AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0)];

/// 挂 Job / 建 Job 失败时留下的那句话(`None` = 没出过事)。
///
/// `start()` 既不能改签名、又**不许**因为挂不上 Job 就挡住服务启动,这句话
/// 就没了返回值的去处;而 `nfarms-core` 这里也不认识 `Log` 的实例。
/// 调用方起完服务顺手取一次写进日志即可(见模块头的例子)。
static JOB_WARNING: Mutex<Option<String>> = Mutex::new(None);

/// `Service` → [`JOBS`] 的下标。**顺序和 `ALL` 是同一份**。
#[cfg(windows)]
fn job_slot(service: Service) -> usize {
    match service {
        Service::Metrics => 0,
        Service::Logs => 1,
        Service::Dashboard => 2,
    }
}

/// 懒建 Job 并复用(和 `live.rs::Live::kill_on_close_job` 同一套做法)。
///
/// 建不出来返回 `0` —— 调用方拿到 0 就按"没人回收"处理,不能因此起不来服务。
#[cfg(windows)]
fn job_for(service: Service) -> usize {
    let slot = &JOBS[job_slot(service)];
    let existing = slot.load(Ordering::Acquire);
    if existing != 0 {
        return existing;
    }
    let fresh = match job_win::create() {
        Ok(handle) => handle,
        // 建不出来通常是"本进程已经在一个不允许嵌套的 Job 里"(CI / 任务计划
        // 程序起进程时很常见)。退回"不回收"的旧行为,但**留话** ——
        // 静默降级正是那 91 个孤儿当初没人发现的原因
        Err(code) => {
            note_job_warning(format!(
                "{} 的进程回收不可用:建 Job Object 失败(Win32 错误码 {code}),\
                 它派生的子进程将来不会被自动结束",
                service_name(service)
            ));
            return 0;
        }
    };
    // 并发下可能已经被别的线程建好了:用它的,把自己这份空的关掉
    //(里面还没有进程,关掉不会牵连谁)
    match slot.compare_exchange(0, fresh, Ordering::AcqRel, Ordering::Acquire) {
        Ok(_) => fresh,
        Err(other) => {
            job_win::close(fresh);
            other
        }
    }
}

/// 把刚起来的子进程挂到**这一件服务自己的** Job 上。
///
/// 失败只记一句话,**不影响启动**:起得来比回收得了重要,和
/// `live.rs::Live::spawn_attached` 一个口径。
#[cfg(windows)]
fn attach_to_job(spec: &Spec, child: &Child) {
    let job = job_for(spec.service);
    if job == 0 {
        // `job_for` 那边已经把原因写下了
        return;
    }
    if let Err(code) = job_win::assign(job, child) {
        note_job_warning(format!(
            "{} 没能挂进 Job Object(pid {},Win32 错误码 {code}):\
             它派生的子进程(如 Grafana 的 gpx_* 插件后端)将来不会被自动结束",
            spec.name,
            child.id()
        ));
    }
}

/// 关掉某一件服务的 Job 句柄 —— **内核随即结束 Job 里的所有进程**。
///
/// 这是 `obs_stop()` 要补的那一下:`Child::kill()` 只打直接子进程,Grafana
/// 派生出来的 13 个插件后端要靠这句话收走。不做"先礼后兵",因为
/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 的语义就是句柄一关就杀 ——
/// 和 `live.rs` 里 `Drop for Live` 走的是同一条路。
///
/// 关完把槽清成 `0`:`start()` 下次会**重新建一个** Job。不清的话它会拿着
/// 一个已经关掉的句柄去 `AssignProcessToJobObject`,那个失败还看不出来。
///
/// 非 Windows 上是个空操作 —— 那边没有 Job Object,`Child::kill()` 就是全部
/// (Android 也要编过,所以这个函数在哪个平台上都存在、签名一样)。
#[cfg_attr(not(windows), allow(unused_variables))]
pub fn close_job(service: Service) {
    #[cfg(windows)]
    {
        let old = JOBS[job_slot(service)].swap(0, Ordering::AcqRel);
        if old != 0 {
            job_win::close(old);
        }
    }
}

/// 取走"进程回收降级了"那句话(**取过一次就没了**)。
///
/// 调用方:起完服务顺手取一次,非 `None` 就写进日志 —— 界面每帧都会轮询,
/// 做成"取走"而不是"读一下",是为了不把同一句话写进日志几十遍。
pub fn take_job_warning() -> Option<String> {
    // 锁中毒(别人持锁时 panic 了)不该在这里再炸一次:取不到就当没有
    JOB_WARNING.lock().ok().and_then(|mut slot| slot.take())
}

/// 记一句"回收降级了"的话,并顺手打到 stderr。
///
/// stderr 那一份是给**没有接日志的调用方**留的(比如直接跑这个 crate 的
/// 测试/自检);界面那边读 [`take_job_warning`]。
#[cfg(windows)]
fn note_job_warning(msg: String) {
    eprintln!("[observability] {msg}");
    if let Ok(mut slot) = JOB_WARNING.lock() {
        *slot = Some(msg);
    }
}

/// 只给测试用:这一件服务的 Job 句柄(`0` = 还没有)。
#[cfg(all(test, windows))]
fn job_handle(service: Service) -> usize {
    JOBS[job_slot(service)].load(Ordering::Acquire)
}


// ---------------------------------------------------------------- 等就绪

/// 等一件服务真的能服务。**阻塞**,调用方放进后台 Job。
///
/// 判定口径很窄:**健康检查返回 2xx**。其它一律当「还没好」继续轮询 ——
/// Loki 在起 TSDB 的途中会回 503,那不是失败,是「再等等」;把非 2xx 当失败
/// 会让 17 秒的 Loki 每次都被判成起不来。
///
/// 超时返回 `Err`,**不会挂死**:没有 deadline 的话,一个永远起不来的服务
/// 会让那个后台 Job 永远不结束,界面上就是转圈转到用户重启程序
/// (`weather.rs` / `chat.rs` 都是这个形态的 bug)。
pub fn wait_ready(spec: &Spec) -> Result<(), String> {
    wait_ready_with(spec, ready_budget(spec))
}

/// 就绪预算 = `ready_hint_secs × 2 + 2` 秒。
///
/// 为什么不是写死一个数:Loki 实测 **17 秒**,拍一个「5 秒够了」的话它每次都
/// 被判成失败 —— 而它其实在正常启动,用户看到的却是「日志服务起不来」,
/// 于是去查一个根本不存在的故障。×2 + 2 给 17 秒的 Loki 留 36 秒余量,
/// 而 1 秒的 VM 只等 4 秒:预算跟着每个服务的实测耗时走,**不让快的陪慢的等**。
///
/// 用 `saturating_*` 是防守:这两个乘加用的是外部给的 `ready_hint_secs`,
/// 溢出会在 release 下变成 0 秒预算,那就是「怎么等都是失败」。
fn ready_budget(spec: &Spec) -> Duration {
    Duration::from_secs(spec.ready_hint_secs.saturating_mul(2).saturating_add(2))
}

/// [`wait_ready`] 的本体,预算当参数传。
///
/// 分开只为了让测试能用几百毫秒真的跑完这条路径(拿真的 36 秒去测 Loki,
/// 测试跑不动,而「跑不动所以不测」正是超时相关的 bug 会漏掉的原因)。
fn wait_ready_with(spec: &Spec, budget: Duration) -> Result<(), String> {
    let agent: ureq::Agent = ureq::Agent::config_builder()
        // 「单次」超时,不是总预算 —— 见 HEALTH_REQUEST_TIMEOUT_SECS
        .timeout_global(Some(Duration::from_secs(HEALTH_REQUEST_TIMEOUT_SECS)))
        .build()
        .into();

    let started = Instant::now();
    let deadline = started + budget;

    loop {
        // 这一轮探测的结果,**只在这一轮里用**(超时那条报错里要带上它),
        // 所以不跨轮保存。跨轮保存的话第一轮的初值永远读不到,
        // rustc 会直接报 `unused_assignments` —— 那不是在挑剔,是在指出
        // 「你以为有个默认值兜底,其实没有」。
        let outcome = match agent.get(&spec.health_url).call() {
            Ok(resp) => {
                let code = resp.status().as_u16();
                if (200..300).contains(&code) {
                    return Ok(());
                }
                format!("HTTP {code}")
            }
            // 4xx/5xx 不是「连不上」,而是「在跑但还没好」:Loki 起 TSDB 时
            // /ready 回 503 就落在这一支
            Err(ureq::Error::StatusCode(code)) => format!("HTTP {code}"),
            // 连接被拒 / 超时 / DNS ……都当「还没好」,下一轮再试
            Err(e) => e.to_string(),
        };

        let remaining = deadline.saturating_duration_since(Instant::now());
        if remaining.is_zero() {
            return Err(format!(
                "{} 等了 {} 秒还没就绪(健康检查 {} 最后一次是:{outcome})—— \
                 进程多半没起来或是起崩了",
                spec.name,
                started.elapsed().as_secs(),
                spec.health_url
            ));
        }
        // 睡不超过剩余预算:否则最后那一下会白睡过去,报出来的等待时间
        // 比调用方给的预算长一截
        std::thread::sleep(POLL_INTERVAL.min(remaining));
    }
}

// ---------------------------------------------------------------- 喂数据

/// 造一个带超时的 agent。
///
/// **每一个** HTTP 调用点都走这里,不允许出现裸的 `ureq::get/post`:
/// ureq 默认不设超时,漏一处就是一个可以永久卡住 Job 的入口。
fn agent() -> ureq::Agent {
    ureq::Agent::config_builder()
        .timeout_global(Some(Duration::from_secs(PUSH_TIMEOUT_SECS)))
        .build()
        .into()
}

/// POST 一段文本,2xx 算成功。
fn post(url: &str, content_type: &str, body: &str) -> Result<(), String> {
    let mut resp = agent()
        .post(url)
        .header("Content-Type", content_type)
        .send(body)
        .map_err(|e| match e {
            // 服务在跑、但拒了这条数据(格式/权限/限流)。和「连不上」分开报,
            // 因为处理方式完全不同
            ureq::Error::StatusCode(code) => {
                format!("服务端返回 HTTP {code}(服务在跑,但拒了这条数据)")
            }
            other => other.to_string(),
        })?;
    // 响应体要读掉:不读的话这条连接用不了(ureq 只能把它丢掉),高频推送就会
    // 每次重新握手。顺带把服务端的话留进错误里(204 没有正文,就是空串)
    resp.body_mut()
        .read_to_string()
        .map_err(|e| format!("读响应失败: {e}"))?;
    Ok(())
}

/// 往 VictoriaMetrics 推一行 Prometheus 文本格式的指标。
///
/// 走 `POST /api/v1/import/prometheus`,`body` **就是那一行文本**(不加换行、
/// 不加包裹):这个接口收的就是裸的 exposition 格式,包成 JSON 反而要它报 400。
/// 一行一个样本,多行请多次调用 —— 每次调用都是一次独立的小写入,
/// 失败也只丢那一行。
pub fn push_metric(vm_port: u16, line: &str) -> Result<(), String> {
    let url = format!("http://127.0.0.1:{vm_port}/api/v1/import/prometheus");
    post(&url, "text/plain; charset=utf-8", line)
        .map_err(|e| format!("推指标到 {url} 失败:{e}"))
}

/// 现在这一刻的 Unix 纳秒时间戳(字符串)。
///
/// 单独一个函数是因为 Loki 对时间戳的形态很挑:必须是**字符串**形式的纳秒。
/// 用整数或秒级时间戳,服务端一律 400。
fn now_nanos() -> Result<String, String> {
    let d = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|e| format!("系统时间早于 1970-01-01,推日志要用纳秒时间戳:{e}"))?;
    Ok(d.as_nanos().to_string())
}

/// 拼 Loki 的推送体。纯函数(不联网、不取时间),单独拎出来是为了能直接
/// 断言 JSON 的形状 —— 形状错了服务端只回一个 400,看不出是哪一层错的。
fn log_payload(labels: &[(&str, &str)], line: &str, ts_nanos: &str) -> String {
    let mut stream = serde_json::Map::new();
    for (k, v) in labels {
        stream.insert((*k).to_string(), serde_json::Value::String((*v).to_string()));
    }
    serde_json::json!({
        "streams": [{
            "stream": serde_json::Value::Object(stream),
            "values": [[ts_nanos, line]],
        }]
    })
    .to_string()
}

/// 往 Loki 推一行日志。
///
/// `streams[].stream` 是标签集,`values` 是 `[纳秒时间戳, 日志行]` 的数组 ——
/// 时间戳**必须是字符串**:Loki 收的是纳秒整数,但 JSON 里的数字会被当成
/// 浮点(double 只有 53 位精度,存不下 19 位的纳秒),所以协议规定写成字符串。
///
/// 标签集**不能为空**:Loki 的硬要求是每个 stream 至少一对标签,空标签集
/// 会被 400 掉。这里提前判掉并给一句人话,免得用户对着一个 400 猜是哪里错了。
pub fn push_log(loki_port: u16, labels: &[(&str, &str)], line: &str) -> Result<(), String> {
    if labels.is_empty() {
        return Err("推日志至少要一个标签(Loki 规定每个 stream 至少一对标签)".to_string());
    }
    let ts = now_nanos()?;
    let body = log_payload(labels, line, &ts);
    let url = format!("http://127.0.0.1:{loki_port}/loki/api/v1/push");
    post(&url, "application/json", &body).map_err(|e| format!("推日志到 {url} 失败:{e}"))
}

// ---------------------------------------------------------------- 配置落盘

/// Loki 配置的位置 —— `data\loki\loki-config.yaml`。
fn loki_config_path(data: &Path) -> PathBuf {
    data.join("loki").join("loki-config.yaml")
}

/// Grafana 的覆盖配置 —— `data\grafana\custom.ini`。
fn grafana_ini_path(data: &Path) -> PathBuf {
    data.join("grafana").join("custom.ini")
}

/// Grafana 的数据源文件 —— `data\grafana\provisioning\datasources\nfarms.yml`。
fn datasources_path(data: &Path) -> PathBuf {
    data.join("grafana")
        .join("provisioning")
        .join("datasources")
        .join("nfarms.yml")
}

/// Grafana 的看板 provider 配置 ——
/// `data\grafana\provisioning\dashboards\nfarms.yml`。
///
/// 注意这是**两层不同的东西**:`provisioning\dashboards\` 下的是"去哪读看板"
/// 的注册文件,真正的看板在 `data\grafana\dashboards\`(见 [`dashboard_json_path`])。
/// 少一层目录就会出现「provider 指着一个空目录」,现象是 Grafana 里一个看板都没有,
/// 而文件明明写出来了。
fn dashboards_provider_path(data: &Path) -> PathBuf {
    data.join("grafana")
        .join("provisioning")
        .join("dashboards")
        .join("nfarms.yml")
}

/// Grafana 的看板本体 —— `data\grafana\dashboards\nfarms.json`。
fn dashboard_json_path(data: &Path) -> PathBuf {
    data.join("grafana").join("dashboards").join("nfarms.json")
}

/// 品牌 logo 的落盘位置 —— `<libs>\grafana\public\img\nfarms-logo.ico`。
///
/// 各段**从 [`LOGO_REL_PATH`] 里拆出来拼**,不在这儿再写一遍字面量:那样就是
/// 两个地方各存一份真话,改了一边就会出现「文件搬到 A、`[branding]` 指着 B」,
/// 现象是登录页一张破图,不报任何错。
///
/// 用 `split('/')` 逐段 `push` 而不是直接 `join("public/img/…")`:后者在
/// Windows 上会拼出 `…\grafana\public/img/nfarms-logo.ico` 这种两种分隔符混着
/// 的路径 —— 能用,但看着像出错了,而且日志/错误信息里显示的就是它。
fn branding_logo_path(libs: &Path) -> PathBuf {
    let mut p = libs.join("grafana");
    for seg in LOGO_REL_PATH.split('/') {
        p.push(seg);
    }
    p
}

/// 把路径写成 **YAML 里能用的正斜杠形式**。
///
/// Windows 的反斜杠在 YAML 的**单引号**标量里本来也是字面量,但这个文件将来
/// 可能被别的手写工具/双引号标量读(那里 `\C` 是转义序列,会直接把 YAML 解析
/// 搞崩),而且 Loki 官方文档里的路径全是正斜杠 —— 统一成正斜杠,少一类
/// 「在 Linux 上没问题、在 Windows 上解析报错」的坑。
fn yaml_path(p: &Path) -> String {
    p.display().to_string().replace('\\', "/")
}

/// Loki 的单机最小配置(**内容照抄已实测通过的那一份**,只把路径和
/// HTTP 端口换成参数)。
fn loki_config_yaml(data: &Path, loki_port: u16) -> String {
    let base = yaml_path(&data.join("loki"));
    // gRPC 端口跟着 HTTP 端口走(+3,和现有那份 9092→9095 的关系一致):
    // 写死 9095 的话,一旦有人把 HTTP 端口改开第二份实例,两份会在 gRPC 端口上
    // 撞车 —— 而报错信息只是「address already in use」,看不出是这个原因
    let grpc_port = loki_port.saturating_add(3);
    format!(
        "# nfarms 用的最小 Loki 单机配置。\n\
         # 由程序在启动时生成 —— 这个文件是**运行产物**,不放进 libs\\(那不是库)。\n\
         auth_enabled: false\n\
         \n\
         server:\n\
         \x20 http_listen_port: {loki_port}\n\
         \x20 grpc_listen_port: {grpc_port}\n\
         \n\
         common:\n\
         \x20 path_prefix: '{base}'\n\
         \x20 storage:\n\
         \x20   filesystem:\n\
         \x20     chunks_directory: '{base}/chunks'\n\
         \x20     rules_directory: '{base}/rules'\n\
         \x20 replication_factor: 1\n\
         \x20 ring:\n\
         \x20   kvstore:\n\
         \x20     store: inmemory\n\
         \n\
         schema_config:\n\
         \x20 configs:\n\
         \x20   - from: 2024-01-01\n\
         \x20     store: tsdb\n\
         \x20     object_store: filesystem\n\
         \x20     schema: v13\n\
         \x20     index:\n\
         \x20       prefix: index_\n\
         \x20       period: 24h\n\
         \n\
         limits_config:\n\
         \x20 allow_structured_metadata: true\n\
         \n\
         # 单机不需要副本和限流那套,保持默认即可"
    )
}

/// Grafana 的覆盖配置(**内容照抄已实测通过的那一份**,只换路径与端口)。
///
/// 这里的路径**用反斜杠**:`.ini` 不是 YAML,没有转义问题,而 Grafana 在
/// Windows 上对两种写法都认 —— 保持和现有那份逐字节一致更重要。
fn grafana_ini(data: &Path, grafana_port: u16) -> String {
    let g = data.join("grafana");
    let dir = g.display();
    format!(
        "; nfarms 用的 Grafana 覆盖配置(叠加在 conf\\defaults.ini 之上)\n\
         [paths]\n\
         data = {dir}\n\
         logs = {dir}\\logs\n\
         plugins = {dir}\\plugins\n\
         provisioning = {dir}\\provisioning\n\
         \n\
         [server]\n\
         http_port = {grafana_port}\n\
         ; 只监听回环 —— 这是本机自用的可视化,没有理由暴露到局域网\n\
         http_addr = 127.0.0.1\n\
         \n\
         [security]\n\
         ; 首次启动要设管理员密码,否则 Grafana 会强制走改密流程\n\
         admin_user = admin\n\
         admin_password = nfarms\n\
         \n\
         [analytics]\n\
         ; **关掉上报。** 这个项目的立场是\"隐私都留在本地\",不该反过来往 Grafana 发遥测\n\
         reporting_enabled = false\n\
         check_for_updates = false\n\
         check_for_plugin_updates = false"
    )
}

/// Grafana 的两个数据源(**指标 → Loki**),uid 固定为 `nfarms-metrics` /
/// `nfarms-logs`。
///
/// uid **写死**而不是让 Grafana 自己生成:看板 JSON 里引用数据源用的是 uid,
/// 每次重新生成都变的话,预置看板会指向一个不存在的数据源(界面表现为
/// 「Datasource not found」,而数据源明明在列表里)。
fn datasources_yaml(vm_port: u16, loki_port: u16) -> String {
    format!(
        "# nfarms 的 Grafana 数据源 —— 由程序生成,别手改(改了会被下一次生成覆盖)。\n\
         apiVersion: 1\n\
         \n\
         datasources:\n\
         \x20 - name: NFarms Metrics\n\
         \x20   uid: nfarms-metrics\n\
         \x20   type: prometheus\n\
         \x20   access: proxy\n\
         \x20   url: http://127.0.0.1:{vm_port}\n\
         \x20   isDefault: true\n\
         \x20   jsonData:\n\
         \x20     httpMethod: POST\n\
         \x20     timeInterval: 5s\n\
         \x20 - name: NFarms Logs\n\
         \x20   uid: nfarms-logs\n\
         \x20   type: loki\n\
         \x20   access: proxy\n\
         \x20   url: http://127.0.0.1:{loki_port}"
    )
}

/// Grafana 的看板 provider 配置(**字段名照 Grafana 13 的口径写**)。
///
/// * `folder: nfarms` —— 预置的看板归到一个叫 nfarms 的目录里,别和 Grafana
///   自带的那些混在一起(那些在 General 里,几十张,找自己的看板很费劲)。
/// * `type: file` + `options.path` —— 告诉它去哪个目录扫 `.json`。**路径用正
///   斜杠**(见 [`yaml_path`])。
/// * `disableDeletion: false` + `allowUiUpdates: true` —— 允许在界面上直接改并
///   保存(改完写回 `data\grafana\dashboards\`)。下一节 [`provision_dashboards`]
///   是幂等的,内容没变就不会重写,所以用户的改动不会被程序启动时抹掉;
///   真要回到出厂的那一版,把那个 json 删掉再启动程序。
/// * `updateIntervalSeconds: 30` —— Grafana 每 30 秒重扫一次目录,改完文件不用
///   重启 Grafana 就能看到(和 `custom.ini` 不同,那个必须重启)。
fn dashboards_provider_yaml(data: &Path) -> String {
    let path = yaml_path(&data.join("grafana").join("dashboards"));
    format!(
        "# nfarms 的 Grafana 看板 provider —— 由程序生成,别手改(改了会被下一次生成覆盖)。\n\
         # 看板本体在 data/grafana/dashboards/ 下,这里只说去哪个目录读(路径统一用正斜杠)。\n\
         apiVersion: 1\n\
         \n\
         providers:\n\
         \x20 - name: nfarms\n\
         \x20   folder: nfarms\n\
         \x20   type: file\n\
         \x20   disableDeletion: false\n\
         \x20   updateIntervalSeconds: 30\n\
         \x20   allowUiUpdates: true\n\
         \x20   options:\n\
         \x20     path: {path}"
    )
}

/// 同目录下的 `.tmp` 兄弟路径。
///
/// **必须同目录**:跨盘/跨卷的 `rename` 不是原子替换(Windows 上直接报错,
/// Unix 上退化成 copy+delete),而这个写法的全部意义就是那一下原子替换。
fn tmp_sibling(path: &Path) -> PathBuf {
    let mut name = path.file_name().map(|s| s.to_os_string()).unwrap_or_default();
    name.push(".tmp");
    match path.parent() {
        Some(dir) => dir.join(name),
        None => PathBuf::from(name),
    }
}

/// 写文件:**先写 `.tmp` 再 `rename` 顶替**。
///
/// 直接 `fs::write` 会先把原文件截断,写到一半失败(磁盘满 / 进程被杀)就只剩
/// 半份配置 —— 而半份 YAML 会让 Loki 起不来,用户还得先猜是配置坏了。
/// 同目录内的 `rename` 是原子的:要么看到旧的完整版,要么看到新的完整版。
///
/// 收 `impl AsRef<[u8]>` 而不是 `&str`:品牌 logo 是**二进制**,走的是同一套
/// `.tmp` + `rename`(半份图标文件的症状是一张破图,比半份配置更难看懂)。
fn write_atomic(path: &Path, bytes: impl AsRef<[u8]>) -> Result<(), String> {
    if let Some(dir) = path.parent() {
        if !dir.as_os_str().is_empty() {
            std::fs::create_dir_all(dir)
                .map_err(|e| format!("建目录 {} 失败: {e}", dir.display()))?;
        }
    }
    let tmp = tmp_sibling(path);
    if let Err(e) = std::fs::write(&tmp, bytes) {
        // 写坏的临时文件要清掉:没人会来收拾它,而用户看到 `xxx.tmp` 只会更困惑
        let _ = std::fs::remove_file(&tmp);
        return Err(format!("写临时文件 {} 失败: {e}", tmp.display()));
    }
    if let Err(e) = std::fs::rename(&tmp, path) {
        // 替换失败时原文件**一个字都没动**(rename 要么成功要么什么都没发生)
        let _ = std::fs::remove_file(&tmp);
        return Err(format!("替换 {} 失败: {e}", path.display()));
    }
    Ok(())
}

/// 幂等落盘:**内容一样就一个字节都不写**,只在不同时才走 [`write_atomic`]。
///
/// 抽出来是因为「生成配置」这件事有三个调用点(数据源、看板 provider、看板本体),
/// 而幂等这件事的理由在每一处都成立 —— 见 [`provision_datasources`] 的注释。
/// 各写一遍的话早晚有一处漏掉判断,而漏掉的那处每次都 `rename` 一遍正在被
/// Grafana 热加载的目录。
fn write_if_changed(path: &Path, text: &str) -> Result<(), String> {
    if std::fs::read_to_string(path).is_ok_and(|old| old == text) {
        return Ok(());
    }
    write_atomic(path, text)
}

/// 写 Grafana 的数据源文件,**返回写到的路径**。
///
/// **幂等**:内容一样就一个字节都不写。这不只是省一次磁盘 IO ——
/// Grafana 是**在跑着的时候**读这个目录的(provisioning 会热加载),
/// 每次启动都 `rename` 一遍等于每次都给正在跑的 Grafana 一次「文件被替换」
/// 事件;而且内容没变时连 mtime 都不动,用户/工具看到的是「本来就配好了」。
///
/// 两个数据源的 uid 固定为 `nfarms-metrics` / `nfarms-logs`(理由见上面
/// `datasources_yaml` 的注释),url 指向 `127.0.0.1:<port>`:
/// 回环地址,和这两个库「只服务本机」的定位一致。
pub fn provision_datasources(data: &Path, vm_port: u16, loki_port: u16) -> Result<PathBuf, String> {
    let path = datasources_path(data);
    write_if_changed(&path, &datasources_yaml(vm_port, loki_port))?;
    Ok(path)
}

/// 写 Grafana 的看板 provider 和看板本体,**返回看板 JSON 的路径**。
///
/// 两个文件,一层套一层(少一层就是「provider 指着一个空目录」,界面上一个
/// 看板都没有):
///
/// 1. `data\grafana\provisioning\dashboards\nfarms.yml` —— 注册文件,说清去哪
///    读看板([`dashboards_provider_yaml`])
/// 2. `data\grafana\dashboards\nfarms.json` —— 看板本体(uid `nfarms-overview`,
///    定义见 [`DASHBOARD_JSON`])
///
/// 两个文件都是**幂等**的(同一个 [`write_if_changed`]):内容一样就一个字节都
/// 不写。这一条在看板上比数据源更要紧 —— `allowUiUpdates` 打开时用户可能在
/// Grafana 里直接改过这张看板,程序每次启动都重写的话,等于每次启动都把他的
/// 改动抹掉(和 [`ensure_configs`] 不覆盖已有配置是同一条立场)。
///
/// 返回**看板 JSON** 的路径而不是 provider 的:调用方多半是想把「看板在这儿」
/// 显示给用户(甚至直接打开),而 provider 那个是纯注册用的,路径可以推出来。
pub fn provision_dashboards(data: &Path) -> Result<PathBuf, String> {
    let provider = dashboards_provider_path(data);
    write_if_changed(&provider, &dashboards_provider_yaml(data))?;
    let dashboard = dashboard_json_path(data);
    write_if_changed(&dashboard, DASHBOARD_JSON)?;
    Ok(dashboard)
}

/// `custom.ini` 里是不是已经有 `[branding]` 段了(ini 的段名大小写不敏感)。
///
/// 只看**段名**、不看内容,而且跳过注释行:用户手改过这一段的键(比如把标题
/// 换成别的)就照用户的来 —— 和 [`ensure_configs`] 同一条立场(生成缺省,
/// 不覆盖人的修改)。注释行要跳过是因为 `; [branding]` 这种"注释掉的段"
/// 也会被 `lines()` 捞出来,认成「已经有了」,于是这一段永远装不上。
fn has_branding_section(ini: &str) -> bool {
    ini.lines()
        .map(str::trim)
        .filter(|l| !l.starts_with(';') && !l.starts_with('#'))
        .any(|l| l.eq_ignore_ascii_case("[branding]"))
}

/// 把应用 logo 装进 Grafana,并让 `[branding]` 指向它;**返回 logo 的路径**。
///
/// # 选了哪条路:直接搬 `.ico`,不抽 PNG
///
/// `nfarms-gui` 里有一个现成的 `logo::extract_largest_png`,能把 ICO 里那张
/// 256×256 的 PNG 抽出来 —— 但 **crate 依赖方向不允许**:`nfarms-core` 是纯逻辑
/// 层、被 gui 依赖,反过来依赖 gui 就是循环依赖。而在 core 里再抄一份 ICO 解析
/// 是同一件事写两遍(ICO 恰好是最容易写错的那种二进制格式:目录项、偏移、
/// BITMAPINFOHEADER、AND mask,少看一个字段就解出半张图),所以这里**不抽**:
/// 直接把 `assets\nfarms.ico` 的字节搬进 Grafana 的 `public\img\`,让浏览器自己解。
///
/// 这条路成立是因为 ico 本来就是**浏览器原生认的图片格式**(favicon 就是它),
/// `<img src="*.ico">` 和 `<link rel="icon">` 走的是同一套解码器;代价是 Grafana
/// 那个位置会按 ICO 里最大的那张(256×256)自己缩放,不做尺寸裁剪 —— 换来的是
/// 「图和 exe 上那个图标逐字节相同」,而不是照它另画一个近似的。
///
/// # 为什么要往 `libs\` 里写 —— 这是本模块唯一的例外
///
/// `libs\` 是第三方组件的地盘(理由见模块开头),正常一个字节都不该往里放。
/// 但 Grafana **只从 homepath 的 `public\`** 提供静态文件(`/public/...`),
/// `[branding]` 里能指的也只有那儿 —— 所以 logo 这一份必须落在
/// `<libs>\grafana\public\img\nfarms-logo.ico`。**这是不得已的**:只要 Grafana
/// 还是"解压即用"的目录式分发,它就不认 `data\` 里的图。
///
/// 代价要说清:**重装/升级 `libs\` 会把这一份冲掉**(`fetch-libs.ps1` 覆盖整个
/// 目录),之后要重新 install 一次;这一步是幂等的,重复跑没有副作用。所以调用
/// 点应该放在"每次启动三件套之前"那条路径上,而不是只在安装时跑一次。
///
/// # `[branding]` 要**重启 Grafana** 才生效
///
/// `custom.ini` 是 Grafana **启动时**读一次的,provisioning 那套热加载管不到它。
/// 这一步写完之后已经跑着的 Grafana 不会变 —— 界面上就是「停止」再「启动」。
/// (看板不同:[`provision_dashboards`] 写的文件会被 provider 按
/// `updateIntervalSeconds` 自动重扫,不用重启。)
///
/// # 调用顺序:必须先 [`ensure_configs`]
///
/// `custom.ini` 不存在时这里**报错,而不是自己造一份**:造出来的那份没有
/// `[server] http_port`,Grafana 会退回默认的 3000,而健康检查还在打 9093 ——
/// 症状是「Grafana 永远起不来」,而真正的原因是一个缺了半段的配置。
pub fn install_branding(libs: &Path, data: &Path) -> Result<PathBuf, String> {
    // 1. logo 落到 Grafana 的 public\ 下。按**字节**比而不是按长度/时间戳:
    //    换一张图标但大小一样是很可能的(同一套设计改个颜色)
    let logo = branding_logo_path(libs);
    if !std::fs::read(&logo).is_ok_and(|old| old == NFARMS_ICO) {
        write_atomic(&logo, NFARMS_ICO)?;
    }

    // 2. `[branding]` 追加进 custom.ini。已有的三段一个字都不动 —— 只往后接
    let ini = grafana_ini_path(data);
    let old = std::fs::read_to_string(&ini).map_err(|e| {
        format!(
            "读 {} 失败:{e} —— 先调 ensure_configs 生成 [server]/[security]/[analytics],\
             再调 install_branding(它只往后追加第四段 [branding])",
            ini.display()
        )
    })?;
    if !has_branding_section(&old) {
        let mut text = old;
        // 上一行结尾没有换行就先补一个,否则 `[analytics]` 的最后一行会和
        // `[branding]` 粘成 `check_for_plugin_updates = false[branding]`,
        // 那一段直接作废(还会把上一个键的值污染成这一串)
        if !text.is_empty() && !text.ends_with('\n') {
            text.push('\n');
        }
        text.push_str(BRANDING_SECTION);
        write_atomic(&ini, &text)?;
    }

    Ok(logo)
}

/// 缺哪个配置就生成哪个,**已存在的一律不覆盖**。
///
/// 「不覆盖」是硬要求而不是优化:`custom.ini` 和 `loki-config.yaml` 里除了
/// 端口,还有用户可能手改过的路径、密码、限流项。程序每次启动都按模板重写,
/// 等于**每次启动都把用户的修改抹掉**,而用户根本不知道是谁改的。
/// 生成的只是「没有时的那一份缺省」。
///
/// 返回 `Err` 的只有「该生成却写不下去」(目录不可写 / 磁盘满),
/// 而不是「已经存在」——后者是正常路径。
pub fn ensure_configs(data: &Path, loki_port: u16, grafana_port: u16) -> Result<(), String> {
    let loki = loki_config_path(data);
    if !loki.exists() {
        write_atomic(&loki, loki_config_yaml(data, loki_port))?;
    }
    let grafana = grafana_ini_path(data);
    if !grafana.exists() {
        write_atomic(&grafana, grafana_ini(data, grafana_port))?;
    }
    Ok(())
}

// ---------------------------------------------------------------- 测试

#[cfg(test)]
mod tests {
    use super::*;

    /// 每个测试一个自己的临时目录,名字里带**进程号**:`cargo test` 默认多线程,
    /// 同名目录会被别的测试删掉,表现成随机失败。临时目录用完不删 ——
    /// 每次跑都是新名字,而删目录这一步本身也可能因为句柄没放干净而失败。
    fn tmp_dir(name: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!(
            "nfarms-observability-{}-{name}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    /// 造一个空文件当 exe(`spec` 只看 `is_file`,不执行它)。
    fn touch(path: &Path) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, b"").unwrap();
    }

    /// 造一个「三件套都在」的 `libs\`。
    ///
    /// **拿已经建好的根目录当参数**,而不是自己在里面再调一次 [`tmp_dir`]:
    /// `tmp_dir` 会先删掉同名目录再重建,再调一次就把上一个变量指着的
    /// `libs\` 连带删了 —— 症状是 `spec()` 莫名其妙返回 `None`。
    fn libs_with_all_exes(root: &Path) -> PathBuf {
        let libs = root.join("libs");
        for s in ALL {
            touch(&exe_path(s, &libs));
        }
        libs
    }

    // ------------------------------------------------------------ spec

    #[test]
    fn spec_is_none_instead_of_panicking_when_the_exe_is_missing() {
        // 空目录 = 没装三件套。这里**不能** panic:界面在没有三件套的机器上
        // 照样要能打开
        let root = tmp_dir("spec-missing");
        let libs = root.join("libs");
        let data = root.join("data");
        for s in ALL {
            assert!(
                spec(s, &libs, &data, default_port(s)).is_none(),
                "{:?} 的 exe 不在,spec 必须返回 None",
                s
            );
        }
    }

    #[test]
    fn spec_args_carry_the_port_and_the_data_paths() {
        let root = tmp_dir("spec-args");
        let libs = libs_with_all_exes(&root);
        let data = root.join("data");

        let vm = spec(Service::Metrics, &libs, &data, 19091).unwrap();
        assert_eq!(vm.name, "VictoriaMetrics");
        assert_eq!(vm.exe, libs.join("victoria-metrics-windows-amd64-prod.exe"));
        // 路径和端口都得进参数,少一个服务就起在别处/别的端口
        assert_eq!(
            vm.args,
            vec![
                "-storageDataPath".to_string(),
                data.join("vm").display().to_string(),
                "-httpListenAddr".to_string(),
                ":19091".to_string(),
            ]
        );
        assert_eq!(vm.port, 19091);
        assert_eq!(vm.health_url, "http://127.0.0.1:19091/health");

        let loki = spec(Service::Logs, &libs, &data, 19092).unwrap();
        assert_eq!(loki.exe, libs.join("loki-windows-amd64.exe"));
        assert_eq!(
            loki.args,
            vec![
                "-config.file".to_string(),
                data.join("loki").join("loki-config.yaml").display().to_string(),
            ]
        );
        assert_eq!(loki.health_url, "http://127.0.0.1:19092/ready");

        let grafana = spec(Service::Dashboard, &libs, &data, 19093).unwrap();
        assert_eq!(grafana.exe, libs.join("grafana").join("bin").join("grafana.exe"));
        assert_eq!(
            grafana.args,
            vec![
                "server".to_string(),
                "--homepath".to_string(),
                libs.join("grafana").display().to_string(),
                "--config".to_string(),
                data.join("grafana").join("custom.ini").display().to_string(),
            ]
        );
        assert_eq!(grafana.health_url, "http://127.0.0.1:19093/api/health");
    }

    /// 健康检查路径和就绪耗时是**实测出来的事实**,不是随手写的;
    /// 谁把它改成 `core` / `ready`(Grafana 确实不叫 `/ready`)或者把 Loki
    /// 的 17 秒改成 5,这条测试就红。
    #[test]
    fn health_paths_and_ready_hints_are_the_measured_ones() {
        let root = tmp_dir("ready-hints");
        let libs = libs_with_all_exes(&root);
        let data = root.join("data");
        let vm = spec(Service::Metrics, &libs, &data, 9091).unwrap();
        let loki = spec(Service::Logs, &libs, &data, 9092).unwrap();
        let grafana = spec(Service::Dashboard, &libs, &data, 9093).unwrap();

        assert!(vm.health_url.ends_with("/health"));
        assert!(loki.health_url.ends_with("/ready"));
        assert!(grafana.health_url.ends_with("/api/health"));
        assert_eq!((vm.ready_hint_secs, loki.ready_hint_secs, grafana.ready_hint_secs), (1, 17, 7));
    }

    #[test]
    fn default_ports_are_the_three_measured_ones() {
        assert_eq!(default_port(Service::Metrics), 9091);
        assert_eq!(default_port(Service::Logs), 9092);
        assert_eq!(default_port(Service::Dashboard), 9093);
    }

    #[test]
    fn missing_names_exactly_the_services_whose_exe_is_absent() {
        let root = tmp_dir("missing");
        let libs = root.join("libs");
        assert_eq!(
            missing(&libs),
            vec!["VictoriaMetrics", "Loki", "Grafana"],
            "空目录下三件套都缺"
        );

        // 只补上 Loki,名单里就只剩另外两件
        touch(&exe_path(Service::Logs, &libs));
        assert_eq!(missing(&libs), vec!["VictoriaMetrics", "Grafana"]);

        for s in ALL {
            touch(&exe_path(s, &libs));
        }
        assert!(missing(&libs).is_empty(), "三件套齐了就不该再报缺");
    }

    /// exe 的**目录层级**也是实测事实,而上面那些测试是「自己造文件给自己看」——
    /// `exe_path` 就算把 Grafana 写成 `libs\grafana.exe`(少了 `bin\` 那层),
    /// 它们照样全绿(造文件时用的是同一个错规则)。这一条拿真的 `dist\libs\` 对,
    /// 所以只在装好了三件套的开发机上跑得动;没装就跳过并打印一句,不静默。
    #[test]
    fn the_real_dist_layout_matches_where_this_module_looks_for_the_exes() {
        let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
        let dist = manifest.join("..").join("..").join("dist");
        let libs = dist.join("libs");
        let data = dist.join("data");

        let absent = missing(&libs);
        if !absent.is_empty() {
            eprintln!(
                "跳过 {}:三件套没装齐(缺 {absent:?}),这一条只在装好的开发机上跑",
                libs.display()
            );
            return;
        }

        for s in ALL {
            let port = default_port(s);
            let sp = spec(s, &libs, &data, port).unwrap_or_else(|| {
                panic!("{s:?} 在真实的 libs 里找不到:路径规则和实际布局对不上")
            });
            assert!(sp.exe.is_file(), "{s:?} 的 exe 不是文件:{}", sp.exe.display());
            assert!(sp.health_url.contains(&port.to_string()));
        }

        // 单独钉一下 Grafana:目录式分发,`--homepath` 指的是 exe 上面那两层
        // (整个 `grafana\`),指到 `bin\` 里去它找不到 conf\ 和 public\
        let g = spec(Service::Dashboard, &libs, &data, 9093).unwrap();
        assert_eq!(g.args[0], "server");
        assert_eq!(g.args[1], "--homepath");
        assert_eq!(g.args[2], libs.join("grafana").display().to_string());
    }

    #[test]
    fn start_reports_a_clear_error_when_the_exe_is_gone() {
        let root = tmp_dir("start-missing");
        let libs = root.join("libs");
        let data = root.join("data");
        // 拿一个手搓的 Spec 直接调:确认这条路径报错而不是 panic
        let fake = Spec {
            service: Service::Logs,
            name: "Loki",
            exe: libs.join("loki-windows-amd64.exe"),
            args: vec![],
            health_url: "http://127.0.0.1:9092/ready".to_string(),
            port: 9092,
            ready_hint_secs: 17,
        };
        let e = start(&fake).unwrap_err();
        assert!(e.contains("Loki"), "报错要说清是哪一件:{e}");
        // 顺带确认 spec 在同一个目录下也确实返回 None(同一份路径规则)
        assert!(spec(Service::Logs, &libs, &data, 9092).is_none());
    }

    // ------------------------------------------------------------ 进程回收

    /// 一个必定存在的长跑替身:`ping -n 30` ≈ 30 秒。
    ///
    /// 不拿真的 Grafana / Loki:那两样不保证测试机上装了,而这条路径要证的
    /// 只是"start() 起的进程进了 Job、关 Job 之后它会不会被内核收走"。
    #[cfg(windows)]
    fn long_running_stand_in() -> Option<PathBuf> {
        let root = std::env::var("SystemRoot").ok()?;
        let ping = PathBuf::from(root).join("System32").join("ping.exe");
        ping.is_file().then_some(ping)
    }

    /// 三件服务各自的 Job 槽必须和 `ALL` 的顺序一致。
    ///
    /// 这是纯函数那一半:错一位的后果是「停止 Grafana」去关了 Loki 的 Job
    /// (或者谁都没关),而现象只是"进程还在",很难指回这张表。
    #[test]
    #[cfg(windows)]
    fn job_slots_line_up_with_the_service_order() {
        for (i, s) in ALL.iter().enumerate() {
            assert_eq!(job_slot(*s), i, "{s:?} 的 Job 槽和 ALL 的顺序对不上");
        }
    }

    /// 回归:**`start()` 起的子进程真的进了 Job,关掉 Job 它就得消失**。
    ///
    /// 改之前 `Child::kill()` 之外什么都没有,Grafana 那 13 个插件后端会变成
    /// 孤儿;这条测试盯的就是"关句柄 → 整棵树走"。
    #[test]
    #[cfg(windows)]
    fn start_attaches_the_child_to_a_job_and_close_job_kills_the_tree() {
        let Some(ping) = long_running_stand_in() else {
            eprintln!("跳过:System32\\ping.exe 不在,拿不到长跑替身");
            return;
        };
        let spec = Spec {
            service: Service::Logs,
            name: "Loki",
            exe: ping,
            args: vec!["-n".to_string(), "30".to_string(), "127.0.0.1".to_string()],
            health_url: "http://127.0.0.1:9092/ready".to_string(),
            port: 9092,
            ready_hint_secs: 17,
        };
        let mut child = start(&spec).expect("替身进程没起来");

        // 先确认它真被挂进去了 —— 没挂进去的话这条测试等于什么都没测
        let job = job_handle(Service::Logs);
        assert_ne!(job, 0, "start() 没给这一件建 Job");
        assert!(
            crate::job_win::is_member(job, &child),
            "子进程没进 Job(AssignProcessToJobObject 失败)"
        );
        assert!(
            child.try_wait().expect("try_wait 失败").is_none(),
            "替身进程应该还在跑"
        );

        // 这一下就是 `obs_stop()` 要补的那一行
        close_job(Service::Logs);

        let deadline = Instant::now() + Duration::from_secs(5);
        loop {
            if child.try_wait().expect("try_wait 失败").is_some() {
                return; // 内核把它收走了
            }
            if Instant::now() >= deadline {
                let _ = child.kill();
                let _ = child.wait();
                panic!("关掉 Job 之后子进程还活着 —— 孤儿进程又回来了");
            }
            std::thread::sleep(Duration::from_millis(50));
        }
    }

    /// Job 只建一次、复用;`close_job` 之后必须能**重新建一个**。
    ///
    /// 不重建的话,下一次 `start()` 会拿着一个已经关掉的句柄去
    /// `AssignProcessToJobObject` —— 那个失败是**静默**的,后果只剩
    /// "没有回收",而"没有回收"正是这次要修的那个 bug。
    #[test]
    #[cfg(windows)]
    fn a_service_keeps_one_job_and_gets_a_fresh_one_after_close() {
        assert_eq!(job_handle(Service::Metrics), 0, "还没起进程就不该先建 Job");
        let first = job_for(Service::Metrics);
        assert_ne!(first, 0, "Job Object 没建出来");
        assert_eq!(job_for(Service::Metrics), first, "第二次应该复用同一个 Job");
        assert_eq!(
            job_handle(Service::Metrics),
            first,
            "句柄要记在槽里,close_job 才关得掉"
        );

        close_job(Service::Metrics);
        assert_eq!(job_handle(Service::Metrics), 0, "close_job 之后槽要清空");
        let again = job_for(Service::Metrics);
        assert_ne!(again, 0, "关掉之后必须能重新建一个(否则下一次 Assign 是静默失败)");
        close_job(Service::Metrics);
    }

    /// 降级的那句话要**留得下、取得走、只取一次**。
    ///
    /// 界面每帧都会调一次 `take_job_warning`,做成"读一下"的话同一句话会被
    /// 写进日志几十遍。
    #[test]
    #[cfg(windows)]
    fn job_warnings_are_recorded_and_taken_exactly_once() {
        note_job_warning("测试用:回收降级了".to_string());
        assert_eq!(
            take_job_warning().as_deref(),
            Some("测试用:回收降级了"),
            "记下的话必须能取到"
        );
        assert!(take_job_warning().is_none(), "取过一次就该清掉");
    }

    // ------------------------------------------------------------ wait_ready

    /// 找出一个**此刻没人监听**的端口:绑上再放掉。
    ///
    /// 比手写一个 59999 更可靠 —— 那个数字可能正好被别的进程占着,于是
    /// 「连接被拒」变成「连上了」,测试就成了偶发失败。
    fn a_port_nobody_listens_on() -> u16 {
        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let port = l.local_addr().unwrap().port();
        drop(l);
        port
    }

    fn probe_spec(name: &'static str, port: u16, path: &str) -> Spec {
        Spec {
            service: Service::Metrics,
            name,
            exe: PathBuf::from("NUL"),
            args: vec![],
            health_url: format!("http://127.0.0.1:{port}{path}"),
            port,
            // 0 秒实测耗时 → 预算 2 秒(见 ready_budget),测试不用干等
            ready_hint_secs: 0,
        }
    }

    #[test]
    fn wait_ready_gives_up_on_a_port_nobody_listens_on() {
        let port = a_port_nobody_listens_on();
        let s = probe_spec("假服务", port, "/health");
        let started = Instant::now();
        let e = wait_ready_with(&s, Duration::from_millis(700)).unwrap_err();
        // 超时返回 Err —— 而不是挂死。给个宽松上限:真正要防的是「永不返回」
        assert!(started.elapsed() < Duration::from_secs(30), "必须自己超时返回");
        assert!(e.contains("假服务"), "报错要说清是哪一件:{e}");
        assert!(e.contains("还没就绪"), "报错要说清是超时:{e}");
    }

    /// 「端口连得上、但一个字都不回」—— 这就是 `weather.rs` 卡死的形态。
    /// 监听端**一直不放**:`connect` 会成功,只有请求级超时能把它兜住。
    #[test]
    fn wait_ready_gives_up_when_the_port_accepts_but_never_answers() {
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        let s = probe_spec("假服务", port, "/health");
        let started = Instant::now();
        let e = wait_ready_with(&s, Duration::from_millis(700)).unwrap_err();
        assert!(started.elapsed() < Duration::from_secs(30), "必须自己超时返回");
        assert!(e.contains("还没就绪"), "报错要说清是超时:{e}");
        // 监听端在本函数结束时才析构:整个过程中端口都有人听着
        drop(listener);
    }

    /// Loki 实测 17 秒 —— 预算必须**明显**大于它,否则每次都被判成起不来。
    #[test]
    fn loki_budget_has_room_over_the_measured_seventeen_seconds() {
        let loki = Spec {
            service: Service::Logs,
            name: "Loki",
            exe: PathBuf::from("NUL"),
            args: vec![],
            health_url: "http://127.0.0.1:9092/ready".to_string(),
            port: 9092,
            ready_hint_secs: 17,
        };
        let budget = ready_budget(&loki);
        assert!(
            budget >= Duration::from_secs(17),
            "预算 {budget:?} 比实测就绪耗时就短,等于每次都判失败"
        );
        assert!(
            budget >= Duration::from_secs(34),
            "只留和实测一样长的预算等于没有余量,机器慢一点就误判"
        );
    }

    // ------------------------------------------------------------ 推数据

    /// 只应答一次请求的假服务端:返回 (端口, 收到的那份请求文本)。
    ///
    /// 用它而不是真的起 Loki/VM:这里要验的是「路径对不对、body 是什么」,
    /// 那是**我们自己的事**;真去起服务,测试就变成了「这台机器上装没装
    /// 三件套」的检查。
    fn one_shot_server() -> (u16, std::sync::mpsc::Receiver<String>) {
        use std::io::{Read, Write};

        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        let (tx, rx) = std::sync::mpsc::channel();
        std::thread::spawn(move || {
            let Ok((mut sock, _)) = listener.accept() else {
                return;
            };
            // 读超时只是「请求就这么多」的结束条件,不是网络故障
            sock.set_read_timeout(Some(Duration::from_millis(1500)))
                .unwrap();
            let mut buf = Vec::new();
            let mut chunk = [0u8; 1024];
            loop {
                match sock.read(&mut chunk) {
                    Ok(0) => break,
                    Ok(n) => {
                        buf.extend_from_slice(&chunk[..n]);
                        let text = String::from_utf8_lossy(&buf).to_string();
                        if let Some(head_end) = text.find("\r\n\r\n") {
                            // 头部按 Content-Length 算准,别靠「读超时」猜 ——
                            // 猜早了会把 body 截断,断出来的 JSON 直接解析失败
                            let want = content_length(&text[..head_end]).unwrap_or(0);
                            if buf.len() >= head_end + 4 + want {
                                break;
                            }
                        }
                    }
                    Err(_) => break,
                }
            }
            let _ = sock.write_all(
                b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
            );
            let _ = tx.send(String::from_utf8_lossy(&buf).to_string());
        });
        (port, rx)
    }

    fn content_length(head: &str) -> Option<usize> {
        head.lines()
            .find(|l| l.to_ascii_lowercase().starts_with("content-length:"))
            .and_then(|l| l.split(':').nth(1))
            .and_then(|v| v.trim().parse().ok())
    }

    fn body_of(request: &str) -> &str {
        request.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or("")
    }

    #[test]
    fn push_metric_posts_the_line_itself_to_the_import_endpoint() {
        let (port, rx) = one_shot_server();
        push_metric(port, "nfarms_probe_seconds 1").unwrap();
        let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();

        assert!(
            req.starts_with("POST /api/v1/import/prometheus "),
            "路径必须是被实测过的那个:{req}"
        );
        assert!(
            req.to_ascii_lowercase().contains("content-type: text/plain"),
            "VM 的 import 接口收的是裸文本:{req}"
        );
        // body **就是那一行**,不加换行、不包 JSON
        assert_eq!(body_of(&req), "nfarms_probe_seconds 1");
    }

    #[test]
    fn push_log_posts_the_loki_json_with_a_nanosecond_string_timestamp() {
        let (port, rx) = one_shot_server();
        push_log(port, &[("app", "nfarms"), ("level", "info")], "启动完成")
            .unwrap();
        let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();

        assert!(
            req.starts_with("POST /loki/api/v1/push "),
            "路径必须是 /loki/api/v1/push:{req}"
        );
        assert!(req.to_ascii_lowercase().contains("content-type: application/json"));

        let v: serde_json::Value = serde_json::from_str(body_of(&req)).unwrap();
        let stream = &v["streams"][0];
        assert_eq!(stream["stream"]["app"], "nfarms");
        assert_eq!(stream["stream"]["level"], "info");
        let values = &stream["values"][0];
        // 时间戳必须是**字符串**形式的纳秒:JSON 数字会掉精度
        let ts = values[0].as_str().expect("纳秒时间戳必须是字符串");
        assert!(ts.len() >= 19, "纳秒时间戳至少 19 位,拿到的是 {ts}");
        assert!(ts.chars().all(|c| c.is_ascii_digit()));
        assert_eq!(values[1], "启动完成");
    }

    #[test]
    fn push_log_refuses_an_empty_label_set_before_hitting_the_network() {
        // 端口故意给一个连不上的值:空标签集要在**发请求之前**就被挡下,
        // 报的才是「至少一个标签」而不是「连不上」
        let e = push_log(1, &[], "x").unwrap_err();
        assert!(e.contains("标签"), "报错要指向真正的原因:{e}");
    }

    #[test]
    fn log_payload_keeps_multi_line_text_and_label_values_verbatim() {
        let body = log_payload(
            &[("msg", "带\"引号\"的值")],
            "第一行\n第二行",
            "1700000000000000000",
        );
        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
        let values = &v["streams"][0]["values"][0];
        assert_eq!(values[1], "第一行\n第二行", "换行不能被转义成两行");
        assert_eq!(v["streams"][0]["stream"]["msg"], "带\"引号\"的值");
    }

    // ------------------------------------------------------------ 数据源

    #[test]
    fn provision_datasources_writes_both_and_is_idempotent() {
        let data = tmp_dir("datasources").join("data");
        let path = provision_datasources(&data, 19091, 19092).unwrap();
        assert_eq!(
            path,
            data.join("grafana")
                .join("provisioning")
                .join("datasources")
                .join("nfarms.yml")
        );

        let first = std::fs::read_to_string(&path).unwrap();
        for needle in [
            "uid: nfarms-metrics",
            "uid: nfarms-logs",
            "url: http://127.0.0.1:19091",
            "url: http://127.0.0.1:19092",
            "type: prometheus",
            "type: loki",
        ] {
            assert!(first.contains(needle), "数据源文件里少了 {needle}:\n{first}");
        }
        // 端口要用传进来的那个,不是默认值。**比完整的 url 行**,不能只比 "9091"
        // —— "19091" 里就含 "9091",那样比等于没比
        assert!(!first.contains("http://127.0.0.1:9091"), "端口要用传进来的那个");
        assert!(!first.contains("http://127.0.0.1:9092"), "端口要用传进来的那个");

        // 再调一次:内容必须一模一样(幂等),临时文件也不能留下
        let again = provision_datasources(&data, 19091, 19092).unwrap();
        assert_eq!(again, path);
        assert_eq!(std::fs::read_to_string(&path).unwrap(), first);
        assert!(
            !tmp_sibling(&path).exists(),
            "落盘走 .tmp + rename,成功之后不能留下 .tmp"
        );
    }

    #[test]
    fn provision_datasources_follows_the_ports_it_is_given() {
        let data = tmp_dir("datasources-regen").join("data");
        let path = provision_datasources(&data, 19091, 19092).unwrap();
        // 端口变了 → 内容跟着变(不然改端口就得手删文件)
        provision_datasources(&data, 29091, 29092).unwrap();
        let text = std::fs::read_to_string(&path).unwrap();
        assert!(text.contains("url: http://127.0.0.1:29091"));
        assert!(!text.contains("19091"));
    }

    // ------------------------------------------------------------ 看板

    /// 看板的两个文件都写出来、路径对、字段名对,而且**幂等**(内容一样不重写)。
    #[test]
    fn provision_dashboards_writes_provider_and_dashboard_and_is_idempotent() {
        let data = tmp_dir("dashboards").join("data");
        let json_path = provision_dashboards(&data).unwrap();
        let provider_path = dashboards_provider_path(&data);

        assert_eq!(
            provider_path,
            data.join("grafana")
                .join("provisioning")
                .join("dashboards")
                .join("nfarms.yml")
        );
        assert_eq!(
            json_path,
            data.join("grafana").join("dashboards").join("nfarms.json")
        );
        // 返回的是**看板本体**(provider 那个是纯注册文件,路径可推导)
        assert_eq!(json_path, dashboard_json_path(&data));

        let yml = std::fs::read_to_string(&provider_path).unwrap();
        // provider 的字段名是 Grafana 13 那一套,少一个/拼错一个它就不装这个 provider
        // (而且不报错,只是看板列表里空着 —— 所以这几行逐个钉住)
        for needle in [
            "apiVersion: 1",
            "providers:",
            "name: nfarms",
            "folder: nfarms",
            "type: file",
            "disableDeletion: false",
            "updateIntervalSeconds: 30",
            "allowUiUpdates: true",
        ] {
            assert!(yml.contains(needle), "provider 配置里少了 `{needle}`:\n{yml}");
        }
        // path 指向 data\grafana\dashboards(不是 provisioning 那层),且是正斜杠
        let want = format!(
            "path: {}",
            yaml_path(&data.join("grafana").join("dashboards"))
        );
        assert!(yml.contains(&want), "provider 的 path 不对,期望 `{want}`:\n{yml}");
        assert!(!yml.contains('\\'), "YAML 里不能出现反斜杠路径:\n{yml}");
        assert!(
            !yml.contains("provisioning/dashboards"),
            "path 指的是看板目录,不是 provider 自己所在的那层:\n{yml}"
        );

        // 再调一次:两个文件都必须一个字节都不变,临时文件也不能留下
        let before_json = std::fs::read_to_string(&json_path).unwrap();
        let before_yml = yml.clone();
        let again = provision_dashboards(&data).unwrap();
        assert_eq!(again, json_path);
        assert_eq!(std::fs::read_to_string(&json_path).unwrap(), before_json);
        assert_eq!(std::fs::read_to_string(&provider_path).unwrap(), before_yml);
        assert!(!tmp_sibling(&json_path).exists(), "不能留下 .tmp");
        assert!(!tmp_sibling(&provider_path).exists(), "不能留下 .tmp");
    }

    /// 看板本体要能被 `serde_json` 解回来,而且**里面引用的是真的存在的东西**:
    /// 数据源的 uid、真推了的指标、互不重叠的面板坐标。
    #[test]
    fn the_dashboard_json_is_a_grafana_dashboard_that_only_queries_metrics_we_push() {
        let data = tmp_dir("dashboard-json").join("data");
        let path = provision_dashboards(&data).unwrap();
        let text = std::fs::read_to_string(&path).unwrap();

        let v: serde_json::Value = serde_json::from_str(&text).expect("看板必须是合法 JSON");
        assert_eq!(v["uid"], "nfarms-overview");
        assert_eq!(v["title"], "NFarms HUB");
        assert_eq!(v["version"], 1);
        assert!(v["schemaVersion"].is_i64(), "缺 schemaVersion,Grafana 会当成很老的版本去迁移");
        // 默认时间范围 = 最近 6 小时
        assert_eq!(v["time"]["from"], "now-6h");
        assert_eq!(v["time"]["to"], "now");

        let panels = v["panels"].as_array().expect("panels 必须是数组");
        assert!(panels.len() >= 3, "至少要有 累计 / 速率 / 输入输出 三类面板");

        // 数据源按 **uid** 关联:写名字或者留空,导入后是一整页 "Datasource not found"
        let metrics_ds = serde_json::json!({"type": "prometheus", "uid": "nfarms-metrics"});
        let mut rects: Vec<(i64, i64, i64, i64, String)> = Vec::new();
        let mut exprs: Vec<String> = Vec::new();
        let mut panels_with_two_series = 0;

        for p in panels {
            let title = p["title"].as_str().unwrap_or("<没有标题>").to_string();
            assert_eq!(p["datasource"], metrics_ds, "panel {title} 的 datasource 不对");
            // 类型拼错(Grafana 对不认识的类型是**静默不画**)在这里挡下
            let kind = p["type"].as_str().unwrap_or("");
            assert!(
                kind == "stat" || kind == "timeseries",
                "panel {title} 的类型 {kind} 不是这两个之一"
            );

            let g = &p["gridPos"];
            let (x, y, w, h) = (
                g["x"].as_i64().unwrap(),
                g["y"].as_i64().unwrap(),
                g["w"].as_i64().unwrap(),
                g["h"].as_i64().unwrap(),
            );
            assert!(w > 0 && h > 0 && x >= 0 && y >= 0, "panel {title} 的坐标/尺寸不合理");
            assert!(x + w <= 24, "panel {title} 超出 24 列网格");
            rects.push((x, y, w, h, title.clone()));

            let targets = p["targets"].as_array().expect("targets 必须是数组");
            assert!(!targets.is_empty(), "panel {title} 一条查询都没有");
            if targets.len() >= 2 {
                panels_with_two_series += 1;
            }
            for t in targets {
                let expr = t["expr"].as_str().expect("expr 必须是字符串");
                assert!(!expr.trim().is_empty(), "panel {title} 里有空表达式");
                assert_eq!(
                    t["datasource"], metrics_ds,
                    "panel {title} 的 target 没带对数据源"
                );
                exprs.push(expr.to_string());
            }
        }

        // 面板坐标**不能重叠**:重叠的两个会互相盖住,看着像少画了一个
        for (i, a) in rects.iter().enumerate() {
            for b in &rects[i + 1..] {
                let hit = a.0 < b.0 + b.2 && b.0 < a.0 + a.2 && a.1 < b.1 + b.3 && b.1 < a.1 + a.3;
                assert!(!hit, "panel {} 和 {} 的 gridPos 重叠了", a.4, b.4);
            }
        }

        // 1) 累计:一个面板里按 kind 分两条
        assert!(
            panels_with_two_series >= 1,
            "「Token 累计」那张要按 kind 出两条,现在没有两系列的面板"
        );
        // 2) 增量速率:必须真有 rate/increase,而不是又画一遍累计
        assert!(
            exprs.iter().any(|e| e.contains("rate(") || e.contains("increase(")),
            "少了增量速率那条(rate/increase):{exprs:?}"
        );
        // 3) 输入 / 输出 各一条
        assert!(
            exprs.iter().any(|e| e.contains("kind=\"prompt\"")),
            "少了输入 token 那条:{exprs:?}"
        );
        assert!(
            exprs.iter().any(|e| e.contains("kind=\"completion\"")),
            "少了输出 token 那条:{exprs:?}"
        );
        // 查的必须是**真的推了的**那条指标
        for e in &exprs {
            assert!(
                e.contains("nfarms_tokens_total"),
                "查询里出现了程序没推过的指标:{e}"
            );
        }
        // 不存在系统指标 → 一个 panel 都不许画(空 panel 会让人以为坏了)
        for ghost in ["nfarms_cpu", "nfarms_mem", "nfarms_memory", "nfarms_disk"] {
            assert!(
                !text.contains(ghost),
                "看板里画了程序根本没推的指标 `{ghost}`"
            );
        }
    }

    // ------------------------------------------------------------ 配置生成

    #[test]
    fn ensure_configs_generates_missing_files_and_never_overwrites_existing_ones() {
        let data = tmp_dir("ensure").join("data");
        ensure_configs(&data, 19092, 19093).unwrap();

        let loki = loki_config_path(&data);
        let ini = grafana_ini_path(&data);
        let loki_text = std::fs::read_to_string(&loki).unwrap();
        let ini_text = std::fs::read_to_string(&ini).unwrap();

        // 端口和路径都落到文件里了;YAML 里的 Windows 路径是正斜杠
        assert!(loki_text.contains("http_listen_port: 19092"), "{loki_text}");
        assert!(
            loki_text.contains(&yaml_path(&data.join("loki"))),
            "path_prefix 要用正斜杠的绝对路径:\n{loki_text}"
        );
        assert!(
            !loki_text.contains(&data.join("loki").display().to_string()),
            "YAML 里不能出现反斜杠路径:\n{loki_text}"
        );
        assert!(ini_text.contains("http_port = 19093"), "{ini_text}");
        assert!(
            ini_text.contains(&data.join("grafana").display().to_string()),
            "ini 里的路径按现有那份用反斜杠:\n{ini_text}"
        );

        // 把两份都改成「用户手改过的样子」,再跑一遍:一个字都不能被动
        std::fs::write(&loki, "手改过的 loki 配置").unwrap();
        std::fs::write(&ini, "手改过的 grafana 配置").unwrap();
        ensure_configs(&data, 19092, 19093).unwrap();
        assert_eq!(std::fs::read_to_string(&loki).unwrap(), "手改过的 loki 配置");
        assert_eq!(
            std::fs::read_to_string(&ini).unwrap(),
            "手改过的 grafana 配置"
        );
        assert!(!tmp_sibling(&loki).exists(), "不能留下 .tmp");
        assert!(!tmp_sibling(&ini).exists(), "不能留下 .tmp");
    }

    #[test]
    fn generated_configs_are_byte_identical_to_the_shipped_ones() {
        // 这条断言直接拿 `dist\data\` 里**已经实测跑通**的那两份比字节。
        // 它只在开发机上跑得动(那两份配置不属于源码,别的机器/CI 上可能没有),
        // 所以没有就跳过并打印一句 —— 但不静默:跳过要说出来。
        let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
        let dist_data = manifest.join("..").join("..").join("dist").join("data");
        let shipped_loki_path = dist_data.join("loki").join("loki-config.yaml");
        let shipped_ini_path = dist_data.join("grafana").join("custom.ini");
        if !shipped_loki_path.is_file() || !shipped_ini_path.is_file() {
            eprintln!(
                "跳过 {}:没有现成的配置可对照(这个断言只在开发机上跑)",
                dist_data.display()
            );
            return;
        }
        let shipped_loki = std::fs::read_to_string(&shipped_loki_path).unwrap();
        let shipped_ini = std::fs::read_to_string(&shipped_ini_path).unwrap();

        // 先确认这两份真的读到了东西:两边都是空串时 `assert_eq!` 也会「通过」,
        // 那这条测试就成了摆设
        assert!(
            shipped_loki.len() > 400 && shipped_ini.len() > 400,
            "读到的现有配置太短,比对已失效(loki {} 字节,ini {} 字节)",
            shipped_loki.len(),
            shipped_ini.len()
        );

        // 基准目录从**现有文件里抠出来**:不用去猜开发机上的绝对路径,
        // 也不能用 canonicalize —— 它在 Windows 上会加上 `\\?\` 前缀,
        // 生成出来的字符串反而和现有那份对不上
        let base_of_loki = shipped_base(&shipped_loki, "path_prefix:", '\'');
        let base_of_ini = shipped_base(&shipped_ini, "data =", '=');

        assert_eq!(
            loki_config_yaml(&base_of_loki, 9092),
            shipped_loki,
            "生成的 loki-config.yaml 必须和 dist 里那份逐字节一致(注释、端口、引号都算)"
        );
        // **只比到 `[branding]` 之前。**
        //
        // 那一段是 `install_branding` **运行时追加**的第四段,不在 `grafana_ini`
        // 的模板里(模板只生成前三段)。而 `ensure_configs` 不覆盖已存在的文件,
        // 所以 dist 里那份一旦被 install 过就**永久**多出这一段 ——
        // 拿它和模板逐字节比会红,而红的原因不是"模板错了"。
        //
        // 直接改成「不比那一段」而不是「让模板也生成它」:品牌化是**运行时**动作
        // (用户可能永远不启 Grafana),模板里塞一段只在一部分机器上出现的配置,
        // 会让 `ensure_configs` 的语义变模糊。
        let shipped_ini_body = shipped_ini
            .split("\n[branding]")
            .next()
            .unwrap_or(&shipped_ini)
            .trim_end();
        assert_eq!(
            grafana_ini(&base_of_ini, 9093),
            shipped_ini_body,
            "生成的 custom.ini 前三段必须和 dist 里那份逐字节一致"
        );
    }

    /// 从现有配置里抠出「基准目录」:loki 的 `path_prefix: '<base>/loki'`、
    /// grafana 的 `data = <base>\grafana`,取它们的父目录。
    fn shipped_base(text: &str, needle: &str, sep: char) -> PathBuf {
        let line = text
            .lines()
            .find(|l| l.contains(needle))
            .unwrap_or_else(|| panic!("现有配置里找不到 {needle} 那行"));
        let value = line
            .split(sep)
            .nth(1)
            .unwrap_or_else(|| panic!("{needle} 那行抠不出路径:{line}"))
            .trim()
            .trim_matches('\'');
        PathBuf::from(value)
            .parent()
            .unwrap_or_else(|| panic!("{value} 没有父目录"))
            .to_path_buf()
    }

    // ------------------------------------------------------------ 品牌 logo

    /// logo 装到位、`[branding]` 接在三段后面,而且**调两次不会追加两遍**。
    #[test]
    fn install_branding_writes_the_logo_and_appends_the_section_exactly_once() {
        let root = tmp_dir("branding");
        let libs = root.join("libs");
        let data = root.join("data");
        // custom.ini 先由 ensure_configs 生成(install_branding 只追加,不自己造)
        ensure_configs(&data, 19092, 19093).unwrap();
        let ini = grafana_ini_path(&data);
        let three_sections = std::fs::read_to_string(&ini).unwrap();

        let logo = install_branding(&libs, &data).unwrap();
        assert_eq!(
            logo,
            libs.join("grafana")
                .join("public")
                .join("img")
                .join("nfarms-logo.ico")
        );
        assert_eq!(logo, branding_logo_path(&libs));
        // 落盘位置和配置里那个相对路径必须是同一个地方,否则就是一张破图
        assert!(
            logo.ends_with(Path::new(LOGO_REL_PATH)),
            "logo 落在 {} ,而 [branding] 指的是 {LOGO_REL_PATH}",
            logo.display()
        );
        assert!(BRANDING_SECTION.contains(LOGO_REL_PATH));

        // 必须是**逐字节**的应用图标:不是转码、不是裁剪过的另一份
        let bytes = std::fs::read(&logo).unwrap();
        assert_eq!(bytes, NFARMS_ICO, "写出来的不是 assets\\nfarms.ico 的字节");
        assert!(bytes.len() > 1024, "图标不该是个空壳:{} 字节", bytes.len());
        assert!(
            bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]),
            "写出去的必须还是那个 ICO 文件(前 4 字节是 ICO 的魔数)"
        );
        assert!(!tmp_sibling(&logo).exists(), "不能留下 .tmp");

        let text = std::fs::read_to_string(&ini).unwrap();
        // 已有的三段一个字都没动:新内容只是接在**原文后面**
        assert!(text.starts_with(&three_sections), "已有的三段被改动了:\n{text}");
        assert_eq!(
            &text[..three_sections.len()],
            three_sections.as_str(),
            "custom.ini 前面那段必须原样保留"
        );
        for needle in [
            "[branding]",
            "app_title = NFarms HUB",
            "login_logo = public/img/nfarms-logo.ico",
            "menu_logo = public/img/nfarms-logo.ico",
            "favicon = public/img/nfarms-logo.ico",
        ] {
            assert!(text.contains(needle), "custom.ini 里少了 `{needle}`:\n{text}");
        }
        // 上一段的最后一行不能被粘住:少一个换行会让 `[analytics]` 那个键的值
        // 变成 `false[branding]`,于是整段配置作废
        assert!(text.contains("\n[branding]"), "段名前面必须有换行:\n{text}");
        assert!(!text.contains("false[branding]"), "上一段被粘住了:\n{text}");

        // 再装一次:内容必须一模一样(幂等),而且只有一段 [branding]
        let before = text.clone();
        let again = install_branding(&libs, &data).unwrap();
        assert_eq!(again, logo);
        assert_eq!(
            std::fs::read_to_string(&ini).unwrap(),
            before,
            "第二次 install 改了 custom.ini"
        );
        assert_eq!(before.matches("[branding]").count(), 1, "追加了两遍:\n{before}");
        assert_eq!(std::fs::read(&logo).unwrap(), NFARMS_ICO);
        assert!(!tmp_sibling(&ini).exists(), "不能留下 .tmp");
    }

    /// `custom.ini` 不在时**报错,而不是自己造一份**:造出来的那份没有
    /// `[server] http_port`,Grafana 会退回默认的 3000,而健康检查还在打 9093 ——
    /// 症状是「Grafana 永远起不来」,原因却是一份缺了半段的配置。
    #[test]
    fn install_branding_refuses_without_a_custom_ini_instead_of_writing_a_half_config() {
        let root = tmp_dir("branding-no-ini");
        let libs = root.join("libs");
        let data = root.join("data");

        let e = install_branding(&libs, &data).unwrap_err();
        assert!(e.contains("ensure_configs"), "报错要指向该先调谁:{e}");
        assert!(
            !grafana_ini_path(&data).exists(),
            "宁可报错也不能生成一份缺 [server] 的 custom.ini"
        );
        // logo 那一步不依赖 ini,已经落好了 —— 这是有意的:补上 ini 再跑一次即可
        assert_eq!(std::fs::read(branding_logo_path(&libs)).unwrap(), NFARMS_ICO);
    }

    /// 用户手写的 `[branding]` 不能被动:两个同名段里后一个会盖掉前一个,
    /// 而用户的标题/图是用户自己的事(和 ensure_configs 不覆盖已有配置同一立场)。
    #[test]
    fn install_branding_keeps_a_hand_written_branding_section() {
        let root = tmp_dir("branding-user");
        let libs = root.join("libs");
        let data = root.join("data");
        ensure_configs(&data, 19092, 19093).unwrap();
        let ini = grafana_ini_path(&data);

        let mine = format!(
            "{}\n[branding]\napp_title = 我自己起的名字\n",
            std::fs::read_to_string(&ini).unwrap()
        );
        std::fs::write(&ini, &mine).unwrap();

        install_branding(&libs, &data).unwrap();
        assert_eq!(
            std::fs::read_to_string(&ini).unwrap(),
            mine,
            "用户写的 [branding] 被覆盖/重复追加了"
        );
        assert_eq!(mine.matches("[branding]").count(), 1);
    }

    /// 「已经有了」的判定:段名大小写不敏感、前后空白不算,但**注释掉的段名不算**
    /// (否则被注释掉的那一行会让这一段永远装不上,而用户看不到任何报错)。
    #[test]
    fn branding_section_detection_ignores_comments_and_case() {
        assert!(has_branding_section("[server]\nhttp_port = 9093\n\n[BRANDING]\nx = y\n"));
        assert!(has_branding_section("  [branding]  \n"));
        assert!(!has_branding_section("[server]\nhttp_port = 9093\n"));
        assert!(
            !has_branding_section("[analytics]\n; [branding]\ncheck_for_updates = false\n"),
            "注释里的 [branding] 不算数"
        );
        assert!(
            !has_branding_section("[analytics]\n# [branding]\n"),
            "`#` 注释也不算数"
        );
    }
}