feat(all): 源精度命名体系、工作流可观测台、节点停用管理与白名单归档

核心变更:

  1. GridAxisValue 源精度命名
     - 新增 GridAxisValue 类型,携带 f64 数值 + YAML 源书写文本(Deref 透明兼容算术)
     - config.rs 绕过 serde_yaml 归一化,逐 token 捕获轴值原文(logg: 5.0 → g5.0)
     - runner/executor/scheduler 全链路改用 DB TEXT 列权威 point_name,
       修复 REAL 列回读丢精度导致的 model_name 错配

  2. 工作流执行可观测台
     - 新增 stats/progress/points 三组 API(进度时间序列、经验速率 ETA、
       停滞预警、逐点明细分页、收敛性热力图数据)
     - 新增 workflow_progress_snapshots 表 + tasks/grid_points 耗时列
     - runner 携带 last_iter/worst_depth/n_depths 进 conv.json
     - 前端新增 hash 路由、工作流详情页(概览/网格点/收敛分析三 Tab)、YAML 编辑器

  3. 节点停用/启用管理
     - 新增 disabled 状态 + disable/enable API;停用节点保持心跳但停止分发,
       worker 空闲待命而非退出;移除 revoke API,token 失效统一走重发覆盖;
       移除 host_name 字段

  4. 白名单结果归档
     - 新增 result_filter 模块,只归档有语义产物,丢弃 Tlusty 中间单元(~2MB/模型)
     - executor 原子写入归档 + 200 点 LRU 上限

  5. 历史数据导入
     - sync_seeds 重写为 import_results:经 /admin/import_seed 标记 converged +
       按新版命名迁移产物树

  6. 部署与目录重规划
     - data/results→seeds、data/archive→result + migrate_data_dirs.sh
     - deploy.sh 增强(SSH 复用、Profile、远程 env);Dockerfile 瘦身

  7. 文档同步更新 api/database/architecture/deployment
This commit is contained in:
fmq
2026-07-31 01:34:05 +08:00
parent b91f1e4fa5
commit 1bfa240cb0
73 changed files with 12332 additions and 1608 deletions
+7 -2
View File
@@ -38,8 +38,8 @@ DCTS_PORT=8090
# 任务队列数据库文件路径 (MQ SQLite 数据库)
# DCTS_QUEUE_DB_PATH=data/dcts_queue.db
# 网格模型计算结果文件保存根目录
# DCTS_RESULTS_DIR=data/results
# server 端种子库目录(node 上报/导入的收敛种子 .7+conv.json 落盘于此,供远程 node 下载热启动)
# DCTS_SEEDS_DIR=data/seeds
# 数据库自动备份目录(每日备份 + 7 天保留期自动清理)。默认 data/backups。
# 注意:生产部署建议与 DCTS_DB_PATH 位于同一持久化卷,避免备份落到临时层。
@@ -64,3 +64,8 @@ DCTS_SERVER_URL=http://127.0.0.1:8090
# 节点计算 Worker Slot 隔离工作沙盒目录 (默认: data/work)
# DCTS_WORK_DIR=data/work
# node 端完整计算结果归档目录:任务上报成功后、沙盒清理前,把完整科学产物
# .spec/.cont/.iden/.7/各阶段快照/.6/.err/.log/conv.json 等)拷贝至此。
# 超过 200 个网格点子目录时按 LRU 删最旧。旧名 DCTS_ARCHIVE_DIR 向后兼容回退。
# DCTS_RESULT_DIR=data/result
+2 -6
View File
@@ -1,13 +1,9 @@
# Generated by Cargo & DCTS
# Rust Cargo build artifacts
/target/
.env
*.env
# Database files
/data/*.db
/data/*.db-wal
/data/*.db-shm
/data/dcts.db*
/data/dcts_queue.db*
/data/
# Dynamic computation outputs & execution sandboxes
/data/results/
+207
View File
@@ -0,0 +1,207 @@
{
"version": "1.0.0",
"lastScanned": 1785390240373,
"projectRoot": "/home/fmq/program/tlusty/tl208-s54/dcts",
"techStack": {
"languages": [
{
"name": "Rust",
"version": null,
"confidence": "high",
"markers": [
"Cargo.toml"
]
}
],
"frameworks": [
{
"name": "axum",
"version": null,
"category": "backend"
}
],
"packageManager": "cargo",
"runtime": null
},
"build": {
"buildCommand": "cargo build",
"testCommand": "cargo test",
"lintCommand": "cargo clippy",
"devCommand": "cargo run",
"scripts": {}
},
"conventions": {
"namingStyle": null,
"importStyle": null,
"testPattern": null,
"fileOrganization": null
},
"structure": {
"isMonorepo": false,
"workspaces": [],
"mainDirectories": [
"assets",
"docs",
"scripts"
],
"gitBranches": {
"defaultBranch": "main",
"branchingStrategy": null
}
},
"customNotes": [],
"directoryMap": {
"assets": {
"path": "assets",
"purpose": "Static assets",
"fileCount": 3,
"lastAccessed": 1785390240368,
"keyFiles": [
"gfVIS99.dat",
"synspec_static",
"tlusty_static"
]
},
"crates": {
"path": "crates",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1785390240369,
"keyFiles": []
},
"dashboard": {
"path": "dashboard",
"purpose": null,
"fileCount": 5,
"lastAccessed": 1785390240369,
"keyFiles": [
"index.html",
"package-lock.json",
"package.json",
"vite.config.js"
]
},
"data": {
"path": "data",
"purpose": "Data files",
"fileCount": 6,
"lastAccessed": 1785390240370,
"keyFiles": [
"dcts.db",
"dcts.db-shm",
"dcts.db-wal",
"dcts_queue.db",
"dcts_queue.db-shm"
]
},
"deploy.env.d": {
"path": "deploy.env.d",
"purpose": null,
"fileCount": 7,
"lastAccessed": 1785390240370,
"keyFiles": [
"node-dckj-linux-01.env",
"node-dckj-win-01.env",
"node-huawei-linux-01.env",
"node-local-docker.env",
"node.env.example"
]
},
"docs": {
"path": "docs",
"purpose": "Documentation",
"fileCount": 9,
"lastAccessed": 1785390240370,
"keyFiles": [
"EXPERIENCE.md",
"PIPELINE.md",
"api.md",
"architecture.md",
"contributing.md"
]
},
"scripts": {
"path": "scripts",
"purpose": "Build/utility scripts",
"fileCount": 3,
"lastAccessed": 1785390240370,
"keyFiles": [
"deploy.sh",
"migrate_data_dirs.sh",
"push_import_results.sh"
]
},
"target": {
"path": "target",
"purpose": null,
"fileCount": 2,
"lastAccessed": 1785390240370,
"keyFiles": [
"CACHEDIR.TAG"
]
},
"tools": {
"path": "tools",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1785390240370,
"keyFiles": []
},
"workflows": {
"path": "workflows",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1785390240371,
"keyFiles": [
"sdB_cno.yaml"
]
},
"assets/data": {
"path": "assets/data",
"purpose": "Data files",
"fileCount": 132,
"lastAccessed": 1785390240372,
"keyFiles": [
"CIA_H2H.dat",
"CIA_H2H2.dat",
"CIA_H2He.dat"
]
},
"dashboard/dist": {
"path": "dashboard/dist",
"purpose": "Distribution/build output",
"fileCount": 1,
"lastAccessed": 1785390240372,
"keyFiles": [
"index.html"
]
},
"dashboard/node_modules": {
"path": "dashboard/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1785390240372,
"keyFiles": []
},
"dashboard/src": {
"path": "dashboard/src",
"purpose": "Source code",
"fileCount": 4,
"lastAccessed": 1785390240373,
"keyFiles": [
"api.js",
"main.js",
"state.js"
]
},
"data/seeds": {
"path": "data/seeds",
"purpose": "Database seeds",
"fileCount": 0,
"lastAccessed": 1785390240373,
"keyFiles": []
}
},
"hotPaths": [],
"userDirectives": []
}
@@ -0,0 +1,8 @@
{
"session_id": "865f1115-942b-4cc4-81f6-17a611773eb2",
"ended_at": "2026-07-30T05:44:26.934Z",
"reason": "prompt_input_exit",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}
Generated
+18 -48
View File
@@ -563,16 +563,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30"
dependencies = [
"rustix 0.38.44",
"windows-targets",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -907,6 +897,22 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "import_results"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"common",
"regex",
"reqwest",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -969,12 +975,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -1110,7 +1110,6 @@ dependencies = [
"clap",
"common",
"dotenvy",
"gethostname",
"mq",
"reqwest",
"serde",
@@ -1477,19 +1476,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
@@ -1499,7 +1485,7 @@ dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.12.1",
"linux-raw-sys",
"windows-sys 0.61.2",
]
@@ -1814,22 +1800,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_seeds"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"common",
"reqwest",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -1895,7 +1865,7 @@ dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"rustix",
"windows-sys 0.61.2",
]
+1 -1
View File
@@ -5,7 +5,7 @@ members = [
"crates/mq",
"crates/server",
"crates/node",
"tools/sync_seeds",
"tools/import_results",
]
[workspace.dependencies]
+9 -9
View File
@@ -20,25 +20,22 @@ COPY assets/tlusty_static assets/synspec_static ./assets/
RUN cargo build --release -p node && \
cp /app/target/release/node /usr/local/bin/dcts-node
# ─── Stage 2: Fortran 运行环境镜像 ──────────────────────────────────────────
# ─── Stage 2: 精简 Debian 运行环境镜像 (包含完整 glibc + libmvec + libgfortran)
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
gfortran \
liblapack-dev \
libblas-dev \
libgfortran5 \
ca-certificates \
curl \
procps \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/*
# 创建非 root 账号管理运行
RUN groupadd -g 65532 dcts && useradd -u 65532 -g dcts -s /bin/bash dcts
WORKDIR /app
COPY --from=node-builder /usr/local/bin/dcts-node /app/
RUN mkdir -p /app/data/runtime /app/data/work /app/logs /app/assets && \
RUN mkdir -p /app/data /app/data/logs /app/data/runtime /app/data/work /app/data/result /app/assets && \
chown -R dcts:dcts /app
USER dcts
@@ -48,8 +45,11 @@ ENV DCTS_MAX_SLOTS=4
ENV DCTS_HEARTBEAT_SEC=15
ENV DCTS_RUNTIME_DIR=/app/data/runtime
ENV DCTS_WORK_DIR=/app/data/work
# node 完整计算结果归档目录(光谱/连续谱/各阶段产物)。旧名 DCTS_ARCHIVE_DIR 向后兼容回退。
ENV DCTS_RESULT_DIR=/app/data/result
ENV DCTS_ASSETS_DIR=/app/assets
ENV LOG_DIR=/app/data/logs
VOLUME ["/app/data/work", "/app/logs"]
VOLUME ["/app/data"]
ENTRYPOINT ["/app/dcts-node"]
+9 -6
View File
@@ -34,7 +34,8 @@ ENV SKIP_DASHBOARD_BUILD=1
COPY Cargo.toml Cargo.lock ./
COPY crates/ ./crates/
COPY tools/ ./tools/
COPY assets/tlusty_static assets/synspec_static ./assets/
COPY workflows/ ./workflows/
COPY --from=frontend-builder /app/dashboard/dist ./dashboard/dist
RUN cargo build --release -p server && \
@@ -58,8 +59,9 @@ WORKDIR /app
COPY --from=backend-builder /usr/local/bin/dcts-server /app/
COPY --from=frontend-builder /app/dashboard/dist ./dashboard/dist
COPY workflows/ ./workflows/
RUN mkdir -p /app/data /app/data/results /app/logs /app/assets && \
RUN mkdir -p /app/data /app/data/logs /app/data/seeds /app/assets && \
chown -R dcts:dcts /app
USER dcts
@@ -69,15 +71,16 @@ EXPOSE 8090
ENV DCTS_PORT=8090
ENV DCTS_DB_PATH=/app/data/dcts.db
ENV DCTS_QUEUE_DB_PATH=/app/data/dcts_queue.db
ENV DCTS_RESULTS_DIR=/app/data/results
ENV DCTS_SEEDS_DIR=/app/data/seeds
ENV DCTS_BACKUP_DIR=/app/data/backups
ENV DCTS_ASSETS_DIR=/app/assets
ENV LOG_DIR=/app/data/logs
VOLUME ["/app/data", "/app/logs"]
VOLUME ["/app/data"]
# 健康检查走不走鉴权的 /healthz(启用 admin/enrollment token 后 /api/status 返回 401
# 健康检查走不走鉴权的 /healthz(启用 admin token 后 /api/status 返回 401
# 会令容器被误判不健康而反复重启)。与 docker-compose.yml 的 healthcheck 保持一致。
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD wget -q --spider http://localhost:8090/healthz || exit 1
CMD wget -q --spider http://127.0.0.1:8090/healthz || exit 1
ENTRYPOINT ["/app/dcts-server"]
+19 -17
View File
@@ -20,7 +20,7 @@ cargo build --release
编译产物位于 `target/release/`
- `server`:调度与 API 主服务端
- `node`:计算节点后台进程
- `sync_seeds`:种子同步管理工具
- `import_results`:历史计算结果导入工具(旧版单机网格结果 → 标记已完成 + 迁移产物树)
### 2. 构建前端与启动服务端 (Master & Web Dashboard)
首先编译前端全域实时网格可视图看班:
@@ -58,28 +58,31 @@ DCTS 采用**分层鉴权**模型,公网部署务必按下表配置凭据。
| 主体 | 环境变量 | 用途 | 持有方式 |
| :--- | :--- | :--- | :--- |
| **Admin** | `DCTS_ADMIN_TOKEN` | Dashboard 登录、工作流 CRUD、起停计算 | 人工,Dashboard 输入 |
| **Enrollment** | `DCTS_ENROLLMENT_TOKEN` | 节点首次注册领取专属 token | 部署脚本/人工 |
| **Node** | _(服务端自动颁发)_ | 心跳、领任务、上报、下载数据 | 节点本地 `.node_token` 文件(权限 600 |
| **Admin** | `DCTS_ADMIN_TOKEN` | Dashboard 登录、工作流 CRUD、起停计算、节点审批授权 | 人工,Dashboard 输入 |
| **Node** | _(服务端审批后自动颁发)_ | 心跳、领任务、上报、下载数据 | 节点本地 `.node_token` 文件(权限 600 |
> **兼容**:旧变量 `DCTS_AUTH_TOKEN` 仍有效,自动同时充当 Admin + Enrollment 凭据(建议迁移到上面两个独立变量)。
> **兼容**:旧变量 `DCTS_AUTH_TOKEN` 仍有效,自动回退用作 Admin 凭据(建议迁移到 `DCTS_ADMIN_TOKEN`)。
### 节点注册流程(L2
### 节点注册流程(免凭据申请 + 管理员审批
1. 启动时优先读取本地 `runtime/.node_token`;不存在则`DCTS_ENROLLMENT_TOKEN``/api/node/register`
2. 服务端注册成功后**颁发该节点专属 token**(仅返回一次,DB 只存 SHA-256 hash,节点持久化到 `.node_token`
3. 后续所有请求携带专属 token;服务端按 token 反查 `node_id` 鉴权
4. **吊销/重发**:通过 Dashboard「节点凭据管理」面板或下方管理 API 操作
1. 节点启动时优先读取本地 `runtime/.node_token`;不存在则**免凭据**向 `/api/node/register` 提交注册申请,进入 `pending_approval` 待审批状态
2. 管理员在 Dashboard「节点管理」面板点击【同意接入】后,服务端**颁发该节点专属 token**(仅返回一次,DB 只存 SHA-256 hash)。
3. 节点轮询 `/api/node/check_status` 取回专属 token 并持久化到 `.node_token`(权限 600
4. 后续所有请求携带专属 token;服务端按 token 反查 `node_id` 鉴权
5. **重发/停用**:通过 Dashboard「节点凭据管理」面板或下方管理 API 操作。
### 节点凭据管理 APIAdmin 角色)
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/admin/nodes` | 列出全部节点及凭据状态(在线/token 有效/吊销/颁发时间) |
| POST | `/api/admin/nodes/:node_id/revoke` | 吊销指定节点 token(立即失效,幂等) |
| POST | `/api/admin/nodes/:node_id/reissue` | 重新颁发 token,返回新明文(旧 token 失效) |
| GET | `/api/admin/nodes` | 列出全部节点及凭据状态(在线/token 有效/颁发时间) |
| POST | `/api/admin/nodes/:node_id/approve` | 同意待审批节点的接入申请并颁发 token |
| POST | `/api/admin/nodes/:node_id/reject` | 拒绝待审批节点的接入申请 |
| POST | `/api/admin/nodes/:node_id/reissue` | 重新颁发 token,返回新明文(旧 token 立即失效) |
| POST | `/api/admin/nodes/:node_id/disable` | 停用节点(保持在线但不再分发任务,可恢复) |
| POST | `/api/admin/nodes/:node_id/enable` | 重新启用被停用的节点 |
所有端点要求 Admin token`Authorization: Bearer <DCTS_ADMIN_TOKEN>`)。被攻陷节点持有的 node token 无权访问这些端点,因此吊销/重发始终是管理员主动行为。
所有端点要求 Admin token`Authorization: Bearer <DCTS_ADMIN_TOKEN>`)。被攻陷节点持有的 node token 无权访问这些端点,因此重发/停用始终是管理员主动行为。
### 公网部署清单
@@ -89,9 +92,8 @@ openssl rand -hex 32
```
```env
# .env(服务端 + 节点共享此文件时各自读取所需变量
# .env(服务端配置;计算节点无需任何凭据,免凭据申请后由管理员审批授权
DCTS_ADMIN_TOKEN=<强随机值>
DCTS_ENROLLMENT_TOKEN=<强随机值>
```
**TLS 反代**(推荐 Caddy,自动 HTTPS):
@@ -122,7 +124,7 @@ docker compose --profile public up -d --build
| [`node`](crates/node/README.md) | Binary | Worker 节点 Daemon 进程,负责任务抢占、自适应环境预热、计算链执行与产物汇报 | [README](crates/node/README.md) |
| [`mq`](crates/mq/README.md) | Library | 基于 SQLite 构建的高可靠事务型分布式任务队列引擎 | [README](crates/mq/README.md) |
| [`dashboard`](dashboard/index.html) | Web UI | 基于 Vite 与原生高交互前端语系创写的分层式恒星网格任务可观测可视化控表空间 | [说明详情](dashboard/package.json) |
| [`sync_seeds`](tools/sync_seeds/README.md) | Tool CLI | 离线 / 增量种子数据文件(`.7`)高效率同步工具 | [README](tools/sync_seeds/README.md) |
| [`import_results`](tools/import_results/README.md) | Tool CLI | 历史计算结果导入工具(旧版单机网格结果 → 标记已完成 + 迁移产物树) | [README](tools/import_results/README.md) |
---
+1 -1
View File
@@ -21,6 +21,6 @@ reqwest.workspace = true
tokio.workspace = true
[features]
default = ["embed-binaries"]
default = []
embed-binaries = []
+166 -43
View File
@@ -1,15 +1,143 @@
use crate::models::GridAxisValue;
use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::OnceLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridAxesConfig {
pub teff: Vec<f64>,
pub logg: Vec<f64>,
pub loghe: Vec<f64>,
pub logc: Vec<f64>,
pub logn: Vec<f64>,
pub logo: Vec<f64>,
pub teff: Vec<GridAxisValue>,
pub logg: Vec<GridAxisValue>,
pub loghe: Vec<GridAxisValue>,
pub logc: Vec<GridAxisValue>,
pub logn: Vec<GridAxisValue>,
pub logo: Vec<GridAxisValue>,
}
/// 解析单个标量 token 为 `GridAxisValue`:保留 YAML 源书写原文(`5.0`→`"5.0"`
/// `20000`→`"20000"``-2`→`"-2"`),同时取其 f64 数值。
fn axis_value_from_token(tok: &str) -> Option<GridAxisValue> {
let tok = tok.trim().trim_matches(',');
// 允许的标量形式:可选符号 + 数字(含小数、科学计数)。剔除引号/非法字符。
if tok.is_empty() {
return None;
}
let cleaned = tok.trim_matches(|c| c == '"' || c == '\'');
if cleaned.parse::<f64>().is_ok() {
Some(GridAxisValue::from_text(cleaned))
} else {
None
}
}
/// 从 YAML 文本中按轴名提取**源精度原文 token**,构造 `GridAxesConfig`。
///
/// # 为什么需要它
/// `serde_yaml` 的公开 API 在解析时会把纯标量 `5.0` 解析为 `visit_f64(5.0)`、`20000`
/// 解析为 `visit_i64`**丢失原始书写文本**——而 `model_name()` 必须严格忠于源精度
/// `logg: 5.0` → `g5.0`,不是 `g5`),否则与旧版 Python `gen_input5.model_name`
/// 对不上、迁移失败。本函数直接扫 YAML 文本的 `grid:` 块,逐 token 捕获原文,绕过
/// serde_yaml 的类型归一化。
///
/// 支持两种块格式(与 run_grid.py / DCTS 配置一致):
/// - 流式:`logg: [5.0, 6.0]`
/// - 块式:`logg:\n - 5.0\n - 6.0`
fn parse_grid_axes_raw(yaml: &str) -> Option<GridAxesConfig> {
let axes_re = {
static RE: OnceLock<Regex> = OnceLock::new();
// 捕获轴名与该行 `key:` 之后的内容(流式 `[...]` 或空),供后续按行解析块式。
RE.get_or_init(|| Regex::new(r"^\s*(teff|logg|loghe|logc|logn|logo)\s*:\s*(.*)$").unwrap())
};
let mut axes: std::collections::HashMap<&str, Vec<GridAxisValue>> =
std::collections::HashMap::new();
let mut current_axis: Option<&str> = None; // 块式 `- value` 续行归属
let lines: Vec<&str> = yaml.lines().collect();
// 是否已进入 `grid:` 块(grid 块之外的同名键不误捕,尽管实际不会重名)。
let mut in_grid = false;
for (i, raw) in lines.iter().enumerate() {
let line = raw.split('#').next().unwrap_or("").trim_end();
let stripped = line.trim_start();
if stripped.is_empty() {
continue;
}
// 顶层键:grid / 其它。仅顶层(无缩进)键切换 in_grid。
if !raw.starts_with(' ') && !raw.starts_with('\t') {
in_grid = stripped.starts_with("grid:");
current_axis = None;
continue;
}
if !in_grid {
continue;
}
// 块式列表续行:` - 5.0`
if let Some(rest) = stripped.strip_prefix("- ") {
if let Some(axis) = current_axis {
if let Some(v) = axis_value_from_token(rest) {
axes.entry(axis).or_default().push(v);
}
}
continue;
}
// 轴定义行:` logg: [5.0, 6.0]` 或 ` logg:`(块式,值在下几行)
if let Some(caps) = axes_re.captures(line) {
let axis = caps.get(1).unwrap().as_str();
let tail = caps.get(2).unwrap().as_str().trim();
current_axis = Some(axis);
if tail.starts_with('[') {
// 流式:`[5.0, 6.0]` —— 可能在单行内闭合,也可能跨行(本配置不会跨行)。
let inner = tail.trim_start_matches('[').split(']').next().unwrap_or("");
for tok in inner.split(',') {
if let Some(v) = axis_value_from_token(tok) {
axes.entry(axis).or_default().push(v);
}
}
current_axis = None; // 流式在本行闭合,不再续行
}
// tail 为空 → 块式,等后续 `- value` 行(current_axis 已设)
}
// 跨行未闭合的流式列表(grid 块极少如此)不单独处理;YAML 规范允许但本配置不使用。
let _ = i;
}
// 六轴齐全才算解析成功;任一缺失回退 None(调用方走纯 serde 数值路径)。
let get = |k: &str| axes.get(k).cloned().filter(|v| !v.is_empty());
Some(GridAxesConfig {
teff: get("teff")?,
logg: get("logg")?,
loghe: get("loghe")?,
logc: get("logc")?,
logn: get("logn")?,
logo: get("logo")?,
})
}
impl GridConfig {
pub fn load_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
GridConfig::from_yaml_str(&content)
.with_context(|| format!("Failed to parse YAML config: {}", path.display()))
}
/// 解析 YAML 配置,**优先用源精度原文重建 grid 轴**。
///
/// 两步:
/// 1. `serde_yaml` 解析整个 `GridConfig`chain/synspec 等正常字段,grid 轴为数值)。
/// 2. 若能从原文捕到六轴 token,用源精度 `GridAxisValue` 覆盖 grid 字段;
/// 捕不到(格式异常)则保留 serde 数值结果(命名精度回退,不阻断解析)。
pub fn from_yaml_str(yaml: &str) -> Result<Self> {
let mut cfg: GridConfig = serde_yaml::from_str(yaml)?;
if let Some(raw_axes) = parse_grid_axes_raw(yaml) {
cfg.grid = raw_axes;
}
Ok(cfg)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -99,6 +227,11 @@ pub struct GridConfig {
pub resume: bool,
#[serde(default = "default_true")]
pub seed_step_fallback: bool,
/// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以
/// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。仅因 `deny_unknown_fields`
/// 必须能解析而保留。workflow YAML 里仍可写(如 `results: data/seeds`)但被忽略。
#[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")]
#[serde(default)]
pub results: Option<String>,
#[serde(default)]
pub itek_fallback: Vec<StageConfig>,
@@ -125,22 +258,16 @@ fn default_true() -> bool {
true
}
impl GridConfig {
pub fn load_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let cfg: GridConfig = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML config: {}", path.display()))?;
Ok(cfg)
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub bind_addr: String,
pub db_path: String,
pub queue_db_path: String,
pub results_dir: String,
/// server 端种子库目录:node 上报/历史导入收敛后,落地 `<name>/conv.json` +
/// `<name>/<name>.7` 于此,供 `download_seed` 端点给远程 node 热启动下载。
/// **永不清理**(种子是 SeedStep 热启动的必需资源,删除会导致已收敛点重算)。
/// 默认 "data/seeds",可经 DCTS_SEEDS_DIR 覆盖(回退读旧 DCTS_RESULTS_DIR)。
pub seeds_dir: String,
/// 数据库备份目录(每日自动备份落盘位置)。默认 "data/backups",可经 DCTS_BACKUP_DIR 覆盖。
pub backup_dir: String,
pub grid_config: String,
@@ -152,9 +279,6 @@ pub struct ServerConfig {
/// Admin 凭据(Dashboard 登录用)。优先 DCTS_ADMIN_TOKEN,回退旧变量 DCTS_AUTH_TOKEN。
#[serde(default)]
pub admin_token: Option<String>,
/// 兼容字段:保留以判断「是否启用鉴权」与旧中间件逻辑。取 admin_token 的值。
#[serde(default)]
pub auth_token: Option<String>,
/// 应急开关:DCTS_AUTH_DISABLE=1 时跳过全部鉴权(仅本地调试,默认关闭)。
#[serde(default)]
pub auth_disabled: bool,
@@ -167,7 +291,7 @@ impl std::fmt::Debug for ServerConfig {
.field("bind_addr", &self.bind_addr)
.field("db_path", &self.db_path)
.field("queue_db_path", &self.queue_db_path)
.field("results_dir", &self.results_dir)
.field("seeds_dir", &self.seeds_dir)
.field("backup_dir", &self.backup_dir)
.field("grid_config", &self.grid_config)
.field("stale_sec", &self.stale_sec)
@@ -178,10 +302,6 @@ impl std::fmt::Debug for ServerConfig {
"admin_token",
&self.admin_token.as_ref().map(|_| "***REDACTED***"),
)
.field(
"auth_token",
&self.auth_token.as_ref().map(|_| "***REDACTED***"),
)
.field("auth_disabled", &self.auth_disabled)
.finish()
}
@@ -200,8 +320,10 @@ impl Default for ServerConfig {
let db_path = std::env::var("DCTS_DB_PATH").unwrap_or_else(|_| "data/dcts.db".to_string());
let queue_db_path = std::env::var("DCTS_QUEUE_DB_PATH")
.unwrap_or_else(|_| "data/dcts_queue.db".to_string());
let results_dir =
std::env::var("DCTS_RESULTS_DIR").unwrap_or_else(|_| "data/results".to_string());
// 种子库目录:优先 DCTS_SEEDS_DIR,回退旧 DCTS_RESULTS_DIR(已弃用,保留兼容)。
let seeds_dir = std::env::var("DCTS_SEEDS_DIR")
.or_else(|_| std::env::var("DCTS_RESULTS_DIR"))
.unwrap_or_else(|_| "data/seeds".to_string());
let backup_dir =
std::env::var("DCTS_BACKUP_DIR").unwrap_or_else(|_| "data/backups".to_string());
let grid_config = std::env::var("DCTS_GRID_CONFIG")
@@ -226,13 +348,10 @@ impl Default for ServerConfig {
.ok()
.filter(|s| !s.is_empty())
.or_else(|| legacy_token.clone());
if legacy_token.is_some()
&& (std::env::var("DCTS_ADMIN_TOKEN").is_err()
|| std::env::var("DCTS_ENROLLMENT_TOKEN").is_err())
{
if legacy_token.is_some() && std::env::var("DCTS_ADMIN_TOKEN").is_err() {
tracing::warn!(
"检测到旧的 DCTS_AUTH_TOKEN,已自动用作 admin/enrollment 凭据。\
建议迁移到 DCTS_ADMIN_TOKEN(管理)与 DCTS_ENROLLMENT_TOKEN(节点注册)"
"检测到旧的 DCTS_AUTH_TOKEN,已自动回退用作 admin 凭据。\
建议迁移到 DCTS_ADMIN_TOKEN"
);
}
@@ -245,19 +364,11 @@ impl Default for ServerConfig {
);
}
// auth_token 兼容字段:用于 main.rs 判断「是否启用鉴权中间件」。
// 启用条件 = 显式配置了 admin 或 enrollment 凭据,且未应急关闭。
let auth_token = if auth_disabled {
None
} else {
admin_token.clone()
};
Self {
bind_addr: format!("0.0.0.0:{}", port),
db_path,
queue_db_path,
results_dir,
seeds_dir,
backup_dir,
grid_config,
stale_sec,
@@ -265,7 +376,6 @@ impl Default for ServerConfig {
mq_type,
rabbitmq_url,
admin_token,
auth_token,
auth_disabled,
}
}
@@ -278,6 +388,13 @@ pub struct NodeConfig {
pub max_slots: usize,
pub runtime_dir: String,
pub work_dir: String,
/// node 端完整计算结果归档目录:任务上报成功后、沙盒清理前,把完整科学产物
/// .spec/.cont/.iden/.7/各阶段快照/.6/.err/.log/conv.json 等)拷贝至此,
/// 避免随沙盒删除而丢失。与 server 的 `seeds` 目录区分:此处存的是**完整产物**
/// (光谱/连续谱/各阶段大气快照等),seeds 只存最小种子集(.7+conv.json)。
/// 默认 "data/result",可经 DCTS_RESULT_DIR 覆盖(回退读旧 DCTS_ARCHIVE_DIR)。
/// 超过 MAX_RESULT_MODELS 个网格点子目录时按 LRU 删除最旧的。
pub result_dir: String,
pub heartbeat_sec: u64,
}
@@ -289,6 +406,7 @@ impl std::fmt::Debug for NodeConfig {
.field("max_slots", &self.max_slots)
.field("runtime_dir", &self.runtime_dir)
.field("work_dir", &self.work_dir)
.field("result_dir", &self.result_dir)
.field("heartbeat_sec", &self.heartbeat_sec)
.finish()
}
@@ -318,6 +436,10 @@ impl Default for NodeConfig {
let runtime_dir =
std::env::var("DCTS_RUNTIME_DIR").unwrap_or_else(|_| "data/runtime".to_string());
let work_dir = std::env::var("DCTS_WORK_DIR").unwrap_or_else(|_| "data/work".to_string());
// 结果归档目录:优先 DCTS_RESULT_DIR,回退旧 DCTS_ARCHIVE_DIR(已弃用,保留兼容)。
let result_dir = std::env::var("DCTS_RESULT_DIR")
.or_else(|_| std::env::var("DCTS_ARCHIVE_DIR"))
.unwrap_or_else(|_| "data/result".to_string());
let heartbeat_sec = std::env::var("DCTS_HEARTBEAT_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
@@ -329,6 +451,7 @@ impl Default for NodeConfig {
max_slots,
runtime_dir,
work_dir,
result_dir,
heartbeat_sec,
}
}
+10 -10
View File
@@ -197,20 +197,20 @@ pub fn make_input5(
// Atoms block
let mut atom_rows: Vec<(i32, String)> = vec![
(2, "0.".to_string()), // 1 H
(2, fmt_abn(params.loghe)), // 2 He
(2, fmt_abn(*params.loghe)), // 2 He
(0, "0.".to_string()), // 3 Li
(0, "0.".to_string()), // 4 Be
(0, "0.".to_string()), // 5 B
];
if has_c {
atom_rows.push((2, fmt_abn(params.logc))); // 6 C
atom_rows.push((2, fmt_abn(*params.logc))); // 6 C
}
if has_n {
atom_rows.push((2, fmt_abn(params.logn))); // 7 N
atom_rows.push((2, fmt_abn(*params.logn))); // 7 N
}
if has_o {
atom_rows.push((2, fmt_abn(params.logo))); // 8 O
atom_rows.push((2, fmt_abn(*params.logo))); // 8 O
}
let natoms =
@@ -271,12 +271,12 @@ mod tests {
#[test]
fn test_make_input5() {
let params = GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let input5 = make_input5(&params, "F", "F", "cno", 100);
assert!(input5.contains("35000.0 5.5"));
+1
View File
@@ -6,5 +6,6 @@ pub mod gen_input5;
pub mod logging;
pub mod models;
pub mod nst_writer;
pub mod result_filter;
pub mod runner;
pub mod seed_finder;
+401 -32
View File
@@ -1,19 +1,122 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::ops::Deref;
use uuid::Uuid;
/// 6D grid point parameter specification
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GridPointParams {
pub teff: f64,
pub logg: f64,
pub loghe: f64,
pub logc: f64,
pub logn: f64,
pub logo: f64,
/// 网格轴值:同时携带**数值**(供算术/排序/DB REAL 列)与**源书写文本**(供命名)。
///
/// # 为什么需要它
/// `f64` 无法区分 YAML 里 `5.0`(带小数)与 `5`(省略小数)——两者解析后都是 `5.0_f64`。
/// 而 `model_name()` 必须严格忠于源精度("配置里多少位小数就多少位"),否则 Rust 名
/// (`t20000_g5_...`) 与 Python `gen_input5.model_name` 名 (`t20000_g5.0_...`) 对不上,
/// 旧版单机网格数据无法迁移到本系统。
///
/// 本类型在反序列化时通过 `deserialize_any` 的 `visit_str` 捕获 YAML/JSON 标量原文,
/// 把它一路携带到 `model_name()` 直接拼接;算术则通过 `Deref<Target = f64>` 透明转发到数值,
/// 绝大多数 `params.teff` 算术调用点无需改动。
///
/// # serde 行为
/// - **反序列化**:优先抓原文(`5.0`→text=`"5.0"``20000`→text=`"20000"`);纯数值来源
/// DB REAL 列回读、JSON 数值 payload)无原文时用数值兜底文本(仅占位,该路径应使用
/// 存储的 `name` 列而非 `model_name()` 重推)。
/// - **序列化**:输出为纯 `f64` 数值,保证 JSON payload / 旧消费者无感。
#[derive(Debug, Clone, PartialEq)]
pub struct GridAxisValue {
value: f64,
text: Box<str>,
}
fn fmt_num(val: f64) -> String {
impl GridAxisValue {
/// 构造:用给定数值,文本由数值生成(兜底路径:DB 回读 / 程序内构造)。
pub fn from_value(value: f64) -> Self {
let text = format_float_minimal(value).into();
Self { value, text }
}
/// 构造:用给定文本,数值由文本解析(反序列化主路径,信任源精度)。
pub(crate) fn from_text(text: &str) -> Self {
let value = text.parse::<f64>().unwrap_or(f64::NAN);
Self {
value,
text: text.into(),
}
}
/// 数值(算术用)。
pub fn value(&self) -> f64 {
self.value
}
/// 源书写文本(命名用)。
pub fn text(&self) -> &str {
&self.text
}
}
/// `Deref` 到 `f64`:所有 `params.teff` 形式的算术调用点(排序、量化、求和等)
/// 无需显式 `.value()` 即可继续工作,把跨 crate 改动量压到最低。
impl Deref for GridAxisValue {
type Target = f64;
fn deref(&self) -> &f64 {
&self.value
}
}
/// 排序委托数值,保证调度排序语义不变。
impl PartialOrd for GridAxisValue {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.value.partial_cmp(&other.value)
}
}
/// 算术运算全部委托内部 f64,使本类型在数值计算上与 f64 完全等价。
/// 结果一律退化为纯 f64——轴值只在其作为网格轴字段时才需保留源文本,
/// 参与运算后的中间结果无需再保留精度(也不会用于命名)。
/// 借用场景(如 `&GridPointParams` 字段相减)请用 `.value()` 显式取值。
macro_rules! impl_arith {
($trait:ident, $method:ident) => {
impl std::ops::$trait for GridAxisValue {
type Output = f64;
fn $method(self, rhs: Self) -> f64 {
self.value.$method(rhs.value)
}
}
impl std::ops::$trait<f64> for GridAxisValue {
type Output = f64;
fn $method(self, rhs: f64) -> f64 {
self.value.$method(rhs)
}
}
};
}
impl_arith!(Add, add);
impl_arith!(Sub, sub);
impl_arith!(Mul, mul);
impl_arith!(Div, div);
/// `Display` 委托数值(用于 `format!("{}", params.x)` 这类数值展示场景,
/// 如 gen_input5 的 `{:.1f}` teff/logg 格式化)。命名场景请用 `.text()`。
impl std::fmt::Display for GridAxisValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.value, f)
}
}
impl From<f64> for GridAxisValue {
fn from(value: f64) -> Self {
Self::from_value(value)
}
}
impl From<i32> for GridAxisValue {
fn from(value: i32) -> Self {
Self::from_value(value as f64)
}
}
/// 把 f64 格式化为最简可解析文本(兜底路径用;源精度路径不走这里)。
/// 整数取整无小数,非整数去尾零。
fn format_float_minimal(val: f64) -> String {
let rounded = (val * 1e6).round() / 1e6;
if (rounded - rounded.round()).abs() < 1e-6 {
format!("{:.0}", rounded.round())
@@ -25,17 +128,85 @@ fn fmt_num(val: f64) -> String {
}
}
impl Serialize for GridAxisValue {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_f64(self.value)
}
}
impl<'de> Deserialize<'de> for GridAxisValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct AxisVisitor;
impl<'de> serde::de::Visitor<'de> for AxisVisitor {
type Value = GridAxisValue;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a numeric grid axis value")
}
// 主路径:YAML/JSON 标量原文(`5.0`、`20000`、`-2`、`"-4"`)。
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(GridAxisValue::from_text(v))
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
Ok(GridAxisValue::from_text(&v))
}
// 兜底路径:纯数值来源(无原文)。先尝试解析原 deserializer 文本不可得,
// 这里只能从数值反推文本(占位;该路径应用存储的 name 列)。
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
Ok(GridAxisValue::from_value(v))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
// 整数来源:文本取整无损(如 JSON 里 `20000`),保留无小数形式。
Ok(GridAxisValue::from_text(&v.to_string()))
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(GridAxisValue::from_text(&v.to_string()))
}
}
// deserialize_anyserde_yaml 的标量会先尝试以 str 形式投递给 visit_str
// 从而捕获原文;纯数值 deserializer(如 serde_json 的 f64 字段)会走数值分支。
deserializer.deserialize_any(AxisVisitor)
}
}
/// 6D grid point parameter specification
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GridPointParams {
pub teff: GridAxisValue,
pub logg: GridAxisValue,
pub loghe: GridAxisValue,
pub logc: GridAxisValue,
pub logn: GridAxisValue,
pub logo: GridAxisValue,
}
impl GridPointParams {
/// Generates canonical model name string e.g. "t35000_g5.5_he-1_c-2_n-2_o-2"
/// 生成规范模型名,**严格忠于各轴的源书写精度**:直接拼接 YAML 原文。
///
/// 例:YAML 中 `teff: [20000]`、`logg: [5.0]`、丰度 `[-2]` → `t20000_g5.0_he-2_c-2_n-2_o-2`。
/// 这与旧版 Python `gen_input5.model_name` 逐字符一致,保证旧数据可迁移。
pub fn model_name(&self) -> String {
format!(
"t{}_g{}_he{}_c{}_n{}_o{}",
fmt_num(self.teff),
fmt_num(self.logg),
fmt_num(self.loghe),
fmt_num(self.logc),
fmt_num(self.logn),
fmt_num(self.logo)
self.teff.text(),
self.logg.text(),
self.loghe.text(),
self.logc.text(),
self.logn.text(),
self.logo.text(),
)
}
@@ -44,7 +215,7 @@ impl GridPointParams {
/// 注:此数值专门用于网格调度中的 Wave 难度分级与保序分组(对数和越小代表重元素丰度越低,
/// 通常在大气模型计算中更容易收敛,作为冷启动基准)。
pub fn cno_sum(&self) -> f64 {
self.logc + self.logn + self.logo
*self.logc + *self.logn + *self.logo
}
}
@@ -109,6 +280,10 @@ pub struct TaskSpec {
/// 旧数据反序列化时缺省为 None。
#[serde(default)]
pub workflow_name: Option<String>,
/// 网格点所属 wave(难度分批),用于队列内按难度优先出队,恢复"先易后难积累种子"语义。
/// 旧 payload 反序列化时缺省为 0。
#[serde(default)]
pub wave: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -149,7 +324,6 @@ pub struct TaskReport {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeRegisterRequest {
pub node_id: String,
pub host_name: String,
pub max_slots: i32,
}
@@ -166,7 +340,6 @@ pub struct NodeHeartbeatRequest {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeInfo {
pub node_id: String,
pub host_name: String,
pub max_slots: i32,
pub active_slots: i32,
pub status: String,
@@ -197,6 +370,16 @@ pub struct StageSummary {
pub best_max_relc: Option<f64>,
pub elapsed_sec: f64,
pub note: Option<String>,
/// 末次迭代序号(fort.9 解析所得);NITER=0 的 grey 阶段为 null。
/// 收敛难度直接指标:17 次迭代收敛 vs 顶着 NITER 上限勉强收敛稳定性迥异。
#[serde(default)]
pub last_iter: Option<i32>,
/// 收敛最差的深度点编号(|maximum| 最大处),诊断定位用。
#[serde(default)]
pub worst_depth: Option<i32>,
/// 深度点总数,诊断用。
#[serde(default)]
pub n_depths: Option<usize>,
}
/// Full execution summary for a grid point
@@ -213,6 +396,9 @@ pub struct ModelSummary {
pub synspec_rc: Option<i32>,
pub synspec_error: Option<String>,
pub synspec_sec: Option<f64>,
/// 单点总墙钟耗时(秒)。极旧版 conv.json 可能缺此字段,default 0.0 兜底
/// (展示层把 ≤0 视为"无数据");现版 run_one.py 总是写入。
#[serde(default)]
pub elapsed_sec: f64,
pub note: Option<String>,
}
@@ -221,30 +407,213 @@ pub struct ModelSummary {
mod tests {
use super::*;
/// StageSummary 新增迭代诊断字段的向后兼容:
/// 旧 conv.json(无 last_iter/worst_depth/n_depths)必须能正常反序列化为 null,
/// 新写入的 conv.json 往返保真。
#[test]
fn test_stage_summary_iter_fields_backward_compat() {
let legacy = r#"{
"label": "nl", "chmax": 0.001, "lte": "F", "converged": true,
"best_max_relc": 0.0005, "elapsed_sec": 64.0, "note": null
}"#;
let st: StageSummary = serde_json::from_str(legacy).unwrap();
assert_eq!(st.last_iter, None);
assert_eq!(st.worst_depth, None);
assert_eq!(st.n_depths, None);
let full = StageSummary {
label: "nl".to_string(),
chmax: Some(0.001),
lte: "F".to_string(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 64.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
};
let round: StageSummary =
serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
assert_eq!(round.last_iter, Some(17));
assert_eq!(round.worst_depth, Some(1));
assert_eq!(round.n_depths, Some(50));
}
/// 旧版 Python run_one.py 写出的 conv.json 必须能完整解析为 ModelSummary
/// (历史结果迁移链路 import_results → /api/admin/import_seed 的兼容性命门)。
///
/// 载荷严格复刻 run_one.py 的真实输出形态:stage 含 `itek_attempts`/`final` 嵌套
/// dict、`note`、可选 `best_max_relc`,顶层含 `final_chmax`/`synspec_*`/`seed` 等。
/// Rust StageSummary 未声明的字段(itek_attempts/final)应被 serde 静默忽略。
#[test]
fn test_model_summary_parses_python_legacy_conv_json() {
let legacy = r#"{
"name": "t20000_g5.0_he-2_c-4_n-4_o-4",
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
"stages": [
{"label": "lte", "chmax": null, "lte": "T",
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0,
"note": "NITER=0 grey start (no iterations)"}],
"converged": true,
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0,
"note": "NITER=0 grey start (no iterations)"},
"best_max_relc": 0.0, "elapsed_sec": 2.1},
{"label": "nc", "chmax": null, "lte": "F",
"itek_attempts": [{"itek": null, "rc": 0, "converged": false, "max_relc": 0.957,
"worst_depth": 1, "last_iter": 10, "n_depths": 50}],
"converged": false,
"final": {"itek": null, "rc": 0, "converged": false, "max_relc": 0.957,
"worst_depth": 1, "last_iter": 10, "n_depths": 50},
"note": "accepted as seed (convergence not required)",
"best_max_relc": 0.957, "elapsed_sec": 62.4},
{"label": "nl", "chmax": null, "lte": "F",
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0069,
"worst_depth": 1, "last_iter": 17, "n_depths": 50}],
"converged": true,
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0069,
"worst_depth": 1, "last_iter": 17, "n_depths": 50},
"best_max_relc": 0.0069, "elapsed_sec": 650.2}
],
"converged": true,
"final_max_relc": 0.0069,
"final_chmax": null,
"seed": null,
"atmosphere_has_nan": false,
"synspec_rc": 0,
"synspec_sec": 3.1,
"elapsed_sec": 715.0
}"#;
let s: ModelSummary = serde_json::from_str(legacy).expect("旧版 conv.json 应可解析");
assert_eq!(s.name, "t20000_g5.0_he-2_c-4_n-4_o-4");
assert_eq!(*s.params.teff, 20000.0);
assert!(s.converged);
assert!(!s.atmosphere_has_nan);
assert_eq!(s.final_max_relc, Some(0.0069));
assert_eq!(s.elapsed_sec, 715.0);
assert_eq!(s.synspec_rc, Some(0));
assert_eq!(s.stages.len(), 3);
assert_eq!(s.stages[0].label, "lte");
assert_eq!(s.stages[0].lte, "T");
assert!(s.stages[0].converged);
assert_eq!(s.stages[2].label, "nl");
assert_eq!(s.stages[2].best_max_relc, Some(0.0069));
assert_eq!(s.stages[2].elapsed_sec, 650.2);
// 旧版把迭代诊断嵌在 final/itek_attempts 里(非扁平字段)→ 扁平字段为 None,
// 但原始诊断仍随 conv.json 原文落盘(import_seed 存原始 summary_json),无数据丢失。
assert_eq!(s.stages[2].last_iter, None);
assert_eq!(s.stages[2].worst_depth, None);
}
/// 极旧 conv.json 变体(无 elapsed_sec 字段)也应可解析(default 0.0 兜底),
/// 使历史迁移不因缺耗时字段而整点跳过。
#[test]
fn test_model_summary_tolerates_missing_elapsed() {
let minimal = r#"{
"name": "t20000_g5.0_he-2_c-4_n-4_o-4",
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
"stages": [],
"converged": true,
"final_max_relc": 0.001,
"seed": null,
"atmosphere_has_nan": false
}"#;
let s: ModelSummary = serde_json::from_str(minimal).expect("缺 elapsed_sec 应可解析");
assert_eq!(s.elapsed_sec, 0.0);
assert_eq!(s.synspec_rc, None);
assert_eq!(s.final_chmax, None);
}
#[test]
fn test_model_name_formatting_and_decimal_precision() {
let p1 = GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
assert_eq!(p1.model_name(), "t35000_g5.5_he-1_c-2_n-2_o-2");
let p2 = GridPointParams {
teff: 35000.0,
logg: 5.25,
loghe: -1.5,
logc: -2.75,
logn: -2.0,
logo: -1.25,
teff: 35000.0.into(),
logg: 5.25.into(),
loghe: (-1.5).into(),
logc: (-2.75).into(),
logn: (-2.0).into(),
logo: (-1.25).into(),
};
assert_eq!(p2.model_name(), "t35000_g5.25_he-1.5_c-2.75_n-2_o-1.25");
assert_ne!(p1.model_name(), p2.model_name());
}
/// 回归:YAML 源书写的整数小数位必须原样保留(`logg: 5.0` → `g5.0`,不是 `g5`)。
/// 这是旧版单机网格数据迁移的命门——Python `gen_input5.model_name` 用 `g{:.1f}`
/// 故 `t20000_g5.0_he-2_c-4_n-4_o-4` 才是正确名。`f64` 无法区分 `5.0` 与 `5`
/// 必须由 `GridAxisValue` 的源文本携带。
#[test]
fn test_model_name_preserves_source_decimal_precision() {
// 模拟 serde_yaml 解析 grid: { teff: [20000], logg: [5.0], loghe: [-2], ... }
// 每个轴值通过 from_textvisit_str 路径)构造,保留原文。
let p = GridPointParams {
teff: GridAxisValue::from_text("20000"),
logg: GridAxisValue::from_text("5.0"),
loghe: GridAxisValue::from_text("-2"),
logc: GridAxisValue::from_text("-4"),
logn: GridAxisValue::from_text("-4"),
logo: GridAxisValue::from_text("-4"),
};
assert_eq!(p.model_name(), "t20000_g5.0_he-2_c-4_n-4_o-4");
// 数值正确解析(算术/排序不受影响)
assert!((p.teff.value() - 20000.0).abs() < 1e-9);
assert!((p.logg.value() - 5.0).abs() < 1e-9);
assert!((p.cno_sum() - (-12.0)).abs() < 1e-9);
}
/// 端到端:`GridConfig::from_yaml_str` 解析真实 config(含 `grid:` 块),确认
/// `logg: 5.0` 的源精度原文被捕获、命名逐字符等于 Python `gen_input5.model_name`。
///
/// 这是数据迁移的命门:旧版单机网格目录名是 `t20000_g5.0_...`DCTS 必须产出同名。
/// 注:纯 `serde_yaml::from_str` 会把 `5.0` 归一化为 `visit_f64` 丢失原文,故走
/// `from_yaml_str` 的源文本捕获路径(见 config.rs `parse_grid_axes_raw`)。
#[test]
fn test_grid_axis_value_preserves_yaml_source_via_serde() {
use crate::config::GridConfig;
// 两种块格式都覆盖:流式 + 块式。
let yaml_flow = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
let yaml_block = "grid:\n teff:\n - 20000\n logg:\n - 5.0\n loghe:\n - -2\n logc:\n - -4\n logn:\n - -4\n logo:\n - -4\n";
for (label, yaml) in [("flow", yaml_flow), ("block", yaml_block)] {
let cfg =
GridConfig::from_yaml_str(yaml).unwrap_or_else(|_| panic!("{} 解析失败", label));
let pt = GridPointParams {
teff: cfg.grid.teff[0].clone(),
logg: cfg.grid.logg[0].clone(),
loghe: cfg.grid.loghe[0].clone(),
logc: cfg.grid.logc[0].clone(),
logn: cfg.grid.logn[0].clone(),
logo: cfg.grid.logo[0].clone(),
};
assert_eq!(
pt.model_name(),
"t20000_g5.0_he-2_c-4_n-4_o-4",
"{} 格式应保留源精度",
label
);
}
}
/// `Deref<Target=f64>` 让算术调用点无感:可直接比较/运算。
#[test]
fn test_grid_axis_value_deref_to_f64() {
let v: GridAxisValue = 5.0.into();
assert!((*v - 5.0).abs() < 1e-9);
assert!(*v > 4.0);
let sum = *v + 1.0_f64;
assert!((sum - 6.0).abs() < 1e-9);
}
#[test]
fn test_grid_point_status_display_and_conversion() {
assert_eq!(GridPointStatus::Pending.to_string(), "pending");
+199
View File
@@ -0,0 +1,199 @@
//! 归档白名单:决定沙盒里的哪些文件值得进入持久归档目录(`data/result/<name>/`)。
//!
//! node 端实时结果归档(`executor::save_result_artifacts`)与离线迁移工具(`import_results`
//! 共用本模块的 [`is_result_worthy`],确保两条路径的结果归档口径一致、不会随时间漂移。
//!
//! ## 设计原则
//!
//! 归档目标只保留**有语义价值的产物**,丢弃 Tlusty/Synspec 运行时产生的中间工作单元
//! `fort.1/2/3/13/14/18/22/42/44/50/57/69/82/95` 等)。旧版用「拷贝所有普通文件」的
//! catch-all 策略,把这些无语义单元也搬进了归档,浪费磁盘(每个模型约 2MB / 4.8MB)。
//!
//! ## 保留的文件名形态(`model_name` 为网格点权威名,如 `t20000_g5.0_he-2_c-4_n-4_o-4`
//!
//! 1. **裸名保留**(有独立语义,不以 model_name 为前缀):
//! `conv.json`、`fort.8`synspec 输入大气)、`fort.55`synspec 控制卡)
//! 2. **科学核心**`<name>.7`、`<name>.spec`、`<name>.cont`、`<name>.iden`、`<name>.log`
//! 3. **阶段快照**`<name>.<label>.5/.6/.err/.nst/.7`label ∈ lte/nc/nl/seed_nc
//! 4. **收敛诊断**`<name>.<label>_chmax*.9`**唯一保留的 .9**;裸 `<name>.<label>.9`
//! 已在 runner 源头停止写出,因其与 `_chmax*.9` 内容完全重复)
//!
//! 符号链接、子目录、`.tmp`、`fort.84` 及所有 Tlusty 中间单元均不在白名单内,自然被跳过。
/// 科学核心产物的文件名后缀(挂在 `<name>.` 之后,无阶段标签)。
const SCIENCE_SUFFIXES: &[&str] = &["7", "spec", "cont", "iden", "log"];
/// 阶段快照的文件名后缀(挂在 `<name>.<label>.` 之后)。
const STAGE_SNAPSHOT_SUFFIXES: &[&str] = &["5", "6", "err", "nst", "7"];
/// 合法阶段标签(来自 `default_cold_chain` / `default_seed_chain` 的 label)。
/// 阶段标签由 workflow 配置保证唯一,不会与科学后缀或 synspec 产物冲突。
///
/// **约束**:此处硬编码了默认链的 4 个标签。runner 的 `run_model_with_timeout`
/// 虽接受 `custom_chain`(可含任意 label),但当前唯一生产调用方(executor)传 `None`
/// 走默认链,故白名单覆盖安全。若将来启用自定义 chain 且引入新标签,需同步加入此处,
/// 否则带新标签的阶段快照(`.5/.6/.err/.nst/.7`)和 `_chmax*.9` 会被白名单静默丢弃。
const STAGE_LABELS: &[&str] = &["lte", "nc", "nl", "seed_nc"];
/// 有独立语义、保留的裸文件名(不以 model_name 为前缀)。
const BARE_KEEPS: &[&str] = &["conv.json", "fort.8", "fort.55"];
/// 判断沙盒里的文件 `fname` 是否值得归档。
///
/// `model_name` 是网格点的权威名(如 `t20000_g5.0_he-2_c-4_n-4_o-4`),用于识别
/// `<name>.<后缀>` 形式的产物。裸名保留(conv.json/fort.8/fort.55)不依赖 model_name。
///
/// 返回 `true` 表示应归档,`false` 表示应跳过(中间单元、未知后缀、`.tmp`、fort.84 等)。
///
/// # 示例
/// ```
/// use common::result_filter::is_result_worthy;
/// let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
/// assert!(is_result_worthy("conv.json", name));
/// assert!(is_result_worthy(&format!("{name}.7"), name));
/// assert!(is_result_worthy(&format!("{name}.nl.nst"), name));
/// assert!(is_result_worthy(&format!("{name}.nl_chmax0.001.9"), name));
/// assert!(!is_result_worthy("fort.18", name));
/// assert!(!is_result_worthy("fort.84", name));
/// assert!(!is_result_worthy(&format!("{name}.nl.9"), name)); // 无 _chmax 的 .9 已不再写
/// ```
pub fn is_result_worthy(fname: &str, model_name: &str) -> bool {
// 原子写入残留、NATOMS 崩溃缓存:显式排除(虽不在白名单,提前 short-circuit 更清晰)。
if fname.ends_with(".tmp") || fname == "fort.84" {
return false;
}
// 1. 裸名保留(有独立语义)。
if BARE_KEEPS.contains(&fname) {
return true;
}
// 其余保留项必须以 `<model_name>.` 开头。不以该前缀开头的文件(含所有其它 fort.N
// 中间单元)一律跳过。
let prefix = format!("{}.", model_name);
let Some(rest) = fname.strip_prefix(&prefix) else {
return false;
};
// 此时 rest 形如:`7` / `spec` / `nl.7` / `nl.nst` / `nl_chmax0.001.9` 等。
// 2. 科学核心:rest 本身就是后缀(`<name>.7`、`<name>.spec` 等)。
if SCIENCE_SUFFIXES.contains(&rest) {
return true;
}
// 3. 阶段快照:`<label>.<suffix>`(如 `nl.7`、`nc.nst`)。
// 用 split_once('.', label/suffix) 切一刀;label 必须在 STAGE_LABELS 内,
// suffix 必须在 STAGE_SNAPSHOT_SUFFIXES 内。这样能精确排除 `<name>.nl.9`
// (suffix=9 不在快照后缀集)等。
if let Some((label, suffix)) = rest.split_once('.') {
if STAGE_LABELS.contains(&label) && STAGE_SNAPSHOT_SUFFIXES.contains(&suffix) {
return true;
}
}
// 4. 收敛诊断:`<label>_chmax*.9`(如 `nl_chmax0.001.9`)。
// rest 以 `<label>_chmax` 开头,以 `.9` 结尾。这是唯一保留的 .9 形态。
if rest.ends_with(".9") {
for label in STAGE_LABELS {
let tag = format!("{}_chmax", label);
if rest.starts_with(&tag) && rest.ends_with(".9") {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
const NAME: &str = "t20000_g5.0_he-2_c-4_n-4_o-4";
#[test]
fn test_bare_keeps() {
assert!(is_result_worthy("conv.json", NAME));
assert!(is_result_worthy("fort.8", NAME));
assert!(is_result_worthy("fort.55", NAME));
}
#[test]
fn test_science_core() {
for s in ["7", "spec", "cont", "iden", "log"] {
let f = format!("{}.{}", NAME, s);
assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
}
}
#[test]
fn test_stage_snapshots() {
// 各阶段标签 × 快照后缀 都应保留
for label in STAGE_LABELS {
for s in STAGE_SNAPSHOT_SUFFIXES {
let f = format!("{}.{}.{}", NAME, label, s);
assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
}
}
}
#[test]
fn test_convergence_diagnostic_chmax9() {
// 唯一保留的 .9:带 _chmax 标签
for label in STAGE_LABELS {
let f = format!("{}.{}_chmax0.001.9", NAME, label);
assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
}
// 科学计数法形态的 chmax(1e-05)也应保留
assert!(is_result_worthy(&format!("{}.nc_chmax1e-05.9", NAME), NAME));
}
#[test]
fn test_redundant_dot9_without_chmax_skipped() {
// 无 _chmax 的阶段 .9 快照:与 _chmax.9 内容重复,跳过
for label in STAGE_LABELS {
let f = format!("{}.{}.9", NAME, label);
assert!(
!is_result_worthy(&f, NAME),
"{} 应跳过(与 _chmax.9 重复)",
f
);
}
}
#[test]
fn test_tlusty_intermediate_units_skipped() {
// 所有 Tlusty 中间工作单元一律跳过(含 0 字节空单元和大文件)
for f in [
"fort.1", "fort.2", "fort.3", "fort.10", "fort.11", "fort.13", "fort.14", "fort.18",
"fort.22", "fort.42", "fort.44", "fort.50", "fort.57", "fort.69", "fort.82", "fort.95",
"fort.84",
] {
assert!(!is_result_worthy(f, NAME), "{} 应跳过", f);
}
}
#[test]
fn test_tmp_and_unknown_skipped() {
assert!(!is_result_worthy("residue.tmp", NAME));
assert!(!is_result_worthy("random_file.txt", NAME));
assert!(!is_result_worthy("nst", NAME)); // 裸 nst(应已被 runner 改名)
assert!(!is_result_worthy("fort.9", NAME)); // 裸 fort.9(应已被 runner 删除)
assert!(!is_result_worthy("fort.12", NAME)); // 裸 fort.12(已 copy 为 .iden
assert!(!is_result_worthy("fort.17", NAME)); // 裸 fort.17(已 copy 为 .cont
}
#[test]
fn test_unknown_suffix_with_name_prefix_skipped() {
// 以 model_name 开头但后缀不在白名单的也应跳过
assert!(!is_result_worthy(&format!("{}.unknown", NAME), NAME));
assert!(!is_result_worthy(&format!("{}.nl.foo", NAME), NAME));
}
#[test]
fn test_unknown_stage_label_skipped() {
// 未知的阶段标签不归档(防御性:未来若引入新标签需显式加入 STAGE_LABELS
assert!(!is_result_worthy(&format!("{}.xxx.7", NAME), NAME));
assert!(!is_result_worthy(&format!("{}.xxx.nst", NAME), NAME));
}
}
+109 -3
View File
@@ -125,6 +125,7 @@ impl<'a> ExecutionRunner<'a> {
pub async fn run_model(
&self,
params: &GridPointParams,
name: &str,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
seed_atmos: Option<&Path>,
@@ -132,6 +133,7 @@ impl<'a> ExecutionRunner<'a> {
) -> Result<ModelSummary> {
self.run_model_with_timeout(
params,
name,
task_type,
custom_chain,
seed_atmos,
@@ -141,17 +143,32 @@ impl<'a> ExecutionRunner<'a> {
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn run_model_with_timeout(
&self,
params: &GridPointParams,
name: &str,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
timeout_sec: u64,
) -> Result<ModelSummary> {
let name = params.model_name();
let model_dir = self.work_dir.join(&name);
// `name` 取自权威的 TaskSpec.point_nameDB 的 grid_points.name 列,源精度正确),
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
// 回读时用 from_value() 反推文本会丢精度("5.0"→"5"),导致 params.model_name()
// 产出错误名(g5 而非 g5.0)。point_name 走独立 TEXT 列,精度全程保留。
// 下游(沙盒子目录、各阶段快照、conv.json.name、归档目录)全部用此 name,
// 故只需在此处用权威 name 即可让整条链精度正确。
let derived = params.model_name();
if derived != name {
warn!(
"网格点权威名 {} 与 params 重推名 {} 不一致(DB REAL 列回读丢精度所致),\
采用权威 point_name",
name, derived
);
}
let model_dir = self.work_dir.join(name);
tokio::fs::create_dir_all(&model_dir).await?;
info!("开始物理计算网格模型 {} (类型: {:?})", name, task_type);
@@ -279,6 +296,9 @@ impl<'a> ExecutionRunner<'a> {
best_max_relc: None,
elapsed_sec: stage_t0.elapsed().as_secs_f64(),
note: None,
last_iter: None,
worst_depth: None,
n_depths: None,
};
if rc == 0 && fort7.is_file() {
@@ -287,6 +307,11 @@ impl<'a> ExecutionRunner<'a> {
let res = check_fort9(&fort9, eff_chmax);
stage_summary.converged = res.converged;
stage_summary.best_max_relc = Some(res.max_relc);
// 携带迭代诊断量进 conv.json(旧版在此处丢弃):
// 迭代数/最差深度点供详情页阶段链展示收敛难度。
stage_summary.last_iter = res.last_iter;
stage_summary.worst_depth = Some(res.worst_depth);
stage_summary.n_depths = Some(res.n_depths);
// Save fort.9 snapshot
let snap_name = format!("{}.{}_chmax{}.9", name, stage_def.label, eff_chmax);
@@ -306,6 +331,30 @@ impl<'a> ExecutionRunner<'a> {
stage_summary.note = Some(format!("tlusty rc={} or missing fort.7", rc));
}
// 快照本阶段的同名输入/输出文件,带阶段标签保留。
// 背景:.5/.6/.err/nst 在每阶段用同名文件覆盖,若不快照则只有最后阶段(nl)
// 的版本能存活到归档,nc 等中间阶段的日志/输入会丢失。失败阶段的日志对排错
// 尤其重要,因此此处无条件(不论 rc 是否为 0)快照。
// 命名风格与上方 .7/.9 快照一致:<name>.<label>.<后缀>(单 name,不重复)。
// 注意:stage_def.label 由配置保证唯一(lte/nc/nl/seed_nc),不会与 synspec 产物冲突。
//
// 不快照 fort.9:上方 L292 已把 fort.9 收敛诊断存为 `<name>.<label>_chmax*.9`
// (带 chmax 阈值语义),再快照成 `<name>.<label>.9` 会与它内容完全重复。
// 故 .9 收敛诊断只保留 `_chmax*.9` 一份,不留重复快照。
// (suffix, full_src_name) —— suffix 用于快照名后缀,full_src_name 用于定位源文件
for (suffix, full_name) in [
("5", format!("{}.5", name)),
("6", format!("{}.6", name)),
("err", format!("{}.err", name)),
("nst", "nst".to_string()),
] {
let src = model_dir.join(&full_name);
if src.is_file() {
let snap = model_dir.join(format!("{}.{}.{}", name, stage_def.label, suffix));
let _ = tokio::fs::copy(&src, &snap).await;
}
}
final_chmax = stage_def.chmax;
final_converged = stage_summary.converged;
if let Some(r) = stage_summary.best_max_relc {
@@ -429,9 +478,28 @@ impl<'a> ExecutionRunner<'a> {
synspec_err = Some("No atmosphere .7 produced".to_string());
}
// 清理冗余的裸文件:这些文件的内容已被带阶段标签的快照或重命名的科学产物覆盖,
// 保留它们只会与归档里的 <name>.<label>.* / <name>.iden / <name>.cont 等重复(尤其
// .spec/.cont 是大文件,双份存储浪费磁盘)。删除后归档目录干净无冗余。
// 注意:fort.8synspec 输入大气)和 fort.55synspec 控制卡)有独立语义,予以保留。
for redundant in [
format!("{}.5", name), // 同 <name>.<最后阶段label>.5
format!("{}.6", name), // 同 <name>.<最后阶段label>.6
format!("{}.err", name), // 同 <name>.<最后阶段label>.err
"nst".to_string(), // 同 <name>.<最后阶段label>.nst
"fort.9".to_string(), // 内容已被 <name>.<label>_chmax*.9 收敛诊断覆盖
"fort.12".to_string(), // 同 <name>.idensynspec 谱线证认)
"fort.17".to_string(), // 同 <name>.contsynspec 连续谱)
] {
let p = model_dir.join(&redundant);
if p.is_file() {
let _ = tokio::fs::remove_file(&p).await;
}
}
let elapsed_sec = t0.elapsed().as_secs_f64();
let summary = ModelSummary {
name,
name: name.to_string(),
params: params.clone(),
stages: stage_summaries,
converged: final_converged,
@@ -460,6 +528,8 @@ impl<'a> ExecutionRunner<'a> {
#[cfg(test)]
mod tests {
use crate::models::{GridAxisValue, GridPointParams};
#[test]
fn test_synspec_timeout_calculation() {
let long_tlusty_timeout: u64 = 7200;
@@ -470,4 +540,40 @@ mod tests {
let synspec_timeout_short = 600_u64.min(short_tlusty_timeout);
assert_eq!(synspec_timeout_short, 300);
}
/// 回归测试:复现命名精度丢失场景,并锁定「runner 用 point_name 作权威名」的契约。
///
/// 背景:服务端 grid_points 表把 GridPointParams 存成 6 个 REAL 列,回读时用
/// `GridAxisValue::from_value()` 反推文本(`format_float_minimal`),整数-valued
/// 浮点数会丢小数(5.0 → "5")。于是 `params.model_name()` 产出 `g5` 而非 `g5.0`。
/// 而 `TaskSpec.point_name`DB 的 name TEXT 列,源精度)始终是 `g5.0`。
///
/// runner 的 `run_model_with_timeout` 现接收外部 `name: &str`(由 executor 传入
/// `task.point_name`),不再用降级的 `params.model_name()`。本测试构造降级后的
/// params,证明二者确实不同,从而确认「必须用 point_name」的修复是必要的。
#[test]
fn test_point_name_bypasses_degraded_params_model_name() {
// 模拟 DB REAL 列回读后的 paramslogg 经 from_value(5.0) 丢精度
let degraded = GridPointParams {
teff: GridAxisValue::from_value(20000.0),
logg: GridAxisValue::from_value(5.0), // text 退化为 "5"
loghe: GridAxisValue::from_value(-2.0),
logc: GridAxisValue::from_value(-4.0),
logn: GridAxisValue::from_value(-4.0),
logo: GridAxisValue::from_value(-4.0),
};
// 权威 point_nameDB name 列,保留源精度)
let point_name = "t20000_g5.0_he-2_c-4_n-4_o-4";
// 降级的 params 重推出的名字丢了 ".0"
assert_ne!(
degraded.model_name(),
point_name,
"降级 params.model_name() 应与权威 point_name 不同(这是 bug 的可观测证据)"
);
assert_eq!(degraded.model_name(), "t20000_g5_he-2_c-4_n-4_o-4");
// runner 现在直接采用 point_name(不再调 params.model_name()),故归档/产物名正确
let authoritative_name = point_name; // 即 executor 传入的 task.point_name
assert_eq!(authoritative_name, "t20000_g5.0_he-2_c-4_n-4_o-4");
}
}
+6 -6
View File
@@ -11,12 +11,12 @@ pub struct SeedMatch {
pub const MAX_GLOBAL_SEED_DISTANCE: f64 = 3.0;
pub fn calculate_seed_distance(cand: &GridPointParams, target: &GridPointParams) -> (bool, f64) {
let d_teff = (cand.teff - target.teff).abs();
let d_logg = (cand.logg - target.logg).abs();
let d_loghe = (cand.loghe - target.loghe).abs();
let d_cno = (cand.logc - target.logc).abs()
+ (cand.logn - target.logn).abs()
+ (cand.logo - target.logo).abs();
let d_teff = (cand.teff.value() - target.teff.value()).abs();
let d_logg = (cand.logg.value() - target.logg.value()).abs();
let d_loghe = (cand.loghe.value() - target.loghe.value()).abs();
let d_cno = (cand.logc.value() - target.logc.value()).abs()
+ (cand.logn.value() - target.logn.value()).abs()
+ (cand.logo.value() - target.logo.value()).abs();
// exact family 判定:Teff/logg/logHe 视为“同物理族”,仅 CNO 丰度不同。
// Teff 容忍度取半步 5000K:实际网格 Teff 档位通常为整数千(20000/30000/.../60000),
+120 -19
View File
@@ -69,12 +69,13 @@ impl SqliteTaskQueue {
created_at DATETIME NOT NULL,
claimed_at DATETIME,
workflow_name TEXT,
claimed_by_node_id TEXT
claimed_by_node_id TEXT,
wave INTEGER NOT NULL DEFAULT 0
)",
[],
)?;
// 兼容旧库:若 task_queue 表已存在但缺少 workflow_name / claimed_by_node_id 列,则补列。
// SQLite 的 ALTER TABLE ADD COLUMN 是在线操作,旧数据该列默认 NULL。
// 兼容旧库:若 task_queue 表已存在但缺少 workflow_name / claimed_by_node_id / wave 列,则补列。
// SQLite 的 ALTER TABLE ADD COLUMN 是在线操作,旧数据该列默认 NULL / 0
// PRAGMA table_info 检测列是否存在以实现幂等 migration。
let has_col = |conn: &rusqlite::Connection, col: &str| -> rusqlite::Result<bool> {
let mut stmt = conn.prepare("PRAGMA table_info(task_queue)")?;
@@ -92,6 +93,12 @@ impl SqliteTaskQueue {
if !has_col(&conn, "claimed_by_node_id")? {
conn.execute("ALTER TABLE task_queue ADD COLUMN claimed_by_node_id TEXT", [])?;
}
if !has_col(&conn, "wave")? {
conn.execute(
"ALTER TABLE task_queue ADD COLUMN wave INTEGER NOT NULL DEFAULT 0",
[],
)?;
}
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_queue_status_created ON task_queue(status, created_at)",
[],
@@ -100,6 +107,12 @@ impl SqliteTaskQueue {
"CREATE INDEX IF NOT EXISTS idx_task_queue_workflow ON task_queue(workflow_name)",
[],
)?;
// 覆盖 pop_task 出队排序:status 固定 'pending' 过滤后,按 wave(全局难度优先)
// → created_at(同 wave 内 FIFO)。
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_queue_pop ON task_queue(status, wave, created_at)",
[],
)?;
Ok(pool)
})
.await??;
@@ -112,14 +125,15 @@ impl SqliteTaskQueue {
let payload = serde_json::to_string(task)?;
let task_id_str = task.task_id.to_string();
let workflow_name = task.workflow_name.clone();
let wave = task.wave;
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
conn.execute(
"INSERT OR REPLACE INTO task_queue (task_id, payload, status, created_at, workflow_name)
VALUES (?1, ?2, 'pending', datetime('now'), ?3)",
params![task_id_str, payload, workflow_name],
"INSERT OR REPLACE INTO task_queue (task_id, payload, status, created_at, workflow_name, wave)
VALUES (?1, ?2, 'pending', datetime('now'), ?3, ?4)",
params![task_id_str, payload, workflow_name, wave],
)?;
Ok(())
})
@@ -139,8 +153,14 @@ impl SqliteTaskQueue {
let tx_res = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate);
match tx_res {
Ok(tx) => {
// 出队排序:wave ASC → created_at ASC(不按 workflow_name 分组)。
// - wave ASC:全局低难度 wave 优先,恢复"先易后难积累种子"的设计意图。
// 各工作流的低难度点会先于任何工作流的高难度点被消化,避免某个工作流
// 在高难度 wave 上饿死另一个工作流的低难度点。
// - created_at ASC:同 wave 内严格 FIFO。调度器按工作流批量推入时,
// 多个工作流的任务 created_at 天然交错,故同 wave 内不会单工作流独占。
let mut stmt = tx.prepare(
"SELECT task_id, payload FROM task_queue WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1"
"SELECT task_id, payload FROM task_queue WHERE status = 'pending' ORDER BY wave ASC, created_at ASC LIMIT 1"
)?;
let row = stmt.query_row([], |row| {
@@ -344,17 +364,18 @@ mod tests {
task_id,
point_name: "t35000_g5.5_he-1_c-2_n-2_o-2".to_string(),
params: GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 3600,
workflow_name: Some("test_wf".to_string()),
wave: 0,
};
queue.push_task(&task).await.unwrap();
@@ -387,12 +408,12 @@ mod tests {
let task_id = Uuid::new_v4();
let params = GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let task = TaskSpec {
task_id,
@@ -402,6 +423,7 @@ mod tests {
seed_point_name: None,
timeout_sec: 60,
workflow_name: None,
wave: 0,
};
queue.push_task(&task).await.unwrap();
@@ -434,4 +456,83 @@ mod tests {
.unwrap();
assert!(claim_after.is_none());
}
/// 辅助:构造一个最小 TaskSpec,方便下面两个排序测试。
fn mk_task(wf: &str, wave: i32, point_name: &str) -> TaskSpec {
TaskSpec {
task_id: Uuid::new_v4(),
point_name: point_name.to_string(),
params: GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 60,
workflow_name: Some(wf.to_string()),
wave,
}
}
/// 跨工作流 wave 优先级:wf_a 推多个高 wave 任务,wf_b 推一个低 wave 任务,
/// 验证 wf_b 的低难度任务不会被 wf_a 的大量高难度任务饿死。
///
/// 排序为 wave ASC, created_at ASC(不按 workflow_name 分组):
/// wf_b 的 wave=0 任务优先于 wf_a 的所有 wave=9 任务出队,
/// 即使 wf_a 的任务 created_at 更早、数量更多。
/// 这保证了"全局先易后难"——任一工作流的低难度点优先于其他工作流的高难度点。
#[tokio::test]
async fn test_pop_cross_workflow_wave_priority() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("pop_fair.db");
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
.await
.unwrap();
// wf_a 推 3 个高难度任务(wave=9created_at 更早)
for i in 0..3 {
queue
.push_task(&mk_task("wf_a", 9, &format!("a{i}")))
.await
.unwrap();
}
// wf_b 推 1 个低难度任务(wave=0created_at 最晚)
queue.push_task(&mk_task("wf_b", 0, "b0")).await.unwrap();
// 第 1 次 popwf_b 的 wave=0(全局最低 wave 优先),而非 wf_a 的先入任务
let p1 = queue.pop_task("n1").await.unwrap().unwrap();
assert_eq!(p1.workflow_name.as_deref(), Some("wf_b"));
assert_eq!(p1.point_name, "b0");
// 之后才轮到 wf_a 的 wave=9 任务(FIFO 顺序)
for i in 0..3 {
let p = queue.pop_task("n2").await.unwrap().unwrap();
assert_eq!(p.workflow_name.as_deref(), Some("wf_a"));
assert_eq!(p.point_name, format!("a{i}"));
}
}
/// 出队同工作流内按 wave ASC:低难度 wave 优先,即使它 created_at 更晚。
#[tokio::test]
async fn test_pop_wave_priority_within_workflow() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("pop_wave.db");
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
.await
.unwrap();
// 先推 wave=5created_at 早),再推 wave=1created_at 晚)
queue.push_task(&mk_task("wf_x", 5, "hard")).await.unwrap();
queue.push_task(&mk_task("wf_x", 1, "easy")).await.unwrap();
// pop 应先拿到 wave=1 的 easy(若纯 FIFO 会先拿到先入的 hard)
let p1 = queue.pop_task("n1").await.unwrap().unwrap();
assert_eq!(p1.point_name, "easy", "低 wave 优先出队");
let p2 = queue.pop_task("n2").await.unwrap().unwrap();
assert_eq!(p2.point_name, "hard");
}
}
+1 -2
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
common = { path = "../common" }
common = { path = "../common", features = ["embed-binaries"] }
mq = { path = "../mq" }
tokio.workspace = true
reqwest.workspace = true
@@ -14,7 +14,6 @@ tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
sysinfo.workspace = true
gethostname.workspace = true
clap.workspace = true
uuid.workspace = true
dotenvy.workspace = true
+362
View File
@@ -1,4 +1,5 @@
use anyhow::Result;
use common::result_filter::is_result_worthy;
use common::embedded::{ensure_specific_data_files, RuntimePaths};
use common::models::{ModelSummary, TaskSpec, TaskType};
use common::runner::ExecutionRunner;
@@ -91,6 +92,9 @@ pub async fn execute_task(
let summary = runner
.run_model_with_timeout(
&task.params,
// 用权威的 point_nameDB grid_points.name 列,源精度正确)作为模型名,
// 而非 task.params.model_name()(后者经 DB REAL 列回读已丢精度 "5.0"→"5")。
&task.point_name,
task.task_type.clone(),
None,
seed_atmos_path.as_deref(),
@@ -148,6 +152,177 @@ pub async fn cleanup_slot_work_dir(slot_work_dir: &Path) -> Result<()> {
Ok(())
}
/// 把单个任务沙盒内的产物拷贝到持久归档目录。
///
/// 采用**白名单**策略([`is_result_worthy`])而非「拷贝所有普通文件」的 catch-all:
/// 只保留有语义价值的产物,丢弃 Tlusty/Synspec 运行时产生的中间工作单元
/// `fort.1/2/3/13/14/18/22/42/44/50/57/69/82/95` 等,旧版 catch-all 会把它们
/// 一并搬进归档,每个模型浪费约 2MB / 4.8MB)。
///
/// 保留内容(详见 [`is_result_worthy`]):
/// - 裸名:`conv.json`、`fort.8`synspec 输入大气)、`fort.55`synspec 控制卡)
/// - 科学核心:`<name>.7/.spec/.cont/.iden/.log`
/// - 阶段快照:`<name>.<label>.5/.6/.err/.nst/.7`
/// - 收敛诊断:`<name>.<label>_chmax*.9`**唯一保留的 .9**
///
/// 跳过内容:符号链接(`data`/`fort.19` 等共享 runtime 资源)、子目录、`.tmp`、`fort.84`、
/// 所有 Tlusty 中间单元、以及不以 `<name>.` 为前缀的无语义裸文件。
///
/// 任何 IO 错误均降级为 warn,不阻断上报/清理主流程(归档是尽力而为)。
///
/// `name` 为网格点权威名:取自 summary.namerunner 现用 task.point_name 作权威名),
/// 严重失败(runner 抛 Err、无 summary)时回退到 task.point_name,确保失败任务的
/// 排错日志也能落盘。
pub async fn save_result_artifacts(
result_dir: &Path,
slot_work_dir: &Path,
name: &str,
) {
let src_dir = slot_work_dir.join(name);
if !src_dir.is_dir() {
// 模型子目录不存在(极早期失败),无可归档内容
return;
}
let dest_dir = result_dir.join(name);
if let Err(e) = tokio::fs::create_dir_all(&dest_dir).await {
warn!(
"归档网格点 {} 失败:创建归档目录 {} 失败: {}",
name,
dest_dir.display(),
e
);
return;
}
let mut kept = 0usize;
let mut skipped = 0usize;
let mut skipped_link = 0usize;
let mut rd = match tokio::fs::read_dir(&src_dir).await {
Ok(rd) => rd,
Err(e) => {
warn!(
"归档网格点 {} 失败:读取源目录 {} 失败: {}",
name,
src_dir.display(),
e
);
return;
}
};
while let Ok(Some(entry)) = rd.next_entry().await {
let path = entry.path();
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
// 跳过符号链接(指向共享 runtime 资源,不归档)
if tokio::fs::symlink_metadata(&path)
.await
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
skipped_link += 1;
continue;
}
// 只归档普通文件(跳过意外的子目录)
if !path.is_file() {
continue;
}
// 白名单判别:只保留有语义价值的产物,丢弃 Tlusty 中间单元
if !is_result_worthy(&file_name, name) {
skipped += 1;
continue;
}
let dest_path = dest_dir.join(&file_name);
// 原子写入:先拷到 .result.tmp.<uuid> 再 rename,防止中途崩溃产生半截文件
let tmp_path = dest_dir.join(format!("{}.result.tmp.{}", file_name, uuid::Uuid::new_v4().simple()));
match tokio::fs::copy(&path, &tmp_path).await {
Ok(_) => {
if let Err(e) = tokio::fs::rename(&tmp_path, &dest_path).await {
// rename 失败则清理 tmp,避免残留
let _ = tokio::fs::remove_file(&tmp_path).await;
warn!(
"归档网格点 {} 的文件 {} rename 失败: {}",
name,
file_name,
e
);
continue;
}
kept += 1;
}
Err(e) => {
let _ = tokio::fs::remove_file(&tmp_path).await;
warn!(
"归档网格点 {} 的文件 {} 拷贝失败: {}",
name,
file_name,
e
);
}
}
}
info!(
"已归档网格点 {} 的产物:保留 {} 个文件到 {}(跳过 {} 个非白名单文件、{} 个符号链接)",
name,
kept,
dest_dir.display(),
skipped,
skipped_link
);
}
/// 归档目录保留的网格点(子目录)数量上限。超过则按 mtime 删除最旧的。
/// 200 足以覆盖中等规模网格的完整归档;更大网格可经环境变量或常量调整。
const MAX_RESULT_MODELS: usize = 200;
/// LRU 治理归档目录:当网格点子目录数超过 `MAX_RESULT_MODELS` 时,
/// 按 mtime 升序删除最旧的若干个子目录,直到不超过上限。
/// 仅统计子目录(每个对应一个网格点),忽略散落文件。错误降级为 warn,不阻断主流程。
pub async fn cleanup_result_dir(result_dir: &Path) {
let mut entries: Vec<(std::time::SystemTime, PathBuf)> =
match tokio::fs::read_dir(result_dir).await {
Ok(mut rd) => {
let mut v = Vec::new();
while let Ok(Some(entry)) = rd.next_entry().await {
let path = entry.path();
// 仅纳入子目录(网格点归档目录),跳过散落文件
if !path.is_dir() {
continue;
}
let meta = match entry.metadata().await {
Ok(m) => m,
Err(_) => continue,
};
let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
v.push((mtime, path));
}
v
}
// 归档目录不存在或不可读:无操作(首次归档尚未创建)
Err(_) => return,
};
if entries.len() <= MAX_RESULT_MODELS {
return;
}
// 按 mtime 升序(最旧在前),删除超出上限的最旧子目录
entries.sort_by_key(|(mtime, _)| *mtime);
let to_remove = entries.len().saturating_sub(MAX_RESULT_MODELS);
for (_, path) in entries.into_iter().take(to_remove) {
if let Err(e) = tokio::fs::remove_dir_all(&path).await {
warn!("LRU 清理归档目录 {} 失败: {}", path.display(), e);
} else {
info!("LRU 清理归档目录: {}", path.display());
}
}
}
/// `.seed_cache/` 内保留的 `.seed.7` 文件上限。超过则按 mtime 删除最旧的。
/// 典型网格内活跃种子点数量有限,8 足以覆盖常用邻域且把磁盘占用控制在 ~8 个种子文件。
const MAX_SEED_CACHE_FILES: usize = 8;
@@ -204,6 +379,33 @@ pub async fn cleanup_seed_cache(seed_dir: &Path) {
#[cfg(test)]
mod tests {
use super::*;
use common::models::{GridPointParams, ModelSummary};
fn make_summary() -> ModelSummary {
let params = GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
ModelSummary {
name: params.model_name(),
params,
stages: vec![],
converged: true,
final_max_relc: Some(0.0005),
final_chmax: None,
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(10.0),
elapsed_sec: 120.0,
note: None,
}
}
#[tokio::test]
async fn test_cleanup_slot_work_dir() {
@@ -218,4 +420,164 @@ mod tests {
cleanup_slot_work_dir(&temp_dir).await.unwrap();
assert!(!temp_dir.exists());
}
/// 验证归档:完整产物被拷贝、符号链接/fort.84/.tmp 被跳过
#[tokio::test]
async fn test_save_result_artifacts() {
let root = std::env::temp_dir().join(format!("test_result_{}", uuid::Uuid::new_v4()));
let result_dir = root.join("result");
let summary = make_summary();
let slot_work_dir = root.join("work");
let model_dir = slot_work_dir.join(&summary.name);
tokio::fs::create_dir_all(&model_dir).await.unwrap();
// 应被归档的白名单产物(科学核心 + 阶段快照 + 收敛诊断 + 裸名保留)
let kept_files = [
format!("{}.7", summary.name), // 最终大气
format!("{}.spec", summary.name), // 合成光谱
format!("{}.cont", summary.name), // 连续谱
format!("{}.iden", summary.name), // 谱线证认
format!("{}.log", summary.name), // synspec 日志
"conv.json".to_string(), // 摘要
"fort.8".to_string(), // synspec 输入大气(裸名保留)
"fort.55".to_string(), // synspec 控制卡(裸名保留)
format!("{}.nl.7", summary.name), // nl 阶段大气快照
format!("{}.nc.7", summary.name), // nc 阶段大气快照
format!("{}.nl.5", summary.name), // nl 阶段输入卡快照
format!("{}.nl.6", summary.name), // nl 阶段输出日志快照
format!("{}.nl.err", summary.name), // nl 阶段错误日志快照
format!("{}.nl.nst", summary.name), // nl 阶段控制卡快照
format!("{}.nc.nst", summary.name), // nc 阶段控制卡快照
format!("{}.nl_chmax0.001.9", summary.name), // nl 收敛诊断(唯一保留的 .9)
];
// 应被白名单过滤掉的文件:Tlusty 中间单元、裸的 runner 已清理文件、
// 无 _chmax 的重复 .9 快照、未知后缀
let skipped_files: [String; 14] = [
"fort.1".to_string(), // 空单元
"fort.13".to_string(), // Tlusty NLTE 跃迁频率网格
"fort.18".to_string(), // Tlusty 大气结构内部表
"fort.22".to_string(), // Tlusty 中间大气副本
"fort.82".to_string(), // Tlusty 运行时诊断表
"fort.95".to_string(), // Tlusty 旧模型定义副本
"fort.84".to_string(), // NATOMS 崩溃缓存
"residue.tmp".to_string(), // 原子写入残留
"nst".to_string(), // 裸 nstrunner 已改名为 <name>.<label>.nst
"fort.9".to_string(), // 裸 fort.9runner 已删,内容在 _chmax.9
"fort.12".to_string(), // 裸 fort.12(已 copy 为 .iden
"fort.17".to_string(), // 裸 fort.17(已 copy 为 .cont
format!("{}.nl.9", summary.name), // 无 _chmax 的 .9 快照(与 _chmax.9 重复)
format!("{}.unknown", summary.name), // 未知后缀
];
for f in kept_files.iter() {
tokio::fs::write(model_dir.join(f), "payload")
.await
.unwrap();
}
for f in skipped_files.iter() {
tokio::fs::write(model_dir.join(f), "payload")
.await
.unwrap();
}
// 符号链接(指向共享资源,应被跳过)
#[cfg(unix)]
{
let link_target = root.join("shared_data");
tokio::fs::create_dir_all(&link_target).await.unwrap();
std::os::unix::fs::symlink(&link_target, model_dir.join("data")).unwrap();
std::os::unix::fs::symlink("/dev/null", model_dir.join("fort.19")).unwrap();
}
save_result_artifacts(&result_dir, &slot_work_dir, &summary.name).await;
let dest_dir = result_dir.join(&summary.name);
assert!(dest_dir.is_dir(), "归档目标目录应被创建");
// 验证白名单产物都被拷贝
for f in &kept_files {
assert!(
dest_dir.join(f).is_file(),
"白名单产物 {} 应被归档",
f
);
}
// 验证非白名单文件未进归档
for f in &skipped_files {
assert!(
!dest_dir.join(f).exists(),
"非白名单文件 {} 应被跳过",
f
);
}
#[cfg(unix)]
{
assert!(!dest_dir.join("data").exists(), "符号链接 data 应被跳过");
assert!(
!dest_dir.join("fort.19").exists(),
"符号链接 fort.19 应被跳过"
);
}
// 不应有残留的 .result.tmp 文件
let mut rd = tokio::fs::read_dir(&dest_dir).await.unwrap();
while let Ok(Some(e)) = rd.next_entry().await {
let name = e.file_name().to_string_lossy().to_string();
assert!(
!name.contains(".result.tmp"),
"不应残留 tmp 文件: {}",
name
);
}
let _ = tokio::fs::remove_dir_all(&root).await;
}
/// 验证 LRU 治理:超过上限时按 mtime 删最旧的子目录
#[tokio::test]
async fn test_cleanup_result_dir() {
let result_dir =
std::env::temp_dir().join(format!("test_result_lru_{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&result_dir).await.unwrap();
// 创建 MAX+10 个子目录,按创建顺序递增 mtime(每个 sleep 制造可测的时间差)。
// model_0000 最早创建(最旧),model_0209 最新创建。
let total = MAX_RESULT_MODELS + 10;
for i in 0..total {
let dir = result_dir.join(format!("model_{:04}", i));
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("marker"), format!("{}", i))
.await
.unwrap();
// 10ms 间隔足以让多数文件系统的 mtime 分辨出先后顺序
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
cleanup_result_dir(&result_dir).await;
let mut remaining: Vec<String> = Vec::new();
let mut rd = tokio::fs::read_dir(&result_dir).await.unwrap();
while let Ok(Some(e)) = rd.next_entry().await {
if e.path().is_dir() {
remaining.push(e.file_name().to_string_lossy().to_string());
}
}
// 清理后剩余数量应恰为上限
assert_eq!(
remaining.len(),
MAX_RESULT_MODELS,
"清理后应剩余 {} 个,实际 {} 个",
MAX_RESULT_MODELS,
remaining.len()
);
// 最旧的那批(model_0000~model_0009)应被删除,最新的 MAX 个应保留
remaining.sort();
assert!(
!remaining.contains(&"model_0000".to_string()),
"最旧的 model_0000 应被 LRU 删除"
);
assert!(
remaining.contains(&format!("model_{:04}", total - 1)),
"最新的 model_{:04} 应被保留",
total - 1
);
let _ = tokio::fs::remove_dir_all(&result_dir).await;
}
}
+3 -3
View File
@@ -84,14 +84,14 @@ pub async fn report_result(
}
Ok(resp) => {
let status = resp.status();
// 401/403 表明 node token 已失效/被吊销(非临时故障),重试无意义且会丢结果。
// 401/403 表明 node token 已失效(被重发覆盖,非临时故障),重试无意义且会丢结果。
// 立即 bail 并打 error,与 claim_task 侧口径统一,提示运维介入。
if status.as_u16() == 401 || status.as_u16() == 403 {
tracing::error!(
"上报任务 {} 被服务端拒绝 (HTTP {})node token 可能已失效或被吊销,请检查并清理 .node_token 文件后重启节点以重新向服务端发起注册审批,停止重试",
"上报任务 {} 被服务端拒绝 (HTTP {})node token 已失效(已被重发覆盖),请用新 token 更新 .node_token 后重启节点,停止重试",
task.task_id, status
);
anyhow::bail!("node token 失效或被吊销 (HTTP {}),结果未上报", status);
anyhow::bail!("node token 失效 (HTTP {}),结果未上报", status);
}
warn!(
"向服务端上报任务 {} 结果失败 (尝试 {}/{}): HTTP {}",
+91 -17
View File
@@ -12,6 +12,25 @@ use std::sync::Arc;
use tokio::time::{sleep, Duration};
use tracing::{info, warn};
/// 领用请求的归一化结果。
///
/// 区分「被管理员停用」与「暂无任务」:前者节点保持存活、空闲待命(拉长轮询),
/// 后者按常规节奏轮询。服务端返回 `{"status":"disabled"}` 映射为 `Disabled`。
enum ClaimOutcome {
/// 成功领用到任务。
Task(TaskSpec),
/// 暂无排队任务。
Empty,
/// 节点被管理员手动停用,不再分发任务。
Disabled,
}
/// 拼接 token 文件的绝对路径,供 401 退出日志给出可操作的恢复位置。
fn token_file_display(runtime_dir: &str) -> String {
let p = std::path::Path::new(runtime_dir).join(".node_token");
p.to_string_lossy().into_owned()
}
pub struct NodeWorker {
config: NodeConfig,
client: Client,
@@ -43,7 +62,6 @@ impl NodeWorker {
let req = NodeRegisterRequest {
node_id: node_id.to_string(),
host_name: gethostname::gethostname().to_string_lossy().to_string(),
max_slots: 0,
};
@@ -122,7 +140,6 @@ impl NodeWorker {
let req = NodeRegisterRequest {
node_id: self.config.node_id.clone(),
host_name: gethostname::gethostname().to_string_lossy().to_string(),
max_slots: self.config.max_slots as i32,
};
@@ -153,6 +170,7 @@ impl NodeWorker {
let hb_node_id = self.config.node_id.clone();
let hb_slots = self.active_slots.clone();
let hb_interval = self.config.heartbeat_sec;
let hb_runtime_dir = self.config.runtime_dir.clone();
tokio::spawn(async move {
let sys_arc = std::sync::Arc::new(std::sync::Mutex::new(sysinfo::System::new_all()));
@@ -210,13 +228,18 @@ impl NodeWorker {
match hb_client.post(&hb_url).json(&req).send().await {
Ok(resp) => {
let status = resp.status();
// 401/403token 失效或被吊销。与 claim_task 口径统一:直接退出进程,
// 401/403token 失效(被重发覆盖)。与 claim_task 口径统一:直接退出进程,
// 避免心跳线程持续发被拒请求刷日志、占用服务端限流计数。心跳通常比
// claim 更高频,往往先于 claim_task 发现吊销
// claim 更高频,往往先于 claim_task 发现 token 失效
if status.as_u16() == 401 || status.as_u16() == 403 {
tracing::error!(
"节点 {} 心跳被服务端拒绝 (HTTP {})node token 已失效或被吊销。请清理 .node_token 文件后重启节点以重新发起注册审批。进程将退出,依赖编排系统重启。",
hb_node_id, status
"节点 {} 心跳被服务端拒绝 (HTTP {})node token 已失效(已被重发覆盖)。\n\
恢复方式:把管理员重发的新 token 明文写入 {} 后重启节点,\n\
或删除该文件后重启以重新提交注册申请等待审批。\n\
进程将退出,依赖编排系统重启。",
hb_node_id,
status,
token_file_display(&hb_runtime_dir)
);
std::process::exit(1);
}
@@ -228,6 +251,7 @@ impl NodeWorker {
let work_dir = PathBuf::from(&self.config.work_dir);
tokio::fs::create_dir_all(&work_dir).await?;
let result_dir = PathBuf::from(&self.config.result_dir);
let shutting_down = Arc::new(std::sync::atomic::AtomicBool::new(false));
let shutdown_signal = shutting_down.clone();
@@ -256,7 +280,7 @@ impl NodeWorker {
let active = self.active_slots.load(Ordering::Acquire);
if (active as usize) < self.config.max_slots {
match self.claim_task().await {
Ok(Some(task)) => {
Ok(ClaimOutcome::Task(task)) => {
if was_disconnected {
info!("与服务端恢复网络连接,已自动重新上线并开始领用计算任务!");
was_disconnected = false;
@@ -267,6 +291,7 @@ impl NodeWorker {
let node_id = self.config.node_id.clone();
let runtime = self.runtime.clone();
let work_dir = work_dir.clone();
let result_dir = result_dir.clone();
let slots_counter = self.active_slots.clone();
tokio::spawn(async move {
@@ -276,9 +301,28 @@ impl NodeWorker {
.await
.map_err(|e| e.to_string());
// 在上报前克隆 summary,供上报成功后的归档使用(归档目录名取
// summary.name,与 executor 内部 model_sub_dir 路径口径一致)。
// 严重失败(runner 抛 Err)时 summary_opt=None,此时回退到 task.point_name
// (二者均源自 params.model_name())作为归档目录名,确保失败任务的
// 排错日志也能落盘而非随沙盒删除丢失。
let summary_opt =
res.as_ref().ok().map(|(s, _)| s.clone());
let result_name =
summary_opt.as_ref().map(|s| s.name.clone()).unwrap_or_else(|| task.point_name.clone());
let report_res =
report_result(&client, &server_url, &node_id, &task, res).await;
if report_res.is_ok() {
// 上报成功后、清理沙盒前,先把完整产物归档到持久目录,
// 避免随沙盒删除丢失(.spec/.cont/.iden/各阶段快照/日志等)。
crate::executor::save_result_artifacts(
&result_dir,
&slot_work_dir,
&result_name,
)
.await;
crate::executor::cleanup_result_dir(&result_dir).await;
if let Err(e) =
crate::executor::cleanup_slot_work_dir(&slot_work_dir).await
{
@@ -290,18 +334,41 @@ impl NodeWorker {
);
}
} else if let Err(ref e) = report_res {
// 上报失败:仍尝试归档(保留产物用于排查),再清沙盒避免滞留泄漏。
warn!("向服务端上报任务 {} 计算结果失败: {}", task.task_id, e);
crate::executor::save_result_artifacts(
&result_dir,
&slot_work_dir,
&result_name,
)
.await;
if let Err(e) =
crate::executor::cleanup_slot_work_dir(&slot_work_dir).await
{
warn!(
"清理任务 {} 的沙盒目录 {} 失败: {}",
task.task_id,
slot_work_dir.display(),
e
);
}
}
slots_counter.fetch_sub(1, Ordering::AcqRel);
});
}
Ok(None) => {
Ok(ClaimOutcome::Empty) => {
if was_disconnected {
info!("与服务端恢复网络连接,已自动重新上线 (当前暂无排队任务)。");
was_disconnected = false;
}
sleep(Duration::from_secs(5)).await;
}
Ok(ClaimOutcome::Disabled) => {
// 节点被管理员手动停用:保持存活、空闲待命,拉长轮询避免刷请求。
// 管理员调用 enable 后,下一次轮询即恢复领用,无需重启节点。
info!("节点已被管理员停用,保持空闲待命(不再领用任务)。等待重新启用...");
sleep(Duration::from_secs(60)).await;
}
Err(e) => {
was_disconnected = true;
warn!("向服务端请求领用计算任务时出错: {}", e);
@@ -343,34 +410,41 @@ impl NodeWorker {
Ok(())
}
async fn claim_task(&self) -> Result<Option<TaskSpec>> {
async fn claim_task(&self) -> Result<ClaimOutcome> {
let claim_url = format!("{}/api/task/claim", self.config.server_url);
let resp = self.client.post(&claim_url).send().await?;
let status = resp.status();
// 401/403 表明 node token 已被吊销或失效(区别于「暂无任务」与服务端 5xx 故障)。
// 服务端故障返回 5xx 会走 !is_success() 的 Ok(None) 分支,仅在网络层/鉴权层拒绝时
// 才是真正的吊销。此时继续轮询只会持续产生被拒请求并刷日志,故直接退出进程,
// 401/403 表明 node token 已失效(被重发覆盖;区别于「暂无任务」与服务端 5xx 故障)。
// 服务端故障返回 5xx 会走 !is_success() 的 Ok(Empty) 分支,仅在网络层/鉴权层拒绝时
// 才是真正的 token 失效。此时继续轮询只会持续产生被拒请求并刷日志,故直接退出进程,
// 由编排系统(Docker restart / systemd / k8s)拉起;新进程发现 .node_token 失效后
// 会自动走注册审批流程重新申请。
if status.as_u16() == 401 || status.as_u16() == 403 {
tracing::error!(
"领用任务被服务端拒绝 (HTTP {})node token 已失效或被吊销。请清理 .node_token 文件后重启节点以重新发起注册审批。进程将退出,依赖编排系统重启。",
status
"领用任务被服务端拒绝 (HTTP {})node token 已失效(已被重发覆盖)。\n\
恢复方式:把管理员重发的新 token 明文写入 {} 后重启节点,\n\
或删除该文件后重启以重新提交注册申请等待审批。\n\
进程将退出,依赖编排系统重启。",
status,
token_file_display(&self.config.runtime_dir)
);
std::process::exit(1);
}
if !status.is_success() {
return Ok(None);
return Ok(ClaimOutcome::Empty);
}
let json: Value = resp.json().await?;
if json["status"] == "ok" && !json["task"].is_null() {
let task: TaskSpec = serde_json::from_value(json["task"].clone())?;
Ok(Some(task))
Ok(ClaimOutcome::Task(task))
} else if json["status"] == "disabled" {
// 管理员手动停用:节点保持存活、空闲待命,不退出进程。
Ok(ClaimOutcome::Disabled)
} else {
Ok(None)
Ok(ClaimOutcome::Empty)
}
}
}
+74 -35
View File
@@ -1,12 +1,11 @@
//! 管理 APIAdmin 角色)。
//!
//! 提供 node 凭据的可视化与运维操作,供 Dashboard 管理界面调用:
//! - 列出所有节点及其凭据状态(在线/token 是否有效/吊销/颁发时间)
//! - 吊销指定节点的专属 token(立即失效,不影响其他节点)
//! - 列出所有节点及其凭据状态(在线/token 是否有效/颁发时间)
//! - 重新颁发指定节点的专属 token(返回新明文,旧 token 失效)
//!
//! 这些端点均要求 Admin 角色(见 mod.rs 授权矩阵),node 自身无权操作他人或自身凭据,
//! 从而保证「吊销/重发」是管理员主动行为,避免被攻陷节点篡改凭据体系。
//! 从而保证「重发」是管理员主动行为,避免被攻陷节点篡改凭据体系。
use super::{is_valid_node_id, AppState};
use axum::{
@@ -31,37 +30,6 @@ pub async fn list_nodes(
}
}
/// POST /api/admin/nodes/:node_id/revoke — 吊销指定节点的专属 token。
///
/// 吊销后该 node 的现有 token 立即失效,须重新走注册流程领取新 token。
/// 操作幂等:对无凭据记录或已吊销的节点调用不会报错。
pub async fn revoke_node(
State(state): State<AppState>,
AxumPath(node_id): AxumPath<String>,
) -> Result<impl IntoResponse, crate::api::AppError> {
// node_id 白名单校验,防止注入或异常输入(与 register_node 的 node_id 来源口径一致)
if !is_valid_node_id(&node_id) {
return Err(crate::api::AppError::BadRequest(
"非法的节点 ID 参数".to_string(),
));
}
match state.db.revoke_node_token(&node_id).await {
Ok(_) => {
info!("管理员已吊销节点 {} 的专属 token", node_id);
Ok((
StatusCode::OK,
Json(
json!({ "success": true, "message": format!("节点 '{}' 的 token 已吊销", node_id) }),
),
))
}
Err(e) => {
warn!("吊销节点 {} token 失败: {}", node_id, e);
Err(e.into())
}
}
}
/// POST /api/admin/nodes/:node_id/reissue — 重新颁发指定节点的专属 token。
///
/// 旧 token 立即失效,返回新 token 明文(仅此一次,DB 只存 hash)。
@@ -93,7 +61,12 @@ pub async fn reissue_node(
StatusCode::OK,
Json(json!({
"success": true,
"message": format!("节点 '{}' 的 token 已重新颁发,请将新 token 同步到该节点", node_id),
// 重发使旧 token 立即失效:node 进程内存里仍握着旧 token,下一次心跳/领用会 401 退出。
// 必须显式提示恢复方式,否则管理员易困惑"为什么重发后节点反而掉了"。
"message": format!(
"节点 '{}' 的 token 已重新颁发,旧 token 立即失效(该节点下次心跳/领用将 401 退出)。\n恢复方式(二选一):① 把下方新 token 写入该节点本地 .node_token 文件后重启节点;② 在节点机器删除 .node_token 后重启(会自动重注册取回新 token,限 1 天内有效)。",
node_id
),
"node_token": new_token,
})),
))
@@ -152,3 +125,69 @@ pub async fn reject_node(
Err(e) => Err(e.into()),
}
}
/// POST /api/admin/nodes/:node_id/disable — 手动停用一个在线/离线节点。
///
/// 停用后节点保持在线心跳(Dashboard 可见其存活),但 claim 不再向其分发任务,
/// worker 收到 `{"status":"disabled"}` 后会拉长轮询、空闲待命。可随时调用 `/enable` 恢复。
/// 仅 `online`/`offline` 节点可停用;对 `pending_approval`/`disabled` 调用返回 409。
pub async fn disable_node(
State(state): State<AppState>,
AxumPath(node_id): AxumPath<String>,
) -> Result<impl IntoResponse, crate::api::AppError> {
if !is_valid_node_id(&node_id) {
return Err(crate::api::AppError::BadRequest(
"非法的节点 ID 参数".to_string(),
));
}
match state.db.set_node_enabled(&node_id, false).await {
Ok(true) => {
info!("管理员已停用节点 {}(停止分发任务,保持空闲待命)", node_id);
Ok((
StatusCode::OK,
Json(
json!({ "success": true, "message": format!("节点 '{}' 已停用,不再分发任务", node_id) }),
),
))
}
Ok(false) => Err(crate::api::AppError::Conflict(format!(
"节点 '{}' 当前状态不支持停用(仅在线/离线节点可停用)",
node_id
))),
Err(e) => Err(e.into()),
}
}
/// POST /api/admin/nodes/:node_id/enable — 重新启用被手动停用的节点。
///
/// 将节点从 `disabled` 切为 `offline`,靠下一次心跳自然翻成 online 后恢复分发。
/// 仅 `disabled` 节点可启用;对其他状态调用返回 409(幂等保护)。
pub async fn enable_node(
State(state): State<AppState>,
AxumPath(node_id): AxumPath<String>,
) -> Result<impl IntoResponse, crate::api::AppError> {
if !is_valid_node_id(&node_id) {
return Err(crate::api::AppError::BadRequest(
"非法的节点 ID 参数".to_string(),
));
}
match state.db.set_node_enabled(&node_id, true).await {
Ok(true) => {
info!(
"管理员已重新启用节点 {}(下一次心跳后恢复分发任务)",
node_id
);
Ok((
StatusCode::OK,
Json(
json!({ "success": true, "message": format!("节点 '{}' 已重新启用,将在下一次心跳后恢复分发任务", node_id) }),
),
))
}
Ok(false) => Err(crate::api::AppError::Conflict(format!(
"节点 '{}' 当前状态不支持启用(仅已停用节点可启用)",
node_id
))),
Err(e) => Err(e.into()),
}
}
+16 -16
View File
@@ -29,11 +29,10 @@ pub struct AppState {
pub db: Database,
pub queue: Arc<SqliteTaskQueue>,
pub scheduler: Arc<GridScheduler>,
pub results_dir: String,
/// server 端种子库目录(原 results_dir)。下载种子时读此路径。**永不清理**。
pub seeds_dir: String,
/// 限流与密码防暴破限速器
pub rate_limiter: rate_limit::RateLimiter,
/// 兼容字段:Some 表示「已启用某种鉴权」,用于 main.rs 决定是否挂载鉴权中间件。
pub auth_token: Option<String>,
/// Admin 凭据(管理 Dashboard / workflow 写操作)。
pub admin_token: Option<String>,
/// 应急开关:跳过全部鉴权(仅本地调试)。
@@ -70,7 +69,7 @@ enum Role {
///
/// 设计依据(最小权限):
/// - Admin 写操作(workflow CRUD / start / stop / status / approve / reject)只对 admin token 开放。
/// - Node 运行态接口只认 node 专属 token(管理员在 Dashboard 审批后颁发,绑定 node_id,可吊销)。
/// - Node 运行态接口只认 node 专属 token(管理员在 Dashboard 审批后颁发,绑定 node_id,可重发轮换)。
/// - 注册端点 /node/register 和状态轮询 /node/check_status 为 Public 免凭据(提交申请 ➔ 待管理员审批)。
///
/// 注意:路径已去掉 `/api` 前缀(nest 挂载后中间件看到的 path 不含 nest 前缀)。
@@ -97,10 +96,14 @@ fn required_role(path: &str, method: &axum::http::Method) -> Option<Role> {
if path == "/status" && method == Method::GET {
return Some(Role::Admin);
}
// 管理 API(节点凭据查看/审批/吊销/重发)→ Admin
// 管理 API(节点凭据查看/审批/重发/停用/启用)→ Admin
if path.starts_with("/admin/") {
return Some(Role::Admin);
}
// 历史种子导入(run_grid.py 旧产物回灌)→ Admin
if path == "/admin/import_seed" && method == Method::POST {
return Some(Role::Admin);
}
// Node 运行态 → Node
if path == "/node/heartbeat" && method == Method::POST {
return Some(Role::Node);
@@ -139,7 +142,7 @@ fn ct_eq_str(a: &str, b: &str) -> bool {
}
/// node_id 白名单:字母、数字、点、下划线、连字符,长度 1-128。
/// 用于 register_node / admin revoke / reissue 统一入口校验,与 Dashboard XSS 防护口径一致。
/// 用于 register_node / admin reissue / disable / enable 统一入口校验,与 Dashboard XSS 防护口径一致。
pub(crate) fn is_valid_node_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
@@ -148,14 +151,6 @@ pub(crate) fn is_valid_node_id(id: &str) -> bool {
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
/// host_name 白名单:可打印 ASCII(排除控制字符),长度 1-128。
/// 防止 host_name 携带 HTML/控制字符进入管理 Dashboard 触发存储型 XSS 或污染显示。
pub(crate) fn is_valid_host_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 128
&& name.chars().all(|c| c.is_ascii() && !c.is_ascii_control())
}
/// 从请求头提取凭据原文(支持 `Authorization: Bearer <t>` 与 `X-API-Key: <t>`)。
///
/// 安全:非 `Bearer ` 前缀的 Authorization 一律视为无 token(不再回退为裸头值比较),
@@ -240,10 +235,15 @@ pub async fn auth_middleware(
let mut sessions = state.admin_sessions.write().await;
let now = std::time::Instant::now();
sessions.retain(|_, expiry| *expiry > now);
if sessions.contains_key(&token) {
// 恒定时间比对:遍历全部 session key 逐个 ct_eq_str,不提前返回
// (与 admin token 的恒定时间口径一致,消除 key 存在性的时序旁路)。
// 容量受 MAX_ADMIN_SESSIONS100)约束,遍历开销可接受。
for k in sessions.keys() {
if ct_eq_str(&token, k) {
valid = true;
}
}
}
if valid {
if is_write {
@@ -273,7 +273,7 @@ pub async fn auth_middleware(
}
None => (
StatusCode::UNAUTHORIZED,
"Unauthorized: invalid or revoked node token",
"Unauthorized: invalid or stale node token",
)
.into_response(),
}
+1 -6
View File
@@ -1,4 +1,4 @@
use super::{is_valid_host_name, is_valid_node_id, AppState, AuthenticatedNode};
use super::{is_valid_node_id, AppState, AuthenticatedNode};
use axum::{
extract::{Extension, State},
http::StatusCode,
@@ -20,11 +20,6 @@ pub async fn register_node(
"非法的节点 ID(仅允许字母、数字、点、下划线、连字符,长度 1-128)".to_string(),
));
}
if !is_valid_host_name(&req.host_name) {
return Err(crate::api::AppError::BadRequest(
"非法的主机名(仅允许可打印 ASCII,长度 1-128".to_string(),
));
}
// 已认证已拿到 Token 的节点刷新元数据配置
if let Some(Extension(ref auth)) = auth_node {
+1 -1
View File
@@ -28,7 +28,7 @@ pub async fn download_seed(
));
}
let seed_file_path = std::path::Path::new(&state.results_dir)
let seed_file_path = std::path::Path::new(&state.seeds_dir)
.join(&name)
.join(format!("{}.7", name));
+2 -1
View File
@@ -24,7 +24,8 @@ pub async fn get_status(
.get_grid_summary_stats(None)
.await
.unwrap_or(serde_json::json!({
"total": 0, "pending": 0, "running": 0, "converged": 0, "failed": 0
"total": 0, "pending": 0, "queued": 0, "running": 0, "converged": 0, "failed": 0,
"cold_run_converged": 0, "seed_step_converged": 0, "imported_converged": 0
}));
Ok(Json(json!({
+185 -9
View File
@@ -1,10 +1,11 @@
use super::{AppState, AuthenticatedNode};
use axum::{
extract::{Extension, Multipart, State},
extract::{Extension, Multipart, Query, State},
response::IntoResponse,
Json,
};
use common::models::{GridPointParams, ModelSummary, TaskReport, TaskStatus};
use serde::Deserialize;
use serde_json::json;
use std::path::Path;
use tokio::fs;
@@ -16,6 +17,23 @@ pub async fn claim_task(
State(state): State<AppState>,
Extension(auth_node): Extension<AuthenticatedNode>,
) -> Result<impl IntoResponse, crate::api::AppError> {
// 管理员手动停用拦截:被停用的节点保持在线但不再分发任务。
// 返回 HTTP 200 + {"status":"disabled"}(绝不能用 403 —— worker 见 403 会判定
// token 失效而 exit(1),停用是运维意图而非凭据失效,应让 worker 空闲待命)。
match state.db.is_node_disabled(&auth_node.node_id).await {
Ok(true) => {
return Ok((
StatusCode::OK,
Json(json!({"status": "disabled", "task": null})),
));
}
Ok(false) => {}
Err(e) => {
tracing::error!("查询节点停用状态异常: {}", e);
return Err(crate::api::AppError::Internal(e));
}
}
// 领用时记录任务归属:pop_task 写入 claimed_by_node_id
// report 阶段据此校验「上报者确为领用者」,杜绝跨节点伪造结果。
match state.queue.pop_task(&auth_node.node_id).await {
@@ -112,12 +130,7 @@ pub async fn report_task(
let name = report.point_name.clone();
if name.is_empty()
|| name.starts_with('.')
|| !name.chars().all(|c| {
c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '+' || c == '@'
})
{
if !super::workflow::is_valid_point_name(&name) {
warn!(
"拒绝可能包含路径穿越或特殊非常规编码号攻击的网格点名称请求: {}",
name
@@ -157,7 +170,7 @@ pub async fn report_task(
}
// 采用原子写入模式保持 conv.json 与核心二进制数据完整落地后才揭晓真实文件名
let model_dir = Path::new(&state.results_dir).join(&name);
let model_dir = Path::new(&state.seeds_dir).join(&name);
if fs::create_dir_all(&model_dir).await.is_ok() {
let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple()));
let conv_path = model_dir.join("conv.json");
@@ -197,7 +210,7 @@ pub async fn report_task(
info!("网格点 {} 计算未成功完成,检查种子回退机制...", name);
if let Err(e) = state
.scheduler
.trigger_seed_step_fallback(&params, &workflow_name)
.trigger_seed_step_fallback(&params, &name, &workflow_name)
.await
{
warn!("网格点 {} 触发种子回退机制失败: {}", name, e);
@@ -218,3 +231,166 @@ fn extract_params(report: &TaskReport) -> Option<GridPointParams> {
.ok()
.map(|summary| summary.params)
}
/// `/admin/import_seed` 的查询参数。
#[derive(Debug, Deserialize)]
pub struct ImportSeedQuery {
/// 目标工作流名(导入到此工作流的 grid_points)。缺省归入 `imported` 工作流。
#[serde(default = "default_import_workflow")]
pub workflow: String,
}
fn default_import_workflow() -> String {
"imported".to_string()
}
/// 历史种子导入端点(Admin 鉴权)。
///
/// 供 `tools/import_results` 把旧版单机 `run_grid.py` 产物(`conv.json` + `.7` 大气文件)
/// 批量回灌进 DCTS。与 `/task/report` 的关键区别:
/// - **跳过任务归属校验**`verify_task_claim`):历史数据无领用语义,导入端点不经过
/// claim/report 队列,直接幂等落库。
/// - **`point_name` 取旧 `conv.json` 的 `name` 字段**Python `gen_input5.model_name`
/// 生成的源精度真名,如 `t20000_g5.0_...`),而非从数值重推——保证迁移逐字符保真。
/// - **真实 `max_relc`** 取自 `summary.final_max_relc`(旧版已记录),不硬编码。
///
/// 幂等:`upsert_grid_point` 用 `ON CONFLICT DO NOTHING``.7`/`conv.json` 原子覆盖写,
/// 可重复运行。
pub async fn import_seed(
State(state): State<AppState>,
Query(query): Query<ImportSeedQuery>,
mut multipart: Multipart,
) -> Result<impl IntoResponse, crate::api::AppError> {
let mut summary_json: Option<String> = None;
let mut seed_file_data: Option<Vec<u8>> = None;
while let Ok(Some(field)) = multipart.next_field().await {
let field_name = field.name().unwrap_or("").to_string();
if field_name == "report" {
if let Ok(bytes) = field.bytes().await {
summary_json = Some(String::from_utf8_lossy(&bytes).to_string());
}
} else if field_name == "seed_file" {
if let Ok(bytes) = field.bytes().await {
seed_file_data = Some(bytes.to_vec());
}
}
}
let summary_json = match summary_json {
Some(s) => s,
None => {
return Err(crate::api::AppError::BadRequest(
"请求中缺少 report 字段(旧版 conv.json 内容)".to_string(),
));
}
};
// 解析旧版 conv.jsonModelSummary 结构)取 name / params / 收敛状态 / 真实 max_relc。
let summary: ModelSummary = match serde_json::from_str(&summary_json) {
Ok(s) => s,
Err(e) => {
warn!("历史种子导入:conv.json 解析失败: {}", e);
return Err(crate::api::AppError::BadRequest(
"conv.json 解析失败,非合法 ModelSummary".to_string(),
));
}
};
// point_name 优先用旧 conv.json 的 name(源精度真名);回退到 params 规范名。
let name = if !summary.name.is_empty() {
summary.name.clone()
} else {
summary.params.model_name()
};
// 名称合法性校验(防路径穿越),与 report_task 同口径。
if !super::workflow::is_valid_point_name(&name) {
warn!("历史种子导入:拒绝非法网格点名称: {}", name);
return Err(crate::api::AppError::BadRequest(
"非法的网格点名称参数".to_string(),
));
}
let workflow_name = query.workflow;
let params = summary.params.clone();
let converged = summary.converged && !summary.atmosphere_has_nan;
let max_relc = summary.final_max_relc;
// 1. 幂等写入 grid_pointsON CONFLICT DO NOTHING):无需事先 start 工作流。
// 用权威 name(旧 conv.json 的源精度真名),而非从 params 重推——导入路径的
// params 来自旧 JSON(无源文本,model_name() 会失真)。
if let Err(e) = state
.db
.upsert_grid_point_named(&name, &params, 0, &workflow_name)
.await
{
tracing::error!("历史种子导入:upsert grid_points {} 失败: {}", name, e);
return Err(crate::api::AppError::Internal(e));
}
// 2. 落地 conv.json(原子 tmp→rename)。
let model_dir = Path::new(&state.seeds_dir).join(&name);
if fs::create_dir_all(&model_dir).await.is_ok() {
let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple()));
let conv_path = model_dir.join("conv.json");
if fs::write(&conv_tmp, &summary_json).await.is_ok() {
let _ = fs::rename(&conv_tmp, &conv_path).await;
}
// 3. 收敛且干净才写 .7 + 入种子库(与 report_task 同口径)。
if converged {
if let Some(bytes) = seed_file_data {
let seed_tmp =
model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple()));
let seed_path = model_dir.join(format!("{}.7", name));
if fs::write(&seed_tmp, bytes).await.is_ok()
&& fs::rename(&seed_tmp, &seed_path).await.is_ok()
{
info!(
"历史种子导入:网格点 {} 收敛种子已落地: {} (max_relc={:?})",
name,
seed_path.display(),
max_relc
);
let _ = state
.db
.insert_seed_named(&name, &params, &seed_path.to_string_lossy())
.await;
}
} else {
warn!(
"历史种子导入:网格点 {} 声称收敛但未上传 seed_file,跳过种子写入",
name
);
}
}
}
// 4. 更新 grid_points 状态:收敛→converged(success_method='imported');否则维持 pending
// 让正常调度处理(导入未收敛点无意义,但记录其尝试)。
if converged {
if let Err(e) = state
.db
.mark_grid_point_imported(&name, &workflow_name, Some(summary.elapsed_sec))
.await
{
warn!("历史种子导入:标记 {} 为 converged 失败: {}", name, e);
}
}
info!(
"历史种子导入完成:网格点 {} (workflow={}, converged={}, max_relc={:?})",
name, workflow_name, converged, max_relc
);
Ok((
StatusCode::OK,
Json(json!({
"status": "ok",
"point_name": name,
"converged": converged,
"max_relc": max_relc,
})),
))
}
+346 -4
View File
@@ -1,6 +1,6 @@
use super::AppState;
use axum::{
extract::{Path as AxumPath, State},
extract::{Path as AxumPath, Query as AxumQuery, State},
http::StatusCode,
response::IntoResponse,
Json,
@@ -71,6 +71,19 @@ fn is_valid_workflow_name(name: &str) -> bool {
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
/// 网格点名称白名单(全端统一口径):字母/数字/`.`/`_`/`-`/`+`/`@`
/// 非空、不以 `.` 开头(拒绝 `..` 穿越)、长度 ≤ 128。
///
/// 字符集不含 `/`、`\`,从源头杜绝路径穿越——任何磁盘路径拼接前的唯一闸门。
pub(crate) fn is_valid_point_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('.')
&& name.len() <= 128
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+' | '@'))
}
pub async fn save_workflow(
State(state): State<AppState>,
Json(req): Json<CreateWorkflowRequest>,
@@ -82,8 +95,8 @@ pub async fn save_workflow(
));
}
// Validate YAML config string
if let Err(e) = serde_yaml::from_str::<GridConfig>(&req.config_yaml) {
// Validate YAML config string(用源精度解析,校验 + 保留 grid 轴书写小数位)
if let Err(e) = GridConfig::from_yaml_str(&req.config_yaml) {
return Err(crate::api::AppError::BadRequest(format!(
"无效的 YAML 配置: {}",
e
@@ -186,7 +199,7 @@ pub async fn start_workflow(
Ok(true) => {}
}
let grid_cfg: GridConfig = match serde_yaml::from_str(&item.config_yaml) {
let grid_cfg: GridConfig = match GridConfig::from_yaml_str(&item.config_yaml) {
Ok(cfg) => cfg,
Err(e) => {
let _ = state.db.update_workflow_status(&name, "idle").await;
@@ -233,6 +246,335 @@ pub async fn start_workflow(
))
}
/// 单工作流执行统计:进度(pending/queued/running/converged/failed 分开计数)、
/// 收敛手段归因(冷启动/种子步进/历史导入)、难度波次分布、近似 ETA。详情页数据源。
pub async fn get_workflow_stats(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> Result<impl IntoResponse, crate::api::AppError> {
if !is_valid_workflow_name(&name) {
return Err(crate::api::AppError::BadRequest(
"工作流名称含非法字符".to_string(),
));
}
let item = match state.db.get_workflow(&name).await {
Ok(Some(item)) => item,
Ok(None) => {
return Err(crate::api::AppError::NotFound(format!(
"工作流 '{}' 未找到",
name
)))
}
Err(e) => return Err(e.into()),
};
// 在线节点总槽位:ETA 并发感知除数(无在线节点时按串行兜底,db 层 max(1))。
let total_slots: i64 = state
.db
.get_active_nodes()
.await
.unwrap_or_default()
.iter()
.map(|n| n.max_slots as i64)
.sum();
match state
.db
.get_workflow_detail_stats(&name, &item.status, total_slots)
.await
{
Ok(stats) => Ok((
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "成功获取工作流统计".to_string(),
data: Some(stats),
}),
)),
Err(e) => Err(e.into()),
}
}
/// 进度时间序列查询参数。
#[derive(Debug, serde::Deserialize)]
pub struct ProgressQuery {
/// 时间窗口(小时),默认 24,钳位 1–168(7 天)。
pub hours: Option<i64>,
}
/// 工作流进度时间序列 + 经验速率 + 停滞时长(详情页概览进度曲线数据源)。
///
/// - `series`:窗口内的计数快照(超 300 点自动降采样,首末点保留);
/// - `rate_per_hour`:窗口首末 converged 增量 ÷ 时长(快照 <2 条或时长 ≤0 为 null);
/// - `stalled_minutes`:终态数(converged+failed)最后一次增长到窗口末端的分钟数
/// (用于"进度停滞"预警;快照 <2 条为 null)。
pub async fn get_workflow_progress(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
AxumQuery(q): AxumQuery<ProgressQuery>,
) -> Result<impl IntoResponse, crate::api::AppError> {
if !is_valid_workflow_name(&name) {
return Err(crate::api::AppError::BadRequest(
"工作流名称含非法字符".to_string(),
));
}
match state.db.get_workflow(&name).await {
Ok(Some(_)) => {}
Ok(None) => {
return Err(crate::api::AppError::NotFound(format!(
"工作流 '{}' 未找到",
name
)))
}
Err(e) => return Err(e.into()),
}
let hours = q.hours.unwrap_or(24).clamp(1, 168);
let mut series = state.db.get_progress_series(&name, hours).await?;
// 降采样:过密时等间隔抽取,首末点强制保留(曲线端点不失真)。
if series.len() > 300 {
let step = (series.len() as f64 / 300.0).ceil() as usize;
let last = series.last().cloned();
series = series.into_iter().step_by(step).collect();
if let Some(l) = last {
if series.last().map(|p| p.ts.as_str()) != Some(l.ts.as_str()) {
series.push(l);
}
}
}
// SQLite datetime('now') 为 UTC 'YYYY-MM-DD HH:MM:SS'
let parse_ts = |ts: &str| chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S").ok();
let rate_per_hour: Option<f64> = match (series.first(), series.last()) {
(Some(first), Some(last)) if series.len() >= 2 => {
match (parse_ts(&first.ts), parse_ts(&last.ts)) {
(Some(t0), Some(t1)) => {
let dh = (t1 - t0).num_seconds() as f64 / 3600.0;
if dh > 0.0 {
Some((last.converged - first.converged) as f64 / dh)
} else {
None
}
}
_ => None,
}
}
_ => None,
};
let stalled_minutes: Option<f64> = if series.len() >= 2 {
let mut last_progress_idx = None;
for i in 1..series.len() {
let prev = series[i - 1].converged + series[i - 1].failed;
let cur = series[i].converged + series[i].failed;
if cur > prev {
last_progress_idx = Some(i);
}
}
let anchor = last_progress_idx.unwrap_or(0);
let end = series.len() - 1;
match (parse_ts(&series[anchor].ts), parse_ts(&series[end].ts)) {
(Some(t0), Some(t1)) => Some(((t1 - t0).num_seconds() as f64 / 60.0).max(0.0)),
_ => None,
}
} else {
None
};
Ok((
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "成功获取进度时间序列".to_string(),
data: Some(serde_json::json!({
"hours": hours,
"series": series,
"rate_per_hour": rate_per_hour,
"stalled_minutes": stalled_minutes,
})),
}),
))
}
/// 逐点列表查询参数。枚举类参数(status/method/sort/order)一律白名单校验后
/// 才进入 db 层;limit/offset 钳位;任何用户文本都不会拼进 SQL 字符串。
#[derive(Debug, serde::Deserialize)]
pub struct PointsQuery {
pub status: Option<String>,
pub method: Option<String>,
pub wave: Option<i32>,
pub q: Option<String>,
pub sort: Option<String>,
pub order: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// 工作流逐点列表:点参数 + 状态 + 收敛手段 + 最近尝试(max_relc/种子来源/节点/错误)。
/// 支持状态/手段/波次过滤、点名搜索、白名单排序与分页(limit ≤ 500)。详情页点表数据源。
pub async fn get_workflow_points(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
AxumQuery(pq): AxumQuery<PointsQuery>,
) -> Result<impl IntoResponse, crate::api::AppError> {
if !is_valid_workflow_name(&name) {
return Err(crate::api::AppError::BadRequest(
"工作流名称含非法字符".to_string(),
));
}
// 未知工作流返回 404(与 stats 端点一致),而非空列表
match state.db.get_workflow(&name).await {
Ok(Some(_)) => {}
Ok(None) => {
return Err(crate::api::AppError::NotFound(format!(
"工作流 '{}' 未找到",
name
)))
}
Err(e) => return Err(e.into()),
}
if let Some(s) = &pq.status {
if !matches!(
s.as_str(),
"pending" | "queued" | "running" | "converged" | "failed"
) {
return Err(crate::api::AppError::BadRequest(format!(
"非法的 status 参数: {}",
s
)));
}
}
if let Some(m) = &pq.method {
if !matches!(m.as_str(), "cold_run" | "seed_step" | "imported") {
return Err(crate::api::AppError::BadRequest(format!(
"非法的 method 参数: {}",
m
)));
}
}
// 排序白名单 → 编译期列名片段;NULL 统一靠后(IS NULL 升序前置),不受 dir 影响。
let sort = pq.sort.as_deref().unwrap_or("wave");
let dir = if pq
.order
.as_deref()
.map(|o| o.eq_ignore_ascii_case("desc"))
.unwrap_or(false)
{
"DESC"
} else {
"ASC"
};
let order_by = match sort {
"wave" => format!("gp.wave {dir}, gp.cno_sum ASC, gp.teff ASC"),
"teff" => format!("gp.teff {dir}, gp.wave ASC, gp.cno_sum ASC"),
"max_relc" => format!("t.max_relc IS NULL ASC, t.max_relc {dir}, gp.wave ASC"),
"attempts" => format!("gp.attempt_count {dir}, gp.wave ASC, gp.cno_sum ASC"),
"last_completed_at" => {
format!("t.completed_at IS NULL ASC, t.completed_at {dir}, gp.wave ASC")
}
_ => {
return Err(crate::api::AppError::BadRequest(format!(
"非法的 sort 参数: {}",
sort
)))
}
};
let filter = crate::db::PointFilter {
status: pq.status.clone(),
method: pq.method.clone(),
wave: pq.wave,
q: pq.q.clone(),
order_by,
limit: pq.limit.unwrap_or(100).clamp(1, 500),
offset: pq.offset.unwrap_or(0).max(0),
};
match state.db.list_workflow_points(&name, &filter).await {
Ok((total, points)) => Ok((
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "成功获取工作流网格点列表".to_string(),
data: Some(serde_json::json!({ "total": total, "points": points })),
}),
)),
Err(e) => Err(e.into()),
}
}
/// 单网格点详情:点行 + 全部尝试历史 + conv.json 逐阶段诊断。
///
/// conv.json 读自 `seeds_dir/<point>/conv.json`(单层目录)。点名经 `is_valid_point_name`
/// 白名单(无 `/`、`\`,拒前导 `.`)——即路径穿越的前置闸门;读盘后再做 canonicalize
/// 归属兜底校验(纵深防御)。缺失/读失败/解析失败一律 `conv: null`(仍 200),
/// 前端降级显示"诊断文件不可用"。
pub async fn get_workflow_point_detail(
State(state): State<AppState>,
AxumPath((name, point)): AxumPath<(String, String)>,
) -> Result<impl IntoResponse, crate::api::AppError> {
if !is_valid_workflow_name(&name) || !is_valid_point_name(&point) {
return Err(crate::api::AppError::BadRequest(
"工作流名称或网格点名称含非法字符".to_string(),
));
}
let point_row = match state.db.get_workflow_point_row(&name, &point).await {
Ok(Some(row)) => row,
Ok(None) => {
return Err(crate::api::AppError::NotFound(format!(
"网格点 '{}' 未找到(工作流 '{}'",
point, name
)))
}
Err(e) => return Err(e.into()),
};
let attempts = state.db.list_point_attempts(&name, &point).await?;
let conv_path = std::path::Path::new(&state.seeds_dir)
.join(&point)
.join("conv.json");
let conv: Option<common::models::ModelSummary> =
match tokio::fs::read_to_string(&conv_path).await {
Ok(s) => {
let confined = std::path::Path::new(&state.seeds_dir)
.canonicalize()
.ok()
.zip(conv_path.canonicalize().ok())
.map(|(root, f)| f.starts_with(root))
.unwrap_or(false);
if confined {
match serde_json::from_str(&s) {
Ok(summary) => Some(summary),
Err(e) => {
tracing::warn!("网格点 {} 的 conv.json 解析失败: {}", point, e);
None
}
}
} else {
tracing::warn!("网格点 {} 的 conv.json 路径越界,拒绝读取", point);
None
}
}
Err(_) => None,
};
Ok((
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "成功获取网格点详情".to_string(),
data: Some(serde_json::json!({
"point": point_row,
"attempts": attempts,
"conv": conv,
})),
}),
))
}
pub async fn stop_workflow(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
+913 -189
View File
File diff suppressed because it is too large Load Diff
+52 -25
View File
@@ -55,11 +55,7 @@ async fn main() -> Result<()> {
let db = Database::new(&server_cfg.db_path).await?;
let queue = Arc::new(SqliteTaskQueue::new(&server_cfg.queue_db_path).await?);
let scheduler = Arc::new(GridScheduler::new(
db.clone(),
queue.clone(),
server_cfg.results_dir.clone(),
));
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
// Auto-register sdB_cno.yaml if exists and not yet in DB
let default_wf_path = Path::new(&server_cfg.grid_config);
@@ -81,18 +77,10 @@ async fn main() -> Result<()> {
}
}
// 弱口令凭据安全警告检测
let is_weak_token = |t: Option<&str>| -> bool {
match t {
Some(s) => {
s.len() < 12 || s == "fmqi123" || s == "admin" || s == "123456" || s == "secret"
}
None => false,
}
};
if is_weak_token(server_cfg.auth_token.as_deref())
|| is_weak_token(server_cfg.admin_token.as_deref())
{
// 弱口令凭据安全警告检测:仅按强度阈值判断(短于 16 字节视为弱口令)。
// 推荐用 `openssl rand -hex 32`64 字符)生成。
let is_weak_token = |t: Option<&str>| -> bool { t.map(|s| s.len() < 16).unwrap_or(false) };
if is_weak_token(server_cfg.admin_token.as_deref()) {
tracing::warn!("⚠️ 检测到系统当前正在使用弱口令凭据或默认 Token!建议生产环境在 .env 中配置使用 openssl rand -hex 32 生成的高强度 Token");
}
@@ -102,9 +90,8 @@ async fn main() -> Result<()> {
db,
queue: queue.clone(),
scheduler: scheduler.clone(),
results_dir: server_cfg.results_dir.clone(),
seeds_dir: server_cfg.seeds_dir.clone(),
rate_limiter,
auth_token: server_cfg.auth_token.clone(),
admin_token: server_cfg.admin_token.clone(),
auth_disabled: server_cfg.auth_disabled,
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
@@ -188,6 +175,23 @@ async fn main() -> Result<()> {
has_error = true;
}
// P3 进度快照:对每个运行中工作流记录计数(record_progress_snapshot
// 内部去重——计数无变化不落库);顺带清理超过 7 天的旧快照。
// 观测性写入失败不回退调度退避(不置 has_error)。
match bg_db_clone.get_running_workflow_names().await {
Ok(names) => {
for wf in names {
if let Err(e) = bg_db_clone.record_progress_snapshot(&wf).await {
tracing::warn!("记录工作流 {} 进度快照失败: {}", wf, e);
}
}
}
Err(e) => tracing::warn!("获取运行中工作流列表失败: {}", e),
}
if let Err(e) = bg_db_clone.purge_progress_snapshots(7).await {
tracing::warn!("清理过期进度快照失败: {}", e);
}
has_error
});
@@ -234,6 +238,8 @@ async fn main() -> Result<()> {
let report_router = Router::new()
.route("/task/report", post(api::task::report_task))
// 历史种子导入同样上传 .7 大气文件,并入宽松 body limit / 并发限流组。
.route("/admin/import_seed", post(api::task::import_seed))
.layer(DefaultBodyLimit::max(REPORT_BODY_LIMIT))
.layer(tower::ServiceBuilder::new().concurrency_limit(REPORT_MAX_CONCURRENCY));
@@ -284,7 +290,24 @@ async fn main() -> Result<()> {
post(api::workflow::start_workflow),
)
.route("/workflows/:name/stop", post(api::workflow::stop_workflow))
// Admin Management API(节点凭据查看/审批/吊销/重发,均要求 Admin 角色)
// 工作流执行观测 API(进度统计 / 逐点明细 / 单点诊断,均要求 Admin 角色)
.route(
"/workflows/:name/stats",
get(api::workflow::get_workflow_stats),
)
.route(
"/workflows/:name/progress",
get(api::workflow::get_workflow_progress),
)
.route(
"/workflows/:name/points",
get(api::workflow::get_workflow_points),
)
.route(
"/workflows/:name/points/:point",
get(api::workflow::get_workflow_point_detail),
)
// Admin Management API(节点凭据查看/审批/重发/停用/启用,均要求 Admin 角色)
.route("/admin/nodes", get(api::admin::list_nodes))
.route(
"/admin/nodes/:node_id/approve",
@@ -294,14 +317,18 @@ async fn main() -> Result<()> {
"/admin/nodes/:node_id/reject",
post(api::admin::reject_node),
)
.route(
"/admin/nodes/:node_id/revoke",
post(api::admin::revoke_node),
)
.route(
"/admin/nodes/:node_id/reissue",
post(api::admin::reissue_node),
)
.route(
"/admin/nodes/:node_id/disable",
post(api::admin::disable_node),
)
.route(
"/admin/nodes/:node_id/enable",
post(api::admin::enable_node),
)
// 合并大体积上报路由(继承各自的 body limit)
.merge(report_router)
.layer(DefaultBodyLimit::max(DEFAULT_BODY_LIMIT));
@@ -320,7 +347,7 @@ async fn main() -> Result<()> {
api_router.layer(auth_layer).layer(rate_limit_layer)
} else {
tracing::warn!(
"⚠️ 警告:未配置 DCTS_ADMIN_TOKEN / DCTS_ENROLLMENT_TOKEN(且未启用 DCTS_AUTH_DISABLE),\
"⚠️ 警告:未配置 DCTS_ADMIN_TOKEN(且未启用 DCTS_AUTH_DISABLE),\
"
);
api_router
+56 -61
View File
@@ -11,16 +11,11 @@ use crate::db::Database;
pub struct GridScheduler {
db: Database,
queue: Arc<SqliteTaskQueue>,
_results_dir: String,
}
impl GridScheduler {
pub fn new(db: Database, queue: Arc<SqliteTaskQueue>, results_dir: String) -> Self {
Self {
db,
queue,
_results_dir: results_dir,
}
pub fn new(db: Database, queue: Arc<SqliteTaskQueue>) -> Self {
Self { db, queue }
}
/// Expands grid points from config and registers them into the database.
@@ -52,19 +47,19 @@ impl GridScheduler {
}
let mut points = Vec::new();
for &teff in &cfg.grid.teff {
for &logg in &cfg.grid.logg {
for &loghe in &cfg.grid.loghe {
for &logc in &cfg.grid.logc {
for &logn in &cfg.grid.logn {
for &logo in &cfg.grid.logo {
for teff in &cfg.grid.teff {
for logg in &cfg.grid.logg {
for loghe in &cfg.grid.loghe {
for logc in &cfg.grid.logc {
for logn in &cfg.grid.logn {
for logo in &cfg.grid.logo {
points.push(GridPointParams {
teff,
logg,
loghe,
logc,
logn,
logo,
teff: teff.clone(),
logg: logg.clone(),
loghe: loghe.clone(),
logc: logc.clone(),
logn: logn.clone(),
logo: logo.clone(),
});
}
}
@@ -196,7 +191,7 @@ impl GridScheduler {
.await?;
let mut dispatched = 0;
for (name, params, _wave) in pending {
for (name, params, wave) in pending {
// Check if any seed is available in DB for active SeedStep schedulingseeds 全局共享)
let (task_type, seed_name) = match self.db.find_best_seed_from_db(&params).await {
Ok(Some(seed_match)) => {
@@ -217,6 +212,7 @@ impl GridScheduler {
seed_point_name: seed_name,
timeout_sec,
workflow_name: Some(workflow_name.to_string()),
wave,
};
self.db.insert_task(&task_spec).await?;
@@ -274,6 +270,7 @@ impl GridScheduler {
pub async fn trigger_seed_step_fallback(
&self,
params: &GridPointParams,
name: &str,
workflow_name: &str,
) -> Result<bool> {
// 该工作流须仍处于 running 态才回退(避免 stop 后继续派发)
@@ -291,9 +288,13 @@ impl GridScheduler {
return Ok(false);
}
let name = params.model_name();
// name 取自权威的 report.point_name= grid_points.name 列,源精度正确),
// 而非 params.model_name()。原因:此处 params 经 node 上报回传,其 logg 等
// 轴在服务端 DB REAL 列回读时已丢精度(5.0→"5"),重推 model_name() 会得到
// 降级名(g5 而非 g5.0),导致回退任务的 point_name 与 grid_points.name 列错配,
// 状态更新静默失败。与 runner 的修复保持同一原则:用权威 name。
// 种子回退仅一次:该点在该工作流中已经派发过 seed_step 任务就不再触发新的回退
if self.db.has_seed_step_attempt(&name, workflow_name).await? {
if self.db.has_seed_step_attempt(name, workflow_name).await? {
info!(
"网格点 {} 已使用过一次种子热启动回退,不再重复回退,保持 failed 终态",
name
@@ -306,30 +307,27 @@ impl GridScheduler {
if let Some(seed_match) = seed_match_opt {
let timeout_sec = self.get_workflow_timeout_sec(workflow_name).await;
let name = params.model_name();
let task_spec = TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
point_name: name.to_string(),
params: params.clone(),
task_type: TaskType::SeedStep,
seed_point_name: Some(seed_match.name.clone()),
timeout_sec,
workflow_name: Some(workflow_name.to_string()),
// seed_step 是失败后的回退任务,wave 设 0 不抢占正常调度队列里的低难度 wave 优先级。
wave: 0,
};
self.db.insert_task(&task_spec).await?;
self.db
.update_grid_status(
&name,
common::models::GridPointStatus::Queued,
workflow_name,
)
.update_grid_status(name, common::models::GridPointStatus::Queued, workflow_name)
.await?;
if let Err(e) = self.queue.push_task(&task_spec).await {
let _ = self
.db
.update_grid_status(
&name,
name,
common::models::GridPointStatus::Pending,
workflow_name,
)
@@ -358,7 +356,6 @@ mod tests {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("sched_db.db");
let queue_db_path = temp_dir.path().join("sched_queue.db");
let results_dir = temp_dir.path().join("results");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
@@ -366,20 +363,17 @@ mod tests {
.await
.unwrap(),
);
let scheduler = GridScheduler::new(
db.clone(),
queue.clone(),
results_dir.to_string_lossy().to_string(),
);
let scheduler = GridScheduler::new(db.clone(), queue.clone());
#[allow(deprecated)] // results 是死字段,构造时必须填 None
let cfg = GridConfig {
grid: GridAxesConfig {
teff: vec![35000.0],
logg: vec![5.5],
loghe: vec![-1.0],
logc: vec![-2.0],
logn: vec![-2.0],
logo: vec![-2.0],
teff: vec![35000.0.into()],
logg: vec![5.5.into()],
loghe: vec![(-1.0).into()],
logc: vec![(-2.0).into()],
logn: vec![(-2.0).into()],
logo: vec![(-2.0).into()],
},
chain: vec![],
synspec: None,
@@ -425,16 +419,17 @@ mod tests {
.await
.unwrap(),
);
let scheduler = GridScheduler::new(db.clone(), queue.clone(), "results".to_string());
let scheduler = GridScheduler::new(db.clone(), queue.clone());
#[allow(deprecated)] // results 是死字段,构造时必须填 None
let mk_cfg = |teff: f64| GridConfig {
grid: GridAxesConfig {
teff: vec![teff],
logg: vec![5.5],
loghe: vec![-1.0],
logc: vec![-2.0],
logn: vec![-2.0],
logo: vec![-2.0],
teff: vec![teff.into()],
logg: vec![5.5.into()],
loghe: vec![(-1.0).into()],
logc: vec![(-2.0).into()],
logn: vec![(-2.0).into()],
logo: vec![(-2.0).into()],
},
chain: vec![],
synspec: None,
@@ -466,12 +461,12 @@ mod tests {
// 重新推一个 wf_a 任务(上一行 pop 掉了),再初始化 wf_b
db.update_grid_status(
&GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
}
.model_name(),
common::models::GridPointStatus::Pending,
@@ -496,12 +491,12 @@ mod tests {
// 关键断言:把 wf_a 任务重新推回队列后,初始化 wf_b 不应清空它。
db.update_grid_status(
&GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
}
.model_name(),
common::models::GridPointStatus::Pending,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
//! 验证「历史种子导入的工作流名」与「正式工作流名」的隔离关系。
//!
//! 用户意图:import_results 把旧 Python 计算结果导入,标记为已完成,避免重算。
//! 关键问题:导入到工作流 A,之后正式启动工作流 B(同名/异名),B 能否看到 A 标记的 converged
use common::config::GridConfig;
use common::models::GridPointParams;
use mq::sqlite_queue::SqliteTaskQueue;
use server::{db::Database, scheduler::GridScheduler};
use std::sync::Arc;
fn make_params() -> GridPointParams {
use common::models::GridAxisValue;
GridPointParams {
teff: GridAxisValue::from_value(20000.0),
logg: GridAxisValue::from_value(5.0),
loghe: GridAxisValue::from_value(-2.0),
logc: GridAxisValue::from_value(-4.0),
logn: GridAxisValue::from_value(-4.0),
logo: GridAxisValue::from_value(-4.0),
}
}
/// 构造只含一个网格点(t20000_g5.0_he-2_c-4_n-4_o-4)的 config。
fn make_grid_cfg() -> GridConfig {
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
GridConfig::from_yaml_str(yaml).unwrap()
}
async fn setup() -> (Database, Arc<GridScheduler>) {
let tmp = tempfile::tempdir().unwrap();
let db = Database::new(&tmp.path().join("db.db").to_string_lossy())
.await
.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&tmp.path().join("q.db").to_string_lossy())
.await
.unwrap(),
);
let sched = Arc::new(GridScheduler::new(db.clone(), queue));
(db, sched)
}
/// 场景 1(正确用法):导入到工作流 "sdB_cno",再用同名 config initialize_grid。
/// 期望:initialize_grid 的 ON CONFLICT(workflow_name, name) DO NOTHING 保留 converged 状态。
#[tokio::test]
async fn test_same_workflow_name_preserves_converged() {
let (db, sched) = setup().await;
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let p = make_params();
// 模拟 import_seedupsert + mark_imported,工作流名 = sdB_cno
db.upsert_grid_point_named(name, &p, 0, "sdB_cno")
.await
.unwrap();
db.mark_grid_point_imported(name, "sdB_cno", None)
.await
.unwrap();
// 之后正式启动同名工作流:initialize_grid(sdB_cno)
sched
.initialize_grid(&make_grid_cfg(), "sdB_cno")
.await
.unwrap();
let st = db.get_grid_point_status(name, "sdB_cno").await.unwrap();
assert_eq!(
st.unwrap().0,
"converged",
"同名工作流:导入的 converged 应被保留,避免重算"
);
println!("✓ 场景1(同名):status=converged,旧结果被保留,不会重算");
}
/// 场景 2(错误用法):导入到工作流 "imported",之后正式启动 "sdB_cno"。
/// 期望:sdB_cno 分区下是新插入的 pending 行,看不到 imported 分区的 converged。
#[tokio::test]
async fn test_different_workflow_name_causes_recompute() {
let (db, sched) = setup().await;
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let p = make_params();
// 模拟 import_seed:导入到 "imported" 工作流
db.upsert_grid_point_named(name, &p, 0, "imported")
.await
.unwrap();
db.mark_grid_point_imported(name, "imported", None)
.await
.unwrap();
// 之后正式启动 "sdB_cno" 工作流
sched
.initialize_grid(&make_grid_cfg(), "sdB_cno")
.await
.unwrap();
// imported 分区:converged(种子库有,但不会被 sdB_cno 调度看到)
let st_imp = db.get_grid_point_status(name, "imported").await.unwrap();
assert_eq!(st_imp.unwrap().0, "converged");
// sdB_cno 分区:pending(重新算!看不到 imported 的 converged
let st_real = db.get_grid_point_status(name, "sdB_cno").await.unwrap();
assert_eq!(
st_real.unwrap().0,
"pending",
"异名工作流:sdB_cno 看不到 imported 的 converged,会重算"
);
println!("✓ 场景2(异名):sdB_cno 分区 status=pending,会重复计算 —— 验证了工作流名必须匹配");
}
/// 场景 3(真实意图验证):旧网格有部分点已算完(导入为 converged),
/// 新网格比旧网格多了若干点。用同名工作流启动后:
/// - 旧点保持 converged(不重算)
/// - 新点是 pending(会被调度计算)
/// 这正是「同步旧结果避免重算」的核心语义。
#[tokio::test]
async fn test_mixed_grid_import_then_init_avoids_recompute() {
let (db, sched) = setup().await;
let p_old = make_params(); // t20000_g5.0_...
// 模拟 import_seed:旧网格里这个点已收敛,导入到 sdB_cno
db.upsert_grid_point_named("t20000_g5.0_he-2_c-4_n-4_o-4", &p_old, 0, "sdB_cno")
.await
.unwrap();
db.mark_grid_point_imported("t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno", None)
.await
.unwrap();
// 正式启动 sdB_cno,config 比旧网格多了一个新点(t25000)
let yaml = "grid:\n teff: [20000, 25000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
sched.initialize_grid(&cfg, "sdB_cno").await.unwrap();
// 旧点:converged(不重算)
let st_old = db
.get_grid_point_status("t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno")
.await
.unwrap();
assert_eq!(
st_old.unwrap().0,
"converged",
"旧点应保持 converged 不重算"
);
// 新点:pending(会被调度)
let st_new = db
.get_grid_point_status("t25000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno")
.await
.unwrap();
assert_eq!(st_new.unwrap().0, "pending", "新点应为 pending 等待计算");
println!("✓ 场景3(混合网格):旧点converged保留 + 新点pending待算 —— 完全符合避免重算的意图");
}
/// 场景 4(精度差异命门):旧 conv.json name=g5(无小数),但配置 logg=5.0 → model_name()=g5.0。
/// 导入工具必须把 name 重写为 g5.0 入库,否则 initialize_grid 插入的 g5.0 行与导入的 g5 行
/// 复合唯一键不匹配,导入的 converged 被孤立、g5.0 被重算。
/// 本测试直接模拟「入库的 grid_points.name = g5.0」(即工具重写后的状态),
/// 验证 initialize_grid(sdB_cno) 后该行保持 converged(不重算)。
#[tokio::test]
async fn test_precision_diff_import_then_init_preserves_converged() {
let (db, sched) = setup().await;
// 配置权威名(logg=5.0 → g5.0,保留小数)
let canonical = "t20000_g5.0_he-2_c-4_n-4_o-4";
use common::models::GridAxisValue;
let p = GridPointParams {
teff: GridAxisValue::from_value(20000.0),
logg: GridAxisValue::from_value(5.0),
loghe: GridAxisValue::from_value(-2.0),
logc: GridAxisValue::from_value(-4.0),
logn: GridAxisValue::from_value(-4.0),
logo: GridAxisValue::from_value(-4.0),
};
// 模拟 import_results 重写 name 后入库:grid_points.name = canonical(g5.0)
db.upsert_grid_point_named(canonical, &p, 0, "sdB_cno")
.await
.unwrap();
db.mark_grid_point_imported(canonical, "sdB_cno", None)
.await
.unwrap();
// 启动同名工作流:initialize_grid 用配置 model_name()(=g5.0) 插入
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
sched.initialize_grid(&cfg, "sdB_cno").await.unwrap();
// 关键断言:name=g5.0 的行保持 convergedON CONFLICT DO NOTHING 命中)
let st = db
.get_grid_point_status(canonical, "sdB_cno")
.await
.unwrap();
assert_eq!(
st.unwrap().0,
"converged",
"精度一致(g5.0=g5.0)时导入的 converged 必须保留,不重算"
);
println!("✓ 场景4(精度差异命门):g5.0 入库 + initialize_grid → converged 保留,避免重算");
}
+91
View File
@@ -0,0 +1,91 @@
import { JSDOM } from 'jsdom';
import fs from 'node:fs';
const html = fs.readFileSync('index.html', 'utf8');
const dom = new JSDOM(html, {
url: 'http://localhost:3000/#/workflows/sdB_cno',
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const { window } = dom;
global.window = window;
global.document = window.document;
global.location = window.location;
global.localStorage = window.localStorage;
global.matchMedia =
window.matchMedia?.bind(window) ?? (() => ({ matches: false, addEventListener() {} }));
window.localStorage.setItem('dcts_admin_token', 'test-token');
const mkPoint = (teff, logc, status, method, attempts = 1, relc = 0.0005) => ({
name: `t${teff}_g5.0_he-2_c${logc}_n-4_o-4`,
teff, logg: 5.0, loghe: -2, logc, logn: -4, logo: -4,
cno_sum: logc - 8, wave: 0,
status, success_method: method, attempt_count: attempts,
last_max_relc: relc, last_task_type: method, seed_point_name: method === 'seed_step' ? 't20000_g5.0_he-2_c-4_n-4_o-4' : null,
node_id: 'node-a1', last_completed_at: status === 'converged' || status === 'failed' ? '2026-07-30 10:00:00' : null,
last_error: status === 'failed' ? 'nl stage diverged' : null,
});
const POINTS = [
mkPoint(20000, -4, 'converged', 'cold_run'),
mkPoint(40000, -4, 'converged', 'seed_step', 2),
mkPoint(60000, -4, 'failed', null, 2, 9.5e5),
mkPoint(20000, -3, 'running', null),
];
global.fetch = window.fetch = async (url) => {
const u = String(url);
if (u.includes('/stats')) {
return new Response(JSON.stringify({
success: true, message: 'ok',
data: { name: 'sdB_cno', status: 'running', total: 4, pending: 0, queued: 0, running: 1, converged: 2, failed: 1, cold_run_converged: 1, seed_step_converged: 1, imported_converged: 0, waves: [{ wave: 0, total: 4, converged: 2, failed: 1 }], avg_point_sec: 600, eta_sec: 600 },
}), { status: 200 });
}
if (u.includes('/points')) {
let pts = [...POINTS];
const q = new URL(u, 'http://localhost');
if (q.searchParams.get('status')) pts = pts.filter(p => p.status === q.searchParams.get('status'));
if (q.searchParams.get('method')) pts = pts.filter(p => p.success_method === q.searchParams.get('method'));
return new Response(JSON.stringify({ success: true, message: 'ok', data: { total: pts.length, points: pts } }), { status: 200 });
}
return new Response('{}', { status: 404 });
};
const { mountWorkflowDetail } = await import('./src/views/workflowDetail.js');
const tick = () => new Promise((r) => setTimeout(r, 30));
mountWorkflowDetail('sdB_cno');
await tick(); await tick();
// Tab2:点表
document.querySelector('[data-wf-tab="points"]').click();
await tick(); await tick();
console.log('points rows:', document.querySelectorAll('#points-table-body tr[data-point-row]').length === 4 ? 'OK' : 'MISSING');
console.log('rescued marker:', document.querySelector('#points-table-body')?.textContent.includes('↻') ? 'OK' : 'MISSING');
// 状态过滤 → 仅 failed
document.querySelector('[data-wf-filter="status"]').value = 'failed';
document.querySelector('[data-wf-filter="status"]').dispatchEvent(new window.Event('change', { bubbles: true }));
await tick();
console.log('status filter:', document.querySelectorAll('#points-table-body tr[data-point-row]').length === 1 ? 'OK' : 'WRONG');
// Tab3:热力图
document.querySelector('[data-wf-tab="analysis"]').click();
await tick(); await tick();
// logc 非轴且多值 → 切片选择器应出现并默认固定 logc=-4(3 点成格,c-3 点被切掉)
const sliceSel = document.querySelector('[data-heat-slice="logc"]');
console.log('slice selector:', sliceSel ? 'OK' : 'MISSING');
const cells = document.querySelectorAll('.heatmap-cell');
console.log('heatmap cells (sliced):', cells.length === 3 ? 'OK' : `GOT ${cells.length}`);
console.log('cold cell:', document.querySelector('.heatmap-cell.hc-cold') ? 'OK' : 'MISSING');
console.log('seed cell:', document.querySelector('.heatmap-cell.hc-seed') ? 'OK' : 'MISSING');
console.log('failed cell:', document.querySelector('.heatmap-cell.hc-failed') ? 'OK' : 'MISSING');
console.log('conclusion:', document.getElementById('heat-conclusion')?.textContent.includes('冷启动成功') ? 'OK' : 'MISSING');
// 切片切到 logc=-3 → 只剩 running 点一格
sliceSel.value = '-3';
sliceSel.dispatchEvent(new window.Event('change', { bubbles: true }));
await tick();
console.log('slice switch:', document.querySelectorAll('.heatmap-cell').length === 1 && document.querySelector('.heatmap-cell.hc-running') ? 'OK' : 'WRONG');
process.exit(0);
+95 -26
View File
@@ -6,14 +6,24 @@
<title>DCTS — 分布式恒星大气网格计算控制台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<script>
/* 首帧前落主题,避免 prefers-color-scheme 先绘制导致深/浅闪变 (FOUC) */
(function () {
try {
var saved = localStorage.getItem('dcts_theme');
var theme = saved || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) { /* localStorage 不可用时回退媒体查询 */ }
})();
</script>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app">
<!-- ===== 登录遮罩(鉴权) ===== -->
<div id="login-overlay" class="login-overlay">
<div class="login-card glass">
<div class="login-card card">
<div class="login-header">
<h2>DCTS 控制台登录</h2>
<p>请输入管理员密码以访问管理控制台</p>
@@ -21,7 +31,7 @@
<form id="form-login" class="stack-col stack-col-md">
<div class="form-group">
<label for="input-admin-token">管理员密码</label>
<input type="password" id="input-admin-token" class="form-input" placeholder="输入管理员密码..." required autocomplete="off" />
<input type="password" id="input-admin-token" class="form-input" placeholder="输入管理员密码..." required autocomplete="current-password" />
</div>
<div id="login-error-msg" class="login-error-msg alert-error hidden"></div>
<button type="submit" id="btn-submit-login" class="btn btn-primary">登录</button>
@@ -51,7 +61,7 @@
</div>
<div class="header-status">
<div class="status-indicator online" id="status-indicator-box">
<div class="status-indicator online" id="status-indicator-box" role="status" aria-live="polite">
<span class="status-dot"></span>
<span id="server-status-text">服务端正常运行</span>
</div>
@@ -69,6 +79,22 @@
</svg>
刷新
</button>
<button id="btn-theme-toggle" class="btn btn-secondary" title="切换主题模式" aria-label="切换主题模式">
<svg class="icon-theme-sun hidden" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg class="icon-theme-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button id="btn-logout" class="btn btn-secondary hidden" title="登出并清除 Token">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
@@ -80,11 +106,16 @@
</div>
</header>
<!-- 主内容区 -->
<!-- 主内容区(视图由 src/router.js 挂载到 #view-root:首页 / 工作流详情页) -->
<main class="main-container">
<div id="view-root"></div>
</main>
<!-- 首页视图模板:router 挂载时克隆(见 src/views/home.js),ID 为渲染锚点,保持原样 -->
<template id="view-home-template">
<!-- 核心指标卡片 -->
<section class="metrics-grid">
<div class="metric-card glass">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">在线计算节点</span>
<div class="metric-icon nodes-icon">
@@ -99,10 +130,13 @@
<div class="metric-body">
<span class="metric-value" id="val-active-nodes">0</span>
<span class="metric-sub" id="val-total-slots">0 / 0 CPU 槽位占用</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill" id="slots-progress-fill"></div>
</div>
</div>
</div>
<div class="metric-card glass">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">待计算网格点</span>
<div class="metric-icon pending-icon">
@@ -118,7 +152,7 @@
</div>
</div>
<div class="metric-card glass">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">已计算网格模型</span>
<div class="metric-icon success-icon">
@@ -131,10 +165,13 @@
<div class="metric-body">
<span class="metric-value" id="val-completed-tasks">0</span>
<span class="metric-sub" id="val-completion-rate">网格总数: 0</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" id="converged-progress-fill"></div>
</div>
</div>
</div>
<div class="metric-card glass">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">计算工作流</span>
<div class="metric-icon workflow-icon">
@@ -155,7 +192,7 @@
<!-- 主要内容双栏 -->
<div class="content-grid">
<!-- 左侧:计算节点集群与凭据管理 (统一全功能控制大表) -->
<div class="panel glass">
<div class="panel card">
<div class="panel-header">
<h2>计算节点集群与凭据管理</h2>
<div class="row-wrap" style="align-items: center; gap: 0.5rem;">
@@ -168,7 +205,6 @@
<thead>
<tr>
<th class="col-node-id">节点 ID</th>
<th class="col-hostname">主机名</th>
<th class="col-slots">CPU 槽位</th>
<th class="col-usage">CPU/内存</th>
<th class="col-status">节点状态</th>
@@ -179,7 +215,7 @@
</thead>
<tbody id="nodes-table-body">
<tr>
<td colspan="8" class="empty-cell">正在连接服务端获取计算节点...</td>
<td colspan="7" class="empty-cell">正在连接服务端获取计算节点...</td>
</tr>
</tbody>
</table>
@@ -189,7 +225,7 @@
<!-- 右侧:工作流管理 -->
<div class="stack-col stack-col-lg">
<!-- 工作流面板 -->
<div class="panel glass">
<div class="panel card">
<div class="panel-header">
<h2>恒星大气网格工作流</h2>
</div>
@@ -201,10 +237,10 @@
</div>
</div>
</div>
</main>
</template>
<!-- 创建 / 编辑工作流 模态弹窗 -->
<div id="modal-create-wf" class="modal-backdrop hidden">
<div id="modal-create-wf" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal-card">
<div class="modal-header">
<h3 id="modal-title">注册新的计算工作流</h3>
@@ -222,7 +258,10 @@
<input type="text" id="input-wf-desc" class="form-input" placeholder="例如: sdB 6维恒星大气模型计算网格" />
</div>
<div class="form-group">
<div class="row-wrap" style="justify-content: space-between; align-items: center;">
<label for="input-wf-yaml">YAML 配置内容 (GridConfig)</label>
<button type="button" id="btn-load-preset-yaml" class="btn btn-secondary btn-sm">填入默认示例</button>
</div>
<textarea id="input-wf-yaml" class="form-textarea" required placeholder="请粘贴工作流 YAML 参数网格定义..."></textarea>
</div>
<div class="modal-footer">
@@ -233,27 +272,57 @@
</div>
</div>
<!-- 查看 YAML 模态弹窗 -->
<div id="modal-view-wf" class="modal-backdrop hidden">
<div class="modal-card">
<!-- 工作流 YAML 配置编辑器(查看/编辑双模式,逻辑见 src/components/yamlEditor.js -->
<div id="modal-view-wf" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="view-wf-title">
<div class="modal-card modal-wide yaml-editor">
<div class="modal-header">
<h3 id="view-wf-title">工作流 YAML 配置详情</h3>
<div class="yaml-editor-head">
<h3 id="view-wf-title">工作流配置</h3>
<span id="yaml-editor-status"></span>
<span id="yaml-editor-dirty" class="dirty-dot hidden" title="有未保存的修改" aria-label="有未保存的修改"></span>
</div>
<button id="close-view-wf" class="modal-close" aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<pre id="view-wf-yaml" class="code-pre"></pre>
<div class="modal-footer">
<button type="button" id="btn-close-view-wf" class="btn btn-secondary">关闭</button>
<!-- 查看模式:语法高亮 + 行号 -->
<div id="yaml-view-pane" class="yaml-view-pane">
<div class="yaml-scroll"><pre id="view-wf-yaml" class="yaml-highlight"></pre></div>
</div>
<!-- 编辑模式:等宽编辑区 + 校验错误/状态重置提示 -->
<div id="yaml-edit-pane" class="yaml-edit-pane hidden">
<div id="yaml-edit-error" class="yaml-edit-error hidden" role="alert"></div>
<div id="yaml-reset-warn" class="yaml-reset-warn hidden">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>保存后工作流状态将重置为「闲置」,需重新启动才能使新配置生效</span>
</div>
<textarea id="yaml-edit-area" class="yaml-edit-area" spellcheck="false" aria-label="YAML 配置编辑区"></textarea>
</div>
<div class="modal-footer yaml-editor-footer">
<span id="yaml-editor-meta" class="yaml-editor-meta tabular-num"></span>
<span id="yaml-editor-lock" class="yaml-editor-lock hidden"></span>
<button type="button" id="btn-edit-yaml" class="btn btn-primary btn-sm">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
编辑
</button>
<button type="button" id="btn-cancel-edit-yaml" class="btn btn-secondary btn-sm hidden">取消</button>
<button type="button" id="btn-save-yaml" class="btn btn-primary btn-sm hidden">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
保存配置
</button>
<button type="button" id="btn-close-view-wf" class="btn btn-secondary btn-sm">关闭</button>
</div>
</div>
</div>
<!-- 重发 Token 结果展示模态弹窗 -->
<div id="modal-reissue-token" class="modal-backdrop hidden">
<div id="modal-reissue-token" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="reissue-modal-title">
<div class="modal-card">
<div class="modal-header">
<h3>节点专属 Token 已重新颁发</h3>
<h3 id="reissue-modal-title">节点专属 Token 已重新颁发</h3>
<button id="reissue-close-btn" class="modal-close" aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
@@ -280,8 +349,8 @@
<div id="toast-container" class="toast-container"></div>
<!-- ===== 通用自定义 Confirm 模态弹窗 ===== -->
<div id="modal-confirm" class="login-overlay hidden">
<div class="login-card glass modal-card">
<div id="modal-confirm" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title" aria-describedby="confirm-modal-msg">
<div class="login-card card modal-card">
<div class="login-header">
<h3 id="confirm-modal-title" class="confirm-modal-title">确认操作</h3>
<p id="confirm-modal-msg" class="confirm-modal-msg">您确定要执行此操作吗?</p>
+64 -7
View File
@@ -25,19 +25,30 @@ export function escapeHtml(s) {
.replace(/'/g, '&#39;');
}
// 统一 fetch 包装:自动注入 Authorization 头,401 时清除 token 并刷新
export async function apiFetch(url, opts = {}) {
// 统一 fetch 包装:自动注入 Authorization 头,支持 10s 超时处理与 401 处理
export async function apiFetch(url, opts = {}, timeoutMs = 10000) {
const token = getAdminToken();
const headers = { ...(opts.headers || {}) };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(url, { ...opts, headers });
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const signal = opts.signal || controller.signal;
try {
const res = await fetch(url, { ...opts, headers, signal });
clearTimeout(timeoutId);
if (res.status === 401) {
clearAdminToken();
location.reload();
}
return res;
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
// API 请求接口定义
@@ -76,6 +87,15 @@ export async function createWorkflow(workflowData) {
});
}
/** 更新既有工作流配置(PUT)。服务端拒绝 running/initializing 状态覆写(400)。 */
export async function updateWorkflowApi(name, workflowData) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowData),
});
}
export async function startWorkflowApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}/start`, { method: 'POST' });
}
@@ -92,6 +112,39 @@ export async function getWorkflowDetailApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`);
}
// ===== 工作流执行观测 API(详情页数据源) =====
// 均返回 raw Response,调用方检查 res.ok && json.success
// opts 可传 { signal } 用于视图卸载时中断在途请求。
export async function fetchWorkflowStatsApi(name, opts = {}) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}/stats`, opts);
}
export async function fetchWorkflowPointsApi(name, params = {}, opts = {}) {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v != null && v !== '') qs.set(k, v);
});
const s = qs.toString();
return apiFetch(`/api/workflows/${encodeURIComponent(name)}/points${s ? `?${s}` : ''}`, opts);
}
export async function fetchPointDetailApi(name, point, opts = {}) {
return apiFetch(
`/api/workflows/${encodeURIComponent(name)}/points/${encodeURIComponent(point)}`,
opts
);
}
export async function fetchWorkflowProgressApi(name, params = {}, opts = {}) {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v != null && v !== '') qs.set(k, v);
});
const s = qs.toString();
return apiFetch(`/api/workflows/${encodeURIComponent(name)}/progress${s ? `?${s}` : ''}`, opts);
}
export async function approveNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/approve`, { method: 'POST' });
}
@@ -100,10 +153,14 @@ export async function rejectNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/reject`, { method: 'POST' });
}
export async function revokeNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/revoke`, { method: 'POST' });
}
export async function reissueNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/reissue`, { method: 'POST' });
}
export async function disableNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/disable`, { method: 'POST' });
}
export async function enableNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/enable`, { method: 'POST' });
}
+62 -18
View File
@@ -1,44 +1,88 @@
/* DCTS Dashboard Modal & Focus Trap Component */
let lastActiveElement = null;
export function setupFocusTrap(modalEl) {
if (!modalEl) return () => {};
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = modalEl.querySelectorAll(focusableSelector);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
// 清理现有在该 Modal 上的 FocusTrap 监听,防止累积
if (modalEl._releaseFocusTrap) {
modalEl._releaseFocusTrap();
}
lastActiveElement = document.activeElement;
if (firstFocusable) {
firstFocusable.focus();
const focusableSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const lastActiveElement = document.activeElement;
const initialElements = modalEl.querySelectorAll(focusableSelector);
if (initialElements.length > 0) {
initialElements[0].focus();
}
function handleKeyDown(e) {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
if (e.key === 'Escape') {
const closeBtn = modalEl.querySelector('.modal-close, #btn-close-view-wf, #btn-close-reissue-modal, #btn-confirm-cancel, #cancel-create-wf');
if (closeBtn && typeof closeBtn.click === 'function') {
e.preventDefault();
lastFocusable?.focus();
closeBtn.click();
return;
}
}
if (e.key !== 'Tab') return;
const focusableElements = Array.from(modalEl.querySelectorAll(focusableSelector));
if (focusableElements.length === 0) return;
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstFocusable || !modalEl.contains(document.activeElement)) {
e.preventDefault();
lastFocusable.focus();
}
} else {
if (document.activeElement === lastFocusable) {
if (document.activeElement === lastFocusable || !modalEl.contains(document.activeElement)) {
e.preventDefault();
firstFocusable?.focus();
}
firstFocusable.focus();
}
}
}
modalEl.addEventListener('keydown', handleKeyDown);
return function releaseFocusTrap() {
const releaseFocusTrap = () => {
modalEl.removeEventListener('keydown', handleKeyDown);
if (lastActiveElement && typeof lastActiveElement.focus === 'function') {
if (lastActiveElement && typeof lastActiveElement.focus === 'function' && document.body.contains(lastActiveElement)) {
lastActiveElement.focus();
}
delete modalEl._releaseFocusTrap;
};
modalEl._releaseFocusTrap = releaseFocusTrap;
return releaseFocusTrap;
}
export function releaseFocusTrap(modalEl) {
if (modalEl && modalEl._releaseFocusTrap) {
modalEl._releaseFocusTrap();
}
}
export function setupModalDismiss(modalEl, onClose) {
if (!modalEl) return;
function handleBackdropClick(e) {
if (e.target === modalEl) {
if (typeof onClose === 'function') {
onClose();
} else {
const closeBtn = modalEl.querySelector('.modal-close, #btn-close-view-wf, #btn-close-reissue-modal, #btn-confirm-cancel, #cancel-create-wf');
if (closeBtn && typeof closeBtn.click === 'function') {
closeBtn.click();
}
}
}
}
modalEl.addEventListener('click', handleBackdropClick);
}
export function showConfirm(title, message) {
+122 -50
View File
@@ -8,51 +8,61 @@ export function renderNodesTableSkeleton() {
const skeletonRows = Array.from({ length: 4 }).map(() => `
<tr class="skeleton-row">
<td><div class="skeleton-shimmer skeleton-text" style="width: 110px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text" style="width: 90px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text" style="width: 60px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text" style="width: 80px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-110"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-90"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-80"></div></td>
<td><div class="skeleton-shimmer skeleton-badge"></div></td>
<td><div class="skeleton-shimmer skeleton-badge"></div></td>
<td><div class="skeleton-shimmer skeleton-text" style="width: 70px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text" style="width: 60px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-70"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-60"></div></td>
</tr>
`).join('');
tbody.innerHTML = skeletonRows;
}
export function renderNodesTable(nodes) {
const tbody = document.getElementById('nodes-table-body');
const countBadge = document.getElementById('node-count-badge');
// 心跳时间容错:字段缺失或非法时间戳一律显示占位符,避免渲染出 "Invalid Date"
function formatHeartbeat(ts) {
if (!ts) return '—';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleTimeString('zh-CN');
}
if (countBadge) {
countBadge.textContent = `${nodes ? nodes.length : 0} 个节点`;
}
if (!tbody) return;
// 行重建签名:仅状态/凭据决定徽章与操作按钮。槽位、负载、心跳是秒级高频字段,
// 走 textContent 原位刷新——整行 innerHTML 重建会销毁用户正悬停/聚焦的按钮。
function rowSignature(node) {
return `${node.status || ''}|${node.token_status || ''}`;
}
if (!nodes || nodes.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="8" class="empty-cell">
暂无计算节点记录启动 DCTS Node Worker 提交注册申请后在此审批授权接入集群
</td>
</tr>
`;
return;
}
function volatileValues(node) {
return {
slots: `${Number(node.active_slots || 0)} / ${Number(node.max_slots || 4)}`,
usage: `${Number(node.cpu_usage || 0).toFixed(1)}% / ${Number(node.memory_usage || 0).toFixed(1)}%`,
heartbeat: formatHeartbeat(node.last_heartbeat),
};
}
// 构建新 HTML
const html = nodes.map(node => {
const nodeId = escapeHtml(node.node_id || node.id || 'N/A');
const hostName = escapeHtml(node.host_name || node.host || '127.0.0.1');
function updateVolatileCells(tr, node) {
const v = volatileValues(node);
const slotsCell = tr.querySelector('.col-slots');
if (slotsCell && slotsCell.textContent !== v.slots) slotsCell.textContent = v.slots;
const usageCell = tr.querySelector('.col-usage');
if (usageCell && usageCell.textContent !== v.usage) usageCell.textContent = v.usage;
const hbCell = tr.querySelector('.col-heartbeat');
if (hbCell && hbCell.textContent !== v.heartbeat) hbCell.textContent = v.heartbeat;
}
function buildRowHtml(node, nodeId) {
const isPending = node.status === 'pending_approval';
const isOnline = node.status === 'online' || node.status === 'active';
const isDisabled = node.status === 'disabled';
let statusBadge;
if (isPending) {
statusBadge = '<span class="status-badge warning"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="vertical-align: -1px; margin-right: 3px;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>待审批</span>';
statusBadge = '<span class="status-badge warning"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="badge-icon-space"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>待审批</span>';
} else if (isDisabled) {
statusBadge = '<span class="status-badge secondary">已停用</span>';
} else if (isOnline) {
statusBadge = '<span class="status-badge online">在线</span>';
} else {
@@ -64,18 +74,10 @@ export function renderNodesTable(nodes) {
tokenBadge = '<span class="status-badge secondary">待授权</span>';
} else if (node.token_status === 'active') {
tokenBadge = '<span class="status-badge online">有效</span>';
} else if (node.token_status === 'revoked') {
tokenBadge = '<span class="status-badge offline">已吊销</span>';
} else {
tokenBadge = '<span class="status-badge secondary">无凭据</span>';
}
const lastHeartbeat = node.last_heartbeat ? escapeHtml(new Date(node.last_heartbeat).toLocaleTimeString('zh-CN')) : '—';
const activeSlots = Number(node.active_slots || 0);
const maxSlots = Number(node.max_slots || 4);
const cpuUsage = Number(node.cpu_usage || 0).toFixed(1);
const memUsage = Number(node.memory_usage || 0).toFixed(1);
let actionBtns;
if (isPending) {
actionBtns = `
@@ -89,34 +91,104 @@ export function renderNodesTable(nodes) {
</div>
`;
} else {
// 启停切换:disabled 时显示「启用」(绿 ▶),否则显示「停用」(琥珀 ⏸)。
// 停用用琥珀+暂停符号,表达"可恢复的软暂停"——节点保持存活待命,与「重发 Token」
// (蓝 🔄,轮换凭据、旧 token 立即失效)是两类不同运维操作,颜色与图标刻意区分。
const toggleBtn = isDisabled
? `<button class="btn-action-icon btn-action-approve" data-node-action="enable" data-node-id="${nodeId}" title="重新启用节点(恢复分发任务)" aria-label="重新启用节点">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
</button>`
: `<button class="btn-action-icon btn-action-warn" data-node-action="disable" data-node-id="${nodeId}" title="停用节点(不再分发任务,保持空闲待命)" aria-label="停用节点">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>
</button>`;
actionBtns = `
<div class="action-cell-wrap">
<button class="btn-action-icon btn-action-reissue" data-node-action="reissue" data-node-id="${nodeId}" title="重新颁发专属 Token" aria-label="重新颁发专属 Token">
${toggleBtn}
<button class="btn-action-icon btn-action-reissue" data-node-action="reissue" data-node-id="${nodeId}" title="重新颁发专属 Token(旧 Token 失效)" aria-label="重新颁发专属 Token">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
</button>
<button class="btn-action-icon btn-action-danger" data-node-action="revoke" data-node-id="${nodeId}" title="吊销 Token 凭据" aria-label="吊销 Token 凭据">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
</button>
</div>
`;
}
const v = volatileValues(node);
return `
<tr data-node-row-id="${nodeId}">
<td class="col-node-id node-id" title="${nodeId}">${nodeId}</td>
<td class="col-hostname" title="${hostName}">${hostName}</td>
<td class="col-slots tabular-num">${activeSlots} / ${maxSlots}</td>
<td class="col-usage tabular-num">${cpuUsage}% / ${memUsage}%</td>
<td class="col-slots tabular-num">${v.slots}</td>
<td class="col-usage tabular-num">${v.usage}</td>
<td class="col-status">${statusBadge}</td>
<td class="col-token">${tokenBadge}</td>
<td class="col-heartbeat tabular-num">${lastHeartbeat}</td>
<td class="col-heartbeat tabular-num">${v.heartbeat}</td>
<td class="col-actions">${actionBtns}</td>
`;
}
export function renderNodesTable(nodes) {
const tbody = document.getElementById('nodes-table-body');
const countBadge = document.getElementById('node-count-badge');
if (countBadge) {
countBadge.textContent = `${nodes ? nodes.length : 0} 个节点`;
}
if (!tbody) return;
if (!nodes || nodes.length === 0) {
const emptyHtml = `
<tr>
<td colspan="7" class="empty-cell">
暂无计算节点记录启动 DCTS Node Worker 提交注册申请后在此审批授权接入集群
</td>
</tr>
`;
}).join('');
// 增量 Diff 判断:如果内容一致,避重刷;否则进行更新
if (tbody.innerHTML.trim() !== html.trim()) {
tbody.innerHTML = html;
if (tbody.innerHTML.trim() !== emptyHtml.trim()) {
tbody.innerHTML = emptyHtml;
}
return;
}
// 构建目前现有 tr 的 Map (nodeId -> tr Element)
const existingRows = new Map();
tbody.querySelectorAll('tr[data-node-row-id]').forEach(tr => {
existingRows.set(tr.getAttribute('data-node-row-id'), tr);
});
// 如果 tbody 里包含 skeleton 或 empty-cell,清空
if (tbody.querySelector('.skeleton-row, .empty-cell')) {
tbody.innerHTML = '';
existingRows.clear();
}
const updatedRowElements = [];
nodes.forEach(node => {
const nodeId = escapeHtml(node.node_id || node.id || 'N/A');
const sig = rowSignature(node);
let tr = existingRows.get(nodeId);
if (tr && tr.getAttribute('data-sig') === sig) {
// 状态/凭据未变:仅原位刷新高频数值单元格,不触碰徽章与操作按钮
updateVolatileCells(tr, node);
} else {
const html = buildRowHtml(node, nodeId);
if (!tr) {
tr = document.createElement('tr');
tr.setAttribute('data-node-row-id', nodeId);
}
tr.innerHTML = html;
tr.setAttribute('data-sig', sig);
}
existingRows.delete(nodeId);
updatedRowElements.push(tr);
});
// 移除不再存在的旧 tr
existingRows.forEach(tr => tr.remove());
// 按顺序重排/挂载到 tbody
updatedRowElements.forEach((tr, idx) => {
if (tbody.children[idx] !== tr) {
tbody.insertBefore(tr, tbody.children[idx] || null);
}
});
}
+26 -5
View File
@@ -1,5 +1,11 @@
/* DCTS Dashboard Toast Component */
const TOAST_ICONS = {
success: `<svg class="toast-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`,
error: `<svg class="toast-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`,
info: `<svg class="toast-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`
};
export function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) {
@@ -8,13 +14,28 @@ export function showToast(message, type = 'info') {
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
// role="alert" 隐含 assertive,仅用于错误;普通提示用 polite,避免与 role 冲突
if (type === 'error') {
toast.setAttribute('role', 'alert');
} else {
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.textContent = message;
}
// 消息一律走 textContent,杜绝 innerHTML 注入(message 可能携带用户输入,如工作流名)
const iconSvg = TOAST_ICONS[type] || TOAST_ICONS.info;
toast.innerHTML = iconSvg;
const textEl = document.createElement('span');
textEl.textContent = message;
toast.appendChild(textEl);
const dismiss = () => {
toast.classList.add('toast-fadeOut');
setTimeout(() => toast.remove(), 250);
};
toast.addEventListener('click', dismiss);
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-fadeOut');
setTimeout(() => toast.remove(), 300);
}, 3000);
setTimeout(dismiss, 3500);
}
+78
View File
@@ -0,0 +1,78 @@
/* DCTS
*
* 统一模式showConfirm 二次确认 调用 API toast 反馈 fetchAllData 立即刷新
* 详情页调用方可传回调afterRefresh启动/暂停后刷新本页统计
* afterDelete删除成功后返回上一视图
*/
import {
startWorkflowApi,
stopWorkflowApi,
deleteWorkflowApi
} from '../api.js';
import { showToast } from './toast.js';
import { showConfirm } from './modal.js';
import { fetchAllData } from '../state.js';
import { openYamlEditor } from './yamlEditor.js';
export async function handleStartWorkflow(name, afterRefresh = null) {
const ok = await showConfirm('启动计算网格', `确定要启动工作流 '${name}' 展开网格计算任务吗?`);
if (!ok) return;
try {
const res = await startWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已成功启动', 'success');
fetchAllData();
if (afterRefresh) afterRefresh();
} else {
showToast(json.message || '启动工作流失败', 'error');
}
} catch (err) {
showToast(`启动失败: ${err.message}`, 'error');
}
}
export async function handleStopWorkflow(name, afterRefresh = null) {
const ok = await showConfirm('暂停工作流', `确定要暂停工作流 '${name}' 的后续任务派发吗?`);
if (!ok) return;
try {
const res = await stopWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已暂停', 'info');
fetchAllData();
if (afterRefresh) afterRefresh();
} else {
showToast(json.message || '暂停失败', 'error');
}
} catch (err) {
showToast(`暂停失败: ${err.message}`, 'error');
}
}
export async function handleDeleteWorkflow(name, afterDelete = null) {
const ok = await showConfirm('删除工作流', `确定要彻底删除工作流 '${name}' 及其关联的网格点数据吗?此操作无法撤销。`);
if (!ok) return;
try {
const res = await deleteWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流删除成功', 'success');
fetchAllData();
if (afterDelete) afterDelete();
} else {
showToast(json.message || '删除失败', 'error');
}
} catch (err) {
showToast(`删除失败: ${err.message}`, 'error');
}
}
/** 查看/编辑工作流 YAML:统一入口,双模式编辑器见 yamlEditor.js。 */
export async function handleViewWorkflow(name) {
await openYamlEditor(name);
}
+84 -19
View File
@@ -10,12 +10,12 @@ export function renderWorkflowsSkeleton() {
<div class="workflow-card skeleton-card">
<div class="workflow-header">
<div>
<div class="skeleton-shimmer skeleton-title" style="width: 140px; height: 18px; margin-bottom: 8px;"></div>
<div class="skeleton-shimmer skeleton-text" style="width: 220px; height: 14px;"></div>
<div class="skeleton-shimmer skeleton-w-140 skeleton-h-18"></div>
<div class="skeleton-shimmer skeleton-w-220 skeleton-h-14"></div>
</div>
<div class="skeleton-shimmer skeleton-badge"></div>
</div>
<div class="workflow-actions" style="margin-top: 16px;">
<div class="workflow-actions wf-actions-mt">
<div class="skeleton-shimmer skeleton-btn"></div>
<div class="skeleton-shimmer skeleton-btn"></div>
</div>
@@ -33,11 +33,23 @@ export function renderWorkflows(workflows) {
if (!workflows || workflows.length === 0) {
if (wfSub) wfSub.textContent = '暂无已注册工作流';
container.innerHTML = `
<div class="empty-cell">
暂无预置工作流可点击右上角创建工作流按钮快速注册
const emptyHtml = `
<div class="empty-state-box">
<svg class="empty-state-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
<p>暂无恒星大气网格工作流配置</p>
<button type="button" class="btn btn-primary btn-sm" data-wf-action="create-first">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
注册第一个工作流
</button>
</div>
`;
if (container.innerHTML.trim() !== emptyHtml.trim()) {
container.innerHTML = emptyHtml;
}
return;
}
@@ -46,23 +58,57 @@ export function renderWorkflows(workflows) {
wfSub.textContent = runningCount > 0 ? `${runningCount} 个工作流运行中` : '集群待命';
}
const html = workflows.map(wf => {
let statusCn = '闲置 (Idle)';
const existingCards = new Map();
container.querySelectorAll('.workflow-card[data-wf-card-name]').forEach(card => {
existingCards.set(card.getAttribute('data-wf-card-name'), card);
});
if (container.querySelector('.skeleton-card, .empty-cell')) {
container.innerHTML = '';
existingCards.clear();
}
const updatedCards = [];
workflows.forEach(wf => {
// 状态徽章全映射:后端可发 idle/initializing/running/paused/completed
let statusCn = '闲置';
let statusClass = 'secondary';
if (wf.status === 'running') {
statusCn = '运行中 (Running)';
statusCn = '运行中';
statusClass = 'online';
} else if (wf.status === 'completed') {
statusCn = '已完成 (Completed)';
statusClass = 'online';
statusCn = '已完成';
statusClass = 'completed';
} else if (wf.status === 'initializing') {
statusCn = '初始化中';
statusClass = 'warning';
} else if (wf.status === 'paused') {
statusCn = '已暂停';
statusClass = 'secondary';
}
const name = escapeHtml(wf.name);
const href = `#/workflows/${encodeURIComponent(wf.name)}`;
const desc = escapeHtml(wf.description || '无描述');
const isRunning = wf.status === 'running';
return `
<div class="workflow-card">
// 内联进度(来自列表接口内联 stats;未启动的工作流 stats=null → 不渲染)
const s = wf.stats;
let progressHtml = '';
if (s && s.total > 0) {
const pct = Math.min(100, Math.max(0, Math.round((s.converged / s.total) * 100)));
progressHtml = `
<div class="wf-card-progress">
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" style="width: ${pct}%"></div>
</div>
<span class="wf-card-counts tabular-num">${s.converged}/${s.total} 收敛 · ${s.seed_step_converged} 种子步进 · ${s.failed} 失败 · ${s.running} 运行</span>
</div>`;
}
const cardInnerHtml = `
<a class="wf-card-link" href="${href}" title="进入工作流详情">
<div class="workflow-header">
<div>
<h3 class="workflow-name">${name}</h3>
@@ -70,10 +116,12 @@ export function renderWorkflows(workflows) {
</div>
<span class="status-badge ${statusClass}">${statusCn}</span>
</div>
${progressHtml}
</a>
<div class="workflow-actions">
<button class="btn btn-secondary btn-sm" data-wf-action="view" data-wf-name="${name}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
查看 YAML
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m16 13-2 2 2 2"/><path d="m8 13 2 2-2 2"/></svg>
YAML 配置
</button>
${
isRunning
@@ -85,11 +133,28 @@ export function renderWorkflows(workflows) {
删除
</button>
</div>
</div>
`;
}).join('');
if (container.innerHTML.trim() !== html.trim()) {
container.innerHTML = html;
let card = existingCards.get(name);
if (card) {
if (card.innerHTML.trim() !== cardInnerHtml.trim()) {
card.innerHTML = cardInnerHtml;
}
existingCards.delete(name);
} else {
card = document.createElement('div');
card.className = 'workflow-card';
card.setAttribute('data-wf-card-name', name);
card.innerHTML = cardInnerHtml;
}
updatedCards.push(card);
});
existingCards.forEach(card => card.remove());
updatedCards.forEach((card, idx) => {
if (container.children[idx] !== card) {
container.insertBefore(card, container.children[idx] || null);
}
});
}
+303
View File
@@ -0,0 +1,303 @@
/* DCTS YAML /
*
* 操作逻辑
* - 查看模式语法高亮 + 行号编辑进入编辑模式
* - 运行中/初始化中服务端拒绝覆写400UI 侧锁定编辑入口并给出提示
* - paused/completed可编辑但保存会重置状态为闲置服务端 upsert 语义进入编辑时警示
* - 编辑模式Tab 缩进两空格Ctrl/Cmd+S 保存脏状态指示取消/关闭前放弃确认
* - 保存PUT /api/workflows/:name服务端 YAML 校验失败400的错误信息内联横幅展示
*
* 模态的关闭X / 关闭按钮 / Esc / 背景点击由本模块独占管理main.js 已将
* #modal-view-wf 排除出全局关闭回路以便统一走脏状态守卫
*/
import { escapeHtml, getWorkflowDetailApi, updateWorkflowApi } from '../api.js';
import { showToast } from './toast.js';
import { showConfirm, setupFocusTrap, releaseFocusTrap } from './modal.js';
import { fetchAllData } from '../state.js';
const LOCKED_STATUSES = ['running', 'initializing'];
const RESET_WARN_STATUSES = ['paused', 'completed'];
const STATUS_META = {
running: ['运行中', 'online'],
completed: ['已完成', 'completed'],
initializing: ['初始化中', 'warning'],
paused: ['已暂停', 'secondary'],
idle: ['闲置', 'secondary'],
};
const editorState = {
name: '',
description: '',
status: '',
original: '',
dirty: false,
mode: 'view',
wired: false,
};
// ===== 语法高亮(轻量 YAML 分词:键/数字/布尔/字符串/注释/列表符) =====
function tokenizeLine(rawLine) {
let code = rawLine;
let comment = '';
// 注释:整行注释或行尾 " # ..."(配置场景足够,不处理引号内 # 的极端情形)
const cm = code.match(/(^|\s)(#.*)$/);
if (cm) {
code = code.slice(0, cm.index + cm[1].length);
comment = cm[2];
}
let s = escapeHtml(code);
s = s.replace(/^(\s*)(-)(\s)/, '$1<span class="tk-dash">$2</span>$3');
s = s.replace(/(^|[\s{[])([A-Za-z_][A-Za-z0-9_.\-]*)(\s*:)/g, '$1<span class="tk-key">$2</span>$3');
s = s.replace(/(:\s*|[\[,]\s*)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)(?=[\s,\]}]|$)/g, '$1<span class="tk-num">$2</span>');
s = s.replace(/(:\s*|[\[,]\s*)(true|false|null|T|F)\b/g, '$1<span class="tk-bool">$2</span>');
s = s.replace(/(&quot;.*?&quot;|&#39;.*?&#39;)/g, '<span class="tk-str">$1</span>');
if (comment) {
s += `<span class="tk-comment">${escapeHtml(comment)}</span>`;
}
return s;
}
function highlightYaml(text) {
return text
.split('\n')
.map((line, i) =>
`<div class="yaml-line"><span class="yaml-ln">${i + 1}</span><span class="yaml-code">${tokenizeLine(line) || ' '}</span></div>`
)
.join('');
}
// ===== 面板填充与模式切换 =====
function statusBadge(status) {
const [cn, cls] = STATUS_META[status] || ['闲置', 'secondary'];
return `<span class="status-badge ${cls}">${cn}</span>`;
}
function setDirty(dirty) {
editorState.dirty = dirty;
document.getElementById('yaml-editor-dirty')?.classList.toggle('hidden', !dirty);
}
function updateMeta() {
const area = document.getElementById('yaml-edit-area');
const meta = document.getElementById('yaml-editor-meta');
if (!meta) return;
const text = editorState.mode === 'edit' && area ? area.value : editorState.original;
const lines = text ? text.split('\n').length : 0;
meta.textContent = `${lines} 行 · ${text.length} 字符`;
}
function switchToView() {
editorState.mode = 'view';
const viewPane = document.getElementById('yaml-view-pane');
const editPane = document.getElementById('yaml-edit-pane');
viewPane?.classList.remove('hidden');
editPane?.classList.add('hidden');
document.getElementById('view-wf-yaml').innerHTML = highlightYaml(editorState.original);
hideEditError();
const locked = LOCKED_STATUSES.includes(editorState.status);
const btnEdit = document.getElementById('btn-edit-yaml');
if (btnEdit) {
btnEdit.classList.toggle('hidden', locked);
}
const lockHint = document.getElementById('yaml-editor-lock');
if (lockHint) {
lockHint.textContent = locked
? (editorState.status === 'running' ? '运行中不可编辑,请先暂停工作流' : '初始化中,请稍候再试')
: '';
lockHint.classList.toggle('hidden', !locked);
}
document.getElementById('btn-cancel-edit-yaml')?.classList.add('hidden');
document.getElementById('btn-save-yaml')?.classList.add('hidden');
document.getElementById('yaml-reset-warn')?.classList.add('hidden');
setDirty(false);
updateMeta();
}
function switchToEdit() {
editorState.mode = 'edit';
const area = document.getElementById('yaml-edit-area');
document.getElementById('yaml-view-pane')?.classList.add('hidden');
document.getElementById('yaml-edit-pane')?.classList.remove('hidden');
document.getElementById('btn-edit-yaml')?.classList.add('hidden');
document.getElementById('btn-cancel-edit-yaml')?.classList.remove('hidden');
document.getElementById('btn-save-yaml')?.classList.remove('hidden');
const warn = document.getElementById('yaml-reset-warn');
if (warn) {
warn.classList.toggle('hidden', !RESET_WARN_STATUSES.includes(editorState.status));
}
if (area) {
area.value = editorState.original;
area.focus();
const end = area.value.length;
area.setSelectionRange(end, end);
}
updateMeta();
}
function showEditError(message) {
const el = document.getElementById('yaml-edit-error');
if (!el) return;
el.textContent = message;
el.classList.remove('hidden');
}
function hideEditError() {
const el = document.getElementById('yaml-edit-error');
if (!el) return;
el.textContent = '';
el.classList.add('hidden');
}
function closeModal() {
const modal = document.getElementById('modal-view-wf');
if (!modal) return;
modal.classList.add('hidden');
releaseFocusTrap(modal);
}
/** 带脏状态守卫的关闭:编辑中且有未保存修改时先确认放弃。 */
async function requestClose() {
if (editorState.mode === 'edit' && editorState.dirty) {
const ok = await showConfirm('放弃修改', '有未保存的配置修改,确定放弃并关闭吗?');
if (!ok) return;
}
closeModal();
}
// ===== 保存 =====
async function saveConfig() {
const area = document.getElementById('yaml-edit-area');
if (!area || editorState.mode !== 'edit') return;
const yaml = area.value;
if (!yaml.trim()) {
showEditError('配置内容不能为空');
return;
}
hideEditError();
const btnSave = document.getElementById('btn-save-yaml');
if (btnSave) btnSave.disabled = true;
try {
const res = await updateWorkflowApi(editorState.name, {
name: editorState.name,
description: editorState.description,
config_yaml: yaml,
});
const json = await res.json().catch(() => ({}));
if (res.ok && json.success) {
editorState.original = yaml;
showToast(`工作流 '${editorState.name}' 配置已保存`, 'success');
if (RESET_WARN_STATUSES.includes(editorState.status)) {
editorState.status = 'idle'; // 服务端 upsert 已将状态重置
}
switchToView();
const statusEl = document.getElementById('yaml-editor-status');
if (statusEl) statusEl.innerHTML = statusBadge(editorState.status);
fetchAllData();
} else {
showEditError(json.message || `保存失败(HTTP ${res.status}`);
}
} catch (err) {
showEditError(`请求异常:${err.message}`);
} finally {
if (btnSave) btnSave.disabled = false;
}
}
// ===== 事件接线(仅一次) =====
function wireOnce() {
if (editorState.wired) return;
editorState.wired = true;
const modal = document.getElementById('modal-view-wf');
if (!modal) return;
document.getElementById('btn-edit-yaml')?.addEventListener('click', switchToEdit);
document.getElementById('btn-close-view-wf')?.addEventListener('click', requestClose);
document.getElementById('close-view-wf')?.addEventListener('click', requestClose);
document.getElementById('btn-cancel-edit-yaml')?.addEventListener('click', async () => {
if (editorState.dirty) {
const ok = await showConfirm('放弃修改', '有未保存的配置修改,确定放弃吗?');
if (!ok) return;
}
switchToView();
});
document.getElementById('btn-save-yaml')?.addEventListener('click', saveConfig);
const area = document.getElementById('yaml-edit-area');
if (area) {
area.addEventListener('input', () => {
setDirty(area.value !== editorState.original);
hideEditError();
updateMeta();
});
area.addEventListener('keydown', (e) => {
// Tab 缩进两空格(保留撤销栈:优先 insertText 命令)
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
if (document.execCommand && document.execCommand('insertText', false, ' ')) return;
const { selectionStart, selectionEnd } = area;
area.value = area.value.slice(0, selectionStart) + ' ' + area.value.slice(selectionEnd);
area.setSelectionRange(selectionStart + 2, selectionStart + 2);
area.dispatchEvent(new Event('input'));
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
saveConfig();
}
});
}
// Esc 与背景点击:走脏状态守卫(全局关闭回路已排除本模态)
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
requestClose();
}
});
modal.addEventListener('click', (e) => {
if (e.target === modal) requestClose();
});
modal.querySelector('.modal-card')?.addEventListener('click', (e) => e.stopPropagation());
}
// ===== 入口 =====
export async function openYamlEditor(name) {
try {
const res = await getWorkflowDetailApi(name);
const json = await res.json();
if (!json.success || !json.data) {
showToast(`获取配置失败: ${json.message}`, 'error');
return;
}
const data = json.data;
editorState.name = data.name;
editorState.description = data.description || '';
editorState.status = data.status;
editorState.original = data.config_yaml || '';
wireOnce();
const title = document.getElementById('view-wf-title');
if (title) title.textContent = `工作流配置 · ${data.name}`;
const statusEl = document.getElementById('yaml-editor-status');
if (statusEl) statusEl.innerHTML = statusBadge(data.status);
switchToView();
const modal = document.getElementById('modal-view-wf');
modal?.classList.remove('hidden');
setupFocusTrap(modal);
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
+109 -233
View File
@@ -1,191 +1,55 @@
/* DCTS Cluster Dashboard Client Logic */
/* DCTS
*
* 只负责全局接线登录鉴权主题切换全局模态创建工作流 / 查看 YAML /
* 重发 Token / 确认框header 刷新与登出以及登录成功后的路由启动
* 视图内容与视图内事件委托见 src/views/ src/router.js 挂载
*/
import {
getAdminToken,
setAdminToken,
clearAdminToken,
loginAdmin,
createWorkflow,
startWorkflowApi,
stopWorkflowApi,
deleteWorkflowApi,
getWorkflowDetailApi,
approveNodeApi,
rejectNodeApi,
revokeNodeApi,
reissueNodeApi
createWorkflow
} from './api.js';
import { showToast } from './components/toast.js';
import { showConfirm, setupFocusTrap } from './components/modal.js';
import { renderNodesTableSkeleton } from './components/nodesTable.js';
import { renderWorkflowsSkeleton } from './components/workflows.js';
import { startPolling, fetchAllData, fetchNodes } from './state.js';
import { showConfirm, setupFocusTrap, releaseFocusTrap } from './components/modal.js';
import { fetchAllData } from './state.js';
import { triggerRefreshAnimation } from './utils.js';
import { startRouter } from './router.js';
const defaultYamlConfig = `name: sdB_cno
description: sdB CNO 6D Stellar Atmosphere Grid
const defaultYamlConfig = `# DCTS 恒星大气网格配置 (GridConfig)
grid:
teff: [20000.0, 40000.0, 5000.0]
logg: [5.0, 6.2, 0.3]
loghe: [-1.0, -1.0, 0.0]
logc: [-5.0, -2.0, 0.5]
logn: [-5.0, -2.0, 0.5]
logo: [-5.0, -2.0, 0.5]
convergence:
max_relc: 0.01
max_iterations: 30
teff: [20000.0, 40000.0]
logg: [5.0, 6.0]
loghe: [-2.0]
logc: [-4.0]
logn: [-4.0]
logo: [-4.0]
chain:
- {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0}
- {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10}
- {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100}
seed_step_fallback: true
nworkers: 4
timeout_sec: 3600
resume: true
synspec:
wstart: 3000.0
wend: 7000.0
imode: 0
idrv: 50
ifreq: 1
rel_cutoff: 0.0001
abs_cutoff: 0.01
results: data/seeds
`;
// ===== 工作流交互处理 =====
async function handleStartWorkflow(name) {
const ok = await showConfirm('启动计算网格', `确定要启动工作流 '${name}' 展开网格计算任务吗?`);
if (!ok) return;
try {
const res = await startWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已成功启动', 'success');
fetchAllData();
} else {
showToast(json.message || '启动工作流失败', 'error');
}
} catch (err) {
showToast(`启动失败: ${err.message}`, 'error');
}
}
async function handleStopWorkflow(name) {
const ok = await showConfirm('暂停工作流', `确定要暂停工作流 '${name}' 的后续任务派发吗?`);
if (!ok) return;
try {
const res = await stopWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已暂停', 'info');
fetchAllData();
} else {
showToast(json.message || '暂停失败', 'error');
}
} catch (err) {
showToast(`暂停失败: ${err.message}`, 'error');
}
}
async function handleDeleteWorkflow(name) {
const ok = await showConfirm('删除工作流', `确定要彻底删除工作流 '${name}' 及其关联的网格点数据吗?此操作无法撤销。`);
if (!ok) return;
try {
const res = await deleteWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流删除成功', 'success');
fetchAllData();
} else {
showToast(json.message || '删除失败', 'error');
}
} catch (err) {
showToast(`删除失败: ${err.message}`, 'error');
}
}
async function handleViewWorkflow(name) {
try {
const res = await getWorkflowDetailApi(name);
const json = await res.json();
if (json.success && json.data) {
document.getElementById('view-wf-title').textContent = `工作流: ${json.data.name}`;
document.getElementById('view-wf-yaml').textContent = json.data.config_yaml;
const modal = document.getElementById('modal-view-wf');
modal?.classList.remove('hidden');
setupFocusTrap(modal);
} else {
showToast(`获取详情失败: ${json.message}`, 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
// ===== 节点授权管理交互处理 =====
async function handleApprove(nodeId) {
const ok = await showConfirm('同意节点接入', `确定同意授权计算节点 '${nodeId}' 加入集群吗?\n系统将自动分配身份 Token 并在节点轮询时下发。`);
if (!ok) return;
try {
const res = await approveNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '节点接入成功已授权', 'success');
fetchAllData();
} else {
showToast(json.message || '审批失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleReject(nodeId) {
const ok = await showConfirm('拒绝节点接入', `确定拒绝节点 '${nodeId}' 的接入申请吗?`);
if (!ok) return;
try {
const res = await rejectNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '已拒绝节点接入申请', 'info');
fetchAllData();
} else {
showToast(json.message || '操作失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleRevoke(nodeId) {
const ok = await showConfirm('吊销节点 Token', `确定吊销节点 '${nodeId}' 的专属 Token 吗?\n该节点的现有 Token 将立即失效。`);
if (!ok) return;
try {
const res = await revokeNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || 'Token 吊销成功', 'info');
fetchAllData();
} else {
showToast(json.message || '吊销失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleReissue(nodeId) {
const ok = await showConfirm('重发节点 Token', `确定为节点 '${nodeId}' 重新颁发专属 Token 吗?旧 Token 将失效。`);
if (!ok) return;
try {
const res = await reissueNodeApi(nodeId);
const json = await res.json();
if (json.success && json.node_token) {
const modal = document.getElementById('modal-reissue-token');
document.getElementById('reissue-token-content').textContent = json.node_token;
modal?.classList.remove('hidden');
setupFocusTrap(modal);
showToast('已重新生成专属 Token', 'success');
fetchAllData();
} else {
showToast(json.message || '重发失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
function showLoginError(msg) {
const errorEl = document.getElementById('login-error-msg');
if (errorEl) {
@@ -219,7 +83,7 @@ async function handleLoginSubmit(password) {
document.getElementById('login-overlay')?.classList.add('hidden');
document.getElementById('btn-logout')?.classList.remove('hidden');
showToast('管理员登录成功', 'success');
startApp();
startRouter();
} else {
const errMsg = json.message || `登录失败: HTTP ${res.status}`;
showLoginError(errMsg);
@@ -231,19 +95,18 @@ async function handleLoginSubmit(password) {
}
}
function startApp() {
renderNodesTableSkeleton();
renderWorkflowsSkeleton();
startPolling();
}
// ===== DOM 初始化与事件委托 =====
// ===== DOM 初始化与全局事件接线 =====
document.addEventListener('DOMContentLoaded', () => {
const btnCreateWf = document.getElementById('btn-create-wf');
const modalCreateWf = document.getElementById('modal-create-wf');
const modalViewWf = document.getElementById('modal-view-wf');
const modalReissueToken = document.getElementById('modal-reissue-token');
function closeModal(modal) {
if (!modal) return;
modal.classList.add('hidden');
releaseFocusTrap(modal);
}
btnCreateWf?.addEventListener('click', () => {
document.getElementById('input-wf-name').value = '';
document.getElementById('input-wf-desc').value = '';
@@ -252,24 +115,37 @@ document.addEventListener('DOMContentLoaded', () => {
setupFocusTrap(modalCreateWf);
});
document.getElementById('close-create-wf')?.addEventListener('click', () => modalCreateWf?.classList.add('hidden'));
document.getElementById('cancel-create-wf')?.addEventListener('click', () => modalCreateWf?.classList.add('hidden'));
document.getElementById('close-view-wf')?.addEventListener('click', () => modalViewWf?.classList.add('hidden'));
document.getElementById('btn-close-view-wf')?.addEventListener('click', () => modalViewWf?.classList.add('hidden'));
const btnLoadPresetYaml = document.getElementById('btn-load-preset-yaml');
if (btnLoadPresetYaml) {
btnLoadPresetYaml.addEventListener('click', () => {
const nameInput = document.getElementById('input-wf-name');
const descInput = document.getElementById('input-wf-desc');
const yamlInput = document.getElementById('input-wf-yaml');
if (nameInput && !nameInput.value) nameInput.value = 'sdB_cno';
if (descInput && !descInput.value) descInput.value = 'sdB CNO 6D Stellar Atmosphere Grid';
if (yamlInput) yamlInput.value = defaultYamlConfig;
showToast('已载入预设示例 YAML 配置', 'info');
});
}
document.getElementById('close-create-wf')?.addEventListener('click', () => closeModal(modalCreateWf));
document.getElementById('cancel-create-wf')?.addEventListener('click', () => closeModal(modalCreateWf));
// #modal-view-wfYAML 编辑器)的关闭由 yamlEditor.js 独占管理(带脏状态守卫),
// 这里不再接线,并将其排除出全局背景点击 / Escape 关闭回路。
document.getElementById('reissue-close-btn')?.addEventListener('click', () => {
document.getElementById('reissue-token-content').textContent = '';
modalReissueToken?.classList.add('hidden');
closeModal(modalReissueToken);
});
document.getElementById('btn-close-reissue-modal')?.addEventListener('click', () => {
document.getElementById('reissue-token-content').textContent = '';
modalReissueToken?.classList.add('hidden');
closeModal(modalReissueToken);
});
// 点击 Backdrop 背景关闭弹窗
// 点击 Backdrop 背景关闭弹窗(YAML 编辑器自带脏守卫关闭,除外)
document.querySelectorAll('.login-overlay, .modal-backdrop').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay && overlay.id !== 'login-overlay') {
overlay.classList.add('hidden');
if (e.target === overlay && overlay.id !== 'login-overlay' && overlay.id !== 'modal-view-wf') {
closeModal(overlay);
}
});
});
@@ -278,12 +154,12 @@ document.addEventListener('DOMContentLoaded', () => {
card.addEventListener('click', (e) => e.stopPropagation());
});
// 按 Escape 键快速关闭 Modal
// 按 Escape 键快速关闭 Modal(YAML 编辑器自带脏守卫关闭,除外)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-backdrop:not(.hidden), .login-overlay:not(.hidden)').forEach(modal => {
if (modal.id !== 'login-overlay' && modal.id !== 'modal-confirm') {
modal.classList.add('hidden');
if (modal.id !== 'login-overlay' && modal.id !== 'modal-confirm' && modal.id !== 'modal-view-wf') {
closeModal(modal);
}
});
}
@@ -306,7 +182,7 @@ document.addEventListener('DOMContentLoaded', () => {
const json = await res.json();
if (res.ok && json.success) {
showToast(`工作流 '${name}' 保存成功!`, 'success');
modalCreateWf?.classList.add('hidden');
closeModal(modalCreateWf);
fetchAllData();
} else {
showToast(`保存失败: ${json.message}`, 'error');
@@ -316,32 +192,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// 工作流卡片事件委托
const workflowsList = document.getElementById('workflows-list');
workflowsList?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-wf-action]');
if (!btn) return;
const action = btn.getAttribute('data-wf-action');
const name = btn.getAttribute('data-wf-name');
if (action === 'start') handleStartWorkflow(name);
else if (action === 'stop') handleStopWorkflow(name);
else if (action === 'delete') handleDeleteWorkflow(name);
else if (action === 'view') handleViewWorkflow(name);
});
// 节点大表事件委托
const nodesTbody = document.getElementById('nodes-table-body');
nodesTbody?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-node-action], [data-cred-action]');
if (!btn) return;
const action = btn.getAttribute('data-node-action') || btn.getAttribute('data-cred-action');
const nodeId = btn.getAttribute('data-node-id');
if (action === 'approve') handleApprove(nodeId);
else if (action === 'reject') handleReject(nodeId);
else if (action === 'revoke') handleRevoke(nodeId);
else if (action === 'reissue') handleReissue(nodeId);
});
// 复制 Token 按钮
document.getElementById('btn-copy-token')?.addEventListener('click', async () => {
const text = document.getElementById('reissue-token-content')?.textContent || '';
@@ -354,17 +204,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// 刷新按钮 & 登出
document.getElementById('refresh-btn')?.addEventListener('click', () => {
showToast('正在刷新数据...', 'info');
// header 全局刷新 & 登出
document.getElementById('refresh-btn')?.addEventListener('click', (e) => {
triggerRefreshAnimation(e.currentTarget);
showToast('正在刷新集群全量数据...', 'info');
fetchAllData();
});
document.getElementById('btn-refresh-nodes')?.addEventListener('click', () => {
showToast('正在刷新节点数据...', 'info');
fetchNodes();
});
document.getElementById('btn-logout')?.addEventListener('click', async () => {
const ok = await showConfirm('退出登录', '确定要登出并清除本页面的 Admin Token 吗?');
if (ok) {
@@ -373,6 +219,36 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// ===== 深色/浅色主题模式控制 =====
function initTheme() {
const savedTheme = localStorage.getItem('dcts_theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
setTheme(initialTheme);
}
function setTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.querySelector('.icon-theme-sun')?.classList.remove('hidden');
document.querySelector('.icon-theme-moon')?.classList.add('hidden');
} else {
document.documentElement.setAttribute('data-theme', 'light');
document.querySelector('.icon-theme-sun')?.classList.add('hidden');
document.querySelector('.icon-theme-moon')?.classList.remove('hidden');
}
localStorage.setItem('dcts_theme', theme);
}
document.getElementById('btn-theme-toggle')?.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(nextTheme);
showToast(`已切换至 ${nextTheme === 'dark' ? '深色' : '浅色'}模式`, 'info');
});
initTheme();
// 登录表单
document.getElementById('form-login')?.addEventListener('submit', (e) => {
e.preventDefault();
@@ -380,11 +256,11 @@ document.addEventListener('DOMContentLoaded', () => {
handleLoginSubmit(pass);
});
// 初始化鉴权判断
// 初始化鉴权判断:已登录直接按当前 hash 渲染对应视图
if (getAdminToken()) {
document.getElementById('login-overlay')?.classList.add('hidden');
document.getElementById('btn-logout')?.classList.remove('hidden');
startApp();
startRouter();
} else {
document.getElementById('btn-logout')?.classList.add('hidden');
}
+44
View File
@@ -0,0 +1,44 @@
/* DCTS hash
*
* #/或空/未知 hash 控制台首页#/workflows/:name 工作流详情页
* 视图挂载/卸载成对调用各视图在 mount 里启动自己的轮询 unmount 里彻底清理
* 保证切换视图不留定时器/监听器泄漏登录门禁 token 时不渲染任何视图
* 登录遮罩由 main.js 管理登录成功后调用 startRouter()
*/
import { getAdminToken } from './api.js';
import { mountHome, unmountHome } from './views/home.js';
import { mountWorkflowDetail, unmountWorkflowDetail } from './views/workflowDetail.js';
let current = null; // { view: 'home' | 'workflow', name?: string }
export function parseHash() {
const h = location.hash.replace(/^#/, '');
const m = h.match(/^\/workflows\/([^/?#]+)\/?$/);
if (m) return { view: 'workflow', name: decodeURIComponent(m[1]) };
return { view: 'home' };
}
function render() {
if (!getAdminToken()) return;
const next = parseHash();
if (current && current.view === next.view && current.name === next.name) return;
if (current) {
if (current.view === 'home') unmountHome();
else unmountWorkflowDetail();
}
if (next.view === 'workflow') mountWorkflowDetail(next.name);
else mountHome();
current = next;
}
/** 注册 hashchange 并按当前 hash 首次渲染(登录成功后调用)。 */
export function startRouter() {
window.addEventListener('hashchange', render);
render();
}
export function navigate(hash) {
location.hash = hash;
}
+34 -12
View File
@@ -7,6 +7,18 @@ import { renderWorkflows } from './components/workflows.js';
let isPolling = false;
let pollTimer = null;
// 监听浏览器 Tab 标签页切换,切入后台时暂停轮询,切回前台时恢复并立即刷新
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
} else if (isPolling) {
scheduleFetchData();
}
});
export function isAppPolling() {
return isPolling;
}
@@ -26,9 +38,14 @@ export function stopPolling() {
}
export async function scheduleFetchData() {
if (pollTimer) clearTimeout(pollTimer);
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
if (document.hidden) return;
await fetchAllData();
if (isPolling) {
if (isPolling && !document.hidden) {
pollTimer = setTimeout(scheduleFetchData, 5000);
}
}
@@ -68,11 +85,6 @@ export async function fetchNodes() {
const json = await fetchNodesList();
if (json.success && Array.isArray(json.data)) {
renderNodesTable(json.data);
return;
}
const data = await fetchClusterStatus();
if (data.nodes) {
renderNodesTable(data.nodes);
}
} catch (err) {
console.warn('获取计算节点列表失败:', err);
@@ -101,13 +113,20 @@ export function updateUI(data) {
const valActiveNodes = document.getElementById('val-active-nodes');
const valTotalSlots = document.getElementById('val-total-slots');
const slotsProgressFill = document.getElementById('slots-progress-fill');
if (valActiveNodes) valActiveNodes.textContent = activeNodesCount;
if (valTotalSlots) {
valTotalSlots.textContent = `${activeSlots} / ${maxSlots} CPU 槽位占用`;
}
if (slotsProgressFill) {
const pct = maxSlots > 0 ? Math.min(100, Math.max(0, Math.round((activeSlots / maxSlots) * 100))) : 0;
slotsProgressFill.style.width = `${pct}%`;
}
const stats = data.grid_stats || {};
const pending = stats.pending ?? data.pending_points ?? 0;
// 后端已将 queued 从 pending 拆出(详情页需区分"未入队/排队中");
// 首页"待计算网格点"保持传统合并口径 = pending + queued。
const pending = (stats.pending ?? data.pending_points ?? 0) + (stats.queued ?? 0);
const running = stats.running ?? data.running_points ?? 0;
const converged = stats.converged ?? data.converged_points ?? 0;
const failed = stats.failed ?? data.failed_points ?? 0;
@@ -122,13 +141,16 @@ export function updateUI(data) {
const valCompletedTasks = document.getElementById('val-completed-tasks');
const valCompletionRate = document.getElementById('val-completion-rate');
const convergedProgressFill = document.getElementById('converged-progress-fill');
if (valCompletedTasks) valCompletedTasks.textContent = completed.toLocaleString();
if (valCompletionRate) {
valCompletionRate.textContent = `${converged.toLocaleString()} 收敛 / ${failed.toLocaleString()} 未收敛`;
}
const valTotalWorkflows = document.getElementById('val-total-workflows');
if (valTotalWorkflows && data.total_workflows !== undefined) {
valTotalWorkflows.textContent = data.total_workflows;
if (convergedProgressFill) {
const totalModels = pending + running + completed;
const pct = totalModels > 0 ? Math.min(100, Math.max(0, Math.round((converged / totalModels) * 100))) : 0;
convergedProgressFill.style.width = `${pct}%`;
}
// 工作流总数由 renderWorkflows 从列表单独写入,避免与此处双源覆盖造成数字抖动
}
+1152 -28
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
/* DCTS 前端小工具 */
/**
* 刷新按钮旋转动画svg .spin 800ms 后恢复并解禁按钮
* header 全局刷新与首页节点面板刷新共用
*/
export function triggerRefreshAnimation(btn) {
if (!btn) return;
const svg = btn.querySelector('svg');
if (svg) svg.classList.add('spin');
btn.disabled = true;
setTimeout(() => {
if (svg) svg.classList.remove('spin');
btn.disabled = false;
}, 800);
}
+176
View File
@@ -0,0 +1,176 @@
/* DCTS
*
* 内容核心指标卡 + 计算节点集群与凭据管理大表 + 工作流卡片列表
* 标记来自 index.html #view-home-template克隆挂载ID 与原版逐字一致
* state.js 的轮询渲染逻辑无需任何改动
*
* 生命周期mountHome 克隆模板 绑定首页作用域事件委托 骨架屏 启动全局轮询
* unmountHome 停轮询 清空 #view-root委托监听随 DOM 移除无泄漏
*/
import {
approveNodeApi,
rejectNodeApi,
reissueNodeApi,
disableNodeApi,
enableNodeApi
} from '../api.js';
import { showToast } from '../components/toast.js';
import { showConfirm, setupFocusTrap } from '../components/modal.js';
import { renderNodesTableSkeleton } from '../components/nodesTable.js';
import { renderWorkflowsSkeleton } from '../components/workflows.js';
import { startPolling, stopPolling, fetchAllData, fetchNodes } from '../state.js';
import { triggerRefreshAnimation } from '../utils.js';
import {
handleStartWorkflow,
handleStopWorkflow,
handleDeleteWorkflow,
handleViewWorkflow
} from '../components/wfActions.js';
// ===== 节点授权管理交互处理(首页专属) =====
async function handleApprove(nodeId) {
const ok = await showConfirm('同意节点接入', `确定同意授权计算节点 '${nodeId}' 加入集群吗?\n系统将自动分配身份 Token 并在节点轮询时下发。`);
if (!ok) return;
try {
const res = await approveNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '节点接入成功已授权', 'success');
fetchAllData();
} else {
showToast(json.message || '审批失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleReject(nodeId) {
const ok = await showConfirm('拒绝节点接入', `确定拒绝节点 '${nodeId}' 的接入申请吗?`);
if (!ok) return;
try {
const res = await rejectNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '已拒绝节点接入申请', 'info');
fetchAllData();
} else {
showToast(json.message || '操作失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleDisable(nodeId) {
const ok = await showConfirm('停用节点', `确定停用节点 '${nodeId}' 吗?\n停用后该节点保持在线但不再被分发任务,worker 将空闲待命。可随时重新启用。`);
if (!ok) return;
try {
const res = await disableNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '节点已停用', 'info');
fetchAllData();
} else {
showToast(json.message || '停用失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleEnable(nodeId) {
const ok = await showConfirm('启用节点', `确定重新启用节点 '${nodeId}' 吗?\n节点将在下一次心跳后恢复分发任务。`);
if (!ok) return;
try {
const res = await enableNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '节点已重新启用', 'success');
fetchAllData();
} else {
showToast(json.message || '启用失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleReissue(nodeId) {
const ok = await showConfirm('重发节点 Token', `确定为节点 '${nodeId}' 重新颁发专属 Token 吗?旧 Token 将立即失效,请随后把新 Token 同步到该节点。`);
if (!ok) return;
try {
const res = await reissueNodeApi(nodeId);
const json = await res.json();
if (json.success && json.node_token) {
// 弹窗展示新 token 明文(仅此一次):管理员须手动复制并写入节点本地 .node_token
// 后重启节点。服务端不持久化明文,故必须在此处交给管理员。
const modal = document.getElementById('modal-reissue-token');
document.getElementById('reissue-token-content').textContent = json.node_token;
modal?.classList.remove('hidden');
setupFocusTrap(modal);
showToast('已重新生成专属 Token,请复制并同步到节点', 'success');
fetchAllData();
} else {
showToast(json.message || '重发失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
export function mountHome() {
const root = document.getElementById('view-root');
const tpl = document.getElementById('view-home-template');
if (!root || !tpl) return;
root.replaceChildren(tpl.content.cloneNode(true));
// 工作流卡片事件委托(随 DOM 移除而销毁,无需手动解绑)
document.getElementById('workflows-list')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-wf-action]');
if (!btn) return;
const action = btn.getAttribute('data-wf-action');
const name = btn.getAttribute('data-wf-name');
if (action === 'start') handleStartWorkflow(name);
else if (action === 'stop') handleStopWorkflow(name);
else if (action === 'delete') handleDeleteWorkflow(name);
else if (action === 'view') handleViewWorkflow(name);
else if (action === 'create-first') document.getElementById('btn-create-wf')?.click();
});
// 节点大表事件委托
document.getElementById('nodes-table-body')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-node-action], [data-cred-action]');
if (!btn) return;
const action = btn.getAttribute('data-node-action') || btn.getAttribute('data-cred-action');
const nodeId = btn.getAttribute('data-node-id');
if (action === 'approve') handleApprove(nodeId);
else if (action === 'reject') handleReject(nodeId);
else if (action === 'reissue') handleReissue(nodeId);
else if (action === 'disable') handleDisable(nodeId);
else if (action === 'enable') handleEnable(nodeId);
});
// 节点面板刷新按钮(header 的全局刷新按钮仍由 main.js 管理)
document.getElementById('btn-refresh-nodes')?.addEventListener('click', (e) => {
triggerRefreshAnimation(e.currentTarget);
showToast('正在刷新节点列表...', 'info');
fetchNodes();
});
renderNodesTableSkeleton();
renderWorkflowsSkeleton();
startPolling();
}
export function unmountHome() {
stopPolling();
const root = document.getElementById('view-root');
if (root) root.innerHTML = '';
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8090',
target: 'http://127.0.0.1:8090',
changeOrigin: true,
},
},
+30
View File
@@ -0,0 +1,30 @@
# ==============================================================================
# DCTS 部署 Profile 模板: 计算节点 (Node)
# 提示: 复制本文件为 node-01.env 即可修改并一键部署对应 Node
# ==============================================================================
# --- 1. 远程 SSH 部署控制参数 (仅 deploy.sh 本地使用) ---
REMOTE_USER=your_username
REMOTE_IP=192.168.x.x
REMOTE_PORT=22
REMOTE_DIR=D:/dcts
# --- 2. 部署预设选项 ---
DEPLOY_ENV=remote
DEPLOY_BACKEND=compose
DEPLOY_ROLE=node
SYNC_ASSETS=skip
# --- 3. 目标服务器上生成的远端容器 .env 运行参数 ---
DCTS_NODE_ID=node-worker-01
DCTS_SERVER_URL=http://your_server_ip:8090
DCTS_MAX_SLOTS=8
DCTS_LOG=info,node=debug
# node 完整计算结果归档目录:任务上报成功后、沙盒清理前,把完整科学产物
# .spec/.cont/.iden/.7/各阶段 .7/.9 快照/.6/.err/.log/conv.json 等)拷贝至此,
# 避免随沙盒删除而丢失。默认 /app/data/result(映射到宿主机 ./data/result)。
# 超过 200 个网格点子目录时自动按 LRU 删除最旧的。留空则用代码默认值。
# 注:DCTS_ARCHIVE_DIR 为旧名,向后兼容回退(node 代码优先读 DCTS_RESULT_DIR)。
DCTS_RESULT_DIR=/app/data/result
+24
View File
@@ -0,0 +1,24 @@
# ==============================================================================
# DCTS 部署 Profile 模板: 主控服务端 (Server)
# 提示: 复制本文件为 server-main.env 即可修改并一键部署 Server
# ==============================================================================
# --- 1. 远程 SSH 部署控制参数 (仅 deploy.sh 本地使用) ---
REMOTE_USER=your_username
REMOTE_IP=your_server_ip
REMOTE_PORT=22
REMOTE_DIR=/opt/dcts
# --- 2. 部署预设选项 ---
DEPLOY_ENV=remote
DEPLOY_BACKEND=compose
DEPLOY_ROLE=server
SYNC_ASSETS=auto
# --- 3. 目标服务器上生成的远端容器 .env 运行参数 ---
DCTS_PORT=8090
DCTS_ADMIN_TOKEN=your_secure_admin_token
DCTS_AUTH_DISABLE=false
DCTS_HEARTBEAT_SEC=15
DCTS_LOG=info,server=debug
LOG_DIR=/app/data/logs
+17 -12
View File
@@ -30,19 +30,21 @@ services:
- .env
environment:
- DCTS_PORT=8090
- TZ=Asia/Shanghai
- DCTS_DB_PATH=/app/data/dcts.db
- DCTS_QUEUE_DB_PATH=/app/data/dcts_queue.db
- DCTS_RESULTS_DIR=/app/data/results
- DCTS_SEEDS_DIR=/app/data/seeds
- DCTS_BACKUP_DIR=/app/data/backups
- DCTS_ASSETS_DIR=/app/assets
- LOG_DIR=/app/data/logs
- DCTS_LOG=info,server=debug
volumes:
- ./assets:/app/assets:ro
- ./workflows:/app/workflows:ro
- ./data:/app/data
- ./logs/server:/app/logs
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8090/healthz"]
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8090/healthz"]
interval: 15s
timeout: 5s
retries: 3
@@ -55,23 +57,26 @@ services:
context: .
dockerfile: Dockerfile.node
image: dcts-node:latest
container_name: dcts-node
restart: unless-stopped
depends_on:
server:
condition: service_healthy
env_file:
- .env
environment:
# DCTS_NODE_ID 留空:由 config.rs 对空串/缺失回退为随机 UUIDnode-<uuid>),
# 这样 `docker compose --scale node=N` 时每个副本各得独立身份。
- DCTS_SERVER_URL=http://dcts-server:8090
- DCTS_MAX_SLOTS=4
- DCTS_HEARTBEAT_SEC=15
- DCTS_LOG=info,node=debug
- TZ=Asia/Shanghai
- DCTS_SERVER_URL=${DCTS_SERVER_URL:-http://dcts-server:8090}
- DCTS_MAX_SLOTS=${DCTS_MAX_SLOTS:-4}
- DCTS_HEARTBEAT_SEC=${DCTS_HEARTBEAT_SEC:-15}
- LOG_DIR=${LOG_DIR:-/app/data/logs}
- DCTS_LOG=${DCTS_LOG:-info,node=debug}
# 完整计算结果归档目录:科学产物(.spec/.cont/.iden/各阶段快照/日志)落盘位置,
# 上报成功后从沙盒拷贝至此避免丢失。挂载在 ./data 下,已随卷持久化。
# DCTS_ARCHIVE_DIR 为旧名,向后兼容回退(node 代码优先读 DCTS_RESULT_DIR)。
- DCTS_RESULT_DIR=${DCTS_RESULT_DIR:-${DCTS_ARCHIVE_DIR:-/app/data/result}}
volumes:
- ./assets:/app/assets:ro
- ./data/node_work:/app/data/work
- ./logs/node:/app/logs
- ./data:/app/data
# 持久化卷(Caddy 证书与配置)
volumes:
+310 -32
View File
@@ -29,7 +29,7 @@
服务端基于角色访问控制 (RBAC) 划分三种请求鉴权级别:
1. **Admin 角色**:具有系统管理权限(工作流 CRUD/起停、节点凭据审批/吊销/重发、系统恢复)。在 Request Header 中需携带:
1. **Admin 角色**:具有系统管理权限(工作流 CRUD/起停、节点凭据审批/重发/停用/启用、系统恢复)。在 Request Header 中需携带:
```http
Authorization: Bearer <DCTS_ADMIN_TOKEN>
# 或
@@ -63,19 +63,29 @@
### 2.1 6维网格点参数 (`GridPointParams`)
定义恒星大气模型的 6 维大气参数:温度 $T_{\text{eff}}$、重力加速度 $\log g$、以及元素丰度 $\log(N_{\text{He}}/N_{\text{H}})$, $\log(N_{\text{C}}/N_{\text{H}})$, $\log(N_{\text{N}}/N_{\text{H}})$, $\log(N_{\text{O}}/N_{\text{H}})$。
- **Rust 定义** ([models.rs](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs#L6-L14)):
- **Rust 定义** ([models.rs](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs)):
```rust
// 每个轴值携带数值与 YAML 源书写文本,使命名严格忠于源精度。
#[derive(Debug, Clone, PartialEq)]
pub struct GridAxisValue { /* 内部:value: f64 + text: 源文本 */ }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GridPointParams {
pub teff: f64, // 有效温度 (K), e.g. 35000.0
pub logg: f64, // 表面重力加速度对数 (cgs), e.g. 5.5
pub loghe: f64, // 氦丰度对数, e.g. -1.0
pub logc: f64, // 碳丰度对数, e.g. -2.0
pub logn: f64, // 氮丰度对数, e.g. -2.0
pub logo: f64, // 氧丰度对数, e.g. -2.0
pub teff: GridAxisValue, // 有效温度 (K), e.g. 35000.0
pub logg: GridAxisValue, // 表面重力加速度对数 (cgs), e.g. 5.5
pub loghe: GridAxisValue, // 氦丰度对数, e.g. -1.0
pub logc: GridAxisValue, // 碳丰度对数, e.g. -2.0
pub logn: GridAxisValue, // 氮丰度对数, e.g. -2.0
pub logo: GridAxisValue, // 氧丰度对数, e.g. -2.0
}
```
> **serde 行为**`GridAxisValue` 序列化为纯数值(`f64`),下游 JSON 消费者无感;
> 反序列化时优先捕获 YAML/JSON 标量原文。**命名精度**:`model_name()` 直接拼接各轴
> 源书写文本——配置里多少位小数就多少位(`logg: 5.0``g5.0``teff: 20000``t20000`),
> 与旧版 Python `gen_input5.model_name` 逐字符一致,保证历史数据可迁移。详见
> [`GridConfig::from_yaml_str`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/config.rs)YAML 源文本捕获路径)。
- **TypeScript 类型声明**:
```typescript
export interface GridPointParams {
@@ -188,7 +198,6 @@ Worker 节点向服务端上报的任务计算结果。
```rust
pub struct NodeRegisterRequest {
pub node_id: String,
pub host_name: String,
pub max_slots: i32,
}
@@ -201,7 +210,6 @@ Worker 节点向服务端上报的任务计算结果。
pub struct NodeInfo {
pub node_id: String,
pub host_name: String,
pub max_slots: i32,
pub active_slots: i32,
pub status: String,
@@ -215,7 +223,6 @@ Worker 节点向服务端上报的任务计算结果。
```typescript
export interface NodeRegisterRequest {
node_id: string;
host_name: string;
max_slots: number;
}
@@ -228,7 +235,6 @@ Worker 节点向服务端上报的任务计算结果。
export interface NodeInfo {
node_id: string;
host_name: string;
max_slots: number;
active_slots: number;
status: 'online' | 'offline';
@@ -237,6 +243,7 @@ Worker 节点向服务端上报的任务计算结果。
last_heartbeat: string;
}
```
> **节点 `status` 取值**: `'online'`(在线分发中)、`'offline'`(心跳超时离线)、`'pending_approval'`(待管理员审批)、`'disabled'`(管理员手动停用——在线但不分发任务,详见 [`/disable`](#6-停用节点-post-apiadminnodesnode_iddisable))。
---
@@ -302,42 +309,42 @@ Worker 节点向服务端上报的任务计算结果。
```rust
pub async fn register_node(
State(state): State<AppState>,
Json(req): Json<NodeRegisterRequest>,
auth_node: Option<Extension<AuthenticatedNode>>,
Json(req): Json<NodeRegisterRequest>
) -> impl IntoResponse
```
- **鉴权**: 是 (若配置 Token)
- **鉴权**: 否(免凭据申请,提交后进入 `pending_approval` 待管理员审批)
- **请求 Header**: `Content-Type: application/json`
- **请求 Body**:
```json
{
"node_id": "node-worker-01",
"host_name": "hpc-node-01.local",
"max_slots": 8
}
```
- **响应 Schema**:
- `200 OK` (成功):
- `200 OK` (新节点申请提交成功,待审批):
```json
{
"status": "ok",
"message": "节点注册成功"
"status": "pending_approval",
"message": "节点注册申请已成功提交!请在管理 Dashboard 控制台上点击【同意接入】授权该节点",
"node_token": null
}
```
- `200 OK` (数据库异常):
- `200 OK` (已授权节点带专属 token 刷新配置):
```json
{
"status": "error",
"message": "数据库错误详情"
"status": "approved",
"message": "节点配置更新成功",
"node_token": null
}
```
- **curl 示例**:
```bash
curl -X POST http://localhost:8090/api/node/register \
-H "Content-Type: application/json" \
-H "Authorization: Bearer secret_token" \
-d '{
"node_id": "node-worker-01",
"host_name": "hpc-node-01.local",
"max_slots": 8
}'
```
@@ -406,13 +413,14 @@ Worker 节点向服务端上报的任务计算结果。
"data": [
{
"node_id": "node-worker-01",
"has_token": true,
"is_revoked": false,
"issued_at": "2026-07-28T12:00:00Z"
"status": "online",
"token_status": "active",
"token_issued_at": "2026-07-28T12:00:00Z"
}
]
}
```
> `token_status` 取值:`active`(有效)/ `none`(无凭据记录)。token 失效靠重发覆盖 hash 实现,不存在「已吊销」中间态。
#### 2. 同意节点接入申请 (`POST /api/admin/nodes/:node_id/approve`)
- **权限**: Admin 角色
@@ -422,13 +430,34 @@ Worker 节点向服务端上报的任务计算结果。
- **权限**: Admin 角色
- **说明**: 拒绝处于待审批状态的 Node 接入。
#### 4. 吊销节点专属 Token (`POST /api/admin/nodes/:node_id/revoke`)
#### 4. 重新颁发节点专属 Token (`POST /api/admin/nodes/:node_id/reissue`)
- **权限**: Admin 角色
- **说明**: 立即吊销节点专属 Token,被吊销的 Token 无法再通过 Node 鉴权,需重新申请
- **说明**: 作废旧 Token(hash 被覆盖,立即失效)并重新生成新 Token 文本返回。新明文同时暂存到服务端,节点可通过 `/node/check_status` 自动拉取(限 1 天内有效),或由管理员手动同步到节点本地 `.node_token`
#### 5. 重新颁发节点专属 Token (`POST /api/admin/nodes/:node_id/reissue`)
#### 5. 停用节点 (`POST /api/admin/nodes/:node_id/disable`)
- **权限**: Admin 角色
- **说明**: 作废旧 Token 并重新生成新 Token 文本返回
- **说明**: 手动停用一个处于 `online`/`offline` 状态的节点。停用后节点**保持在线心跳**(Dashboard 仍可见其存活),但 `claim` 不再向其分发任务,Worker 收到 `{"status":"disabled"}` 后会拉长轮询、空闲待命,可随时调用 `enable` 恢复
- **状态语义**: 节点 `status` 切为 `disabled`。与「重发 Token」(凭据失效、Worker 强制退出重注册)不同——停用仅是运维意图,不退出 Worker 进程、不动凭据。
- **响应**:
- `200 OK`: `{"success": true, "message": "节点 '...' 已停用,不再分发任务"}`
- `409 Conflict`: 节点当前状态不支持停用(仅 `online`/`offline` 可停用,`pending_approval`/`disabled` 返回此码)。
- **curl 示例**:
```bash
curl -X POST http://localhost:8090/api/admin/nodes/node-worker-01/disable \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
#### 6. 重新启用节点 (`POST /api/admin/nodes/:node_id/enable`)
- **权限**: Admin 角色
- **说明**: 重新启用被手动停用(`disabled`)的节点。状态切为 `offline`,靠节点下一次心跳自然翻为 `online` 后即恢复分发任务——既能自愈,又不会对真实离线的节点虚报在线。
- **响应**:
- `200 OK`: `{"success": true, "message": "节点 '...' 已重新启用,将在下一次心跳后恢复分发任务"}`
- `409 Conflict`: 节点当前状态不支持启用(仅 `disabled` 可启用)。
- **curl 示例**:
```bash
curl -X POST http://localhost:8090/api/admin/nodes/node-worker-01/enable \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
---
@@ -474,6 +503,14 @@ Worker 节点向服务端上报的任务计算结果。
"task": null
}
```
- `200 OK` (节点被管理员手动停用,不再分发任务):
```json
{
"status": "disabled",
"task": null
}
```
> Worker 收到此响应后保持存活、拉长轮询(每 60s)空闲待命,**不会**退出进程(区别于 401/403 的 Token 失效语义)。管理员调用 `/enable` 后下一次轮询即恢复领用。
- `500 Internal Server Error`:
```json
{
@@ -525,7 +562,7 @@ Worker 节点向服务端上报的任务计算结果。
"message": "无法解析 params 或 summary_json"
}
```
- **说明**: 当 `converged == true``atmosphere_has_nan == false` 且包含 `seed_file` 时,服务端会将种子保存至 `results_dir/<point_name>/<point_name>.7` 并记入 `seeds` 表。若冷启动任务失败,服务端会自动唤醒 `GridScheduler` 触发针对该网格点的步进回退算法 (Seed-step Fallback)。
- **说明**: 当 `converged == true``atmosphere_has_nan == false` 且包含 `seed_file` 时,服务端会将种子保存至 `seeds_dir/<point_name>/<point_name>.7` 并记入 `seeds` 表。若冷启动任务失败,服务端会自动唤醒 `GridScheduler` 触发针对该网格点的步进回退算法 (Seed-step Fallback)。
- **curl 示例**:
```bash
curl -X POST http://localhost:8090/api/task/report \
@@ -545,6 +582,44 @@ Worker 节点向服务端上报的任务计算结果。
-F 'seed_file=@/path/to/t35000_g5.5_he-1_c-2_n-2_o-2.7'
```
### 4.3 历史种子批量导入 (`POST /api/admin/import_seed`)
把旧版单机 `run_grid.py` 产物(`conv.json` + `.7` 大气文件)批量回灌进 DCTS。与 `/task/report` 的关键区别:**跳过任务归属校验**(历史数据无领用语义),直接幂等落库。供 [`tools/import_results`](file:///home/fmq/program/tlusty/tl208-s54/dcts/tools/import_results/src/main.rs) 调用。
- **处理函数**: [`import_seed`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/server/src/api/task.rs)
- **鉴权**: **Admin 角色**admin token 或登录 session
- **请求格式**: `multipart/form-data`
- Part `report`: 旧版 `conv.json` 的**原文 JSON**(映射为 `ModelSummary`,服务端解析出 `name` / `params` / `converged` / `final_max_relc`
- Part `seed_file`: 二进制数据(`.7` 大气种子文件;收敛点必传)
- **Query 参数**: `workflow`(可选,默认 `imported`):目标工作流名,种子导入到该工作流的 `grid_points`
- **命名保真**: `point_name` 取旧 `conv.json``name` 字段(源精度真名,如 `t20000_g5.0_...`),**逐字符**落库(磁盘目录、`grid_points.name``seeds.point_name`),与旧版 Python `gen_input5.model_name` 完全一致。
- **幂等**: `ON CONFLICT DO NOTHING` upsert `grid_points``conv.json``.7` 原子覆盖写,可重复运行。
- **响应 Schema**:
- `200 OK`:
```json
{
"status": "ok",
"point_name": "t20000_g5.0_he-2_c-4_n-4_o-4",
"converged": true,
"max_relc": 0.000321
}
```
- `400 Bad Request`: 缺少 `report` / `conv.json` 解析失败 / 非法网格点名称
- `401 Unauthorized`: 缺少或无效 admin 凭据
- **curl 示例**:
```bash
curl -X POST "http://localhost:8090/api/admin/import_seed?workflow=sdB_cno" \
-H "Authorization: Bearer <admin_token>" \
-F 'report=@/path/to/old_results/t20000_g5.0_he-2_c-4_n-4_o-4/conv.json;type=application/json' \
-F 'seed_file=@/path/to/old_results/t20000_g5.0_he-2_c-4_n-4_o-4/t20000_g5.0_he-2_c-4_n-4_o-4.7'
```
- **批量导入工具**: [`import_results`](file:///home/fmq/program/tlusty/tl208-s54/dcts/tools/import_results/src/main.rs) 自动扫描结果目录、校验无 NaN 且收敛、逐点调用本端点(同时把完整产物树迁移到 `data/result/`):
```bash
cargo run -p import_results -- --dir <旧 results 根目录> \
--config workflows/sdB_cno.yaml \
--server http://127.0.0.1:8090 --workflow sdB_cno --token <admin_token>
```
---
## 5. 种子文件管理 API (Seed Management)
@@ -656,7 +731,6 @@ Worker 节点向服务端上报的任务计算结果。
"nodes": [
{
"node_id": "node-worker-01",
"host_name": "hpc-node-01.local",
"max_slots": 8,
"active_slots": 2,
"status": "online",
@@ -930,6 +1004,210 @@ Worker 节点向服务端上报的任务计算结果。
---
### 8.7 工作流执行统计 (`GET /api/workflows/:name/stats`)
- **处理函数**: `get_workflow_stats``crates/server/src/api/workflow.rs`
- **权限**: Admin`/workflows/*` 前缀统一映射)
- **用途**: 详情页"任务控制条 + 执行概览"数据源。在 `get_grid_summary_stats(Some(wf))`
之上追加难度波次分布与近似 ETA。**`pending``queued` 分开计数**(与 `/api/status`
的全局口径不同,后者由前端合并展示)。
- **路径参数**: `:name` — 工作流名(白名单 `[A-Za-z0-9._-]{1,64}`);未知工作流 → `404`
- **响应 Schema (`200 OK`)**:
```json
{
"success": true,
"message": "成功获取工作流统计",
"data": {
"name": "sdB_cno",
"status": "running",
"total": 432,
"pending": 120,
"queued": 40,
"running": 8,
"converged": 261,
"failed": 3,
"cold_run_converged": 220,
"seed_step_converged": 41,
"imported_converged": 0,
"waves": [
{ "wave": 0, "total": 108, "converged": 108, "failed": 0 }
],
"avg_point_sec": 740.5,
"eta_sec": 9620.0
}
}
```
> `avg_point_sec` = `AVG(COALESCE(tasks.elapsed_sec, created_at→completed_at 时间戳差))`——
> 优先用 Worker 回报的精确墙钟(不含排队等待),历史无 `elapsed_sec` 的行回退时间戳差近似;
> `eta_sec` = `avg_point_sec × (total - converged - failed) ÷ 在线节点总槽位`(并发感知;
> 无在线节点按串行兜底);无历史数据时二者为 `null`
---
### 8.8 工作流网格点列表 (`GET /api/workflows/:name/points`)
- **处理函数**: `get_workflow_points``crates/server/src/api/workflow.rs`
- **权限**: Admin
- **用途**: 详情页"网格点明细"表与"收敛性分析"热力图、概览"最近动态"流的数据源。
每行附带**最近一次尝试**信息(`tasks` 关联子查询取最新行,从未派发过的点 `last_*` 为 null)。
- **查询参数**(全部可选,枚举值白名单校验,非法 → `400`:
| 参数 | 取值 | 默认 |
| :--- | :--- | :--- |
| `status` | `pending`/`queued`/`running`/`converged`/`failed` | 不过滤 |
| `method` | `cold_run`/`seed_step`/`imported` | 不过滤 |
| `wave` | 整数波次 | 不过滤 |
| `q` | 点名子串(LIKE 通配符已转义) | 不过滤 |
| `sort` | `wave`/`teff`/`max_relc`/`attempts`/`last_completed_at` | `wave` |
| `order` | `asc`/`desc` | `asc` |
| `limit` | 1500(超出钳位) | 100 |
| `offset` | ≥0 | 0 |
- **响应 Schema (`200 OK`)**:
```json
{
"success": true,
"message": "成功获取工作流网格点列表",
"data": {
"total": 432,
"points": [
{
"name": "t60000_g5.0_he-2_c-4_n-4_o-4",
"teff": 60000.0, "logg": 5.0, "loghe": -2.0,
"logc": -4.0, "logn": -4.0, "logo": -4.0,
"cno_sum": -12.0, "wave": 0,
"status": "converged",
"success_method": "seed_step",
"attempt_count": 2,
"last_max_relc": 0.00043,
"last_task_type": "seed_step",
"seed_point_name": "t60000_g5.0_he2_c-4_n-4_o-4",
"node_id": "node-a1b2",
"last_completed_at": "2026-07-30 11:12:00",
"last_error": null
}
]
}
}
```
---
### 8.9 网格点详情 (`GET /api/workflows/:name/points/:point`)
- **处理函数**: `get_workflow_point_detail``crates/server/src/api/workflow.rs`
- **权限**: Admin
- **用途**: 点详情滑入面板——尝试历史(还原"冷启动失败 → 种子步进救回"剧情)
+ `conv.json` 逐阶段诊断。
- **路径参数**: `:point` — 点名白名单 `[A-Za-z0-9._-+@]`(非空、拒前导 `.`、≤128
即路径穿越前置闸门);conv.json 读盘前再经 canonicalize 归属校验。非法点名 → `400`
点不存在 → `404`
- **响应 Schema (`200 OK`)**:
```json
{
"success": true,
"message": "成功获取网格点详情",
"data": {
"point": { "...": "同 8.8 单行" },
"attempts": [
{
"task_id": "uuid",
"task_type": "cold_run",
"seed_point_name": null,
"status": "failed",
"max_relc": 954000.0,
"atmosphere_has_nan": false,
"node_id": "node-a1b2",
"error_message": "nl stage diverged",
"created_at": "2026-07-30 09:00:00",
"completed_at": "2026-07-30 09:30:00"
},
{ "task_type": "seed_step", "status": "completed", "...": "第二次尝试(救回)" }
],
"conv": {
"converged": true,
"final_max_relc": 0.00043,
"final_chmax": 0.001,
"seed": "data/seeds/<seed_name>/<seed_name>.7",
"atmosphere_has_nan": false,
"synspec_rc": 0,
"synspec_sec": 3.1,
"elapsed_sec": 126.4,
"stages": [
{ "label": "seed_nc", "chmax": 0.001, "lte": false, "converged": false, "best_max_relc": 0.02, "elapsed_sec": 62.4, "note": null },
{ "label": "nl", "chmax": 0.001, "lte": false, "converged": true, "best_max_relc": 0.00043, "elapsed_sec": 64.0, "note": null }
]
}
}
}
```
> `conv` 来自 `seeds_dir/<point>/conv.json``ModelSummary` 结构,见
> `crates/common/src/models.rs`)。文件缺失/解析失败时为 `null`(仍返回 200
> 前端降级显示"诊断文件不可用")。
---
### 8.10 工作流进度时间序列 (`GET /api/workflows/:name/progress`)
- **处理函数**: `get_workflow_progress``crates/server/src/api/workflow.rs`
- **权限**: Admin
- **用途**: 详情页概览的进度曲线(sparkline)、经验速率 ETA 与停滞预警数据源。
快照由服务端后台循环(~30s)对每个运行中工作流写入 `workflow_progress_snapshots`
表,**计数无变化不落库**(去重防膨胀),保留期 7 天自动清理。
- **查询参数**: `hours`(时间窗口,默认 24,钳位 1–168)。
- **响应 Schema (`200 OK`)**:
```json
{
"success": true,
"message": "成功获取进度时间序列",
"data": {
"hours": 24,
"series": [
{ "ts": "2026-07-31 08:00:00", "total": 432, "pending": 120, "queued": 40,
"running": 8, "converged": 261, "failed": 3 }
],
"rate_per_hour": 12.5,
"stalled_minutes": 0.0
}
}
```
> `series` 超 300 条自动降采样(首末点保留);`rate_per_hour` = 窗口首末
> converged 增量 ÷ 时长(快照不足 2 条为 null);`stalled_minutes` = 终态数
> converged+failed)最后一次增长至窗口末端的分钟数(前端 >10 分钟触发停滞预警)。
---
### 8.11 工作流列表内联统计(`GET /api/workflows` 响应增强)
`WorkflowSummary` 新增 `stats` 字段(单条 `GROUP BY` 聚合回填,无 N+1):
工作流尚无网格点(未启动)时为 `null`,前端据此不渲染卡片进度条。
```json
{
"success": true,
"message": "成功获取工作流列表",
"data": [
{
"name": "sdB_cno",
"description": "sdB 6维恒星大气模型计算网格",
"status": "running",
"created_at": "2026-07-30 10:00:00",
"updated_at": "2026-07-30 11:00:00",
"stats": {
"total": 432,
"converged": 261,
"failed": 3,
"running": 8,
"cold_run_converged": 220,
"seed_step_converged": 41
}
}
]
}
```
---
## 9. 错误处理与状态码汇总
| HTTP 状态码 | 触发场景说明 | 响应格式 | 核心原因与解决建议 |
+68 -10
View File
@@ -15,7 +15,7 @@ flowchart TB
DB[(主数据库 dcts.db)]
MQ[(任务队列 dcts_queue.db)]
Scheduler["Grid Scheduler 网格调度器"]
SeedsStorage["本地种子库 results/*.7"]
SeedsStorage["本地种子库 seeds/*.7"]
API <--> DB
API <--> MQ
@@ -33,8 +33,8 @@ flowchart TB
Worker1 -- "2. 下载种子/基础数据" --> API
Worker1 -- "3. 汇报结果/上传 .7 大气" --> API
Worker2 -- "HTTP REST / Bearer Auth" --> API
WorkerN -- "HTTP REST / Bearer Auth" --> API
Worker2 -- "免凭据注册申请 / 审批后心跳" --> API
WorkerN -- "免凭据注册申请 / 审批后心跳" --> API
API --> SeedsStorage
```
@@ -46,8 +46,8 @@ flowchart TB
### 2.1 Master 服务端 (`server` & Web `dashboard`)
- **工作流与多任务隔离**:支持多工作流并发隔离运行(`grid_points``task_queue` 增加 `workflow_name` 复合唯一索引),调度与重置操作严格隔离在单工作流作用域内。支持旧版 SQLite 数据库启动时无缝平滑迁移。
- **安全中间件与 RBAC**:基于角色访问控制 (Admin / Node / Public) 划分 API 权限,内嵌 Bearer Token 校验、Governor / 漏桶算法限流中间件与 CORS 跨域控制,防御侧信道攻击与暴力破解。
- **节点凭据生命周期管理**:支持 Worker 节点注册申请、管理员审批授权、Token 吊销与重新颁发全生命周期管理。
- **任务调度与分配**通过 `mq` 队列管理任务生命周期,响应 Worker 的 Claim 请求分配就绪任务
- **节点凭据生命周期管理**:支持 Worker 节点注册申请、管理员审批授权、Token 重新颁发(旧 token 失效)与节点停用/启用全生命周期管理。
- **任务调度与分配**调度器将 pending 网格点推入 `mq` 队列;Worker 以 **pull 抢占式** 领用任务(详见 [§5 任务调度模型](#5-任务调度模型-task-scheduling)
- **状态维护与心跳监测**:后台离线检测线程定期标记超时未心跳的节点为 `offline`,并能将僵挂在超时节点上的任务自动回收到队列中(Requeue)。
- **ESM 模块化 Web 看板**:前端采用 ESM 模块解耦设计(`state.js`, `api.js`, `components/`),支持节点凭据管理、工作流控制与全局 Toast 通知。
@@ -66,7 +66,8 @@ flowchart TB
```mermaid
stateDiagram-v2
[*] --> Pending : 工作流注册生成网格点
Pending --> Running : Worker 成功 Claim 抢占
Pending --> Queued : 调度器推入 mq 队列
Queued --> Running : Worker 成功 Claim 抢占
state Running {
[*] --> ExecutingChain
@@ -76,14 +77,18 @@ stateDiagram-v2
SeedStepChain --> Synspec : 热启动收敛
}
Running --> Completed : 计算成功 & 上传 .7 产物
Running --> Pending : Worker 节点心跳超时/主动释放 (Requeue)
Running --> Failed : 重试次数达到上限 / 彻底发散
Running --> Converged : 计算成功 & 上传 .7 产物
Running --> Pending : Worker 心跳超时/掉线 (Requeue, 直接回 Pending 等待重新派发)
Running --> Failed : 无可邻近种子回退 / 彻底发散
Completed --> [*]
Converged --> [*]
Failed --> [*]
```
> 状态值与 [`GridPointStatus`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs#L237) 一一对应:
> `pending``queued``running``converged` / `failed`
> Requeue 把 `running` 直接重置回 `pending`(不经过 `queued`),由调度器下一轮重新推入队列。
---
## 4. 容错与高可用设计 (Fault Tolerance)
@@ -92,3 +97,56 @@ stateDiagram-v2
2. **零文件扫描与连接并发缩流**:底层任务分批取配、排队清洗与邻接优化(Seed-Stepping)全链线依赖常驻内存的 SQlite 主从精算并调优收敛连接池配置(主库=8,队列=4 减免本地排他写冲突并发挂断);去除了历史残存的高损及同步阻塞磁盘遍历 API,在确保零卡死响应的前提下提升查询搜索效力。
3. **任务超时与流控平稳保护 (Stale & Requeue)**:服务端后台定期向已超时死挂的 `Running` (默认 >1800 秒)作业予以强退回转为 `Pending`;此外当操作维护员发起暂停或终止工作流行为时,将仅平滑洗退待调 `Queued` 项,悉心保育在途已投的 Worker 数值演算完整出计算归表,防假命题竞合。
4. **多级退避与种子隔离**:若某点冷启动发散,自动隔离失败现场,依靠数据库记录寻找欧氏空间距离最匹配的热启动合拢 `.7` 气象序列;即便遇到底层强硬大步发散也不产生干扰并留存物理运行根系以便溯源。
---
## 5. 任务调度模型 (Task Scheduling)
### 5.1 Pull 抢占式模型(非服务端负载分发)
DCTS 的任务分发是 **Worker 主动拉取(pull** 模型,**不是**服务端按节点负载推派(push):
- **调度器**[`schedule_pending_tasks`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/server/src/scheduler.rs#L153))只负责把 `pending` 网格点推入 `mq` 队列(`push_task`),不参与"分给哪个 node"的决策。
- **Worker**[领用主循环](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/node/src/worker.rs#L275))只要 `active_slots < max_slots`,就持续向服务端发起 `claim` 请求抢任务。
- **服务端** [`pop_task`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/mq/src/sqlite_queue.rs#L145) 的出队决策**只看任务自身属性**,不看谁来领用:谁先抢到写锁、谁就拿到队首任务。
出队排序(难度优先 → 同难度先进先出):
```sql
SELECT task_id, payload FROM task_queue
WHERE status = 'pending'
ORDER BY wave ASC, created_at ASC
LIMIT 1
```
### 5.2 为什么不做服务端负载分发
关键观察:**Worker 满载后会主动停止领用**([worker.rs](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/node/src/worker.rs#L280-L281)):
```rust
let active = self.active_slots.load(Ordering::Acquire);
if (active as usize) < self.config.max_slots {
match self.claim_task().await { /* 抢任务 */ }
} else {
sleep(Duration::from_secs(2)).await; // 满载:不 claim,等槽位释放
}
```
由此分两种工况:
| 工况 | 条件 | 是否会扎堆 | 负载分发是否需要 |
|------|------|-----------|-----------------|
| **稳态** | 任务数 ≥ 所有 node 总槽位 | 否——各 node 抢满 `max_slots` 即停止 claim,空出槽位者再去抢,**天然均衡** | 不需要 |
| **稀疏态** | 任务数 < 总槽位(如调试用 2 个点) | 是——最快的 node 连续抢光少量任务 | 收益极小,省的仅是几个任务的串行墙钟时间 |
稳态(网格规模任务,如 sdB_cno 的 432 点)是实际生产场景,该场景下 pull 模型已自动达成负载均衡。稀疏态的扎堆是固有现象且代价可忽略(任务跑完即结束)。
引入服务端负载分发的**代价**反而更高:破坏 FIFO 可预测性、带来心跳负载数据滞后导致的退让抖动、增加队首饥饿风险。综合权衡,**当前 pull 模型是刻意选择,无需改为服务端负载分发**。
> **稀疏态调试建议**:若调试时确实希望任务分散到多个 node 观察,最省事的做法是把各 node 的 `DCTS_MAX_SLOTS` 设为 `1`——每个 node 抢 1 个即满载停止,自然分散,零代码改动、零风险。
### 5.3 出队优先级语义(wave + FIFO)
- **`wave ASC`(难度优先)**:网格点按 CNO 丰度之和(`cno_sum`)升序分组,低丰度(低难度、更易收敛)的点先出队。设计意图是"先易后难"——先跑出收敛点作为种子库,后续高难度点可借热启动种子收敛。
- **`created_at ASC`(同难度 FIFO**:同一 `wave` 内严格按推入队列的时间先后出队。
- **跨工作流公平**:出队不按 `workflow_name` 分组,多个并发工作流的低难度点会先于任何工作流的高难度点出队,避免某工作流在高难度 wave 上饿死其他工作流的低难度点。
+1 -1
View File
@@ -32,7 +32,7 @@ dcts/
│ ├── node/ # [Binary] Worker 节点 Daemon 与任务抢占回路
│ └── mq/ # [Library] SQLite 事务型分布式任务队列
├── tools/
│ └── sync_seeds/ # [Binary] 离线/增量种子同步 CLI 工具
│ └── import_results/ # [Binary] 历史计算结果导入工具(旧版网格结果 → 标记已完成 + 迁移产物树)
└── docs/ # 分主题架构与规格技术文档
```
+53 -40
View File
@@ -9,55 +9,64 @@
```mermaid
erDiagram
WORKFLOWS ||--o{ GRID_POINTS : contains
GRID_POINTS ||--o{ TASK_HISTORY : logs
GRID_POINTS ||--o{ TASKS : logs
NODES ||--o{ TASK_QUEUE : executes
subgraph PrimaryDB ["主数据库 (dcts.db)"]
WORKFLOWS {
string name PK
string description
text yaml_config
text config_yaml
string status
datetime created_at
datetime updated_at
}
GRID_POINTS {
string point_id PK
string name PK
string workflow_name FK
double teff
double logg
double he_abund
double c_abund
double n_abund
double o_abund
double loghe
double logc
double logn
double logo
double cno_sum
integer wave
string status
datetime updated_at
integer attempt_count
string success_method
}
TASK_HISTORY {
string id PK
string point_id FK
TASKS {
string task_id PK
string point_name FK
string node_id
boolean success
text conv_info_json
datetime duration_sec
string task_type
string status
double max_relc
integer retry_count
datetime created_at
}
NODES {
string node_id PK
string hostname
integer cpu_cores
integer max_slots
integer active_slots
string status
double cpu_usage
double memory_usage
datetime last_heartbeat
}
end
}
subgraph QueueDB ["队列库 (dcts_queue.db / mq)"]
TASK_QUEUE {
string task_id PK
string workflow_name
string payload_json
string payload
string status
string assigned_node
integer retry_count
datetime created_at
datetime claimed_at
string workflow_name
string claimed_by_node_id
integer wave
}
end
```
@@ -68,11 +77,11 @@ erDiagram
### 2.1 `workflows` (工作流配置表)
存储用户定义的计算网格配置及整体状态。
- `name` (`VARCHAR(64) PRIMARY KEY`):工作流唯一标志(如 `sdB_cno`)。
- `name` (`TEXT PRIMARY KEY`):工作流唯一标志(如 `sdB_cno`)。
- `description` (`TEXT`):描述信息。
- `yaml_config` (`TEXT`):完整的参数网格定义与物理配置 YAML 内容。
- `status` (`VARCHAR(32)`)状态:`idle` / `running` / `paused` / `completed`
- `created_at` (`DATETIME DEFAULT CURRENT_TIMESTAMP`):创建时间。
- `config_yaml` (`TEXT NOT NULL`):完整的参数网格定义与物理配置 YAML 内容。
- `status` (`TEXT NOT NULL DEFAULT 'idle'`)`idle`(就绪)→ `initializing`(原子抢占加载中)→ `running`(运行中)/ 回退 `idle``running` `paused`(暂停)/ `completed`(全部网格点收敛)
- `created_at` / `updated_at` (`DATETIME NOT NULL`):创建 / 最后更新时间。
### 2.2 `grid_points` (网格点物理参数表)
存储多维笛卡尔积展开后的每一个独立参数点。
@@ -93,26 +102,30 @@ erDiagram
> 标记为 `__legacy__`,不干扰新工作流查询。
### 2.3 `nodes` (计算节点心跳与状态表)
- `node_id` (`VARCHAR(64) PRIMARY KEY`):节点唯一标识。
- `hostname` (`VARCHAR(128)`):节点主机名或 IP
- `cpu_cores` (`INTEGER`):节点 CPU 核心数
- `status` (`VARCHAR(32)`)`online` / `offline` / `busy`
- `last_heartbeat` (`DATETIME`):最后一次心跳上报时间
- `node_id` (`TEXT PRIMARY KEY`):节点唯一标识(未指定 `DCTS_NODE_ID` 时自动生成 `node-<uuid>`
- `max_slots` (`INTEGER NOT NULL`):节点声明的最大并发计算槽位数(即 `DCTS_MAX_SLOTS`,领用上限)
- `active_slots` (`INTEGER NOT NULL DEFAULT 0`):当前正在执行的任务数,随 claim 自增、report 完结自减
- `status` (`VARCHAR(32) NOT NULL DEFAULT 'online'`)`pending_approval`(注册待审批)/ `online`(在线)/ `offline`(心跳超时离线)/ `disabled`(管理员手动停用,保持心跳但不再分发任务)/ `rejected`
- `cpu_usage` / `memory_usage` (`REAL NOT NULL DEFAULT 0.0`):心跳上报的 CPU/内存使用率(仅观测展示用,**不参与任务分发决策**)
- `last_heartbeat` (`DATETIME NOT NULL`):最后一次心跳上报时间,后台线程据此判定 `online → offline`
### 2.4 `task_queue` (分布式任务队列表 - `mq`)
驱动分布式抢占与超时重试的核心表,使用 SQLite `WAL` 模式确保高吞吐并发安全。
- `task_id` (`VARCHAR(128) PRIMARY KEY`):任务 ID。
- `workflow_name` (`VARCHAR(64)`):工作流
- `payload_json` (`TEXT`):任务所含参数 payload
- `status` (`VARCHAR(32)`)`pending`(就绪) / `running`(计算中) / `completed`(完成) / `failed`(失败)。
- `assigned_node` (`VARCHAR(64)`):当前抢占该任务的节点 ID
- `retry_count` (`INTEGER DEFAULT 0`):失败或超时重发次数
- `claimed_at` (`DATETIME`):抢占时间戳(用于超时释放判定)。
驱动分布式抢占与超时重试的核心表,使用 SQLite `WAL` 模式确保高吞吐并发安全(详见 [`sqlite_queue.rs`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/mq/src/sqlite_queue.rs#L65)
- `task_id` (`VARCHAR(128) PRIMARY KEY`):任务 IDUUID
- `payload` (`TEXT NOT NULL`):序列化的 [`TaskSpec`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs)(含 point_name / params / task_type / seed_point_name / workflow_name 等)
- `status` (`TEXT NOT NULL`)`pending`(就绪待领用)/ `claimed`(已被某 node 领用,计算中)
- `created_at` (`DATETIME NOT NULL`):推入队列时间(同 `wave` 内 FIFO 排序键)。
- `claimed_at` (`DATETIME`):被领用的时间戳(`requeue_stale_tasks` 据此判定超时回投)
- `workflow_name` (`TEXT`):所属工作流(多工作流分区键)
- `claimed_by_node_id` (`TEXT`):领用方节点 IDclaim 时写入,report 阶段据此校验归属,防跨节点伪造)。
- `wave` (`INTEGER NOT NULL DEFAULT 0`):难度波次(`pop_task` 出队第一排序键,低 `wave` 优先)。
> 任务完成后由 `remove_task` 直接从本表删除(不保留 `completed`/`failed` 终态行),历史记录落在主库 `tasks` 表。超时的 `claimed` 行由 [`requeue_stale_tasks`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/mq/src/sqlite_queue.rs#L274) 改回 `pending` 重投。
---
## 3. 并发与事务安全设计
1. **WAL (Write-Ahead Logging) 模式**SQLite 连接自动启用 `PRAGMA journal_mode=WAL;``PRAGMA busy_timeout=5000;`,解决多线程/多进程读写锁竞争。
1. **WAL (Write-Ahead Logging) 模式**SQLite 连接自动启用 `PRAGMA journal_mode=WAL;``PRAGMA busy_timeout=15000;`,解决多线程/多进程读写锁竞争。
2. **连接池机制**:借助 `r2d2` + `r2d2_sqlite` 维护异步连接池,防止高并发下数据库句柄冲突。
3. **原子 Claim 事务**:任务抢占在单个 SQLite 事务完成`UPDATE task_queue SET status='running', assigned_node=? WHERE status='pending' ... LIMIT 1`),保证绝对防重领
3. **原子 Claim 事务**:任务抢占在单个 `IMMEDIATE` 事务完成——先 `SELECT ... WHERE status='pending' ORDER BY wave ASC, created_at ASC LIMIT 1` 取队首,再 `UPDATE SET status='claimed', claimed_by_node_id=?`,提交后返回。事务保证同一任务不会被两个 node 同时领用;遇到 `SQLITE_BUSY/LOCKED` 最多退避重试 5 次(详见 [`pop_task`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/mq/src/sqlite_queue.rs#L145)
+24 -7
View File
@@ -12,7 +12,7 @@ DCTS 计算节点无复杂系统依赖,仅需网络能访问 Master 的 HTTP
[ Master 服务端 ] (拥有固定 IP / 域名, 如 http://192.168.1.100:8090)
├── dcts_server
├── data/dcts.db & data/dcts_queue.db
└── data/results/ 集中种子仓库与计算摘要
└── data/seeds/ 集中种子仓库(node 上报/导入的收敛种子 .7+conv.json,供远程 node 下载热启动)
│ HTTP / REST (8090)
┌─────┴───────────────┬──────────────────────┐
@@ -106,6 +106,7 @@ DCTS 运行时所依赖的物理计算底座二进制文件 `assets/tlusty_stati
```
#### 关键编译选项说明:
- `-fno-automatic`: 禁用局部变量的自动栈分配(强制将局部变量保存在静态内存区)。这是保证传统 FORTRAN 77 程序正常运行的关键参数,防止大型局部数组造成栈溢出(Stack Overflow)或段错误(Segmentation Fault)。
- `-O3`: 开启全量 LLVM/GCC 代码优化,极大加快完全线性化/加速 Lambda 迭代(CL/ALI)及辐射转移方程形式解的计算速度。
- `-mcmodel=large`: 当模型数组与数据段超越 2GB 寻址限制时,允许可执行文件使用 64 位大内存寻址模式。
@@ -119,15 +120,15 @@ DCTS 运行时所依赖的物理计算底座二进制文件 `assets/tlusty_stati
### 3.1 服务端环境变量表 (`server`)
| 环境变量名 | 默认值 | 说明 |
| :---------------------- | :--------------------- | :--------------------------------------------------------------- |
| :---------------------- | :--------------------- | :----------------------------------------------------- |
| `DCTS_PORT` | `8090` | 服务端 HTTP REST API 监听端口 |
| `DCTS_DB_PATH` | `data/dcts.db` | 主 SQLite 数据库文件路径(存放节点、网格点及种子记录) |
| `DCTS_QUEUE_DB_PATH` | `data/dcts_queue.db` | 任务队列 SQLite 数据库文件路径 |
| `DCTS_RESULTS_DIR` | `data/results` | 集中种子仓库与计算总结保存目录 |
| `DCTS_SEEDS_DIR` | `data/seeds` | server 端种子库目录(收敛种子 .7+conv.json,供远程 node 下载热启动,**永不清理**) |
| `DCTS_BACKUP_DIR` | `data/backups` | 数据库自动/手动备份输出目录 |
| `DCTS_ADMIN_TOKEN` | ** | 管理员控制台与敏感 API 鉴权令牌 |
| `DCTS_AUTH_TOKEN` | ** | 旧版全局 API 鉴权令牌(兼容 Admin 与 Enrollment 校验) |
| `DCTS_AUTH_DISABLED` | `false` | 应急开发参数:设置为 `1``true` 时跳过鉴权 |
| `DCTS_AUTH_TOKEN` | ** | 旧版全局令牌(兼容回退为 Admin 凭据,建议迁移到 `DCTS_ADMIN_TOKEN` |
| `DCTS_AUTH_DISABLED` | `false` | 应急开发参数:设置为`1``true` 时跳过鉴权 |
| `DCTS_STALE_SEC` | `1800` | 任务运行超时重新放回队列的时间上限(秒) |
| `DCTS_NODE_STALE_SEC` | `120` | 判定 Worker 节点离线的心跳超时时间(秒) |
@@ -140,8 +141,16 @@ DCTS 运行时所依赖的物理计算底座二进制文件 `assets/tlusty_stati
| `DCTS_MAX_SLOTS` | `4` | 本地 Worker 节点的并发计算 Slot 槽位数 |
| `DCTS_RUNTIME_DIR` | `data/runtime` | 本地 TLUSTY/SYNSPEC 可执行程序及物理谱线数据存放目录 |
| `DCTS_WORK_DIR` | `data/work` | 本地计算沙盒工作目录 |
| `DCTS_RESULT_DIR` | `data/result` | node 端**完整计算结果归档**目录(光谱/连续谱/各阶段大气快照等全部产物)。超过 200 个网格点子目录时按 LRU 删最旧。旧名 `DCTS_ARCHIVE_DIR` 向后兼容 |
| `DCTS_HEARTBEAT_SEC` | `15` | 向服务端发送心跳报告的时间间隔(秒) |
| `DCTS_AUTH_TOKEN` | ** | 匹配服务端的 API 鉴权令牌 |
> **两目录分工**(重要,避免混淆):
> - **`data/seeds/`**server 端,`DCTS_SEEDS_DIR`):**种子库**。只存最小集 `conv.json` + `<name>.7`,供 `download_seed` 端点给远程 node 热启动下载。**永不清理**(种子是 SeedStep 必需资源,删除会导致已收敛点重算)。
> - **`data/result/`**node 端,`DCTS_RESULT_DIR`):**完整计算结果归档**。存全部科学产物(`.spec/.cont/.iden/.7/各阶段快照/日志`),供本地留档/排错。LRU 上限 200。
>
> 多机部署下两者物理分离:seeds 在 server 机、result 在各 node 机。单机部署下都挂在 `./data` 下。
> **注**:Worker 节点**无需配置任何鉴权令牌**。节点启动后免凭据提交注册申请,由管理员在 Dashboard 审批后自动下发专属 token(持久化到 `runtime/.node_token`)。
---
@@ -191,23 +200,28 @@ DCTS 运行时所依赖的物理计算底座二进制文件 `assets/tlusty_stati
您可以根据具体场景灵活运用以下策略组:
| 安装环境 | 技术引擎 | 适用场景说明 | 推荐自动化直呼执行命令 |
|---|---|---|---|
| ----------------------- | ------------------------ | ------------------------------------------------------ | ------------------------------------------------------- |
| **本地 (Local)** | **Docker Compose** | 单机联调或微服务生态整装秒启动 | `./scripts/deploy.sh -e local -b compose -r all` |
| **远程 (Remote)** | **Docker Compose** | 面对严苛依赖的机群打散化打包发布与全动态差分更新 | `./scripts/deploy.sh -e remote -b compose -r all` |
| **本地 (Local)** | **Systemd 原生** | 宿主无虚拟化损耗的高并发物理机运行(具备自启提权判定) | `sudo ./scripts/deploy.sh -e local -b systemd -r all` |
| **远程 (Remote)** | **Systemd 原生** | 主端向分站超算节点无界穿梭远抛落地与托管后台注册 | `./scripts/deploy.sh -e remote -b systemd -r node` |
#### 1. 交互式多重导航进站直奔体验
在宿主机或者编译主工作区,以最简洁无参数方式执行即唤醒主线指引,全程遵循人性化三层连贯设计选项:
```bash
./scripts/deploy.sh
```
1. **第一步 (环境定位)**:指明需要作用于**本地主机**还是经 SSH 高速管道传输并管理**远端机房控制端**;
2. **第二步 (底层引擎)**:指明借助 **Docker Compose 容器微服务架构**(绝佳无冲突隔离)或是注入原生主机执行的 **Systemd 系统级常驻服务**(也含双端优雅拆毁卸载/一键强停命令分支);
3. **第三步 (目标角色)**:选定**全部服务 [All: Server + Node]**、**仅运维主控服务端 [Server]** 或 **仅挂扣物理分流算力池计算 Worker 节点 [Node]**
#### 2. 系统服务与集群清收降解管理 (去除与关停)
无论是系统底层的 Systemd 表项或者是持续处于自运行圈范围内部的 Compose 集群容器套,随时均可指派拆除动作清除干净:
```bash
# 卸载或清除对应部署架构,例如卸载本地所有的 systemd 原生守护任务链
./scripts/deploy.sh remove -e local -b systemd -r all
@@ -217,6 +231,7 @@ DCTS 运行时所依赖的物理计算底座二进制文件 `assets/tlusty_stati
```
#### 3. 守护进程实时勘侦管控技巧 (当直接采用 Systemd 环境时)
```bash
# 检查服务端 / Node 计算分核任务运行常态与生存心跳
sudo systemctl status dcts-server
@@ -230,10 +245,12 @@ tail -f data/logs/dcts_node.*.log
### 4.4 方式四:Docker / Docker Compose 标准纯手工控制容器联调(可选方案)
系统由专门精简构筑过的 [Dockerfile.server](file:///home/fmq/program/tlusty/tl208-s54/dcts/Dockerfile.server) 和 [Dockerfile.node](file:///home/fmq/program/tlusty/tl208-s54/dcts/Dockerfile.node) 作为构建基础,默认通过只读载入 `assets` 并以多路复用方式提供极致并发计算生态体系:
```bash
# 快速于本机执行容器冷启聚合与并跑
docker compose up -d --build
```
启动之后访问对口暴露监控 HTTP Dashboard 地址(常规默认指引定位至端口 `8090`),立即获尽实时拓扑状态曲线!
---
+2 -2
View File
@@ -27,8 +27,8 @@
## 2. 节点与网络故障 (Node & Network Issues)
### 现象 1Worker 节点提示 `Unauthorized: Invalid or missing authentication token`
- **原因**Master 服务端启用了 API Auth Token,但 Worker 的配置文件或环境变量中未配置匹配的 `auth_token`
- **解决办法**在 Worker 的 `.env` 中加入 `DCTS_AUTH_TOKEN=<your_secret_token>`
- **原因**该节点的专属 node token 已失效或被重发覆盖(节点鉴权不依赖任何环境变量,而是使用审批时下发的专属 token
- **解决办法**删除节点本地 `runtime/.node_token` 文件并重启节点进程,使其重新免凭据提交注册申请,等待管理员在 Dashboard 审批授权后获取新 token。
### 现象 2:任务长时间处于 `Running` 状态没有进展
- **原因**:Worker 节点在计算中途遭遇断电、内存溢出(OOM)或僵死进程卡死。
+388
View File
@@ -0,0 +1,388 @@
# 工作流执行可视化 —— 功能重设计文档
> 目标:把"恒星大气网格工作流"面板从当前的 **3 个动作(查看 YAML / 启动 / 删除)**
> 升级为完整的 **执行可观测台**:执行进度、逐网格点执行详情、收敛判定、
> 冷启动 vs 种子步进归因、参数空间冷启动成功图谱,以及失败点重试。
>
> 原则:**数据已经在库里大半,缺口主要是 HTTP 暴露 + 少量持久化 + 全新前端 UI。**
> 零新增前端依赖(不引入图表库),沿用现有 ESM + 原生 DOM + 设计令牌风格。
---
## 1. 现状盘点(带代码证据)
### 1.1 已经持久化、可直接用的数据
| 数据 | 位置 | 说明 |
|---|---|---|
| 网格点 6 维参数、`cno_sum``wave` | `grid_points` 表(`db.rs:246-263` | 按 `workflow_name` 分区,复合唯一 `(workflow_name, name)` |
| 点状态 `pending/queued/running/converged/failed` | `grid_points.status` | `GridPointStatus``models.rs:235-243` |
| **成功手段** `cold_run` / `seed_step` / `imported` | `grid_points.success_method` | 收敛时由成功任务的 `task_type` 回填(`db.rs:1097-1101`);失败点为 NULL |
| 重试次数 | `grid_points.attempt_count` | 每次回报 +1`db.rs:1089-1095` |
| 每次尝试的收敛指标 | `tasks.max_relc` | 收敛判据 `max_relc < chmax`(默认 0.001`conv_check.rs:105-109` |
| **每次尝试的种子来源** | `tasks.seed_point_name` | 派发时写入(`scheduler.rs:194-205` |
| 每次尝试的方法 / 错误 / 节点 / 完成时间 | `tasks.task_type / error_message / node_id / completed_at` | 一个点多行(每次尝试一行),工作流删除前长期保留 |
| 全局种子库 | `seeds` 表 + `data/seeds/<name>/<name>.7` | 仅收敛且无 NaN 的点入库(`task.rs:187-207` |
| **逐阶段完整诊断**lte→nc→nl / seed_nc→nl | `data/seeds/<name>/conv.json` | `ModelSummary``models.rs:376-391`):每阶段 `converged / best_max_relc / chmax / elapsed_sec`、总耗时、`synspec_sec`、所用种子。**成功与失败的点都会写**(`task.rs:178-184` |
| 按工作流的统计聚合 | `db.get_grid_summary_stats(Some(wf))` | **已实现且有单测**`db.rs:1582-1639`),但没有任何 HTTP 端点调用它 |
### 1.2 缺口(需要补的)
| # | 缺口 | 影响 | 补法 |
|---|---|---|---|
| G1 | 无逐点 HTTP 端点 | 前端拿不到任何点级数据 | 新增 `/workflows/:name/points` |
| G2 | 无按工作流的统计端点(`get_grid_summary_stats` 未暴露;`/api/status` 只有全局) | 卡片/详情无法显示每工作流进度 | 新增 `/workflows/:name/stats`;列表接口内联计数 |
| G3 | `queued` 被并进 `pending``db.rs:1596,1612` | 无法区分"排队中"与"未入队" | 新端点拆开统计 |
| G4 | 单点耗时/迭代数未进库(`TaskReport.elapsed_sec` 收到即丢;`last_iter``StageSummary` 前被丢弃) | 列表无耗时列、无 ETA | 迁移补列 + runner 携带 `last_iter`Phase 3 |
| G5 | 无进度时间序列 | 画不出"进度-时间"曲线 | 新表 `workflow_progress_snapshots`Phase 3 |
| G6 | 失败点无法重试(start 只重置 `queued``db.rs:886-899`) | 失败点永久卡死 | 新增 retry 端点(复用 `reset_specific_grid_points_to_pending` 思路) |
| G7 | conv.json 不提供下载/查看 | 逐阶段诊断无法展示 | 点详情端点读盘解析返回 |
| G8 | 前端无任何进度/详情 UI;无 tab 组件 | —— | 全新 UI |
### 1.3 领域语义(设计依据)
- **冷启动 vs 种子步进不是用户选的,是调度器按"种子可用性"自动决定的**
派发时 `find_best_seed_from_db` 命中(同族精确匹配优先,否则全局加权距离 ≤ 3.0,
`seed_finder.rs:13-38`)→ `SeedStep`(热启动链 `seed_nc→nl`),否则 `ColdRun``lte→nc→nl`)。
- **wave = 难度波次**:按 `cno_sum` 升序分组,"先易后难",让早期收敛点充当种子。
所以"哪些参数冷启动能成功" ≈ 低 cno_sum / 中低温区的点;高温 He-poor 区几乎全靠种子步进。
- **还有一次性失败回退**:冷启动发散且存在邻居种子时,自动以 `seed_step` 重投一次
`scheduler.rs:270-350`,受 `seed_step_fallback` 配置与 `has_seed_step_attempt` 一次性闸门约束)。
因此 `success_method='seed_step'` 的点可能是"冷启动失败后被救回"的——该归因需从 `tasks` 多行历史还原。
- **第三类 `imported`**:历史 `run_grid.py` 结果导入(`db.rs:966-981`),UI 需单独呈现为"历史导入"。
- **收敛判据**:末次迭代最差深度点的最大相对修正 `max_relc < chmax`(默认 1e-3),
且大气 NaN 占比 ≤ 10%`conv_check.rs:127-149`)。
---
## 2. 功能设计(信息架构)
### 2.1 工作流卡片(改造现有 `workflows.js` 卡片)
```
┌─────────────────────────────────────────────┐
│ sdB_cno [运行中] │ ← 现有:名称/描述/状态徽章
│ sdB 6维恒星大气模型计算网格 │
│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░ 42% │ ← 新增:收敛进度条 + 百分比
│ 181/432 收敛 · 12 种子步进 · 3 失败 · 8 运行 │ ← 新增:一行计数摘要
│ [进入详情 →] [查看YAML] [暂停] [删除] │ ← 整卡头部可点进详情页
└─────────────────────────────────────────────┘
```
- 卡片名/头部区域是 `<a href="#/workflows/sdB_cno">`;操作按钮 `stopPropagation` 保持独立语义。
- 进度数据来自 **列表接口内联计数**(见 §3.2),不额外轮询。
- 状态徽章扩充:`initializing` 显示"初始化中"warning 琥珀),`paused` 显示"已暂停"secondary),
不再把三者都归为"闲置"。
### 2.2 工作流详情页(独立页面,hash 路由 `#/workflows/:name`
> **信息架构决策**:详情内容量(三 Tab + 可过滤点表 + 热力矩阵 + 逐点诊断)远超模态框承载,
> 且需要深链/返回键/长时间监控驻留。因此采用**独立视图页面** + 零依赖 hash 路由,
> 而非宽模态。入口:卡片整卡或"详情"按钮 → `location.hash = '#/workflows/<name>'`
**页面骨架**(顶部常驻"任务控制条",是全页最先映入眼帘的部分——直接呈现这个网格的实时生命体征):
```
← 返回控制台 sdB_cno [运行中] [启动/暂停] [重试失败] [查看YAML] [删除]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░ ← 分段进度条:绿=收敛 红=失败 琥珀=运行 蓝=排队 灰=待定
432 网格点 · 181 收敛(42%) · 8 运行 · 40 排队 · 3 失败 · 冷启动 169 / 种子步进 12 · ETA ≈ 2h40m
```
- 分段进度条按状态占比着色拼接,5s 轮询下各段宽度此消彼长——进度是"看得见在动"的。
- 计数用 `tabular-num` 防跳动;数字变化可加轻量过渡。
- 工作流不存在(hash 指向已删除的名字)→ 空态卡 + 返回按钮。
**Tab 1 — 执行概览**
- 4 个 metric tile(复用 `.metric-card`):网格总数 / 已收敛 / 失败 / 运行中+排队。
- **方法归因**:冷启动收敛 X · 种子步进收敛 Y · 历史导入 Z · 失败 N(彩色计数行)。
- wave(难度波次)分布:每波次 `converged/total` 的小条形列表——直观看到"难度推进到哪了"。
- **最近动态流**:最近 12 条尝试回报(点名 / 方法徽章 / 结果 / max_relc / 节点 / 完成时间),
轮询下新条目从顶部淡入——数据就是 `points` 端点 `sort=last_completed_at&order=desc&limit=12`,零新增端点。
- ETA(Phase 3):按平均单点耗时 × 剩余点数 ÷ 集群空闲槽位估算。
**Tab 2 — 网格点明细**
- 过滤器:状态(全部/排队/运行/收敛/失败)、方法(全部/冷启动/种子步进/导入)、wave 下拉、点名搜索框。
- 表格(复用 `.data-table` + 关键行增量刷新,仿 `nodesTable.js`):
| 列 | 来源 |
|---|---|
| 点名(mono 省略,title 全文) | `name` |
| Teff / logg / logHe / CNO和 | 6 维参数 |
| wave | `wave` |
| 状态 | badge:收敛=online 绿 / 失败=danger 红 / 运行=warning 琥珀 / 排队=info 蓝 / 待定=secondary |
| 方法 | badge:冷启动=蓝(blueprint/ 种子步进=紫(purple/ 导入=secondary——**刻意区别于状态色** |
| max_relc | `tabular-num`,科学计数;失败点显示最后一次尝试值 |
| 尝试数 | `attempt_count`>1 且收敛 = "被救回",加 ↻ 角标) |
| 耗时 | Phase 3 起有值;之前用 `completed_at-created_at` 粗估并标 ~ |
| 操作 | [查看] → 点详情 |
- 分页:`limit=100 + offset`,服务端排序(默认 wave→cno_sum→teff)。
- **刷新策略**:打开弹窗时拉一次;Tab 激活期间每 5s 自动刷新(可手动刷新按钮强制);
弹窗关闭即停。不污染全局轮询。
**Tab 3 — 收敛性分析(冷启动图谱)**
- 核心问题:"哪些参数范围内冷启动能成功"。
- 6 维空间降到 2D 热力矩阵:用户从下拉选 X 轴 / Y 轴(默认 X=Teff、Y=cno_sum);
其余维度若为多值则提供切片选择器(固定某维取值)。
- 单元格着色(CSS grid,无图表库):
- 🟩 冷启动收敛(`converged && method=cold_run`
- 🟪 种子步进收敛(`seed_step`
- ⬜ 历史导入(`imported`
- 🟥 失败(`failed`
- 🟨 运行/排队中
- ░ 未开始(`pending` 且从未尝试)
- 矩阵下方:图例 + 聚合结论条("冷启动成功率 62%268/432);Teff≥60000 区间冷启动成功率 0%,全部由种子步进救回 41 个、失败 3 个")。
- 单元格 hover 显示点名与 max_relc;点击 → 同 Tab 2 的点详情。
### 2.3 网格点详情(右侧滑入面板 slide-over,不再模态套模态)
点表格/热力图/动态流中任一点名 → 右侧滑入 480px 面板(`.point-panel`,背景遮罩 + Esc/点击遮罩关闭,
复用焦点圈管理;独立页面上滑入面板比二级模态更轻、视线不跳转)。
- 头部:点名(mono)+ 状态/方法徽章 + [复制点名]。
- **尝试历史表**`tasks` 多行):# / 方法 / 种子来源(点名,可点击跳查该点)/ max_relc / 结果 / 节点 / 完成时间 / 错误摘要。
这一张表直接还原"冷启动失败 → 种子步进救回"的完整剧情。
- **逐阶段链诊断**(读 `conv.json`):每阶段一张小卡:
`lte` ✅ grey start · `nc` ⚠️ max_relc=0.957(不要求收敛)· `nl` ✅ max_relc=6.9e-3 / 17 iter / 612s
种子步进链则显示 `seed_nc → nl`,并标注种子点名与总耗时、synspec 耗时、NaN 标志。
- 失败点:突出显示最后一次 `error_message``code-pre-wrap`textContent 渲染防 XSS)。
### 2.4 视图与操作逻辑
**路由**(新增 `src/router.js`,约 70 行,零依赖):
| hash | 视图 | 挂载动作 | 卸载动作 |
|---|---|---|---|
| `#/`(或空/未知) | 控制台首页(现有布局) | 渲染骨架 + `startPolling()` | 停全局轮询 |
| `#/workflows/:name` | 工作流详情页 | 渲染页面骨架 + 拉 stats/points + 启作用域轮询 | 停作用域轮询 |
- `window.addEventListener('hashchange', render)`;首次加载按当前 hash 渲染。
- **登录门禁不变**:无 token 时任何 hash 都先展示登录遮罩,登录成功后按 hash 进入对应视图。
- 全局 header/footer 保持常驻;视图内容渲染进 `#view-root` 容器(`index.html` 结构调整:
现有 `main.main-container` 内容收敛为首页视图模板)。
- 返回键/深链/刷新天然可用(hash 不触发整页刷新)。
**交互一览**
| 操作 | 前端 | 后端 |
|---|---|---|
| 进详情页 | hash 切换 → 按需 fetch stats + points(第1页) | —— |
| 切 Tab | 委托 `data-wf-tab`;切到"明细/分析"时惰性加载 | —— |
| 过滤/翻页/排序 | change/click → 重新 fetch(带 query | 端点支持参数 |
| 自动刷新 | 详情页挂载期间 5s 拉 stats(控制条+概览常驻刷新);明细 Tab 激活时同步刷新表格 | —— |
| 点详情面板 | 行内 [查看]/点名链接 → 拉 `points/:point` → 滑入面板;种子来源点名可跳查(换载面板内容) | —— |
| 重试失败点 | `showConfirm` → POST → toast → 刷新 stats/points | 新端点,仅 `running` 状态允许 |
| 返回首页 | "← 返回控制台"链接 / 浏览器返回 | —— |
| 首页卡片 | 整卡点击进详情;卡片上保留 启动/暂停/删除 快捷操作 + 查看YAML(仍为模态) | —— |
---
## 3. 后端设计
### 3.1 新增端点(全部挂在 `/api/workflows/:name/...` 下)
> 鉴权:`required_role``path.starts_with("/workflows/")` 已映射 Admin`mod.rs:92-95`),
> 子路由**无需改鉴权矩阵**。统一返回 `{success, message, data}` 信封(与现有 workflow API 一致)。
> 点名参数复用现有字符集白名单校验(`task.rs:133-146` 同款),防路径穿越。
#### ① `GET /api/workflows/:name/stats` —— 每工作流进度(G2/G3
复用并增强 `get_grid_summary_stats(Some(wf))`**拆开 pending/queued**,补方法与波次维度:
```json
{
"success": true, "message": "ok",
"data": {
"name": "sdB_cno", "status": "running",
"total": 432,
"pending": 120, "queued": 40, "running": 8, "converged": 261, "failed": 3,
"cold_run_converged": 220, "seed_step_converged": 41, "imported_converged": 0,
"waves": [ {"wave": 0, "total": 108, "converged": 108, "failed": 0}, ... ],
"avg_point_sec": 740.0, "eta_sec": 9600
}
}
```
- `avg_point_sec` / `eta_sec`Phase 3 有 `elapsed_sec` 列后精确计算;
之前用 `tasks.completed_at - tasks.created_at` 均值近似(含排队等待,偏大,标注近似)。
- SQL 走现有覆盖索引 `idx_grid_points_wf_status`,单次聚合扫描,5s 轮询无压力。
#### ② `GET /api/workflows/:name/points` —— 逐点列表(G1
参数:`status``method``wave``q`(点名子串)、`sort``wave|teff|max_relc|attempts`,默认 wave)、
`order``limit`(≤500,默认 100)、`offset`
```json
{
"success": true, "message": "ok",
"data": {
"total": 432,
"points": [
{
"name": "t60000_g5.0_he-2_c-4_n-4_o-4",
"teff": 60000, "logg": 5.0, "loghe": -2, "logc": -4, "logn": -4, "logo": -4,
"cno_sum": -12, "wave": 0,
"status": "converged", "success_method": "seed_step", "attempt_count": 2,
"last_max_relc": 0.00043, "last_task_type": "seed_step",
"seed_point_name": "t60000_g5.0_he2_c-4_n-4_o-4",
"node_id": "node-a1b2", "last_completed_at": "2026-07-30T11:12:00Z",
"elapsed_sec": 126.4
}
]
}
}
```
- "最近一次尝试"用关联子查询取 `tasks` 最新行:
```sql
SELECT gp.*, t.max_relc AS last_max_relc, t.task_type AS last_task_type,
t.seed_point_name, t.node_id, t.completed_at AS last_completed_at,
t.error_message AS last_error
FROM grid_points gp
LEFT JOIN tasks t ON t.task_id = (
SELECT t2.task_id FROM tasks t2
WHERE t2.point_name = gp.name AND t2.workflow_name = gp.workflow_name
ORDER BY t2.completed_at IS NULL, t2.completed_at DESC, t2.created_at DESC
LIMIT 1)
WHERE gp.workflow_name = ?1 [AND ...filters]
ORDER BY gp.wave, gp.cno_sum, gp.teff
LIMIT ? LIMIT OFFSET ?;
```
- 配套新索引:`CREATE INDEX idx_tasks_point_wf_time ON tasks(point_name, workflow_name, completed_at);`
(迁移中补;432 点规模下无索引也能跑,加索引是为大网格兜底。)
- 备选(若大网格性能不够):把 `last_*` 字段反规范化进 `grid_points`,在 `record_task_report` 里顺手写——
但**默认不做**,避免迁移面扩大;先用 JOIN,压测后再议。
#### ③ `GET /api/workflows/:name/points/:point` —— 单点详情 + 阶段诊断(G7
```json
{
"success": true, "message": "ok",
"data": {
"point": { /* 同 ② 的单点字段 */ },
"attempts": [
{"task_id": "...", "task_type": "cold_run", "seed_point_name": null,
"status": "failed", "max_relc": 954000.0, "atmosphere_has_nan": true,
"node_id": "node-a1b2", "error_message": "nl stage diverged...",
"created_at": "...", "completed_at": "..."},
{"task_id": "...", "task_type": "seed_step", "seed_point_name": "t60000_...",
"status": "completed", "max_relc": 0.00043, ...}
],
"conv": { /* data/seeds/<name>/conv.json 解析后的 ModelSummary;文件不在则 null */ }
}
}
```
- `conv` 读盘路径严格拼为 `seeds_dir/<point>/conv.json`(单层目录,point 先过白名单 +
canonicalize 归属兜底),解析失败/不存在 → `null`(前端降级为"诊断文件不可用")。
**不**把整文件塞进列表接口(体积控制)。
#### ④ `POST /api/workflows/:name/points/retry` —— 失败点重试(G6
请求体:`{"names": ["t80000_..."], "all_failed": true}`(二选一)。
- 前置:工作流必须 `running`(否则 400,提示"请先启动工作流"——避免重置后无调度器消费的僵尸态)。
- 逻辑:新 db 函数 `reset_failed_points(wf, names|all)`
`UPDATE grid_points SET status='pending', success_method=NULL WHERE workflow_name=? AND status='failed' [AND name IN (...)]`
随后立即触发一次 `schedule_pending_tasks()`(不必等 30s tick)。
- 重试后的方法仍由调度器按种子可用性自动决定(大概率 seed_step,因为此时种子池已更丰富)——
这与领域语义一致,前端文案如实说明:"重试的点将由调度器自动选择冷启动或种子步进"。
#### ⑤ 列表接口内联计数(G2,卡片进度条用)
`GET /api/workflows``WorkflowSummary` 追加:
```json
{"name": "...", "status": "...", "description": "...",
"created_at": "...", "updated_at": "...",
"stats": {"total": 432, "converged": 261, "failed": 3, "running": 8,
"cold_run_converged": 220, "seed_step_converged": 41}}
```
- 实现:`list_workflows` 里对 `grid_points` 做一次 `GROUP BY workflow_name` 聚合再 map 合并(**单条 SQL,无 N+1**)。
- 未启动(无网格点)的工作流 `stats: null`,前端不显示进度条。
### 3.2 持久化变更(Phase 3,最小迁移)
> Phase 1/2 **零迁移**——全部基于现有列。以下为 Phase 3 的可选增强:
1. `grid_points` 加列:`elapsed_sec REAL`(最近一次成功/失败尝试的墙钟耗时)。
`record_task_report` 写入——`TaskReport.elapsed_sec` 本来就在手(`models.rs:318`),当前被丢弃。
2. `StageSummary` 携带 `last_iter / worst_depth / n_depths``models.rs:364-373` 扩字段,
`runner.rs:302-317``ConvCheckResult` 已有值,只是没搬过去)→ conv.json 与点详情获得迭代数。
3. 新表 `workflow_progress_snapshots(workflow_name TEXT, ts DATETIME, pending INT, queued INT,
running INT, converged INT, failed INT)`:由 `main.rs:111-197` 的 30s 后台循环顺手写一行;
保留策略:仅当计数相对上次快照有变化才写;定期清理 24h 前的行。供"进度-时间"曲线与精确 ETA。
### 3.3 明确不做 / 保持现状
- **不**改 pull 调度模型、不改 wave 语义、不改种子匹配算法——只读取与展示。
- **不**引入 WebSocket/SSE5s 轮询 + 详情弹窗作用域轮询足够,复杂度与收益不匹配。
- **不**给 `/api/seed/:name` 开 Admin 权限(它是 Node 角色的二进制通道);前端只需 conv.json 的 JSON 内容,走 ③ 即可。
- `seeds` 表/`.7` 文件不暴露列表下载(超出本次范围,且属 Node 域)。
### 3.4 安全清单(对照全局安全规约)
- 所有新端点经现有 `auth_middleware`(Admin 角色)+ 全局限流;无新增鉴权旁路。
- 点名/工作流名全部过字符集白名单(`[A-Za-z0-9._-]`);conv.json 读盘路径不接受 `..`/绝对路径。
- 无新增密钥;错误信息用现有 `AppError`Internal 不回显细节,`error.rs:28-34`)。
- 前端所有服务端字符串经 `escapeHtml`(列表/表格)或 `textContent`conv.json、错误文本)渲染。
- retry 端点为写操作:仅 Admin、受限于 running 状态、参数白名单,无注入面(参数化查询)。
---
## 4. 前端实现设计
### 4.1 新增/改动文件
| 文件 | 改动 |
|---|---|
| `src/router.js`(新) | hash 路由:`parseHash()``{view:'home''workflow', name?}``hashchange` 监听 + 首载渲染;视图挂载/卸载生命周期钩子(停/启作用域轮询);登录门禁判断 |
| `src/views/home.js`(新) | 现 `index.html``main.main-container` 的首页内容收敛为模板函数 + 挂载逻辑(metric 卡/节点表/工作流卡列表),`startPolling()` 在此触发 |
| `src/views/workflowDetail.js`(新) | 详情页:任务控制条(分段进度条/计数/ETA/操作)+ Tab 容器;概览(tiles/方法归因/wave 分布/最近动态流)、点表格(关键行签名 diff,仿 nodesTable)、过滤器、热力矩阵(CSS grid 单元格)、点详情滑入面板。所有插值 `escapeHtml`;作用域 5s 轮询自管 |
| `src/api.js` | 新增 `fetchWorkflowStatsApi(name)``fetchWorkflowPointsApi(name, params)``fetchPointDetailApi(name, point)``retryPointsApi(name, body)`;全部经 `apiFetch` + `encodeURIComponent`,返回 raw Response |
| `src/components/workflows.js` | 卡片加进度条/计数/状态徽章扩充;头部包成进详情的锚链接;读取列表内联 `stats`;空 stats 降级 |
| `src/main.js` | 瘦身为全局接线:登录/主题/登出/全局模态(create-wf / view-yaml / reissue / confirm/ header 刷新;视图级委托移入各 view |
| `src/state.js` | 全局轮询仅服务首页;视图切换时由 router 停启 |
| `index.html` | `main` 收敛为 `<div id="view-root">`(视图由 JS 渲染);保留登录遮罩/header/footer/全局模态/toast;新增 `#point-panel` 滑入面板骨架 |
| `src/style.css` | 新组件:`.wf-status-strip`(控制条 + 分段进度条 `.seg-progress` 五色段)、`.wf-tabs`tab 栏,双主题)、`.method-badge.cold/.seed/.imported`(蓝/紫/中性,**区别于状态色**)、`.heatmap`/`.heatmap-cell`(状态色填充 + hover)、`.stage-chip`(阶段诊断小卡)、`.point-panel`(右侧滑入 + 遮罩)、`.activity-feed`(动态流条目淡入);全部走设计令牌 + `prefers-reduced-motion` 降级 |
### 4.2 必须遵循的现有约定(探查结论)
1. 事件一律 `data-*` 委托,不挂逐按钮监听(`main.js:377-388` 模式)。
2. 服务端字符串进 `innerHTML` 前必过 `escapeHtml`;整块文本用 `textContent`
3. 模态:静态骨架 + `setupFocusTrap`close 按钮类 `modal-close`(否则 Escape 映射失效,`modal.js:21`)。
4. 破坏性操作先 `showConfirm`,反馈只走 `showToast`,变更后 `fetchAllData()` 立即刷新。
5. 轮询下渲染:行/卡 keyed by `data-*` + 签名 diff + 高频单元格 `textContent` 原位刷新(`nodesTable.js:34-54` 模式),空态写入做 trim 比较防抖。
6. 样式只用令牌;状态复用 `.status-badge` 变体;进度复用 `.progress-bar-track/-fill`;表格复用 `.data-table` + `.col-*` + `.tabular-num`
7. 工作流标识是 `name`(无数字 id);后端状态含 `initializing`,前端要显式映射。
---
## 5. 分期实施计划
### Phase 1 — 进度可见(MVP,零迁移)
- 后端:`stats` 端点(拆 queued+ `points` 端点(JOIN 最近尝试 + 新索引)+ 列表内联 `stats`
- 前端:**hash 路由 + 视图化重构** + 卡片进度条/计数/徽章 + 工作流详情页(任务控制条 + 「概览」「网格点明细」两 Tab + 过滤器/分页 + 最近动态流)。
- 验收:深链 `#/workflows/sdB_cno` 打开详情页,能看到 432 点逐行状态、每点方法/max_relc/尝试数/种子来源,进度条随轮询而动。
### Phase 2 — 诊断与归因
- 后端:`points/:point` 端点(尝试历史 + conv.json 解析)+ `retry` 端点 + db `reset_failed_points`
- 前端:点详情滑入面板(尝试历史 + 阶段链诊断 + 错误)+「收敛性分析」热力 Tab + 「重试失败点」按钮。
- 验收:能回答"这个点为什么失败/被谁救回",能看到冷启动成功参数区,能一键重试失败点。
### Phase 3 — 时序与精算(可选增强)
- 后端:迁移(`elapsed_sec` 列、`StageSummary.last_iter`+ `workflow_progress_snapshots` + ETA 精算。
- 前端:概览 ETA 精确化 + 进度-时间 sparkline + 点表耗时列去"~"。
---
## 6. 待确认的开放问题
1. Phase 范围:三期全做,还是先交付 Phase 1+2?
2. 「重试失败点」是否需要(它会触发真实计算任务)?默认仅 running 时可用。
3. 热力矩阵默认轴 Teff × cno_sum 是否符合你的物理直觉?(sdB_cno 当前网格 logg/logHe/CNO 各只有单值,实际是 Teff 一维——多值网格下矩阵才有意义。)
4. conv.json 之外是否还需要在点详情里提供逐阶段 `.err`/`fort.9` 原始文件下载(目前节点归档 `data/result` 有 LRU 200 上限,服务端只有 conv.json)?
</content>
+18
View File
@@ -0,0 +1,18 @@
# ==============================================================================
# DCTS 多节点集群部署配置文件 (hosts.ini)
# ==============================================================================
# 每行格式:
# IP地址 用户名 远程部署目录 [SSH端口, 默认22] [运行平台: compose|systemd, 默认compose]
# ==============================================================================
[server]
# 格式: IP 用户名 目录 [端口] [平台]
100 fmq 100.66.1.2 22 compose
[nodes]
# 各计算节点可灵活指定独立运行平台 (compose 容器部署 或 systemd 原生服务部署)
REMOTE_IP="192.168.7.102" REMOTE_USER="dckj" REMOTE_DIR="/home/dckj/DataSheel/dcts" ./scripts/deploy.sh
REMOTE_IP="192.168.6.102" REMOTE_USER="fmq" REMOTE_DIR="E:/fmq/dcts" ./scripts/deploy.sh
REMOTE_IP="113" REMOTE_USER="root" REMOTE_DIR="/home/root/dcts" ./scripts/deploy.sh
+513
View File
@@ -0,0 +1,513 @@
{
"name": "dcts",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"jsdom": "^30.0.1"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
"dev": true,
"dependencies": {
"@csstools/css-calc": "^3.2.1",
"@csstools/css-color-parser": "^4.1.9",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz",
"integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==",
"dev": true,
"dependencies": {
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true
},
"node_modules/jsdom": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
"dev": true,
"dependencies": {
"@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^8.3.0",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.2",
"undici": "^8.9.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true
},
"node_modules/tldts": {
"version": "7.4.9",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz",
"integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
"dev": true,
"dependencies": {
"tldts-core": "^7.4.9"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.9",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz",
"integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
"dev": true
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/undici": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"dev": true,
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"dev": true,
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"jsdom": "^30.0.1"
}
}
+535 -43
View File
@@ -12,13 +12,14 @@
# ./scripts/deploy.sh -e remote -b compose -r all --skip-assets # 跳过静态资源同步,仅更新程序代码
# ==============================================================================
set -e
set -eo pipefail
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
# 定位至项目根目录
@@ -30,15 +31,260 @@ cd "${WORK_DIR}"
# 默认配置与环境变量
# =============================================================================
REMOTE_USER="${REMOTE_USER:-fmq}"
REMOTE_IP="${REMOTE_IP:-192.168.5.3}"
REMOTE_IP="${REMOTE_IP:-100.66.1.2}"
REMOTE_PORT="${REMOTE_PORT:-22}"
REMOTE_DIR="${REMOTE_DIR:-/vol1/1000/program/dcts}"
# SSH ControlMaster 连接复用目录 (全局统一,使父子/兄弟进程共享 socket)
SSH_CONTROL_DIR="${SSH_CONTROL_DIR:-$HOME/.ssh/cm}"
ENV_MODE="${DEPLOY_ENV:-}"
BACKEND_MODE="${DEPLOY_BACKEND:-}"
ROLE_TARGET="${DEPLOY_ROLE:-}"
ACTION="install"
SYNC_ASSETS="auto" # auto / skip / force
PROFILE_FILE=""
PROFILE_LOADED=""
PASSED_CLI_ARGS=("$@")
load_profile_file() {
local pfile="$1"
if [ -f "$pfile" ]; then
echo -e "${CYAN}[i] 正在加载 Profile 配置文件: ${pfile}${NC}"
set -a
# shellcheck disable=SC1090
source "$pfile"
set +a
PROFILE_LOADED="$pfile"
# 优先级: 命令行显式参数 > Profile > 默认值。仅在命令行未指定时才采用 Profile 的值。
[ -z "${CLI_ENV_SET:-}" ] && [ -n "${DEPLOY_ENV}" ] && ENV_MODE="${DEPLOY_ENV}"
[ -z "${CLI_BACKEND_SET:-}" ] && [ -n "${DEPLOY_BACKEND}" ] && BACKEND_MODE="${DEPLOY_BACKEND}"
[ -z "${CLI_ROLE_SET:-}" ] && [ -n "${DEPLOY_ROLE}" ] && ROLE_TARGET="${DEPLOY_ROLE}"
if [ -z "${CLI_ASSETS_SET:-}" ]; then
[ -n "${SYNC_ASSETS}" ] || SYNC_ASSETS="auto"
fi
[ -n "${ACTION}" ] || ACTION="install"
return 0
else
echo -e "${RED}错误: 找不到 Profile 配置文件 '${pfile}'${NC}"
exit 1
fi
}
write_remote_runtime_env() {
local is_win="$1"
local env_content=""
if [ -n "${PROFILE_LOADED}" ] && [ -f "${PROFILE_LOADED}" ]; then
env_content=$(grep -E '^(DCTS_|LOG_|RUST_LOG)' "${PROFILE_LOADED}" 2>/dev/null || echo "")
fi
local var_list=(
DCTS_NODE_ID
DCTS_SERVER_URL
DCTS_MAX_SLOTS
DCTS_PORT
DCTS_ADMIN_TOKEN
DCTS_AUTH_DISABLE
LOG_DIR
DCTS_LOG
DCTS_HEARTBEAT_SEC
)
for var in "${var_list[@]}"; do
if ! echo "${env_content}" | grep -q "^${var}=" 2>/dev/null; then
local val="${!var}"
if [ -n "${val}" ]; then
if [ -n "${env_content}" ]; then
env_content="${env_content}"$'\n'"${var}=${val}"
else
env_content="${var}=${val}"
fi
fi
fi
done
if [ -z "${env_content}" ]; then
env_content="DCTS_SERVER_URL=${DCTS_SERVER_URL:-http://100.66.1.2:8090}"$'\n'"DCTS_MAX_SLOTS=${DCTS_MAX_SLOTS:-4}"$'\n'"LOG_DIR=${LOG_DIR:-/app/data/logs}"
fi
echo "=== 在远端服务器直接生成专属 .env 配置文件 (无需 SCP 传输文件) ==="
# 统一通过 SSH stdin 管道写入 .env 内容。
# Windows 侧原用 PowerShell here-string (@'...'@),但其闭合标记 '@ 要求独占
# 行首,经 bash→SSH→cmd.exe→PowerShell 多层转义与换行重组后无法被识别,
# 报 "字符串缺少终止符: '@" (TerminatorExpectedAtEndOfString)。
# 改用 stdin 管道 + [Console]::In.ReadToEnd() 读取,命令字符串内不含任何
# 多行内容或 here-string,彻底规避该转义问题。
if [ "$is_win" = "true" ]; then
printf '%s\n' "${env_content}" | ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
"powershell -NoProfile -Command \"Set-Content -Path '${REMOTE_DIR}/.env' -Value ([Console]::In.ReadToEnd()) -Encoding utf8\""
else
printf '%s\n' "${env_content}" | ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
"cat > '${REMOTE_DIR}/.env' && chmod 600 '${REMOTE_DIR}/.env'"
fi
}
batch_deploy_profiles() {
local profiles=("$@")
# 批量部署面向多台远程主机,剔除本地 Profile (DEPLOY_ENV=local),避免无意义的
# SSH 预连接与本地构建混入远程批量流程。
local filtered_profiles=()
for p in "${profiles[@]}"; do
local p_env
p_env=$(grep -E '^DEPLOY_ENV=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
if [ "$p_env" = "local" ]; then
echo -e "${YELLOW}[i] 已从批量部署中排除本地 Profile: ${p} (本地部署请单独执行)${NC}"
continue
fi
filtered_profiles+=("$p")
done
profiles=("${filtered_profiles[@]}")
local total=${#profiles[@]}
if [ "$total" -eq 0 ]; then
echo -e "${YELLOW}[i] 批量部署列表中无远程节点,已跳过。${NC}"
return 0
fi
echo -e "\n${GREEN}==============================================================${NC}"
echo -e "${GREEN}🚀 启动高效多节点批量部署引擎: 目标节点总数 [${total}]${NC}"
echo -e "${GREEN}==============================================================${NC}"
local need_server=false
local need_node=false
local need_all=false
local need_compose=false
local need_systemd=false
local p
for p in "${profiles[@]}"; do
local r_role
r_role=$(grep -E '^DEPLOY_ROLE=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "all")
local r_backend
r_backend=$(grep -E '^DEPLOY_BACKEND=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "compose")
case "$r_role" in
server) need_server=true ;;
node) need_node=true ;;
all|*) need_server=true; need_node=true; need_all=true ;;
esac
if [ "$r_backend" = "systemd" ]; then need_systemd=true; else need_compose=true; fi
done
# ─── 【阶段 0】提前为所有目标节点建立 SSH 主连接 ─────────────────────────────
# 在本地构建/打包之前完成所有密码输入,避免"构建→输密码→推送→输密码→推送"的割裂等待。
# 连接经 ControlPersist 长期保持,后续递归子进程通过共享 ControlPath 直接复用,无需重复认证。
echo -e "\n${CYAN}=== 【阶段 0/2】预建立所有目标节点 SSH 连接 (请在此时集中输入各节点密码) ===${NC}"
mkdir -p "${SSH_CONTROL_DIR}"
local pre_idx=0
for p in "${profiles[@]}"; do
pre_idx=$((pre_idx + 1))
local r_user r_ip r_port
r_user=$(grep -E '^REMOTE_USER=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_USER}")
r_ip=$(grep -E '^REMOTE_IP=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_IP}")
r_port=$(grep -E '^REMOTE_PORT=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_PORT}")
[ -z "$r_user" ] && r_user="${REMOTE_USER}"
[ -z "$r_ip" ] && r_ip="${REMOTE_IP}"
[ -z "$r_port" ] && r_port="${REMOTE_PORT}"
local sock="${SSH_CONTROL_DIR}/${r_user}@${r_ip}:${r_port}"
if ssh -p "${r_port}" -o ControlPath="${sock}" -O check "${r_user}@${r_ip}" >/dev/null 2>&1; then
echo " -> [${pre_idx}/${total}] ${r_user}@${r_ip}:${r_port} (连接已存在,复用)"
continue
fi
echo " -> [${pre_idx}/${total}] 正在连接 ${r_user}@${r_ip}:${r_port} ..."
ssh -p "${r_port}" -o ControlMaster=yes -o ControlPath="${sock}" -o ControlPersist=1800 -fN "${r_user}@${r_ip}" \
&& echo -e " ${GREEN}[√] 连接已建立${NC}" \
|| echo -e " ${YELLOW}[!] 连接失败,将在部署该节点时重试${NC}"
done
local build_services=""
if [ "$need_server" = true ] && [ "$need_node" = true ]; then
build_services="server node"
elif [ "$need_server" = true ]; then
build_services="server"
else
build_services="node"
fi
echo -e "${CYAN}=== 【阶段 1/2】本地全局单次构建与程序/镜像打包 [服务: ${build_services}] ===${NC}"
if [ "$need_compose" = true ]; then
echo " -> [Docker Compose] 统一构建镜像 (仅构建 1 次)..."
docker compose build ${build_services}
fi
if [ "$need_systemd" = true ]; then
echo " -> [Systemd] 统一编译 release 二进制程序 (仅编译 1 次)..."
cargo build --release --workspace
fi
# 按角色分别打包镜像 (server/node 各自独立),避免给单角色节点传输不需要的镜像。
local server_tar=""
local node_tar=""
if [ "$need_compose" = true ]; then
mkdir -p data
if [ "$need_server" = true ]; then
server_tar="data/.batch_dcts-server.tar.gz"
echo " -> [Docker Compose] 导出 server 镜像归档包: ${server_tar} (仅打包 1 次)..."
docker save dcts-server:latest | gzip > "${server_tar}"
fi
if [ "$need_node" = true ]; then
node_tar="data/.batch_dcts-node.tar.gz"
echo " -> [Docker Compose] 导出 node 镜像归档包: ${node_tar} (仅打包 1 次)..."
docker save dcts-node:latest | gzip > "${node_tar}"
fi
fi
echo -e "\n${CYAN}=== 【阶段 2/2】同步镜像并为各个节点定制生成专属 .env ===${NC}"
local idx=1
local all_ok=true
for p in "${profiles[@]}"; do
echo -e "\n${MAGENTA}--------------------------------------------------------------${NC}"
echo -e "${MAGENTA}▶▶▶ [节点 ${idx}/${total}] 正在部署 Profile: ${p}${NC}"
echo -e "${MAGENTA}--------------------------------------------------------------${NC}"
# 依据该 Profile 的角色选择对应的镜像归档包 (server/node 分开)all 角色传两个。
local p_role
p_role=$(grep -E '^DEPLOY_ROLE=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "all")
local selected_tar=""
case "$p_role" in
server) selected_tar="${server_tar}" ;;
node) selected_tar="${node_tar}" ;;
all|*) selected_tar="${server_tar} ${node_tar}" ;;
esac
if ! PREBUILT_TAR="${selected_tar}" bash "$0" -f "${p}"; then
echo -e "${RED}[✗] 节点 Profile '${p}' 部署失败!${NC}"
all_ok=false
else
echo -e "${GREEN}[√] 节点 Profile '${p}' 部署成功!${NC}"
fi
((idx++))
done
# 清理本阶段打包的临时镜像归档包
[ -n "${server_tar}" ] && rm -f "${server_tar}" 2>/dev/null || true
[ -n "${node_tar}" ] && rm -f "${node_tar}" 2>/dev/null || true
# 关闭阶段 0 预建立的所有 SSH 主连接
for p in "${profiles[@]}"; do
local cu cip cp2 csock
cu=$(grep -E '^REMOTE_USER=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_USER}")
cip=$(grep -E '^REMOTE_IP=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_IP}")
cp2=$(grep -E '^REMOTE_PORT=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_PORT}")
[ -z "$cu" ] && cu="${REMOTE_USER}"; [ -z "$cip" ] && cip="${REMOTE_IP}"; [ -z "$cp2" ] && cp2="${REMOTE_PORT}"
csock="${SSH_CONTROL_DIR}/${cu}@${cip}:${cp2}"
ssh -p "${cp2}" -o ControlPath="${csock}" "${cu}@${cip}" -O exit 2>/dev/null || true
done
echo -e "\n${BLUE}==============================================================${NC}"
if [ "$all_ok" = true ]; then
echo -e "${GREEN}🎉 批量部署完成!所有 ${total} 个节点均已一次性更新并启动服务!${NC}"
else
echo -e "${YELLOW}⚠️ 批量部署结束,部分节点出现错误,请检查日志。${NC}"
exit 1
fi
exit 0
}
show_help() {
echo -e "${BLUE}DCTS 部署与管理控制台${NC}"
@@ -50,6 +296,8 @@ show_help() {
echo " remove | clean 停止并卸载指定服务"
echo ""
echo "选项 (Options):"
echo " -f, --profile <file|dir|list> 指定节点 Profile (例: deploy.env.d/node1.env, 或 node1,node2, 或 deploy.env.d/)"
echo " --all-profiles 批量部署 deploy.env.d/ 下的所有生效 Profile"
echo " -e, --env <local|remote> 部署环境: local (本地) 或 remote (远程)"
echo " -b, --backend <compose|systemd> 底层平台: compose (Docker Compose) 或 systemd (Systemd 服务)"
echo " -r, --role <all|server|node> 目标模块: all (全量), server (服务端), node (计算节点)"
@@ -57,7 +305,7 @@ show_help() {
echo " --force-assets 强制重新全量同步 assets/ 静态资源"
echo " -h, --help 显示帮助信息"
echo ""
echo "示例: $0 -e remote -b compose -r all --skip-assets"
echo "示例: $0 -f deploy.env.d/node-win-01.env -e remote -b compose -r node"
exit 0
}
@@ -74,24 +322,37 @@ while [[ $# -gt 0 ]]; do
fi
shift
;;
-f|--profile|--env-file)
PROFILE_ARG="$2"
shift 2
;;
--all-profiles)
PROFILE_ARG="all"
shift
;;
-e|--env)
ENV_MODE="${2,,}"
CLI_ENV_SET=1
shift 2
;;
-b|--backend)
BACKEND_MODE="${2,,}"
CLI_BACKEND_SET=1
shift 2
;;
-r|--role)
ROLE_TARGET="${2,,}"
CLI_ROLE_SET=1
shift 2
;;
--skip-assets)
SYNC_ASSETS="skip"
CLI_ASSETS_SET=1
shift
;;
--force-assets)
SYNC_ASSETS="force"
CLI_ASSETS_SET=1
shift
;;
-h|--help)
@@ -100,10 +361,13 @@ while [[ $# -gt 0 ]]; do
*)
if [ "$arg_lower" = "server" ] || [ "$arg_lower" = "node" ] || [ "$arg_lower" = "all" ]; then
ROLE_TARGET="$arg_lower"
CLI_ROLE_SET=1
elif [ "$arg_lower" = "local" ] || [ "$arg_lower" = "remote" ]; then
ENV_MODE="$arg_lower"
CLI_ENV_SET=1
elif [ "$arg_lower" = "compose" ] || [ "$arg_lower" = "systemd" ]; then
BACKEND_MODE="$arg_lower"
CLI_BACKEND_SET=1
else
echo -e "${RED}错误: 未知参数 '$1'。使用 '$0 --help' 查看用法。${NC}"
exit 1
@@ -113,6 +377,37 @@ while [[ $# -gt 0 ]]; do
esac
done
# 命令行指定多 Profile 或目录自动展开解析
if [ -n "${PROFILE_ARG}" ]; then
CLI_PROFILES=()
if [ "${PROFILE_ARG}" = "all" ] || [ -d "${PROFILE_ARG}" ]; then
pdir="${PROFILE_ARG}"
[ "${pdir}" = "all" ] && pdir="deploy.env.d"
for p in "${pdir}"/*.env; do
if [ -f "$p" ] && [[ "$p" != *.example ]]; then
CLI_PROFILES+=("$p")
fi
done
else
IFS=',' read -ra ADDR <<< "${PROFILE_ARG}"
for p in "${ADDR[@]}"; do
if [ -f "$p" ]; then
CLI_PROFILES+=("$p")
elif [ -f "deploy.env.d/${p}" ]; then
CLI_PROFILES+=("deploy.env.d/${p}")
elif [ -f "deploy.env.d/${p}.env" ]; then
CLI_PROFILES+=("deploy.env.d/${p}.env")
fi
done
fi
if [ ${#CLI_PROFILES[@]} -gt 1 ]; then
batch_deploy_profiles "${CLI_PROFILES[@]}"
elif [ ${#CLI_PROFILES[@]} -eq 1 ]; then
load_profile_file "${CLI_PROFILES[0]}"
fi
fi
# =============================================================================
# 2. 交互式向导 (参数未指定时调用)
# =============================================================================
@@ -121,6 +416,63 @@ if [ -z "${ENV_MODE}" ] || [ -z "${BACKEND_MODE}" ] || [ -z "${ROLE_TARGET}" ];
echo -e "${CYAN} 🚀 DCTS 部署与管理控制台 ${NC}"
echo -e "${BLUE}==============================================================${NC}"
# [零步]:检测 deploy.env.d 中的 Profile 预设
if [ -z "${PROFILE_LOADED}" ] && [ -z "${PROFILE_ARG}" ] && [ -d "deploy.env.d" ]; then
valid_profiles=()
for p in deploy.env.d/*.env; do
if [ -f "$p" ] && [[ "$p" != *.example ]]; then
valid_profiles+=("$p")
fi
done
if [ ${#valid_profiles[@]} -gt 0 ]; then
echo -e "\n${YELLOW}【预设 Profile】检测到可用的服务器部署 Profile 配置文件:${NC}"
echo " 0) 手动交互式配置新服务器 (不加载预设 Profile)"
idx=1
for p in "${valid_profiles[@]}"; do
pname=$(basename "$p" .env)
p_ip=$(grep -E '^REMOTE_IP=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
p_role=$(grep -E '^DEPLOY_ROLE=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
p_node=$(grep -E '^DCTS_NODE_ID=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
echo " ${idx}) ${pname} (IP: ${p_ip:-未指定}, 角色: ${p_role:-全量}, NodeID: ${p_node:-默认})"
((idx++))
done
if [ ${#valid_profiles[@]} -gt 1 ]; then
echo " a) [批量部署] 依次一键部署上述全部 ${#valid_profiles[@]} 个 Profile 节点"
fi
read -p "请选择预设 Profile [序号如 1, 多个如 1,2 或 1-2, 全部如 a, 默认 0]: " PROFILE_CHOICE
PROFILE_CHOICE=${PROFILE_CHOICE:-0}
SELECTED_PROFILES=()
if [[ "${PROFILE_CHOICE,,}" == "a" ]] || [[ "${PROFILE_CHOICE,,}" == "all" ]]; then
SELECTED_PROFILES=("${valid_profiles[@]}")
elif [[ "${PROFILE_CHOICE}" =~ [0-9] ]]; then
IFS=',' read -ra ADDR <<< "${PROFILE_CHOICE}"
for part in "${ADDR[@]}"; do
if [[ "$part" =~ ^([0-9]+)-([0-9]+)$ ]]; then
s="${BASH_REMATCH[1]}"
e="${BASH_REMATCH[2]}"
for ((i=s; i<=e; i++)); do
if [ "$i" -ge 1 ] && [ "$i" -le "${#valid_profiles[@]}" ]; then
SELECTED_PROFILES+=("${valid_profiles[$((i-1))]}")
fi
done
elif [[ "$part" =~ ^[0-9]+$ ]]; then
if [ "$part" -ge 1 ] && [ "$part" -le "${#valid_profiles[@]}" ]; then
SELECTED_PROFILES+=("${valid_profiles[$((part-1))]}")
fi
fi
done
fi
if [ ${#SELECTED_PROFILES[@]} -gt 1 ]; then
batch_deploy_profiles "${SELECTED_PROFILES[@]}"
elif [ ${#SELECTED_PROFILES[@]} -eq 1 ]; then
load_profile_file "${SELECTED_PROFILES[0]}"
fi
fi
fi
# [第一步]:选择环境
if [ -z "${ENV_MODE}" ]; then
echo -e "\n${YELLOW}【第一步】请选择部署环境:${NC}"
@@ -169,19 +521,26 @@ if [ -z "${ENV_MODE}" ] || [ -z "${BACKEND_MODE}" ] || [ -z "${ROLE_TARGET}" ];
esac
fi
# [第四步]:远程部署安装时选择静态资源同步策略
if [ "${ENV_MODE}" = "remote" ] && [ "${ACTION}" != "remove" ]; then
# [第四步]:远程部署安装时选择静态资源同步策略 (已加载 Profile 时自动跳过)
if [ -z "${PROFILE_LOADED}" ] && [ "${ENV_MODE}" = "remote" ] && [ "${ACTION}" != "remove" ]; then
default_asset_choice=1
default_asset_desc="1 (Auto)"
if [ "${ROLE_TARGET}" = "node" ]; then
default_asset_choice=2
default_asset_desc="2 (Skip, 仅节点推荐)"
fi
echo -e "\n${YELLOW}【第四步】请选择静态资源 (assets/) 同步策略:${NC}"
echo " 1) 自动模式 [Auto] (推荐: rsync 增量同步;若退化至 scp,目标端已有资源时自动跳过)"
echo " 2) 跳过同步 [Skip] (等同 --skip-assets,仅更新程序代码,不传输静态资源)"
echo " 3) 强制同步 [Force] (等同 --force-assets,无视目标端状态,强制全量覆盖传输)"
read -p "请选择 [1-3, 默认 1]: " ASSET_CHOICE
ASSET_CHOICE=${ASSET_CHOICE:-1}
read -p "请选择 [1-3, 默认 ${default_asset_desc}]: " ASSET_CHOICE
ASSET_CHOICE=${ASSET_CHOICE:-$default_asset_choice}
case "$ASSET_CHOICE" in
1) SYNC_ASSETS="auto" ;;
2) SYNC_ASSETS="skip" ;;
3) SYNC_ASSETS="force" ;;
*) echo -e "${RED}输入无效,默认选择 auto。${NC}"; SYNC_ASSETS="auto" ;;
*) SYNC_ASSETS=$([ "$default_asset_choice" = "2" ] && echo "skip" || echo "auto") ;;
esac
fi
@@ -220,22 +579,55 @@ else
TAR_NAME="dcts_images_node.tar.gz"
fi
# 纯 CLI 未指定资源同步策略时,默认 auto(增量同步)
SYNC_ASSETS="${SYNC_ASSETS:-auto}"
echo -e "${CYAN}📢 配置确认: 环境=[${YELLOW}${ENV_MODE}${CYAN}] 平台=[${YELLOW}${BACKEND_MODE}${CYAN}] 模块=[${YELLOW}${ROLE_DESC}${CYAN}] 动作=[${YELLOW}${ACTION}${CYAN}] 资源同步=[${YELLOW}${SYNC_ASSETS}${CYAN}]${NC}\n"
# =============================================================================
# 静态资源同步函数
# =============================================================================
check_remote_is_windows() {
local kernel
kernel=$(ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "uname -s 2>/dev/null || cmd /c ver 2>/dev/null" 2>/dev/null || echo "Windows")
if [[ "${kernel}" == *"Linux"* ]] || [[ "${kernel}" == *"GNU"* ]]; then
echo "false"
else
echo "true"
fi
}
sync_remote_assets() {
if [ "${SYNC_ASSETS}" = "skip" ]; then
echo -e "${YELLOW}[i] 已启用 --skip-assets,跳过静态资源 (assets/) 同步。${NC}"
return 0
fi
if [ "${ROLE_TARGET}" = "node" ] && [ "${SYNC_ASSETS}" != "force" ]; then
echo -e "${YELLOW}[i] 目标模块为仅节点 (node),默认跳过静态资源 (assets/) 同步。${NC}"
return 0
fi
if [ ! -d "assets" ]; then
return 0
fi
echo "=== 检查并同步静态资源 (assets/) ==="
local is_win
is_win=$(check_remote_is_windows)
if [ "$is_win" = "true" ]; then
local remote_has_assets
remote_has_assets=$(ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"Test-Path '${REMOTE_DIR}/assets'\" 2>NUL || cmd /c \"if exist \\\"${REMOTE_DIR}\\assets\\\" echo True\" 2>NUL" 2>/dev/null | grep -i "True" || echo "")
if [ -n "${remote_has_assets}" ] && [ "${SYNC_ASSETS}" != "force" ]; then
echo -e "${GREEN}[√] 远端 Windows 已有 assets/ 目录。为避免 scp 全量传输数据,本次自动跳过 assets/ 同步。${NC}"
else
echo -e "${CYAN} -> 开始向远端 Windows 传输 assets/ 静态资源数据...${NC}"
scp -P "${REMOTE_PORT}" ${SSH_OPTS} -r assets "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/"
fi
return 0
fi
local local_rsync=false
local remote_rsync=false
if command -v rsync >/dev/null 2>&1; then local_rsync=true; fi
@@ -263,6 +655,36 @@ sync_remote_assets() {
fi
}
sync_remote_workflows() {
if [ -d "workflows" ]; then
echo -e "${CYAN} -> 同步工作流配置文件 workflows/ 到远端...${NC}"
scp -P "${REMOTE_PORT}" ${SSH_OPTS} -r workflows "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/"
fi
}
setup_ssh_control() {
# ControlPath 以 user@ip:port 为 key (不含 PID),使同一目标主机的父子/兄弟进程
# 共享同一条复用 socket;批量部署递归调用子进程时无需重复输入密码。
local socket_key="${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PORT}"
SSH_CONTROL_PATH="${SSH_CONTROL_DIR}/${socket_key}"
SSH_OPTS="-o ControlMaster=auto -o ControlPath=${SSH_CONTROL_PATH} -o ControlPersist=1800"
mkdir -p "${SSH_CONTROL_DIR}"
# 若已有活跃的主连接 (如批量部署父进程已建立),直接复用,避免重复认证。
# 此时不注册 cleanup (连接由建立者管理),避免子进程退出时关闭共享连接影响后续节点。
if ssh -p "${REMOTE_PORT}" -o ControlPath="${SSH_CONTROL_PATH}" -O check "${REMOTE_USER}@${REMOTE_IP}" >/dev/null 2>&1; then
SSH_REUSED_CONNECTION=true
return 0
fi
echo "=== 1. 建立 SSH 主连接复用 (SSH ControlMaster) ==="
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} -fN "${REMOTE_USER}@${REMOTE_IP}" || true
# 仅当本进程建立了主连接时,才在退出时清理;复用的连接交给建立者管理。
SSH_REUSED_CONNECTION=false
cleanup_cm() { [ "${SSH_REUSED_CONNECTION}" = "true" ] && return 0; ssh -p "${REMOTE_PORT}" -o ControlPath="${SSH_CONTROL_PATH}" "${REMOTE_USER}@${REMOTE_IP}" -O exit 2>/dev/null || true; }
trap cleanup_cm EXIT
}
# =============================================================================
# 3. 清理/卸载流程 (ACTION="remove")
# =============================================================================
@@ -295,16 +717,32 @@ if [ "${ACTION}" = "remove" ]; then
elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "compose" ]; then
echo -e "${YELLOW}清理远端 Docker Compose 服务 [${REMOTE_USER}@${REMOTE_IP}]...${NC}"
setup_ssh_control
is_win=$(check_remote_is_windows)
if [ "${ROLE_TARGET}" = "all" ]; then
ssh -p "${REMOTE_PORT}" "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' 2>/dev/null && docker compose down 2>/dev/null || true"
if [ "$is_win" = "true" ]; then
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"Set-Location '${REMOTE_DIR}'; docker compose down\"" 2>/dev/null || true
else
ssh -p "${REMOTE_PORT}" "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' 2>/dev/null && docker compose stop ${SERVICES} 2>/dev/null && docker compose rm -f -s -v ${SERVICES} 2>/dev/null || true"
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && docker compose down" 2>/dev/null || true
fi
else
if [ "$is_win" = "true" ]; then
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"Set-Location '${REMOTE_DIR}'; docker compose stop ${SERVICES}; docker compose rm -f -s -v ${SERVICES}\"" 2>/dev/null || true
else
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && docker compose stop ${SERVICES} && docker compose rm -f -s -v ${SERVICES}" 2>/dev/null || true
fi
fi
echo -e "${GREEN}[✓] 远端 Docker Compose 服务已清理。${NC}"
elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "systemd" ]; then
echo -e "${YELLOW}清理远端 Systemd 服务 [${REMOTE_USER}@${REMOTE_IP}]...${NC}"
ssh -t -p "${REMOTE_PORT}" "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' 2>/dev/null && sudo ./scripts/deploy.sh -e local -b systemd -r ${ROLE_TARGET} remove"
setup_ssh_control
is_win=$(check_remote_is_windows)
if [ "$is_win" = "true" ]; then
echo -e "${RED}错误: Systemd 不支持 Windows 远端目标,无法清理。${NC}"
exit 1
fi
ssh -t -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && sudo ./scripts/deploy.sh -e local -b systemd -r ${ROLE_TARGET} remove"
echo -e "${GREEN}[✓] 远端 Systemd 服务已清理。${NC}"
fi
exit 0
@@ -316,12 +754,19 @@ fi
# ─── 4.1 本地 + Docker Compose ────────────────────────────────────────────────
if [ "${ENV_MODE}" = "local" ] && [ "${BACKEND_MODE}" = "compose" ]; then
echo -e "${CYAN}>>> 预先创建挂载目录并配置读取与写入权限 (UID 65532)...${NC}"
mkdir -p ./data ./data/logs ./data/runtime ./data/work ./data/seeds ./data/result ./assets ./workflows
chmod -R 777 ./data 2>/dev/null || true
if command -v docker >/dev/null 2>&1; then
docker run --rm -v "$(pwd)":/work alpine chown -R 65532:65532 /work/data 2>/dev/null || true
fi
echo -e "${CYAN}>>> 启动本地 Docker Compose 服务构建与运行...${NC}"
docker compose build ${SERVICES}
if [ "${ROLE_TARGET}" = "all" ]; then
docker compose up -d
docker compose up -d --force-recreate
else
docker compose up -d ${SERVICES}
docker compose up -d --force-recreate ${SERVICES}
fi
echo -e " -> 等待 3 秒进行运行状态检测..."
sleep 3 && docker compose ps
@@ -333,16 +778,30 @@ if [ "${ENV_MODE}" = "local" ] && [ "${BACKEND_MODE}" = "compose" ]; then
# ─── 4.2 远程 + Docker Compose ────────────────────────────────────────────────
elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "compose" ]; then
SSH_CONTROL_DIR="$HOME/.ssh/cm"
SSH_CONTROL_PATH="${SSH_CONTROL_DIR}/${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PORT}-$$"
SSH_OPTS="-o ControlMaster=auto -o ControlPath=${SSH_CONTROL_PATH} -o ControlPersist=1800"
mkdir -p "${SSH_CONTROL_DIR}"
cleanup_cm() { ssh -p "${REMOTE_PORT}" -o ControlPath="${SSH_CONTROL_PATH}" "${REMOTE_USER}@${REMOTE_IP}" -O exit 2>/dev/null || true; rm -f "${SSH_CONTROL_PATH}" 2>/dev/null || true; }
trap cleanup_cm EXIT
setup_ssh_control
echo "=== 1. 建立 SSH 主连接复用 (SSH ControlMaster) ==="
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} -fN "${REMOTE_USER}@${REMOTE_IP}" || true
if [ -n "${PREBUILT_TAR}" ]; then
# 批量模式: PREBUILT_TAR 为父进程按角色分包的镜像归档 (空格分隔,可能多个)。
# 仅当所有包均存在时才复用;否则回退到本进程自行构建打包。
all_prebuilt_exist=true
PREBUILT_LIST=()
for t in ${PREBUILT_TAR}; do
if [ -f "${t}" ]; then
PREBUILT_LIST+=("${t}")
else
all_prebuilt_exist=false
fi
done
if [ "${all_prebuilt_exist}" = true ] && [ ${#PREBUILT_LIST[@]} -gt 0 ]; then
echo "=== 2 & 3. 复用全局构建阶段一生成的镜像归档包 [${PREBUILT_LIST[*]}] ==="
TAR_LIST=("${PREBUILT_LIST[@]}")
else
echo -e "${YELLOW}[!] 部分预构建镜像包缺失,回退至本进程自行构建打包...${NC}"
PREBUILT_TAR=""
fi
fi
if [ -z "${PREBUILT_TAR}" ]; then
echo "=== 2. 在本地构建 Docker 镜像 [${SERVICES}] ==="
docker compose build ${SERVICES}
@@ -354,21 +813,57 @@ elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "compose" ]; then
else
docker save dcts-node:latest | gzip > "${TAR_NAME}"
fi
TAR_LIST=("${TAR_NAME}")
fi
echo "=== 4. 同步静态资源与配置文件 ==="
is_win=$(check_remote_is_windows)
if [ "$is_win" = "true" ]; then
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"New-Item -ItemType Directory -Force -Path '${REMOTE_DIR}'\" 2>NUL || cmd /c \"if not exist \\\"${REMOTE_DIR}\\\" mkdir \\\"${REMOTE_DIR}\\\"\" 2>NUL" >/dev/null 2>&1 || true
else
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "mkdir -p '${REMOTE_DIR}'"
fi
sync_remote_assets
sync_remote_workflows
ENV_FILE=".env"
[ ! -f ".env" ] && ENV_FILE=".env.example"
scp -P "${REMOTE_PORT}" ${SSH_OPTS} "${TAR_NAME}" docker-compose.yml "${ENV_FILE}" "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/"
[ "${ENV_FILE}" = ".env.example" ] && ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cp -n '${REMOTE_DIR}/.env.example' '${REMOTE_DIR}/.env' 2>/dev/null || true"
write_remote_runtime_env "$is_win"
echo "=== 5. 远端导入镜像并启动 Docker Compose 服务 ==="
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && docker load < '${TAR_NAME}' && rm -f '${TAR_NAME}' && docker compose up -d ${SERVICES} && sleep 3 && docker compose ps"
# 传输所有镜像归档包 (scp 落地名为 basename,丢掉本地 data/ 前缀)。
# 同时收集远端引用名 (纯文件名,与 docker load 的路径对齐)。
REMOTE_TAR_NAMES=()
for t in "${TAR_LIST[@]}"; do
scp -P "${REMOTE_PORT}" ${SSH_OPTS} "${t}" "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/"
REMOTE_TAR_NAMES+=("$(basename "${t}")")
done
scp -P "${REMOTE_PORT}" ${SSH_OPTS} docker-compose.yml "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/"
echo "=== 5. 远端预设目录权限、导入镜像并启动 Docker Compose 服务 ==="
# 构造远端批量导入与清理命令 (逐个包 docker load 后删除)
remote_load_cmd=""
for rn in "${REMOTE_TAR_NAMES[@]}"; do
remote_load_cmd="${remote_load_cmd}docker load -i '${rn}' && rm -f '${rn}' && "
done
remote_load_cmd="${remote_load_cmd}true"
if [ "$is_win" = "true" ]; then
win_load_cmd=""
for rn in "${REMOTE_TAR_NAMES[@]}"; do
win_load_cmd="${win_load_cmd}docker load -i '${rn}'; Remove-Item -Force '${rn}' -ErrorAction SilentlyContinue; "
done
win_load_cmd="${win_load_cmd}Write-Output done"
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"Set-Location '${REMOTE_DIR}'; New-Item -ItemType Directory -Force -Path 'data/logs','data/seeds','data/result','data/node_work','assets','workflows' -ErrorAction SilentlyContinue; ${win_load_cmd}; docker compose up -d --force-recreate ${SERVICES}; Start-Sleep -Seconds 3; docker compose ps\""
else
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && mkdir -p ./data ./data/logs ./data/seeds ./data/result ./data/node_work ./assets ./workflows 2>/dev/null || true && (chmod -R 777 ./data 2>/dev/null || true; docker run --rm -v \"\$(pwd)\":/work alpine chown -R 65532:65532 /work/data 2>/dev/null || true) && ${remote_load_cmd} && docker compose up -d --force-recreate ${SERVICES} && sleep 3 && docker compose ps"
fi
if [ -z "${PREBUILT_TAR}" ]; then
echo "=== 6. 清理本地临时归档包 ==="
rm -f "${TAR_NAME}"
for t in "${TAR_LIST[@]}"; do
rm -f "${t}"
done
fi
echo -e "${GREEN}🎉 远程 Docker Compose [${ROLE_DESC}] 部署完成!${NC}"
if [[ "${ROLES}" == *"server"* ]]; then
echo -e "👉 远程面板访问地址: ${CYAN}http://${REMOTE_IP}:8090${NC}"
@@ -382,7 +877,7 @@ elif [ "${ENV_MODE}" = "local" ] && [ "${BACKEND_MODE}" = "systemd" ]; then
fi
TARGET_USER="${DCTS_DAEMON_USER:-${SUDO_USER:-$USER}}"
[ "$TARGET_USER" = "root" ] && [ $(id -u fmq 2>/dev/null || echo 0) -ne 0 ] && TARGET_USER="fmq"
[ "$TARGET_USER" = "root" ] && [ "$(id -u fmq 2>/dev/null || echo 0)" -ne 0 ] && TARGET_USER="fmq"
REAL_USER="${TARGET_USER}"
if [ ! -f "${WORK_DIR}/.env" ]; then
@@ -457,15 +952,13 @@ EOF
# ─── 4.4 远程 + Systemd ───────────────────────────────────────────────────────
elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "systemd" ]; then
SSH_CONTROL_DIR="$HOME/.ssh/cm"
SSH_CONTROL_PATH="${SSH_CONTROL_DIR}/${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PORT}-$$"
SSH_OPTS="-o ControlMaster=auto -o ControlPath=${SSH_CONTROL_PATH} -o ControlPersist=1800"
mkdir -p "${SSH_CONTROL_DIR}"
cleanup_cm() { ssh -p "${REMOTE_PORT}" -o ControlPath="${SSH_CONTROL_PATH}" "${REMOTE_USER}@${REMOTE_IP}" -O exit 2>/dev/null || true; rm -f "${SSH_CONTROL_PATH}" 2>/dev/null || true; }
trap cleanup_cm EXIT
setup_ssh_control
echo "=== 1. 建立 SSH 主连接复用 (SSH ControlMaster) ==="
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} -fN "${REMOTE_USER}@${REMOTE_IP}" || true
is_win=$(check_remote_is_windows)
if [ "$is_win" = "true" ]; then
echo -e "${RED}错误: Systemd 服务不支持 Windows 远端目标,请改用 Docker Compose 部署 (-b compose)。${NC}"
exit 1
fi
echo "=== 2. 检查架构与准备二进制文件 [${ROLES}] ==="
REMOTE_ARCH=$(ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "uname -m 2>/dev/null || echo unknown")
@@ -484,6 +977,7 @@ elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "systemd" ]; then
echo "=== 3. 传输文件与静态资源 ==="
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "mkdir -p '${REMOTE_DIR}/scripts' '${REMOTE_DIR}/data' '${REMOTE_DIR}/logs'"
sync_remote_assets
sync_remote_workflows
for r in ${ROLES}; do
BIN_SRC="target/release/${r}"
@@ -493,10 +987,8 @@ elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "systemd" ]; then
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "mv -f '${REMOTE_DIR}/${r}.tmp' '${REMOTE_DIR}/${r}' && chmod +x '${REMOTE_DIR}/${r}'"
done
ENV_FILE=".env"
[ ! -f ".env" ] && ENV_FILE=".env.example"
scp -P "${REMOTE_PORT}" ${SSH_OPTS} "${ENV_FILE}" scripts/deploy.sh "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/scripts/"
[ "${ENV_FILE}" = ".env.example" ] && ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cp -n '${REMOTE_DIR}/scripts/.env.example' '${REMOTE_DIR}/.env' 2>/dev/null || true" || ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cp -n '${REMOTE_DIR}/scripts/.env' '${REMOTE_DIR}/.env' 2>/dev/null || true"
write_remote_runtime_env "$is_win"
scp -P "${REMOTE_PORT}" ${SSH_OPTS} scripts/deploy.sh "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/scripts/"
echo "=== 4. 在远端配置并启动 Systemd 服务 ==="
ssh -t -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && chmod +x scripts/deploy.sh && sudo ./scripts/deploy.sh -e local -b systemd -r ${ROLE_TARGET} install"
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# 一次性数据目录迁移:results→seeds、archive→result(目录重命名后的历史数据搬迁)。
#
# 背景:data 目录重新规划后,两个子目录改名:
# data/results/ → data/seeds/ server 种子库;原名 results 误导,实际只存种子)
# data/archive/ → data/result/ node 完整计算结果归档;原名 archive 不够准确)
# 本脚本把已落盘的旧目录搬到新位置,并修正 DB 的 seeds.file_path 审计字段。
#
# 关键事实(已确认):
# - download_seed 端点不读 seeds.file_path(从 seeds_dir 重拼路径),故 DB 迁移失败
# 不影响功能;但保持审计一致仍建议执行。
# - 迁移需在 server 停机时进行(mv 整目录 + 写 DB)。
#
# 用法:
# ./scripts/migrate_data_dirs.sh # dry-run,只看将做什么
# ./scripts/migrate_data_dirs.sh --apply # 实际执行
#
# 环境变量:
# DCTS_DATA_DIR 数据根目录(默认 data)
# DCTS_DB_PATH 主库路径(默认 data/dcts.db,相对 DCTS_DATA_DIR
set -euo pipefail
DATA_DIR="${DCTS_DATA_DIR:-data}"
DB_PATH="${DCTS_DB_PATH:-${DCTS_DATA_DIR:-data}/dcts.db}"
APPLY=0
if [[ "${1:-}" == "--apply" ]]; then
APPLY=1
fi
run_or_echo() {
# dry-run 时打印命令,--apply 时执行
if [[ $APPLY -eq 1 ]]; then
eval "$1"
else
echo " [dry-run] $1"
fi
}
echo "数据根目录: $DATA_DIR"
echo "主库路径: $DB_PATH"
echo "模式: $([[ $APPLY -eq 1 ]] && echo ' APPLY(实际执行)' || echo ' dry-run(只预览)')"
echo "------------------------------------------------"
moved=0
# 1. results → seeds
OLD_RESULTS="${DATA_DIR}/results"
NEW_SEEDS="${DATA_DIR}/seeds"
if [[ -d "$OLD_RESULTS" ]]; then
echo "[1/2] 发现旧目录 $OLD_RESULTS"
if [[ -d "$NEW_SEEDS" ]]; then
echo " ⚠️ 目标 $NEW_SEEDS 已存在!不会自动合并,请手动处理后重跑。"
else
run_or_echo "mv '$OLD_RESULTS' '$NEW_SEEDS'"
moved=$((moved + 1))
echo " → results 重命名为 seeds"
fi
else
echo "[1/2] 旧目录 $OLD_RESULTS 不存在,跳过"
fi
# 2. archive → result
OLD_ARCHIVE="${DATA_DIR}/archive"
NEW_RESULT="${DATA_DIR}/result"
if [[ -d "$OLD_ARCHIVE" ]]; then
echo "[2/2] 发现旧目录 $OLD_ARCHIVE"
if [[ -d "$NEW_RESULT" ]]; then
echo " ⚠️ 目标 $NEW_RESULT 已存在!不会自动合并,请手动处理后重跑。"
else
run_or_echo "mv '$OLD_ARCHIVE' '$NEW_RESULT'"
moved=$((moved + 1))
echo " → archive 重命名为 result"
fi
else
echo "[2/2] 旧目录 $OLD_ARCHIVE 不存在,跳过"
fi
# 3. DB seeds.file_path 审计字段修正(非功能性,但保持一致)
echo "------------------------------------------------"
if [[ -f "$DB_PATH" ]] && command -v sqlite3 >/dev/null 2>&1; then
# 统计待更新的行数(results→seeds
STMT_COUNT="SELECT COUNT(*) FROM seeds WHERE file_path LIKE '%/results/%';"
if [[ $APPLY -eq 1 ]]; then
cnt=$(sqlite3 "$DB_PATH" "$STMT_COUNT" 2>/dev/null || echo "0")
else
# dry-run:尝试只读,读不到就标"无法预览"
cnt=$(sqlite3 "file:${DB_PATH}?mode=ro" "$STMT_COUNT" 2>/dev/null || echo "?")
fi
echo "[3/3] DB seeds.file_path 含旧路径的行数: $cnt"
if [[ "$cnt" != "0" && "$cnt" != "?" ]]; then
# 把路径中的 /results/ 替换为 /seeds/
run_or_echo "sqlite3 \"$DB_PATH\" \"UPDATE seeds SET file_path = REPLACE(file_path, '/results/', '/seeds/') WHERE file_path LIKE '%/results/%';\""
echo " → 已修正 seeds.file_path 审计路径"
fi
else
echo "[3/3] 跳过 DB 迁移:$DB_PATH 不存在或无 sqlite3 命令"
echo " download_seed 不读此列,不影响功能;可后续手动执行 UPDATE)"
fi
echo "------------------------------------------------"
if [[ $APPLY -eq 1 ]]; then
echo "✅ 完成:迁移了 $moved 个目录"
else
echo "✅ dry-run 完成。加 --apply 实际执行。"
echo " ⚠️ 执行前请先停掉 server(mv 整目录 + 写 DB 需停机)。"
fi
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env bash
# ==============================================================================
# push_import_results.sh — 推送 import_results 工具到 Windows 计算节点并编译
# ==============================================================================
# 把 import_results 工具的源码推送到 deploy.env.d/<profile> 指定的 Windows 节点,
# 在该节点原生编译(Windows 上 cargo build),并打印确切的运行命令。
#
# 设计前提:
# - 旧版 Python run_grid.py 的结果在 Windows 机的 E:/fmq/grid(工具必须在该机运行才能读到)
# - 该机已部署 nodeDocker Compose),data 目录在 E:/fmq/dcts/data
# - DCTS 服务端地址来自 profile 的 DCTS_SERVER_URL
# - Windows 机需已装 Rust 工具链(cargo/rustc);脚本会自检并给出安装提示
#
# 用法:
# ./scripts/push_import_results.sh # 默认用 node-dckj-win-01.env
# ./scripts/push_import_results.sh -f deploy.env.d/other-node.env # 指定其他 profile
# ./scripts/push_import_results.sh --no-build # 只推源码不编译(远端已有缓存时加速)
# ./scripts/push_import_results.sh --archive-dir E:/fmq/dcts/data/result # 覆盖归档目录
#
# 运行(推完编译后,在 Windows 机上执行,或本脚本末尾会打印):
# E:\fmq\dcts-src\target\release\import_results.exe \
# -d E:/fmq/grid -c E:/fmq/dcts-src/workflows/sdB_cno.yaml \
# -r E:/fmq/dcts/data/result -s http://100.66.1.2:8090 \
# -w sdB_cno -t <admin_token>
# ==============================================================================
set -eo pipefail
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${WORK_DIR}"
# 默认值(可被 profile 覆盖)
PROFILE_FILE="deploy.env.d/node-dckj-win-01.env"
REMOTE_USER="fmq"
REMOTE_IP="192.168.6.102"
REMOTE_PORT="22"
REMOTE_SRC_DIR="E:/fmq/dcts-src"
DCTS_SERVER_URL="http://100.66.1.2:8090"
RESULT_DIR="" # 空 → 用 profile 推导(默认 E:/fmq/dcts/data/result
DO_BUILD=true
# SSH ControlMaster 复用目录(与 deploy.sh 一致,便于共享连接)
SSH_CONTROL_DIR="${SSH_CONTROL_DIR:-$HOME/.ssh/cm}"
# =============================================================================
# 解析命令行参数
# =============================================================================
while [[ $# -gt 0 ]]; do
case "$1" in
-f|--profile) PROFILE_FILE="$2"; shift 2 ;;
--no-build) DO_BUILD=false; shift ;;
--archive-dir) RESULT_DIR="$2"; shift 2 ;;
-h|--help)
sed -n '2,30p' "$0"
exit 0 ;;
*) echo -e "${RED}未知参数: $1${NC}"; exit 1 ;;
esac
done
# =============================================================================
# 加载 profile(SSH 连接信息 + 服务端地址)
# =============================================================================
if [ ! -f "$PROFILE_FILE" ]; then
echo -e "${RED}错误: 找不到 profile '${PROFILE_FILE}'${NC}"
exit 1
fi
echo -e "${CYAN}[i] 加载 profile: ${PROFILE_FILE}${NC}"
# shellcheck disable=SC1090
set -a; source "$PROFILE_FILE"; set +a
# profile 提供的变量优先(REMOTE_* / DCTS_SERVER_URL
[ -n "${REMOTE_USER:-}" ] && REMOTE_USER="${REMOTE_USER}"
[ -n "${REMOTE_IP:-}" ] && REMOTE_IP="${REMOTE_IP}"
[ -n "${REMOTE_PORT:-}" ] && REMOTE_PORT="${REMOTE_PORT}"
# 源码目录默认放到部署目录旁(E:/fmq/dcts-src),与运行中的 nodeE:/fmq/dcts)隔离
[ -n "${REMOTE_DIR:-}" ] && REMOTE_SRC_DIR="${REMOTE_DIR}-src"
# 结果目录:命令行 > 参数 > 默认(部署目录下的 data/resultnode 的 DCTS_RESULT_DIR 卷映射到此)
if [ -z "${RESULT_DIR}" ]; then
RESULT_DIR="${REMOTE_DIR}/data/result"
fi
echo -e "${CYAN}📢 目标节点: ${YELLOW}${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PORT}${NC}"
echo -e "${CYAN}📢 源码推送: ${YELLOW}${REMOTE_SRC_DIR}${NC}"
echo -e "${CYAN}📢 结果目录: ${YELLOW}${RESULT_DIR}${NC}"
echo -e "${CYAN}📢 服务端: ${YELLOW}${DCTS_SERVER_URL}${NC}"
echo ""
# =============================================================================
# 建立 SSH ControlMaster 连接复用(一次性输密码,后续 scp/ssh 复用)
# =============================================================================
mkdir -p "${SSH_CONTROL_DIR}"
SOCK="${SSH_CONTROL_DIR}/${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PORT}"
SSH_OPTS="-o ControlMaster=auto -o ControlPath=${SOCK} -o ControlPersist=1800 -o StrictHostKeyChecking=no"
if ssh -p "${REMOTE_PORT}" -o ControlPath="${SOCK}" -O check "${REMOTE_USER}@${REMOTE_IP}" >/dev/null 2>&1; then
echo -e "${GREEN}[√] 复用已有 SSH 连接${NC}"
else
echo -e "${CYAN}=== 建立 SSH 连接(请输入 ${REMOTE_USER}@${REMOTE_IP} 的密码)===${NC}"
if ! ssh -p "${REMOTE_PORT}" ${SSH_OPTS} -fN "${REMOTE_USER}@${REMOTE_IP}"; then
echo -e "${RED}SSH 连接失败${NC}"
exit 1
fi
echo -e "${GREEN}[√] SSH 连接已建立${NC}"
fi
trap 'ssh -p "${REMOTE_PORT}" -o ControlPath="${SOCK}" "${REMOTE_USER}@${REMOTE_IP}" -O exit 2>/dev/null || true' EXIT
# =============================================================================
# 自检远端 Rust 工具链
# =============================================================================
echo -e "\n${CYAN}=== 检查远端 Rust 工具链 ===${NC}"
HAS_CARGO=$(ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
"powershell -NoProfile -Command \"if (Get-Command cargo -ErrorAction SilentlyContinue) { 'yes' } else { 'no' }\"" 2>/dev/null | tr -d '[:space:]')
if [ "$HAS_CARGO" != "yes" ]; then
echo -e "${RED}[✗] 远端 ${REMOTE_IP} 未检测到 cargo/rustc${NC}"
echo -e "${YELLOW}请先在该 Windows 机上安装 Rust 工具链:${NC}"
echo -e " 方式1PowerShell,需联网): winget install Rustlang.Rustup"
echo -e " 方式2(浏览器): 访问 https://rustup.rs 下载 rustup-init.exe 并运行"
echo -e " 安装后重开 PowerShell 再跑本脚本。"
exit 1
fi
echo -e "${GREEN}[√] 远端已安装 Rust 工具链${NC}"
# =============================================================================
# 同步源码到远端(仅 git 跟踪文件,排除 target/data/assets 等大目录)
# =============================================================================
echo -e "\n${CYAN}=== 同步源码到 ${REMOTE_SRC_DIR} ===${NC}"
# 远端先建目录
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
"powershell -NoProfile -Command \"New-Item -ItemType Directory -Force -Path '${REMOTE_SRC_DIR}' | Out-Null\""
# 单文件 scp 辅助:失败时打印诊断并退出(不静默吞错)。
# Windows OpenSSH scp 对带正斜杠的远端路径需用 ':' 分隔主机,路径部分正斜杠 OK。
scp_one() {
local src="$1" dst="$2"
if ! scp -P "${REMOTE_PORT}" ${SSH_OPTS} "$src" "${REMOTE_USER}@${REMOTE_IP}:${dst}"; then
echo -e "${RED}[✗] 传输失败: ${src} -> ${dst}${NC}"
exit 1
fi
}
# 递归目录 scp:先建/清远端目标目录再传,避免 Windows scp -r 创建子目录失败。
scp_dir() {
local sub="$1"
[ -d "$sub" ] || return 0
# 远端先删除旧目录并重建空目录(确保 scp -r 能写入)
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
"powershell -NoProfile -Command \"Remove-Item -Recurse -Force '${REMOTE_SRC_DIR}/${sub}' -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Force -Path '${REMOTE_SRC_DIR}/${sub}' | Out-Null\"" || true
echo -e " -> ${sub}/"
if ! scp -P "${REMOTE_PORT}" ${SSH_OPTS} -r "$sub" "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_SRC_DIR}/"; then
echo -e "${RED}[✗] 传输目录失败: ${sub}${NC}"
echo -e "${YELLOW} 若反复失败,可在 Windows 机上手动 git clone 后仅同步增量,或检查磁盘空间/权限${NC}"
exit 1
fi
}
echo -e "${CYAN} -> 传输顶层文件 + crates/tools/workflows ...${NC}"
scp_one Cargo.toml "${REMOTE_SRC_DIR}/"
[ -f Cargo.lock ] && scp_one Cargo.lock "${REMOTE_SRC_DIR}/"
# import_results 依赖 common crate,二者都要同步;workflows 含配置(决定命名精度)。
scp_dir crates
scp_dir tools
scp_dir workflows
echo -e "${GREEN}[√] 源码同步完成${NC}"
# =============================================================================
# 远端编译 import_resultsrelease
# =============================================================================
if [ "$DO_BUILD" = true ]; then
echo -e "\n${CYAN}=== 远端编译 import_results (release) ===${NC}"
echo -e "${YELLOW} (首次编译会拉 crate 依赖,可能耗时数分钟,请耐心等待...)${NC}"
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
"powershell -NoProfile -Command \"Set-Location '${REMOTE_SRC_DIR}'; cargo build --release -p import_results 2>&1; \$LASTEXITCODE\"" 2>&1 | tee /tmp/import_build.log | tail -20
BUILD_RC=$(tail -1 /tmp/import_build.log | tr -d '[:space:]')
rm -f /tmp/import_build.log
if [ "$BUILD_RC" != "0" ]; then
echo -e "${RED}[✗] 远端编译失败 (exit=${BUILD_RC})${NC}"
exit 1
fi
echo -e "${GREEN}[√] 编译成功${NC}"
else
echo -e "${YELLOW}[i] 跳过编译(--no-build${NC}"
fi
# =============================================================================
# 在远端生成 run_import.batWindows 批处理),用户双击或在 cmd 里运行即可,无需手敲命令
# =============================================================================
EXE_PATH_WIN=$(echo "${REMOTE_SRC_DIR}/target/release/import_results.exe" | sed 's|/|\\|g')
CONFIG_PATH="${REMOTE_SRC_DIR}/workflows/sdB_cno.yaml"
WORK_DIR_WIN=$(echo "${REMOTE_SRC_DIR}" | sed 's|/|\\|g')
# .bat 内容:用 quoted heredoc 避免反引号/% 被 bash 解释,占位符再替换。
# .bat 用 set /p 交互读取 admin token(用户无需手动编辑文件),空回车则跳过入库只迁移 result。
BAT_CONTENT=$(cat <<'BATEOF'
@echo off
chcp 65001 >nul
setlocal
cd /d "__WORK_DIR__"
echo ============================================================
echo DCTS 历史计算结果导入工具
echo ============================================================
echo 旧数据目录 : E:\fmq\grid
echo workflow : __CONFIG__
echo 结果目录 : __RESULT__
echo 服务端 : __SERVER__
echo 工作流名 : sdB_cno
echo ============================================================
echo.
set /p ADMIN_TOKEN="请输入 Admin Token (DCTS_ADMIN_TOKEN,直接回车则只迁移 result 不入库): "
if "%ADMIN_TOKEN%"=="" (
echo [i] 未输入 token,仅迁移产物树到 result (入库会因无鉴权失败但不阻断迁移)
"__EXE__" -d E:/fmq/grid -c "__CONFIG__" -r "__RESULT__" -s __SERVER__ -w sdB_cno
) else (
"__EXE__" -d E:/fmq/grid -c "__CONFIG__" -r "__RESULT__" -s __SERVER__ -w sdB_cno -t "%ADMIN_TOKEN%"
)
echo.
echo ============================================================
echo 执行结束。按任意键退出...
pause >nul
BATEOF
)
BAT_CONTENT="${BAT_CONTENT//__WORK_DIR__/${WORK_DIR_WIN}}"
BAT_CONTENT="${BAT_CONTENT//__EXE__/${EXE_PATH_WIN}}"
BAT_CONTENT="${BAT_CONTENT//__CONFIG__/${CONFIG_PATH}}"
BAT_CONTENT="${BAT_CONTENT//__RESULT__/${RESULT_DIR}}"
BAT_CONTENT="${BAT_CONTENT//__SERVER__/${DCTS_SERVER_URL}}"
# 经 SSH stdin 管道写入远端 run_import.batUTF-8 with BOM 由 chcp 65001 处理中文)
BAT_REMOTE_PATH="${REMOTE_SRC_DIR}/run_import.bat"
echo -e "\n${CYAN}=== 在远端生成 run_import.bat ===${NC}"
printf '%s\r\n' "$BAT_CONTENT" | ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
"powershell -NoProfile -Command \"\$input = [Console]::In.ReadToEnd(); [System.IO.File]::WriteAllText('${BAT_REMOTE_PATH}', \$input, [System.Text.Encoding]::UTF8)\"" \
&& echo -e "${GREEN}[√] 已生成: ${BAT_REMOTE_PATH}${NC}" \
|| { echo -e "${RED}[✗] 生成 run_import.bat 失败${NC}"; exit 1; }
# =============================================================================
# 输出最终提示
# =============================================================================
echo ""
echo -e "${BLUE}==============================================================${NC}"
echo -e "${GREEN}🎉 推送 + 编译完成!${NC}"
echo -e "${BLUE}==============================================================${NC}"
echo ""
echo -e "${CYAN}在 Windows 机 ${REMOTE_IP} 上启动导入(任选其一):${NC}"
echo ""
echo -e " 方式1(推荐):双击运行 ${YELLOW}${WORK_DIR_WIN}\\run_import.bat${NC}"
echo -e " 会交互提示输入 Admin Token(回车则只迁移 result 不入库)"
echo ""
echo -e " 方式2cmd): cd /d ${WORK_DIR_WIN} 然后 run_import.bat"
echo ""
echo -e "${YELLOW}参数(已内置在 .bat 里,无需手填):${NC}"
echo -e " -d E:/fmq/grid 旧版 Python run_grid.py 的 results 根目录"
echo -e " -c ${CONFIG_PATH}"
echo -e " -r ${RESULT_DIR} 结果写入目标(node 容器卷映射处)"
echo -e " -s ${DCTS_SERVER_URL} 服务端地址(来自 profile)"
echo -e " -w sdB_cno 工作流名(必须等于将来要启动的工作流名)"
echo ""
echo -e "${YELLOW}⚠️ 注意:${NC}"
echo -e " - -w sdB_cno 必须匹配将来在 Dashboard 创建/启动的工作流名,否则 converged 标记被隔离看不到、导致重算"
echo -e " - 工具可重复运行(DB 幂等 + result 原子覆盖),网络中断后重跑 run_import.bat 即可补齐"
echo ""
@@ -1,5 +1,5 @@
[package]
name = "sync_seeds"
name = "import_results"
version = "0.1.0"
edition = "2021"
@@ -11,6 +11,6 @@ tracing-subscriber = { workspace = true }
clap = { workspace = true }
anyhow = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
regex = { workspace = true }
+109
View File
@@ -0,0 +1,109 @@
# import_results
> DCTS 历史计算结果导入工具。把旧版 Python 单机 `run_grid.py`/`run_one.py` 的计算结果
> 完整迁移到当前分布式系统,**避免重算已完成的网格点**。
---
## 📦 模块概览
`import_results` 承担**双职责**
1. **入库 + 种子库**:经服务端 `/api/admin/import_seed` 把旧版的收敛结果(`conv.json` + `.7`
大气文件)上传,把 `grid_points` 标记为 `converged`。这样启动工作流时这些点会被跳过,
不再重复计算。
2. **完整产物树迁移**:把旧目录的全部产物文件,按**新版 node result 格式 + workflow 配置
的小数精度命名**,拷贝到本地 `data/result/<name>/`。docker-compose 里 server/node 都挂载
`./data:/app/data`node 的 `DCTS_RESULT_DIR=/app/data/result`,故工具在服务器宿主机
运行即可被 node 识别为已有结果归档。
**命名以 workflow 配置为准**`GridConfig::from_yaml_str` 保留 YAML 源小数精度(如
`logg: 5.0``g5.0`),而非旧 `conv.json` 里的 name。
---
## 🚀 编译
```bash
cargo build -p import_results --release
```
## 使用示例
```bash
# 完整导入(产物树 + 入库)
./target/release/import_results \
--dir /旧数据/results \
--config workflows/sdB_cno.yaml \
--result-dir data/result \
--server http://127.0.0.1:8090 \
--workflow sdB_cno \
--token <admin_token>
```
### 参数说明
| 参数 | 说明 |
|---|---|
| `-d/--dir <dir>` | 旧版 `run_grid.py` 的 results 根目录(默认 `data/results` |
| `-c/--config <yaml>` | **必填**。workflow 配置,用于推导权威命名 + 校验网格点 |
| `-r/--result-dir <dir>` | 产物树写入目标(默认 `data/result`,与 node 的 DCTS_RESULT_DIR 同卷)。旧长名 `--archive-dir` 仍兼容(仅长选项) |
| `-s/--server <url>` | 服务端 API 地址(默认 `http://127.0.0.1:8090` |
| `-w/--workflow <name>` | **必填**。目标工作流名,**必须等于将来要启动的工作流名** |
| `-t/--token <token>` | 服务端 Admin 鉴权令牌 |
---
## ⚠️ 关键注意事项
### 1. `-w` 工作流名必须匹配将来要启动的工作流
`grid_points` 唯一键是复合 `(workflow_name, name)`,工作流强隔离。若导入到工作流 `A`
之后启动工作流 `B``B` 看不到 `A` 的 converged 标记,会**重算全部已算过的点**。
正确顺序:
```bash
# 1. 先导入(-w = 目标工作流名)
./import_results -d /旧数据/results -c workflows/sdB_cno.yaml -w sdB_cno ...
# 2. 在 Dashboard 用同名配置创建工作流 sdB_cno
# 3. 启动 sdB_cno
# → initialize_grid 对已导入点 DO NOTHING(保留 converged,跳过重算)
# → 对配置里有但旧数据没有的新点,插入 pending(正常计算)
```
### 2. 文件名映射规则(旧 → 新 result 格式)
| 旧文件 | 新文件 |
|---|---|
| `conv.json` | `conv.json` |
| `<old>.7/.spec/.cont/.iden/.log` | `<new>.7/.spec/.cont/.iden/.log` |
| `<old>.lte.7/.nc.7/.nl.7` | `<new>.lte.7/.nc.7/.nl.7` |
| `<old>.nc_chmax0p001_itekNone.9` | `<new>.nc_chmax0.001.9` |
| `<old>.5/.6`(旧版只留最后阶段) | `<new>.nl.5/.nl.6` |
| `fort.8` | `fort.8` |
**跳过**:迁移后的文件名统一经结果白名单(`common::result_filter::is_result_worthy`
与 node 实时结果归档共用同一份判别规则)复核。不在白名单内的一律跳过,包括:
- 软链(`data`/`fort.19`
- `fort.84``*.tmp`
- 新版已清理的裸文件(`nst`/`fort.9`/`fort.12`/`fort.17`,内容已被 `.iden/.cont/_chmax.9` 覆盖)
- Tlusty 中间工作单元(`fort.1/2/3/13/14/18/22/42/44/50/57/69/82/95` 等,无语义价值)
- 无 `_chmax``.9` 阶段快照(与 `<name>.<label>_chmax*.9` 内容重复,唯一保留 `_chmax.9`
旧版因「每阶段覆盖」丢失的中间阶段 `.5/.6/.err/.nst`(只留最后 nl 阶段)无法重建,
归到 `.nl.` 标签下。
### 3. 导入数据的字段去向
| 旧 conv.json 字段 | 落库位置 | 展示 |
|---|---|---|
| `name`(被工具重写为配置权威名) | `grid_points.name` | 点表/详情 |
| `converged && !atmosphere_has_nan` | `grid_points.status='converged'` | 状态徽章 |
| —(固定值) | `grid_points.success_method='imported'` | 方法徽章"导入" |
| `final_max_relc` | 种子库记录 + conv.json 原文 | 点详情 |
| `elapsed_sec` | `grid_points.last_elapsed_sec` | 点表"耗时"列 |
| 全文(含 `stages[].itek_attempts/final` 嵌套诊断) | `seeds_dir/<name>/conv.json` 原文 | 点详情阶段链(扁平字段兼容解析,嵌套诊断原文保留不丢失) |
旧版 stage 的迭代诊断(`last_iter` 等)嵌在 `final`/`itek_attempts` 里而非扁平字段,
详情页阶段链不显示迭代数,但原始数据随 conv.json 原文完整保留。
+958
View File
@@ -0,0 +1,958 @@
use anyhow::{Context, Result};
use clap::Parser;
use common::config::GridConfig;
use common::conv_check::atmosphere_has_nan;
use common::models::{GridPointParams, ModelSummary};
use common::result_filter::is_result_worthy;
use regex::Regex;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
/// DCTS 历史计算结果导入工具。
///
/// 双职责:
/// 1. **入库 + 种子库**:经 `/api/admin/import_seed` 把旧版 `run_grid.py` 的收敛结果
/// `conv.json` + `.7` 大气)上传服务端,标记 grid_points 为 converged,避免重算。
/// 2. **完整产物树迁移**:把旧目录的全部产物按**新版 node result 格式 + workflow 配置
/// 的小数精度命名**,拷贝到本地 `data/result/<name>/`(与 node 的 `DCTS_RESULT_DIR`
/// 同卷,docker-compose 里 server/node 都挂载 `./data:/app/data`)。
///
/// 命名以 **workflow 配置为准**`GridConfig::from_yaml_str` 保留源小数,如 `logg: 5.0`
/// → `g5.0`),而非旧 conv.json 里的 name —— 配置是当前项目权威。
#[derive(Parser, Debug)]
#[command(
author,
version,
about = "DCTS 历史计算结果导入工具(旧版 run_grid.py 产物 → 标记为已完成 + 迁移产物树)"
)]
struct Args {
/// 存放历史计算结果的目录路径(旧版 run_grid.py 的 results 根目录)
#[arg(short, long, default_value = "data/results")]
dir: PathBuf,
/// workflow 配置文件路径(YAML)。用于推导权威命名(保留小数精度)+ 校验网格点。
/// 命名以配置为准,而非旧 conv.json 的 name。
#[arg(short, long)]
config: PathBuf,
/// 产物树写入目标目录(默认: data/result,与 node 的 DCTS_RESULT_DIR 同卷)。
/// 旧长名 --archive-dir 作为别名保留(仅长选项,无 -a 短选项),向后兼容。
#[arg(
short = 'r',
long = "result-dir",
visible_alias = "archive-dir",
default_value = "data/result"
)]
result_dir: PathBuf,
/// Master 服务端 API 地址(默认: http://127.0.0.1:8090)。
/// 若服务端未运行或不想入库,可指向不可达地址,工具会继续完成本地产物树迁移。
#[arg(short, long, default_value = "http://127.0.0.1:8090")]
server: String,
/// 目标工作流名称:grid_points 导入到该工作流分区。
/// **必须等于将来要启动的工作流名**,否则导入的 converged 标记会被工作流强隔离
/// 看不到,导致重算(grid_points 唯一键是复合 (workflow_name, name))。
#[arg(short, long)]
workflow: String,
/// 服务端 Admin 鉴权令牌(/api/admin/import_seed 需 Admin 角色)。
#[arg(short, long)]
token: Option<String>,
}
/// 一个经校验的待导入历史结果点。
#[derive(Debug)]
struct CandidateResult {
/// 旧 conv.json 中的 name(旧 Python 命名,仅用于定位旧目录文件)。
legacy_name: String,
/// 旧版 conv.json 原文:服务端解析出 params / 收敛状态 / final_max_relc。
summary_json: String,
/// 对应的 .7 大气种子文件路径。
seed_path: PathBuf,
/// 旧结果所在子目录(产物树迁移的源)。
src_dir: PathBuf,
/// 权威新名(来自 workflow 配置的小数精度命名)。
canonical_name: String,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let args = Args::parse();
info!("=== DCTS 历史计算结果导入工具启动 ===");
info!("扫描结果目录: {}", args.dir.display());
info!("workflow 配置: {}", args.config.display());
info!("产物树写入: {}", args.result_dir.display());
info!("目标工作流: {}", args.workflow);
info!("目标服务端: {}", args.server);
if !args.dir.is_dir() {
anyhow::bail!("指定的结果目录不存在或不是文件夹: {}", args.dir.display());
}
// 1. 解析 workflow 配置,构建「旧名 → 权威新名」映射(保留小数精度)。
let config_yaml = std::fs::read_to_string(&args.config)
.with_context(|| format!("读取 workflow 配置失败: {}", args.config.display()))?;
let grid_cfg = GridConfig::from_yaml_str(&config_yaml)
.with_context(|| format!("解析 workflow 配置失败: {}", args.config.display()))?;
let name_map = build_canonical_name_map(&grid_cfg);
info!(
"workflow 配置含 {} 个网格点,命名保留源小数精度",
name_map.len()
);
// 收敛链最后阶段标签:用于把旧版「每阶段覆盖、只留最后阶段」的 .5/.6 归到正确标签。
// 从配置 chain 取末段;chain 为空则回退到默认收敛链的末段(都是 nl)。
let last_stage_label = derive_last_stage_label(&grid_cfg);
info!(
"收敛链最后阶段标签: {}(用于 .5/.6 归档)",
last_stage_label
);
// 2. 扫描旧版结果目录,匹配到配置里的网格点。
let candidates = scan_dir_for_results(&args.dir, &name_map).await?;
info!(
"扫描完成,共找到 {} 个经校验无 NaN 且物理收敛、且命中配置网格点的合格结果!",
candidates.len()
);
if candidates.is_empty() {
info!("未查找到符合条件的合格结果。");
return Ok(());
}
// 3. 先迁移本地完整产物树(新版 result 格式 + 配置命名)。
info!("=== 启动本地产物树迁移(→ result 格式)===");
let mut migrated = 0usize;
for c in &candidates {
migrate_result_tree(
&args.result_dir,
&c.src_dir,
&c.legacy_name,
&c.canonical_name,
&last_stage_label,
&c.summary_json,
)
.await;
migrated += 1;
}
info!("产物树迁移完成:{}/{}", migrated, candidates.len());
// 4. 经 /api/admin/import_seed 批量入库(标记 converged,避免重算)。
info!("=== 启动 HTTP 结果入库(→ grid_points=converged===");
let ingested = upload_results_to_server(
&args.server,
&args.workflow,
&candidates,
args.token.as_deref(),
)
.await?;
// 软一致性提示:result 树已迁移但 DB 入库失败时,提示重跑(DB 幂等,重跑会补齐)。
if ingested < migrated {
warn!(
"产物树已迁移 {} 个,但仅 {} 个成功入库 DB。未入库的 {} 个点 result 已有文件但 grid_points 未标记 converged\
/DB ",
migrated, ingested, migrated - ingested
);
}
info!("=== 导入全量完成(迁移 {migrated},入库 {ingested}===");
Ok(())
}
/// 从 workflow 配置展开全部网格点,构建「参数数值指纹 → 权威名」映射。
///
/// 权威名取自 `GridPointParams::model_name()`,该方法拼接各轴的 `.text()`(源精度原文),
/// 故 `logg: 5.0` → `g5.0`。键用 6 个数值四舍五入到 1e-6 后拼成的字符串指纹(f64 不实现
/// Eq/Hash,故转字符串),以便和旧 conv.json 里的数值 params 匹配(旧 params 无源文本,
/// 只能比数值)。
fn build_canonical_name_map(cfg: &GridConfig) -> HashMap<String, String> {
let mut map = HashMap::new();
for teff in &cfg.grid.teff {
for logg in &cfg.grid.logg {
for loghe in &cfg.grid.loghe {
for logc in &cfg.grid.logc {
for logn in &cfg.grid.logn {
for logo in &cfg.grid.logo {
let p = GridPointParams {
teff: teff.clone(),
logg: logg.clone(),
loghe: loghe.clone(),
logc: logc.clone(),
logn: logn.clone(),
logo: logo.clone(),
};
let key = param_fingerprint(&[
teff.value(),
logg.value(),
loghe.value(),
logc.value(),
logn.value(),
logo.value(),
]);
map.insert(key, p.model_name());
}
}
}
}
}
}
map
}
/// 把 6 个 f64 四舍五入到 1e-6,拼成字符串指纹作为 map 键(消除浮点尾差,绕过 f64 无 Hash)。
fn param_fingerprint(k: &[f64; 6]) -> String {
let mut s = String::with_capacity(64);
for v in k {
let mut r = (v * 1e6).round() / 1e6;
// 归一化 -0.0 → 0.0:否则 format!("{}", -0.0)="-0" 与 "0" 不匹配,导致静默跳点。
if r == 0.0 {
r = 0.0;
}
s.push_str(&format!("{}|", r));
}
s
}
/// 从 workflow 配置派生收敛链最后阶段标签。
///
/// 旧版 run_one.py 每阶段覆盖同名 .5/.6/.err/nst,只留最后阶段的版本。迁移时需把这些
/// 裸文件归到正确的阶段标签下。标签取自配置 chain 的末段;chain 为空则回退到默认收敛链
/// 的末段(default_cold_chain 末段是 nl)。
fn derive_last_stage_label(cfg: &GridConfig) -> String {
if let Some(last) = cfg.chain.last() {
return last.label.clone();
}
// chain 为空(仅 grid,无 chain 配置):回退到默认收敛链末段。
common::runner::default_cold_chain()
.last()
.map(|s| s.label.clone())
.unwrap_or_else(|| "nl".to_string())
}
/// 扫描旧版结果目录,匹配到 workflow 配置里的网格点。
///
/// 跳过 `.OLD`/`.FAILED`/`.coldfail` 备份目录;要求 conv.json 存在且
/// `converged && !atmosphere_has_nan`,并对 .7 文件二次 NaN 校验。
/// 仅保留能匹配到配置网格点(按 params 数值)的结果 —— 配置外的点不入库。
async fn scan_dir_for_results(
dir_path: &Path,
name_map: &HashMap<String, String>,
) -> Result<Vec<CandidateResult>> {
let mut results = Vec::new();
let entries = std::fs::read_dir(dir_path)?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.')
|| name.contains(".OLD")
|| name.contains(".FAILED")
|| name.contains(".coldfail")
{
continue;
}
let sub_path = entry.path();
if !sub_path.is_dir() {
continue;
}
let conv_json = sub_path.join("conv.json");
// .7 候选:旧版 run_one.py 写 <name>.7;也兼容 fort.7 / 阶段快照。
let candidates_7 = [
sub_path.join(format!("{}.7", name)),
sub_path.join(format!("{}.nl.7", name)),
sub_path.join(format!("{}.nc.7", name)),
sub_path.join("fort.7"),
];
let atmo_7 = candidates_7.into_iter().find(|p| p.is_file());
if !conv_json.is_file() {
continue;
}
let Some(seed_file) = atmo_7 else {
continue;
};
let content = match std::fs::read_to_string(&conv_json) {
Ok(c) => c,
Err(e) => {
warn!("目录 {} 的 conv.json 读取失败,跳过: {}", name, e);
continue;
}
};
let summary = match serde_json::from_str::<ModelSummary>(&content) {
Ok(s) => s,
Err(e) => {
warn!(
"目录 {} 的 conv.json 解析失败(非合法 ModelSummary),跳过: {}",
name, e
);
continue;
}
};
if !(summary.converged && !summary.atmosphere_has_nan && !atmosphere_has_nan(&seed_file)) {
continue;
}
// 诊断:目录名应与 conv.json.name 一致(旧 run_one.py 用同一 model_name 命名两者)。
// 若不一致,map_filename 用 summary.name 做前缀匹配会找不到以目录名命名的文件,导致迁移丢文件。
if name != summary.name {
warn!(
"目录名 {} 与 conv.json.name ({}) 不一致:迁移时以 conv.json.name 为准定位文件,\
",
name, summary.name
);
}
// 用旧 conv.json 的数值 params 匹配配置网格点,拿到权威新名。
let key = param_fingerprint(&[
*summary.params.teff,
*summary.params.logg,
*summary.params.loghe,
*summary.params.logc,
*summary.params.logn,
*summary.params.logo,
]);
let Some(canonical_name) = name_map.get(&key) else {
warn!(
"网格点 {} 的参数不在 workflow 配置中,跳过(teff={} logg={} ...",
summary.name, *summary.params.teff, *summary.params.logg,
);
continue;
};
results.push(CandidateResult {
legacy_name: summary.name.clone(),
summary_json: content,
seed_path: seed_file,
src_dir: sub_path,
canonical_name: canonical_name.clone(),
});
}
Ok(results)
}
/// 把旧版结果目录的产物按**新版 node result 格式**拷贝到 `result_dir/<new_name>/`。
///
/// 文件名映射规则(旧 → 新):
/// - `conv.json` → `conv.json`**内容重写 name 为 canonical_name**,与目录名一致)
/// - `<old>.7/.spec/.cont/.iden/.log` → `<new>.7/.spec/.cont/.iden/.log`(前缀重命名)
/// - `<old>.<label>.7`lte/nc/nl 阶段大气快照)→ `<new>.<label>.7`(前缀重命名)
/// - `<old>.<label>_chmaxX_itekY.9` → `<new>.<label>_chmaxX.9`(正则重命名:
/// 去 itek 段;旧版 `stagename.replace(".","p")` 里的 `p` 还原为 `.`
/// - `<old>.5/.6`(旧版每阶段覆盖、只留最后阶段)→ `<new>.<last_stage>.5/.6`
/// (补最后阶段标签,从配置 chain 派生)
/// - `fort.8`synspec 输入大气)→ `fort.8`(直接拷,保留独立语义)
///
/// 跳过:软链(data/fort.19)、fort.84、*.tmp、以及新版已清理的裸文件
/// nst/fort.9/fort.12/fort.17 —— 其内容已被 .iden/.cont 覆盖)。
///
/// 原子写入(tmp→rename),IO 错误降级 warn 不阻断。
async fn migrate_result_tree(
result_dir: &Path,
src_dir: &Path,
legacy_name: &str,
canonical_name: &str,
last_stage_label: &str,
summary_json: &str,
) {
let dest_dir = result_dir.join(canonical_name);
if let Err(e) = tokio::fs::create_dir_all(&dest_dir).await {
warn!(
"产物树迁移 {} 失败:创建归档目录 {} 失败: {}",
canonical_name,
dest_dir.display(),
e
);
return;
}
let mut rd = match tokio::fs::read_dir(src_dir).await {
Ok(rd) => rd,
Err(e) => {
warn!(
"产物树迁移 {} 失败:读取源目录 {} 失败: {}",
canonical_name,
src_dir.display(),
e
);
return;
}
};
// 匹配旧版 .9 收敛快照:<rest>_chmax<chmax_raw>_itek<itek>.9
// 旧版 stagename = "{label}_chmax{eff_chmax}_itek{it}",再 .replace(".","p")
// 故 eff_chmax 的小数点变 p(如 0.001→0p001),但科学计数法(1e-05)无小数点则原样保留。
// 用宽泛的 [^_]+ 捕获 chmax 段(含 p 还原与科学计数法两种形态),重写时把 p 还原为 .。
let re_chmax9 =
Regex::new(r"(?P<rest>.+?)_chmax(?P<chmax>[^_]+)_itek[^.]*\.9$").expect("正则编译失败");
let mut copied = 0usize;
let mut skipped = 0usize;
while let Ok(Some(entry)) = rd.next_entry().await {
let path = entry.path();
let Some(fname) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
// 跳过软链(data/fort.19 指向共享 runtime 资源,归档无意义)。
if tokio::fs::symlink_metadata(&path)
.await
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
skipped += 1;
continue;
}
if !path.is_file() {
continue;
}
// 推导新文件名(旧格式 → 新格式)。
let new_fname = map_filename(
fname,
legacy_name,
canonical_name,
&re_chmax9,
last_stage_label,
);
let Some(new_fname) = new_fname else {
warn!(
"产物树迁移 {}:跳过无法映射的文件: {}",
canonical_name, fname
);
skipped += 1;
continue;
};
// 白名单复核(与 node 实时归档口径一致):映射后的文件名必须落在归档白名单内。
// 这会统一过滤 .tmp/fort.84/nst/fort.9/12/17(新版已清理的裸文件)、以及所有
// Tlusty 中间单元(fort.1/13/18/22 等)、无 _chmax 的重复 .9 快照。旧 Python
// 目录通常无这些中间单元,但白名单兜底保证与 node 归档产物树逐文件一致。
if !is_result_worthy(&new_fname, canonical_name) {
warn!(
"产物树迁移 {}:跳过非白名单文件: {} → {}",
canonical_name, fname, new_fname
);
skipped += 1;
continue;
}
let dest_path = dest_dir.join(&new_fname);
// 原子写入:先拷到 .migrate.tmp.<uuid> 再 rename。
let tmp_path = dest_dir.join(format!(
"{}.migrate.tmp.{}",
new_fname,
uuid::Uuid::new_v4().simple()
));
// conv.json 特殊处理:内容里的 name 重写为 canonical_name(与目录名一致)。
let write_result = if fname == "conv.json" {
match rewrite_summary_name(summary_json, canonical_name) {
Ok(rewritten) => tokio::fs::write(&tmp_path, rewritten).await,
Err(e) => {
warn!(
"产物树迁移 {}:重写 conv.json name 失败,原样拷贝: {}",
canonical_name, e
);
tokio::fs::copy(&path, &tmp_path).await.map(|_| ())
}
}
} else {
tokio::fs::copy(&path, &tmp_path).await.map(|_| ())
};
match write_result {
Ok(_) => {
if let Err(e) = tokio::fs::rename(&tmp_path, &dest_path).await {
let _ = tokio::fs::remove_file(&tmp_path).await;
warn!(
"产物树迁移 {}:文件 {} rename 失败: {}",
canonical_name, new_fname, e
);
continue;
}
copied += 1;
}
Err(e) => {
let _ = tokio::fs::remove_file(&tmp_path).await;
warn!("产物树迁移 {}:拷贝 {} 失败: {}", canonical_name, fname, e);
}
}
}
info!(
"产物树迁移 {}:拷贝 {} 个文件,跳过 {} 个",
canonical_name, copied, skipped
);
}
/// 把旧文件名映射为新版 result 文件名。返回 None 表示该文件不迁移。
///
/// 规则(按优先级):
/// 1. `conv.json` / `fort.8` → 原名保留(conv.json 的内容重写在 migrate_result_tree 里做)。
/// 2. 旧版 .9 收敛快照 `<old>.<label>_chmaxX_itekY.9` → `<new>.<label>_chmaxX.9`
/// X 里的 `p` 还原为 `.`;去 itek 段)。
/// 3. `<old>.5` / `<old>.6`(旧版只留最后阶段)→ `<new>.<last_stage>.5` / `.<last_stage>.6`。
/// 4. `<old>.<rest>` → `<new>.<rest>`(前缀重命名,覆盖 .7/.spec/.cont/.iden/.log/.lte.7 等)。
/// 5. 其它(不以 legacy_name 开头的非保留名)→ None。
fn map_filename(
fname: &str,
legacy_name: &str,
canonical_name: &str,
re_chmax9: &Regex,
last_stage: &str,
) -> Option<String> {
// 1. 保留名(无前缀,直接保留)。
if fname == "conv.json" || fname == "fort.8" {
return Some(fname.to_string());
}
// 2. 旧版 .9 收敛快照重命名(优先于通用前缀规则,因为正则更具体)。
// rest 形如 "<old>.<label>"chmax 段是旧版 stagename.replace(".","p") 后的形态。
if let Some(caps) = re_chmax9.captures(fname) {
let rest = caps.name("rest").unwrap().as_str();
let chmax_raw = caps.name("chmax").unwrap().as_str();
// rest 前缀换成 canonical_name。
let new_rest = replace_legacy_prefix(rest, legacy_name, canonical_name);
// 若 rest 不以 legacy_name 开头(异常),跳过。
if new_rest == rest && !rest.starts_with(canonical_name) {
return None;
}
// chmax 段:旧版把小数点替换成了 p(0.001→0p001),这里还原。
// 科学计数法(1e-05)无小数点则不含 p,原样保留。
let chmax_restored = chmax_raw.replace('p', ".");
return Some(format!("{}_chmax{}.9", new_rest, chmax_restored));
}
// 3. 旧版只留最后阶段的 .5/.6(裸文件,无阶段标签)→ 补最后阶段标签。
if fname == format!("{}.5", legacy_name) {
return Some(format!("{}.{}.5", canonical_name, last_stage));
}
if fname == format!("{}.6", legacy_name) {
return Some(format!("{}.{}.6", canonical_name, last_stage));
}
// 4. 通用前缀重命名:<old>.<rest> → <new>.<rest>。
let prefix = format!("{}.", legacy_name);
if let Some(rest) = fname.strip_prefix(&prefix) {
return Some(format!("{}.{}", canonical_name, rest));
}
// 5. 其它文件(不以 legacy_name 开头)→ 不迁移。
None
}
/// 若 s 形如 "<legacy>.<suffix>",把前缀换成 canonical;否则原样返回。
fn replace_legacy_prefix(s: &str, legacy: &str, canonical: &str) -> String {
let p = format!("{}.", legacy);
if let Some(rest) = s.strip_prefix(&p) {
format!("{}.{}", canonical, rest)
} else {
s.to_string()
}
}
/// 经 `/api/admin/import_seed` 逐个上传历史结果入库。
///
/// **关键**:上传前把 conv.json 里的 `name` 字段重写为配置权威名(canonical_name)。
/// 原因:服务端 `import_seed` 用 `summary.name` 落库(task.rs:306),而调度器
/// `initialize_grid` 用配置 `model_name()` 插入新行 —— 两者必须逐字符一致,否则
/// 复合唯一键 `(workflow_name, name)` 不匹配,导入的 converged 行会被孤立、该点被重算。
/// 旧版 conv.json 的 name 可能与配置精度不一致(如旧 `g5` vs 配置 `g5.0`),故必须重写。
/// 返回成功入库的点数(供调用方与已迁移的 result 点数比对,发现软不一致)。
async fn upload_results_to_server(
server_url: &str,
workflow: &str,
candidates: &[CandidateResult],
token: Option<&str>,
) -> Result<usize> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?;
// 注意路径必须带 /api 前缀:服务端把 report_router(含 import_seed)合并进 api_router
// 后统一 nest 在 /api 下(见 crates/server/src/main.rs)。
let import_url = format!(
"{}/api/admin/import_seed?workflow={}",
server_url.trim_end_matches('/'),
urlencoding(workflow)
);
let total = candidates.len();
let mut success_count = 0;
for (idx, c) in candidates.iter().enumerate() {
info!(
"正在入库 [{}/{}] 网格点: {} (旧名: {}, 路径: {})...",
idx + 1,
total,
c.canonical_name,
c.legacy_name,
c.seed_path.display()
);
let seed_bytes = match tokio::fs::read(&c.seed_path).await {
Ok(b) => b,
Err(e) => {
warn!("读取 .7 大气文件失败,跳过入库: {}", e);
continue;
}
};
// report 段:重写 name 为配置权威名后发送。服务端用此 name 落库 grid_points
// 必须与调度器 initialize_grid 的 model_name() 一致,否则复合唯一键不匹配。
let report_json = match rewrite_summary_name(&c.summary_json, &c.canonical_name) {
Ok(s) => s,
Err(e) => {
warn!(
"网格点 {} 重写 conv.json name 失败,跳过入库: {}",
c.canonical_name, e
);
continue;
}
};
let form = Form::new()
.part(
"report",
Part::text(report_json).mime_str("application/json")?,
)
.part(
"seed_file",
Part::bytes(seed_bytes)
.file_name(format!("{}.7", c.canonical_name))
.mime_str("application/octet-stream")?,
);
let mut req = client.post(&import_url);
if let Some(t) = token {
req = req.header("Authorization", format!("Bearer {}", t));
}
match req.multipart(form).send().await {
Ok(resp) if resp.status().is_success() => {
success_count += 1;
info!("网格点 {} 入库成功!", c.canonical_name);
}
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
warn!(
"网格点 {} 入库失败,服务端响应 HTTP {}: {}",
c.canonical_name, status, body
);
}
Err(e) => {
warn!("网格点 {} 网络上传失败: {}", c.canonical_name, e);
}
}
}
info!(
"成功将 {}/{} 个结果入库至服务端工作流 {}!",
success_count, total, workflow
);
Ok(success_count)
}
/// 极简的 query 参数百分号编码(仅编码工作流名里非保留字符)。
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
/// 把 conv.json 文本里的 `name` 字段重写为配置权威名。
///
/// 服务端 `import_seed` 用 `summary.name` 落库 grid_points;为使其与调度器
/// `initialize_grid` 的 `model_name()` 逐字符一致,必须用配置权威名覆盖旧 name。
/// 解析为 `serde_json::Value` 后仅替换 name,其余字段(params/stages/收敛状态等)原样保留。
fn rewrite_summary_name(summary_json: &str, canonical_name: &str) -> Result<String> {
let mut val: serde_json::Value =
serde_json::from_str(summary_json).context("解析旧 conv.json 失败")?;
let obj = val
.as_object_mut()
.context("旧 conv.json 顶层不是 JSON 对象,无法重写 name")?;
obj.insert(
"name".to_string(),
serde_json::Value::String(canonical_name.to_string()),
);
Ok(serde_json::to_string(&val)?)
}
#[cfg(test)]
mod tests {
use super::*;
/// 测试用宽泛 .9 正则(与 migrate_result_tree 内的编译正则一致)。
fn test_re() -> Regex {
Regex::new(r"(?P<rest>.+?)_chmax(?P<chmax>[^_]+)_itek[^.]*\.9$").unwrap()
}
#[test]
fn test_map_filename_basic() {
let re = test_re();
let old = "t20000_g5.0_he-2_c-4_n-4_o-4";
let new = "t20000_g5.0_he-2_c-4_n-4_o-4";
// conv.json / fort.8 保留
assert_eq!(
map_filename("conv.json", old, new, &re, "nl"),
Some("conv.json".into())
);
assert_eq!(
map_filename("fort.8", old, new, &re, "nl"),
Some("fort.8".into())
);
// 科学产物前缀重命名
assert_eq!(
map_filename(&format!("{}.7", old), old, new, &re, "nl"),
Some(format!("{}.7", new))
);
assert_eq!(
map_filename(&format!("{}.spec", old), old, new, &re, "nl"),
Some(format!("{}.spec", new))
);
assert_eq!(
map_filename(&format!("{}.cont", old), old, new, &re, "nl"),
Some(format!("{}.cont", new))
);
// 阶段 .7 快照前缀重命名
assert_eq!(
map_filename(&format!("{}.lte.7", old), old, new, &re, "nl"),
Some(format!("{}.lte.7", new))
);
assert_eq!(
map_filename(&format!("{}.nc.7", old), old, new, &re, "nl"),
Some(format!("{}.nc.7", new))
);
// .9 收敛快照正则重命名(默认 chmax=0.001 → 旧版 0p001 → 还原 0.001
let old9 = format!("{}.nc_chmax0p001_itekNone.9", old);
assert_eq!(
map_filename(&old9, old, new, &re, "nl"),
Some(format!("{}.nc_chmax0.001.9", new))
);
let old9b = format!("{}.nl_chmax0p001_itekNone.9", old);
assert_eq!(
map_filename(&old9b, old, new, &re, "nl"),
Some(format!("{}.nl_chmax0.001.9", new))
);
// 旧版最后阶段 .5/.6 补 nl 标签
assert_eq!(
map_filename(&format!("{}.5", old), old, new, &re, "nl"),
Some(format!("{}.nl.5", new))
);
assert_eq!(
map_filename(&format!("{}.6", old), old, new, &re, "nl"),
Some(format!("{}.nl.6", new))
);
}
#[test]
fn test_map_filename_chmax_variants() {
// 覆盖非默认 chmax 形态(旧版 stagename.replace(".","p") 后的各种形态)。
let re = test_re();
let old = "t20000_g5.0_he-2_c-4_n-4_o-4";
let new = "t20000_g5.0_he-2_c-4_n-4_o-4";
// chmax=0.01 → 旧版 0p01 → 还原 0.01
let f = format!("{}.nc_chmax0p01_itekNone.9", old);
assert_eq!(
map_filename(&f, old, new, &re, "nl"),
Some(format!("{}.nc_chmax0.01.9", new))
);
// chmax=0.1 → 旧版 0p1 → 还原 0.1
let f = format!("{}.nc_chmax0p1_itekNone.9", old);
assert_eq!(
map_filename(&f, old, new, &re, "nl"),
Some(format!("{}.nc_chmax0.1.9", new))
);
// itek 是数字(itek=15)→ itek 段被去掉,不影响 chmax 还原
let f = format!("{}.nc_chmax0p001_itek15.9", old);
assert_eq!(
map_filename(&f, old, new, &re, "nl"),
Some(format!("{}.nc_chmax0.001.9", new))
);
// 科学计数法 chmax1e-05,无小数点,旧版 replace 不改变)→ 原样保留
let f = format!("{}.nc_chmax1e-05_itekNone.9", old);
assert_eq!(
map_filename(&f, old, new, &re, "nl"),
Some(format!("{}.nc_chmax1e-05.9", new))
);
}
#[test]
fn test_map_filename_last_stage_label() {
// last_stage 从配置派生(非 nl,如 seed_step chain 末段 seed_nc
let re = test_re();
let old = "t20000_g5.0_he-2_c-4_n-4_o-4";
let new = "t20000_g5.0_he-2_c-4_n-4_o-4";
assert_eq!(
map_filename(&format!("{}.5", old), old, new, &re, "seed_nc"),
Some(format!("{}.seed_nc.5", new))
);
assert_eq!(
map_filename(&format!("{}.6", old), old, new, &re, "seed_nc"),
Some(format!("{}.seed_nc.6", new))
);
}
#[test]
fn test_map_filename_rename_with_diff_precision() {
let re = test_re();
let old = "t20000_g5_he-2_c-4_n-4_o-4";
let new = "t20000_g5.0_he-2_c-4_n-4_o-4"; // 配置权威名补回小数
assert_eq!(
map_filename(&format!("{}.7", old), old, new, &re, "nl"),
Some(format!("{}.7", new))
);
let old9 = format!("{}.nc_chmax0p001_itekNone.9", old);
assert_eq!(
map_filename(&old9, old, new, &re, "nl"),
Some(format!("{}.nc_chmax0.001.9", new))
);
}
#[test]
fn test_map_filename_skip_unmapped() {
let re = test_re();
let old = "t20000_g5.0_he-2_c-4_n-4_o-4";
let new = "t20000_g5.0_he-2_c-4_n-4_o-4";
assert_eq!(map_filename("random_file.txt", old, new, &re, "nl"), None);
assert_eq!(map_filename("fort.19", old, new, &re, "nl"), None);
}
/// 验证 map_filename 改名 + is_result_worthy 白名单复核的组合(migrate_result_tree 的
/// 实际过滤路径):科学产物/阶段快照/_chmax.9 保留;Tlusty 中间单元/无 _chmax 的 .9 丢弃。
#[test]
fn test_map_then_whitelist_filtering() {
use common::result_filter::is_result_worthy;
let re = test_re();
let old = "t20000_g5_he-2_c-4_n-4_o-4"; // 旧 Python 命名(无 .0
let new = "t20000_g5.0_he-2_c-4_n-4_o-4"; // 新格式权威名
let last_stage = "nl";
// 保留:map_filename 改名后落在白名单内
let keep_inputs = [
"conv.json",
"fort.8",
&format!("{old}.7"),
&format!("{old}.spec"),
&format!("{old}.nl.7"),
&format!("{old}.nl.nst"),
&format!("{old}.nc_chmax0p001_itekNone.9"), // 旧格式 .9 收敛诊断
];
for fname in keep_inputs {
let mapped = map_filename(fname, old, new, &re, last_stage);
assert!(mapped.is_some(), "应能映射: {}", fname);
let mapped = mapped.unwrap();
assert!(
is_result_worthy(&mapped, new),
"映射后应通过白名单: {} → {}",
fname,
mapped
);
}
// 丢弃:Tlusty 中间单元(map_filename 返回 None,根本不进白名单)
for fname in ["fort.18", "fort.13", "fort.84", "fort.1", "fort.95"] {
assert_eq!(
map_filename(fname, old, new, &re, last_stage),
None,
"Tlusty 中间单元应无法映射: {}",
fname
);
}
// 丢弃:无 _chmax 的 .9 快照(旧版若有),map_filename 通用前缀规则会映射成
// <new>.<label>.9,但白名单拒绝(与 _chmax.9 重复)
let bare_dot9 = format!("{old}.nl.9");
let mapped = map_filename(&bare_dot9, old, new, &re, last_stage);
assert_eq!(mapped, Some(format!("{new}.nl.9")));
assert!(
!is_result_worthy(mapped.as_ref().unwrap(), new),
"无 _chmax 的 .9 应被白名单拒绝: {}",
mapped.unwrap()
);
}
#[test]
fn test_rewrite_summary_name_changes_only_name() {
let original = r#"{"name":"t20000_g5_he-2_c-4_n-4_o-4","params":{"teff":20000.0,"logg":5.0,"loghe":-2.0,"logc":-4.0,"logn":-4.0,"logo":-4.0},"converged":true,"final_max_relc":0.001,"atmosphere_has_nan":false}"#;
let rewritten = rewrite_summary_name(original, "t20000_g5.0_he-2_c-4_n-4_o-4").unwrap();
let v: serde_json::Value = serde_json::from_str(&rewritten).unwrap();
assert_eq!(v["name"].as_str().unwrap(), "t20000_g5.0_he-2_c-4_n-4_o-4");
assert_eq!(v["params"]["logg"].as_f64().unwrap(), 5.0);
assert!(v["converged"].as_bool().unwrap());
assert_eq!(v["final_max_relc"].as_f64().unwrap(), 0.001);
}
#[test]
fn test_rewrite_summary_name_rejects_non_object() {
// 非对象 JSON(数组/标量)应报错,而非静默放过
assert!(rewrite_summary_name("[1,2,3]", "x").is_err());
assert!(rewrite_summary_name("42", "x").is_err());
assert!(rewrite_summary_name("\"hello\"", "x").is_err());
}
#[test]
fn test_param_fingerprint_normalizes_neg_zero() {
// -0.0 与 0.0 应产出相同指纹(否则静默跳点)
assert_eq!(
param_fingerprint(&[0.0, -0.0, 0.0, 0.0, 0.0, 0.0]),
param_fingerprint(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
);
// 含 0 值轴的网格点能匹配(如 loghe: [0])
assert_eq!(
param_fingerprint(&[20000.0, 5.0, 0.0, -4.0, -4.0, -4.0]),
param_fingerprint(&[20000.0, 5.0, -0.0, -4.0, -4.0, -4.0]),
);
}
#[test]
fn test_build_canonical_name_map_preserves_decimal() {
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
let map = build_canonical_name_map(&cfg);
let key = param_fingerprint(&[20000.0, 5.0, -2.0, -4.0, -4.0, -4.0]);
assert_eq!(
map.get(&key).unwrap(),
"t20000_g5.0_he-2_c-4_n-4_o-4",
"logg=5.0 应保留小数 → g5.0"
);
}
#[test]
fn test_derive_last_stage_label_from_chain() {
// 配置显式指定 chain → 取末段标签
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\nchain:\n - {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0}\n - {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10}\n - {label: nl_final, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100}\n";
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
assert_eq!(derive_last_stage_label(&cfg), "nl_final");
}
#[test]
fn test_derive_last_stage_label_default_when_no_chain() {
// chain 为空 → 回退到默认收敛链末段(nl)
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
assert_eq!(derive_last_stage_label(&cfg), "nl");
}
}
-27
View File
@@ -1,27 +0,0 @@
# sync_seeds
> DCTS 离线与增量 `.7` 种子文件双向同步命令行工具。
---
## 📦 模块概览
`sync_seeds` 用于在没有连通 Master REST API 的离线环境或需要手动归档种子库时,扫描本地 `results/` 目录与远端 Master 或 Worker 节点的 `.7` 大气文件并完成同步。
---
## 🚀 Setup & Usage
### 编译
```bash
cargo build -p sync_seeds --release
```
### 使用示例
```bash
# 扫描本地 results 目录并打印离线种子统计摘要
./target/release/sync_seeds --dir ./results
# 与远端 Master 节点同步种子
./target/release/sync_seeds --dir ./results --server http://master.cluster:8080
```
-210
View File
@@ -1,210 +0,0 @@
use anyhow::Result;
use clap::Parser;
use common::conv_check::atmosphere_has_nan;
use common::models::{GridPointParams, ModelSummary, TaskReport, TaskStatus};
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use uuid::Uuid;
#[derive(Parser, Debug)]
#[command(author, version, about = "DCTS 历史种子批量导入与 HTTP 上发同步工具")]
struct Args {
/// 存放历史计算结果与 .7 大气文件的目录路径
#[arg(short, long, default_value = "data/results")]
dir: PathBuf,
/// Master 服务端 API 地址 (默认: http://127.0.0.1:8090)
#[arg(short, long, default_value = "http://127.0.0.1:8090")]
server: String,
/// 服务端 API 鉴权令牌 Token (若服务端开启了鉴权)
#[arg(short, long)]
token: Option<String>,
}
#[derive(Debug)]
struct CandidateSeed {
point_name: String,
params: GridPointParams,
summary_json: String,
seed_path: PathBuf,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let args = Args::parse();
info!("=== DCTS 历史种子 HTTP 上传同步工具启动 ===");
info!("扫描结果目录: {}", args.dir.display());
if !args.dir.is_dir() {
anyhow::bail!("指定的种子目录不存在或不是文件夹: {}", args.dir.display());
}
// 1. 自动兼容扫描旧版 run_grid.py 产物与新版 DCTS 目录结构
let seeds = scan_dir_for_seeds(&args.dir).await?;
info!(
"扫描完成,共找到 {} 个经校验无 NaN 且物理收敛的合格大气种子!",
seeds.len()
);
if seeds.is_empty() {
info!("未查找到符合条件的合格种子文件。");
return Ok(());
}
// 2. HTTP 传输上传模式 (HTTP Upload Mode)
info!("=== 启动 HTTP 增量上传模式 ===");
info!("目标 Master 服务端地址: {}", args.server);
upload_seeds_to_remote_server(&args.server, &seeds, args.token.as_deref()).await?;
info!("=== 上传全量完成 ===");
Ok(())
}
/// 自动兼容扫描旧版与新版格式的有效种子点
async fn scan_dir_for_seeds(dir_path: &Path) -> Result<Vec<CandidateSeed>> {
let mut results = Vec::new();
let entries = std::fs::read_dir(dir_path)?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.')
|| name.contains(".OLD")
|| name.contains(".FAILED")
|| name.contains(".coldfail")
{
continue;
}
let sub_path = entry.path();
// 场景 A: 子目录形态 (新版或标准 run_grid.py 子目录)
if sub_path.is_dir() {
let conv_json = sub_path.join("conv.json");
let candidates_7 = [
sub_path.join(format!("{}.7", name)),
sub_path.join(format!("{}.nl.7", name)),
sub_path.join(format!("{}.nc.7", name)),
sub_path.join("fort.7"),
];
let atmo_7 = candidates_7.into_iter().find(|p| p.is_file());
if conv_json.is_file() {
if let Some(seed_file) = atmo_7 {
if let Ok(content) = std::fs::read_to_string(&conv_json) {
if let Ok(summary) = serde_json::from_str::<ModelSummary>(&content) {
if summary.converged
&& !summary.atmosphere_has_nan
&& !atmosphere_has_nan(&seed_file)
{
results.push(CandidateSeed {
point_name: summary.name.clone(),
params: summary.params.clone(),
summary_json: content,
seed_path: seed_file,
});
}
}
}
}
}
}
}
Ok(results)
}
/// 向远程 Master 服务端逐个发送 HTTP POST /api/task/report 请求同步种子与元数据
async fn upload_seeds_to_remote_server(
server_url: &str,
seeds: &[CandidateSeed],
token: Option<&str>,
) -> Result<()> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?;
let report_url = format!("{}/api/task/report", server_url.trim_end_matches('/'));
let total = seeds.len();
let mut success_count = 0;
for (idx, seed) in seeds.iter().enumerate() {
info!(
"正在上传种子 [{}/{}] 网格点: {} (路径: {})...",
idx + 1,
total,
seed.point_name,
seed.seed_path.display()
);
let seed_bytes = match tokio::fs::read(&seed.seed_path).await {
Ok(b) => b,
Err(e) => {
warn!("读取种子二进制文件失败,跳过: {}", e);
continue;
}
};
let report = TaskReport {
task_id: Uuid::new_v4(),
point_name: seed.point_name.clone(),
params: Some(seed.params.clone()),
node_id: "sync_seeds_uploader".to_string(),
status: TaskStatus::Completed,
converged: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 0.0,
error_message: None,
summary_json: seed.summary_json.clone(),
};
let report_bytes = serde_json::to_vec(&report)?;
let seed_file_name = format!("{}.7", seed.point_name);
let form = Form::new()
.part(
"report",
Part::bytes(report_bytes).mime_str("application/json")?,
)
.part(
"seed_file",
Part::bytes(seed_bytes)
.file_name(seed_file_name)
.mime_str("application/octet-stream")?,
);
let mut req = client.post(&report_url);
if let Some(t) = token {
req = req.header("Authorization", format!("Bearer {}", t));
}
match req.multipart(form).send().await {
Ok(resp) if resp.status().is_success() => {
success_count += 1;
info!("网格点 {} 上传成功!", seed.point_name);
}
Ok(resp) => {
warn!(
"网格点 {} 上传失败,服务端响应 HTTP {}",
seed.point_name,
resp.status()
);
}
Err(e) => {
warn!("网格点 {} 网络上传失败: {}", seed.point_name, e);
}
}
}
info!(
"成功将 {}/{} 个有效大气种子上传至 Master 服务端!",
success_count, total
);
Ok(())
}
+3 -3
View File
@@ -56,12 +56,12 @@ resume: true # 跳过已完成的模型(conv.json 中 converged=true
# ---- 光谱合成 SYNSPEC 控制参数 ----
synspec:
wstart: 1400.0 # 光谱波长起始点 (Å)
wend: 1410.0 # 光谱波长终止点 (Å)
wstart: 30000.0 # 光谱波长起始点 (Å)
wend: 7000.0 # 光谱波长终止点 (Å)
imode: 0
idrv: 50
ifreq: 1
rel_cutoff: 0.0001
abs_cutoff: 0.01
results: data/results
results: data/seeds