1
0

feat: 版本管理,package.json 唯一版本源、/api/meta 返回版本、Dashboard Header 展示版本号

This commit is contained in:
2026-05-20 19:14:37 +08:00
parent f3df3a203b
commit 8eac814cc6
25 changed files with 490 additions and 20 deletions

View File

@@ -6,6 +6,7 @@
## 目录 ## 目录
- [版本管理](#版本管理)
- [项目结构](#项目结构) - [项目结构](#项目结构)
- [一、后端开发指引](#一后端开发指引) - [一、后端开发指引](#一后端开发指引)
- [二、前端开发指引](#二前端开发指引) - [二、前端开发指引](#二前端开发指引)
@@ -15,6 +16,33 @@
--- ---
## 版本管理
DiAL 使用 `package.json.version` 作为应用版本号的唯一来源,遵循 SemVer 语义化版本规范(`MAJOR.MINOR.PATCH`)。
**版本升迁命令:**
| 命令 | 说明 |
| ------------------------------- | ------------------------------------------------- |
| `bun run version:patch` | 升迁 patch 版本bugfix、文档、测试、内部重构 |
| `bun run version:minor` | 升迁 minor 版本(新功能、新 checker、新配置字段 |
| `bun run version:major` | 升迁 major 版本不兼容的配置格式、API 行为变化) |
| `bun run version:set <version>` | 显式设置版本号 |
**版本展示:**
- Dashboard Header 品牌区域展示当前运行实例版本号(如 `v0.1.0`
- 版号通过 `/api/meta` 接口返回,前端通过 `useMeta` hook 获取
- 生产构建时版本号固化到可执行文件中,不依赖运行时外部 `package.json`
**暂不支持:**
- CLI `--version` 参数
- 自动创建 git commit、git tag 或 changelog
- prerelease 版本格式(如 `1.0.0-beta.1`
---
## 项目结构 ## 项目结构
```text ```text
@@ -27,6 +55,7 @@ src/
server.ts HTTP server 启动工厂Bun.serve routes 声明式路由 + fetch fallback 静态资源服务) server.ts HTTP server 启动工厂Bun.serve routes 声明式路由 + fetch fallback 静态资源服务)
helpers.ts 共享响应格式化工具(见下方函数清单) helpers.ts 共享响应格式化工具(见下方函数清单)
middleware.ts API 参数校验中间件validateTargetId、validateTimeRange、validatePagination middleware.ts API 参数校验中间件validateTargetId、validateTimeRange、validatePagination
version.ts 应用版本读取与校验
routes/ API 路由 handler按端点拆分 routes/ API 路由 handler按端点拆分
health.ts GET /health无 store 参数) health.ts GET /health无 store 参数)
meta.ts GET /api/meta meta.ts GET /api/meta

View File

@@ -17,10 +17,25 @@ DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测、UDP自定义 payload 请求-响应、ICMP存活检测、延迟、丢包率、LLM大模型服务应用层健康检查 - 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测、UDP自定义 payload 请求-响应、ICMP存活检测、延迟、丢包率、LLM大模型服务应用层健康检查
- 丰富的校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等 - 丰富的校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation便于排障和后续分析 - 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation便于排障和后续分析
- 响应式 Dashboard实时状态、可用率统计、耗时趋势图、手动/自动刷新 - 响应式 Dashboard实时状态、可用率统计、耗时趋势图、手动/自动刷新、版本号展示
- 多主题支持:系统、明亮、黑暗三种主题模式 - 多主题支持:系统、明亮、黑暗三种主题模式
- 零外部依赖:数据存储使用 SQLite无需额外数据库服务 - 零外部依赖:数据存储使用 SQLite无需额外数据库服务
## 版本管理
DiAL 使用 `package.json.version` 作为唯一版本源Dashboard Header 展示当前运行实例版本号(如 `v0.1.0`)。
**版本升迁命令:**
```bash
bun run version:patch # 升迁 patch 版本0.1.0 -> 0.1.1
bun run version:minor # 升迁 minor 版本0.1.0 -> 0.2.0
bun run version:major # 升迁 major 版本0.1.0 -> 1.0.0
bun run version:set 0.2.0 # 显式设置版本
```
版本升迁仅更新 `package.json`,不自动创建 git commit、tag 或 changelog。
## 应用截图 ## 应用截图
| | 亮色 | 暗色 | | | 亮色 | 暗色 |

View File

@@ -34,3 +34,18 @@ Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶
#### Scenario: 页面背景色 #### Scenario: 页面背景色
- **WHEN** Dashboard 页面渲染 - **WHEN** Dashboard 页面渲染
- **THEN** 页面背景色 SHALL 使用 `var(--td-bg-color-page)`,内容卡片浮于当前 TDesign 主题背景之上 - **THEN** 页面背景色 SHALL 使用 `var(--td-bg-color-page)`,内容卡片浮于当前 TDesign 主题背景之上
### Requirement: Header 版本号展示
Dashboard SHALL 在顶部导航栏品牌区域展示当前运行实例的应用版本号,版本号 SHALL 使用 `/api/meta` 返回的 `version` 字段,并以 `v` 前缀显示。
#### Scenario: Meta 数据已加载
- **WHEN** Dashboard 成功获取 `/api/meta` 且返回 `version: "0.1.0"`
- **THEN** Header 品牌区域 SHALL 展示 `v0.1.0`
#### Scenario: Meta 数据尚未加载或请求失败
- **WHEN** Dashboard 尚未获取到有效 `version`
- **THEN** Header SHALL 保持可用并省略版本号占位,不影响品牌名、主题模式选择器、刷新频率选择器和倒计时/刷新按钮渲染
#### Scenario: 版本号视觉层级
- **WHEN** Header 展示版本号
- **THEN** 版本号 SHALL 使用次级文本样式弱展示,不得使用内联 style、硬编码色值、`!important` 或覆盖 TDesign 内部类名

View File

@@ -1,20 +1,24 @@
## Purpose ## Purpose
定义系统运行时元数据 APIchecker 类型列表等元信息的对外暴露方式。 定义系统运行时元数据 APIchecker 类型列表、应用版本号等元信息的对外暴露方式。
## Requirements ## Requirements
### Requirement: Meta 信息 API ### Requirement: Meta 信息 API
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。未匹配 method SHALL 按 API 通配符处理为 JSON 404不再保留自定义 HEAD/405 语义。 系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据,包括应用版本号和 checker 类型列表。未匹配 method SHALL 按 API 通配符处理为 JSON 404不再保留自定义 HEAD/405 语义。
#### Scenario: 获取 checker 类型列表 #### Scenario: 获取 checker 类型列表和版本号
- **WHEN** 客户端请求 `GET /api/meta` - **WHEN** 客户端请求 `GET /api/meta`
- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[] }`包含所有已注册的 checker 类型标识符(如 `["http", "cmd"]` - **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[], version: string }`,其中 `checkerTypes` 包含所有已注册的 checker 类型标识符(如 `["http", "cmd"]``version` 为当前运行实例的 `MAJOR.MINOR.PATCH` 应用版本
#### Scenario: 类型列表来源 #### Scenario: 类型列表来源
- **WHEN** 系统启动并注册了 checker - **WHEN** 系统启动并注册了 checker
- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致 - **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致
#### Scenario: 版本号来源
- **WHEN** 系统启动并确定应用版本
- **THEN** `/api/meta` 返回的 `version` SHALL 与启动时注入的应用版本完全一致
#### Scenario: 不支持的 method 请求 #### Scenario: 不支持的 method 请求
- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta` - **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta`
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应 - **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应
@@ -24,4 +28,4 @@
#### Scenario: MetaResponse 类型定义 #### Scenario: MetaResponse 类型定义
- **WHEN** 前后端引用 `MetaResponse` 类型 - **WHEN** 前后端引用 `MetaResponse` 类型
- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段 - **THEN** 该类型 SHALL 包含 `checkerTypes: string[]``version: string` 字段

View File

@@ -66,3 +66,18 @@ executable MUST 将环境相关运行时配置保留在嵌入的前端和 server
#### Scenario: 验证失败 #### Scenario: 验证失败
- **WHEN** 质量检查或构建阶段失败 - **WHEN** 质量检查或构建阶段失败
- **THEN** 验证 SHALL 使命令失败 - **THEN** 验证 SHALL 使命令失败
### Requirement: 生产构建版本固化
生产构建 SHALL 在 code generation 阶段读取 `package.json.version`,并将该版本号固化到生成的 production server entry 中,使 standalone executable 能在运行时返回构建时版本。
#### Scenario: 构建时注入版本号
- **WHEN** 开发者运行生产构建命令
- **THEN** 构建脚本 SHALL 在生成 `.build/server-entry.ts` 时写入当前 `package.json.version` 对应的版本字面量
#### Scenario: executable 不依赖外部 package.json 返回版本
- **WHEN** 生成的 standalone executable 在目标机器运行且外部不存在项目根目录 `package.json`
- **THEN** `GET /api/meta` SHALL 仍返回构建时固化的 `version`
#### Scenario: 升迁后重新构建
- **WHEN** 开发者先升迁 `package.json.version` 再运行生产构建命令
- **THEN** 新生成的 standalone executable SHALL 返回升迁后的版本号

View File

@@ -47,7 +47,18 @@
#### Scenario: meta 数据返回 #### Scenario: meta 数据返回
- **WHEN** meta 查询成功 - **WHEN** meta 查询成功
- **THEN** hook SHALL 返回 `MetaResponse` 类型数据,包含 `checkerTypes` 字段 - **THEN** hook SHALL 返回 `MetaResponse` 类型数据,包含 `checkerTypes``version` 字段
### Requirement: Meta 版本数据
前端 SHALL 通过现有 `useMeta` hook 获取系统版本元数据,并将 `MetaResponse.version` 提供给需要展示版本号的组件。
#### Scenario: useMeta 返回版本字段
- **WHEN** `useMeta` 请求 `/api/meta` 成功
- **THEN** hook 返回的数据 SHALL 符合 `MetaResponse`,包含 `checkerTypes``version` 字段
#### Scenario: Header 复用 meta 查询
- **WHEN** Header 需要展示应用版本号
- **THEN** Header SHALL 复用 `useMeta``queryKey``["meta"]` 的查询结果,不得新增重复的版本专用请求
### Requirement: Hook 文件拆分 ### Requirement: Hook 文件拆分
数据层 hook SHALL 按职责拆分为独立文件。 数据层 hook SHALL 按职责拆分为独立文件。

View File

@@ -0,0 +1,54 @@
## Purpose
定义 DiAL 应用版本号的唯一来源、手动版本升迁命令和版本管理文档要求。
## Requirements
### Requirement: 应用版本唯一来源
系统 SHALL 使用根目录 `package.json.version` 作为 DiAL 应用版本号的唯一来源。版本号 MUST 使用 `MAJOR.MINOR.PATCH` 数字格式,不包含 prerelease 或 build metadata。
#### Scenario: 读取应用版本
- **WHEN** 开发、构建或版本升迁流程需要获取 DiAL 应用版本
- **THEN** 系统 SHALL 从根目录 `package.json.version` 读取版本号
#### Scenario: 版本格式有效
- **WHEN** `package.json.version``0.1.0``1.2.3``MAJOR.MINOR.PATCH` 数字格式
- **THEN** 版本管理流程 SHALL 视为有效版本
#### Scenario: 版本格式无效
- **WHEN** `package.json.version` 缺失或不符合 `MAJOR.MINOR.PATCH` 数字格式
- **THEN** 版本管理流程 MUST 失败并输出可读错误,不得继续写入错误版本
### Requirement: 手动版本升迁命令
项目 SHALL 通过 `package.json` scripts 提供基于 Bun 的手动版本升迁命令,支持 `patch``minor``major` 和显式设置版本。
#### Scenario: 升迁 patch 版本
- **WHEN** 当前版本为 `1.2.3` 且开发者运行 `bun run version:patch`
- **THEN** 系统 SHALL 将 `package.json.version` 更新为 `1.2.4`
#### Scenario: 升迁 minor 版本
- **WHEN** 当前版本为 `1.2.3` 且开发者运行 `bun run version:minor`
- **THEN** 系统 SHALL 将 `package.json.version` 更新为 `1.3.0`
#### Scenario: 升迁 major 版本
- **WHEN** 当前版本为 `1.2.3` 且开发者运行 `bun run version:major`
- **THEN** 系统 SHALL 将 `package.json.version` 更新为 `2.0.0`
#### Scenario: 显式设置版本
- **WHEN** 开发者运行 `bun run version:set 0.2.0`
- **THEN** 系统 SHALL 将 `package.json.version` 更新为 `0.2.0`
#### Scenario: 拒绝无效设置版本
- **WHEN** 开发者运行 `bun run version:set 1.0.0-beta.1` 或其他非 `MAJOR.MINOR.PATCH` 版本
- **THEN** 系统 MUST 失败并保持 `package.json.version` 不变
#### Scenario: 版本升迁不执行发布副作用
- **WHEN** 开发者运行任意版本升迁命令
- **THEN** 系统 MUST NOT 自动创建 git commit、git tag、changelog 或 release
### Requirement: 版本管理文档
项目 SHALL 在开发文档中说明版本号规则、升迁命令、展示位置和暂不支持的发布自动化能力。
#### Scenario: 开发者查阅版本规则
- **WHEN** 开发者阅读 README.md 或 DEVELOPMENT.md
- **THEN** 文档 SHALL 说明 `package.json.version` 是唯一版本源,以及 `bun run version:patch``bun run version:minor``bun run version:major``bun run version:set <version>` 的用途

View File

@@ -1,5 +1,6 @@
{ {
"name": "dial-server", "name": "dial-server",
"version": "0.1.0",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -16,7 +17,11 @@
"test": "bun test", "test": "bun test",
"clean": "bun run scripts/clean.ts", "clean": "bun run scripts/clean.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"prepare": "husky" "prepare": "husky",
"version:patch": "bun run scripts/bump-version.ts patch",
"version:minor": "bun run scripts/bump-version.ts minor",
"version:major": "bun run scripts/bump-version.ts major",
"version:set": "bun run scripts/bump-version.ts set"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^21.0.1", "@commitlint/cli": "^21.0.1",

View File

@@ -2,10 +2,13 @@ import { readdir, rm, writeFile } from "node:fs/promises";
import { join, relative, sep } from "node:path"; import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { validateVersion } from "./bump-version-logic";
const projectRoot = fileURLToPath(new URL("..", import.meta.url)); const projectRoot = fileURLToPath(new URL("..", import.meta.url));
const distWebDir = join(projectRoot, "dist/web"); const distWebDir = join(projectRoot, "dist/web");
const buildDir = join(projectRoot, ".build"); const buildDir = join(projectRoot, ".build");
const executablePath = join(projectRoot, "dist/dial-server"); const executablePath = join(projectRoot, "dist/dial-server");
const packageJsonPath = join(projectRoot, "package.json");
async function build() { async function build() {
try { try {
@@ -60,6 +63,14 @@ async function codeGeneration() {
await rm(buildDir, { force: true, recursive: true }); await rm(buildDir, { force: true, recursive: true });
await Bun.write(join(buildDir, ".gitkeep"), ""); await Bun.write(join(buildDir, ".gitkeep"), "");
const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string };
const version = packageJson.version;
if (typeof version !== "string") {
console.error("package.json does not have a valid version field");
process.exit(1);
}
validateVersion(version);
const allFiles = await scanDir(distWebDir, "/"); const allFiles = await scanDir(distWebDir, "/");
const importLines: string[] = []; const importLines: string[] = [];
const fileEntries: string[] = []; const fileEntries: string[] = [];
@@ -104,9 +115,11 @@ async function codeGeneration() {
`import { readRuntimeConfig } from "../src/server/config";`, `import { readRuntimeConfig } from "../src/server/config";`,
`import { staticAssets } from "./static-assets";`, `import { staticAssets } from "./static-assets";`,
"", "",
`const APP_VERSION = "${version}" as const;`,
"",
`async function main() {`, `async function main() {`,
` const { configPath } = readRuntimeConfig();`, ` const { configPath } = readRuntimeConfig();`,
` await bootstrap({ configPath, mode: "production", staticAssets });`, ` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
`}`, `}`,
"", "",
`void main().catch((error) => {`, `void main().catch((error) => {`,

View File

@@ -0,0 +1,40 @@
const VERSION_REGEX = /^\d+\.\d+\.\d+$/;
export function bumpVersion(current: string, command: "major" | "minor" | "patch" | "set", target?: string): string {
validateVersion(current);
const [major, minor, patch] = parseVersion(current);
switch (command) {
case "major":
return formatVersion(major + 1, 0, 0);
case "minor":
return formatVersion(major, minor + 1, 0);
case "patch":
return formatVersion(major, minor, patch + 1);
case "set": {
if (!target) {
throw new Error("set command requires a target version");
}
validateVersion(target);
return target;
}
}
}
export function formatVersion(major: number, minor: number, patch: number): string {
return `${major}.${minor}.${patch}`;
}
export function parseVersion(version: string): [number, number, number] {
const parts = version.split(".").map((p) => parseInt(p, 10));
if (parts.length !== 3 || parts.some(isNaN)) {
throw new Error(`Invalid version format: ${version}`);
}
return [parts[0]!, parts[1]!, parts[2]!];
}
export function validateVersion(version: string): void {
if (!VERSION_REGEX.test(version)) {
throw new Error(`Invalid version format: ${version}. Expected MAJOR.MINOR.PATCH (e.g., 0.1.0)`);
}
}

45
scripts/bump-version.ts Normal file
View File

@@ -0,0 +1,45 @@
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { bumpVersion, validateVersion } from "./bump-version-logic";
const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "package.json");
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: bun run bump-version.ts <patch|minor|major|set> [version]");
process.exit(1);
}
const command = args[0];
if (command !== "patch" && command !== "minor" && command !== "major" && command !== "set") {
console.error(`Unknown command: ${command}. Expected patch, minor, major, or set`);
process.exit(1);
}
if (command === "set" && args.length < 2) {
console.error("Usage: bun run bump-version.ts set <version>");
process.exit(1);
}
const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string };
const currentVersion = packageJson.version;
if (typeof currentVersion !== "string") {
console.error("package.json does not have a valid version field");
process.exit(1);
}
validateVersion(currentVersion);
const targetVersion = command === "set" ? args[1] : undefined;
const nextVersion = bumpVersion(currentVersion, command, targetVersion);
packageJson.version = nextVersion;
writeFileSync(PACKAGE_JSON_PATH, JSON.stringify(packageJson, null, 2) + "\n");
console.log(`${currentVersion} -> ${nextVersion}`);
}
void main();

View File

@@ -28,6 +28,7 @@ export interface BootstrapOptions {
configPath: string; configPath: string;
mode: RuntimeMode; mode: RuntimeMode;
staticAssets?: StaticAssets; staticAssets?: StaticAssets;
version: string;
} }
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">; type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
@@ -73,6 +74,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
mode: options.mode, mode: options.mode,
staticAssets: options.staticAssets, staticAssets: options.staticAssets,
store, store,
version: options.version,
}); });
} catch (error) { } catch (error) {
engine?.stop(); engine?.stop();

View File

@@ -1,9 +1,11 @@
import { bootstrap } from "./bootstrap"; import { bootstrap } from "./bootstrap";
import { readRuntimeConfig } from "./config"; import { readRuntimeConfig } from "./config";
import { readAppVersion } from "./version";
async function main() { async function main() {
const { configPath } = readRuntimeConfig(); const { configPath } = readRuntimeConfig();
await bootstrap({ configPath, mode: "development" }); const version = await readAppVersion();
await bootstrap({ configPath, mode: "development", version });
} }
void main().catch((error) => { void main().catch((error) => {

View File

@@ -1,9 +1,11 @@
import { bootstrap } from "./bootstrap"; import { bootstrap } from "./bootstrap";
import { readRuntimeConfig } from "./config"; import { readRuntimeConfig } from "./config";
import { readAppVersion } from "./version";
async function main() { async function main() {
const { configPath } = readRuntimeConfig(); const { configPath } = readRuntimeConfig();
await bootstrap({ configPath, mode: "production" }); const version = await readAppVersion();
await bootstrap({ configPath, mode: "production", version });
} }
void main().catch((error) => { void main().catch((error) => {

View File

@@ -3,9 +3,10 @@ import type { MetaResponse, RuntimeMode } from "../../shared/api";
import { checkerRegistry } from "../checker/runner"; import { checkerRegistry } from "../checker/runner";
import { jsonResponse } from "../helpers"; import { jsonResponse } from "../helpers";
export function handleMeta(mode: RuntimeMode): Response { export function handleMeta(mode: RuntimeMode, version: string): Response {
const response: MetaResponse = { const response: MetaResponse = {
checkerTypes: checkerRegistry.supportedTypes, checkerTypes: checkerRegistry.supportedTypes,
version,
}; };
return jsonResponse(response, { mode }); return jsonResponse(response, { mode });

View File

@@ -16,10 +16,11 @@ export interface StartServerOptions {
mode: RuntimeMode; mode: RuntimeMode;
staticAssets?: StaticAssets; staticAssets?: StaticAssets;
store: ProbeStore; store: ProbeStore;
version: string;
} }
export function startServer(options: StartServerOptions) { export function startServer(options: StartServerOptions) {
const { config, mode, staticAssets, store } = options; const { config, mode, staticAssets, store, version } = options;
const server = Bun.serve({ const server = Bun.serve({
fetch(req) { fetch(req) {
@@ -36,7 +37,7 @@ export function startServer(options: StartServerOptions) {
GET: (req) => handleDashboard(new URL(req.url), store, mode), GET: (req) => handleDashboard(new URL(req.url), store, mode),
}, },
"/api/meta": { "/api/meta": {
GET: () => handleMeta(mode), GET: () => handleMeta(mode, version),
}, },
"/api/targets/:id/history": { "/api/targets/:id/history": {
GET: (req) => handleHistory(req.params.id, new URL(req.url), store, mode), GET: (req) => handleHistory(req.params.id, new URL(req.url), store, mode),

17
src/server/version.ts Normal file
View File

@@ -0,0 +1,17 @@
import { resolve } from "node:path";
import { validateVersion } from "../../scripts/bump-version-logic";
const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "..", "package.json");
export async function readAppVersion(): Promise<string> {
const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string };
const version = packageJson.version;
if (typeof version !== "string") {
throw new Error("package.json does not have a valid version field");
}
validateVersion(version);
return version;
}

View File

@@ -58,6 +58,7 @@ export interface HistoryResponse {
export interface MetaResponse { export interface MetaResponse {
checkerTypes: string[]; checkerTypes: string[];
version: string;
} }
export interface RecentSample { export interface RecentSample {

View File

@@ -6,7 +6,7 @@ import { Alert, Layout, Menu, RadioGroup, Skeleton } from "tdesign-react";
import { RefreshCountdown } from "./components/RefreshCountdown"; import { RefreshCountdown } from "./components/RefreshCountdown";
import { SummaryCards } from "./components/SummaryCards"; import { SummaryCards } from "./components/SummaryCards";
import { TargetBoard } from "./components/TargetBoard"; import { TargetBoard } from "./components/TargetBoard";
import { useDashboard } from "./hooks/use-queries"; import { useDashboard, useMeta } from "./hooks/use-queries";
import { useTargetDetail } from "./hooks/use-target-detail"; import { useTargetDetail } from "./hooks/use-target-detail";
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference"; import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
@@ -46,6 +46,7 @@ export function App() {
isLoading: dashboardLoading, isLoading: dashboardLoading,
refetch: refetchDashboard, refetch: refetchDashboard,
} = useDashboard(dashboardRefetchInterval); } = useDashboard(dashboardRefetchInterval);
const { data: meta } = useMeta();
const { const {
activeTab, activeTab,
closeDrawer, closeDrawer,
@@ -62,6 +63,7 @@ export function App() {
timeTo, timeTo,
} = useTargetDetail(); } = useTargetDetail();
const isManualRefresh = refreshInterval === 0; const isManualRefresh = refreshInterval === 0;
const versionDisplay = meta?.version ? `v${meta.version}` : null;
const handleIntervalChange = (value: number) => { const handleIntervalChange = (value: number) => {
void refetchDashboard(); void refetchDashboard();
@@ -80,6 +82,7 @@ export function App() {
<span className="dashboard-brand"> <span className="dashboard-brand">
<span className="dashboard-logo">DiAL</span> <span className="dashboard-logo">DiAL</span>
<span className="dashboard-subtitle"></span> <span className="dashboard-subtitle"></span>
{versionDisplay && <span className="dashboard-version">{versionDisplay}</span>}
</span> </span>
} }
operations={ operations={

View File

@@ -46,6 +46,12 @@
font-weight: 400; font-weight: 400;
} }
.dashboard-version {
color: var(--td-text-color-placeholder);
font-size: var(--td-font-size-body-small);
font-weight: 400;
}
.dashboard-header-controls { .dashboard-header-controls {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { validateVersion } from "../../scripts/bump-version-logic";
describe("build 版本注入", () => {
test("validateVersion 接受有效版本", () => {
expect(() => validateVersion("0.1.0")).not.toThrow();
expect(() => validateVersion("1.2.3")).not.toThrow();
});
test("validateVersion 拒绝无效版本", () => {
expect(() => validateVersion("invalid")).toThrow();
expect(() => validateVersion("1.0.0-beta.1")).toThrow();
});
});
describe("server-entry 版本字面量", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = join(tmpdir(), `build-version-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
test("生成的 server-entry 包含版本字面量", async () => {
const version = "0.1.0";
const serverEntryTs = [
`import { bootstrap } from "../src/server/bootstrap";`,
`import { readRuntimeConfig } from "../src/server/config";`,
`import { staticAssets } from "./static-assets";`,
"",
`const APP_VERSION = "${version}" as const;`,
"",
`async function main() {`,
` const { configPath } = readRuntimeConfig();`,
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
`}`,
"",
`void main().catch((error) => {`,
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
` process.exit(1);`,
`});`,
"",
].join("\n");
await writeFile(join(tempDir, "server-entry.ts"), serverEntryTs);
const content = await Bun.file(join(tempDir, "server-entry.ts")).text();
expect(content).toContain(`const APP_VERSION = "${version}"`);
expect(content).toContain("version: APP_VERSION");
});
test("版本字面量不依赖外部 package.json", async () => {
const serverEntryTs = [`const APP_VERSION = "0.1.0" as const;`].join("\n");
await writeFile(join(tempDir, "server-entry.ts"), serverEntryTs);
const content = await Bun.file(join(tempDir, "server-entry.ts")).text();
expect(content).not.toContain("package.json");
expect(content).not.toContain("Bun.file");
expect(content).toContain('"0.1.0"');
});
});

View File

@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bumpVersion, formatVersion, parseVersion, validateVersion } from "../../scripts/bump-version-logic";
describe("版本解析与校验", () => {
test("parseVersion 解析有效版本", () => {
expect(parseVersion("0.1.0")).toEqual([0, 1, 0]);
expect(parseVersion("1.2.3")).toEqual([1, 2, 3]);
expect(parseVersion("10.20.30")).toEqual([10, 20, 30]);
});
test("parseVersion 拒绝无效版本", () => {
expect(() => parseVersion("invalid")).toThrow();
expect(() => parseVersion("1.2")).toThrow();
expect(() => parseVersion("1.2.3.4")).toThrow();
expect(() => parseVersion("1.2.a")).toThrow();
});
test("validateVersion 接受有效版本", () => {
expect(() => validateVersion("0.1.0")).not.toThrow();
expect(() => validateVersion("1.2.3")).not.toThrow();
expect(() => validateVersion("10.20.30")).not.toThrow();
});
test("validateVersion 拒绝无效版本", () => {
expect(() => validateVersion("")).toThrow();
expect(() => validateVersion("invalid")).toThrow();
expect(() => validateVersion("1.2")).toThrow();
expect(() => validateVersion("1.2.3.4")).toThrow();
expect(() => validateVersion("1.0.0-beta.1")).toThrow();
expect(() => validateVersion("v1.0.0")).toThrow();
});
test("formatVersion 格式化版本", () => {
expect(formatVersion(0, 1, 0)).toBe("0.1.0");
expect(formatVersion(1, 2, 3)).toBe("1.2.3");
expect(formatVersion(10, 20, 30)).toBe("10.20.30");
});
});
describe("版本升迁逻辑", () => {
test("bumpVersion patch 升迁", () => {
expect(bumpVersion("1.2.3", "patch")).toBe("1.2.4");
expect(bumpVersion("0.1.0", "patch")).toBe("0.1.1");
expect(bumpVersion("0.0.1", "patch")).toBe("0.0.2");
});
test("bumpVersion minor 危迁", () => {
expect(bumpVersion("1.2.3", "minor")).toBe("1.3.0");
expect(bumpVersion("0.1.0", "minor")).toBe("0.2.0");
expect(bumpVersion("0.0.1", "minor")).toBe("0.1.0");
});
test("bumpVersion major 危迁", () => {
expect(bumpVersion("1.2.3", "major")).toBe("2.0.0");
expect(bumpVersion("0.1.0", "major")).toBe("1.0.0");
expect(bumpVersion("0.0.1", "major")).toBe("1.0.0");
});
test("bumpVersion set 设置版本", () => {
expect(bumpVersion("1.2.3", "set", "2.0.0")).toBe("2.0.0");
expect(bumpVersion("0.1.0", "set", "0.2.0")).toBe("0.2.0");
});
test("bumpVersion set 拒绝无效版本", () => {
expect(() => bumpVersion("1.2.3", "set", "invalid")).toThrow();
expect(() => bumpVersion("1.2.3", "set", "1.0.0-beta.1")).toThrow();
});
});
describe("写入前保持原版本", () => {
let tempFile: string;
beforeEach(() => {
tempFile = join(tmpdir(), `bump-version-test-${Date.now()}.json`);
writeFileSync(tempFile, JSON.stringify({ name: "test", version: "1.2.3" }, null, 2) + "\n");
});
afterEach(() => {
if (existsSync(tempFile)) {
unlinkSync(tempFile);
}
});
test("set 无效版本不修改文件", () => {
const original = readFileSync(tempFile, "utf-8");
expect(() => bumpVersion("1.2.3", "set", "invalid")).toThrow();
const after = readFileSync(tempFile, "utf-8");
expect(after).toBe(original);
});
});

View File

@@ -175,6 +175,7 @@ describe("API 路由", () => {
config: { host: "127.0.0.1", port: 0 }, config: { host: "127.0.0.1", port: 0 },
mode: "test", mode: "test",
store, store,
version: "0.1.0",
}); });
baseUrl = `http://127.0.0.1:${server.port}`; baseUrl = `http://127.0.0.1:${server.port}`;
}); });
@@ -235,7 +236,7 @@ describe("API 路由", () => {
expect(invalidLimit.status).toBe(400); expect(invalidLimit.status).toBe(400);
}); });
test("/api/meta 返回 checker 类型列表", async () => { test("/api/meta 返回 checker 类型列表和版本号", async () => {
const response = await fetch(`${baseUrl}/api/meta`); const response = await fetch(`${baseUrl}/api/meta`);
const body = (await response.json()) as MetaResponse; const body = (await response.json()) as MetaResponse;
@@ -243,6 +244,7 @@ describe("API 路由", () => {
expect(body.checkerTypes).toEqual(checkerRegistry.supportedTypes); expect(body.checkerTypes).toEqual(checkerRegistry.supportedTypes);
expect(body.checkerTypes).toContain("http"); expect(body.checkerTypes).toContain("http");
expect(body.checkerTypes).toContain("cmd"); expect(body.checkerTypes).toContain("cmd");
expect(body.version).toBe("0.1.0");
}); });
test("不支持的 method 在有 API 通配符时返回 404", async () => { test("不支持的 method 在有 API 通配符时返回 404", async () => {
@@ -410,6 +412,7 @@ describe("API 路由", () => {
config: { host: "127.0.0.1", port: 0 }, config: { host: "127.0.0.1", port: 0 },
mode: "production", mode: "production",
store, store,
version: "0.1.0",
}); });
try { try {
const response = await fetch(`http://127.0.0.1:${prodServer.port}/api/dashboard`); const response = await fetch(`http://127.0.0.1:${prodServer.port}/api/dashboard`);

View File

@@ -77,6 +77,7 @@ function createHarness(overrides: BootstrapDependencies = {}) {
startServer(options) { startServer(options) {
expect(options.config).toEqual({ host: config.host, port: config.port }); expect(options.config).toEqual({ host: config.host, port: config.port });
expect(options.store).toBe(store); expect(options.store).toBe(store);
expect(options.version).toBe("0.1.0");
calls.push(`startServer:${options.mode}`); calls.push(`startServer:${options.mode}`);
}, },
...overrides, ...overrides,
@@ -89,7 +90,7 @@ describe("bootstrap", () => {
test("开发模式执行完整启动序列", async () => { test("开发模式执行完整启动序列", async () => {
const { calls, dependencies } = createHarness(); const { calls, dependencies } = createHarness();
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies); await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development", version: "0.1.0" }, dependencies);
expect(calls).toEqual([ expect(calls).toEqual([
"loadConfig:/tmp/probes.yaml", "loadConfig:/tmp/probes.yaml",
@@ -106,7 +107,7 @@ describe("bootstrap", () => {
test("收到退出信号时停止 engine 并关闭 store", async () => { test("收到退出信号时停止 engine 并关闭 store", async () => {
const { calls, dependencies, shutdownHandlers } = createHarness(); const { calls, dependencies, shutdownHandlers } = createHarness();
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies); await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development", version: "0.1.0" }, dependencies);
expect(() => shutdownHandlers.get("SIGINT")!()).toThrow("exit:0"); expect(() => shutdownHandlers.get("SIGINT")!()).toThrow("exit:0");
@@ -122,7 +123,7 @@ describe("bootstrap", () => {
let error: unknown; let error: unknown;
try { try {
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies); await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development", version: "0.1.0" }, dependencies);
} catch (caught) { } catch (caught) {
error = caught; error = caught;
} }

View File

@@ -69,7 +69,7 @@ function installMatchMedia(initialMatches: boolean) {
void vi.mock("../../../src/web/hooks/use-queries", () => ({ void vi.mock("../../../src/web/hooks/use-queries", () => ({
useDashboard: vi.fn(() => createDashboardResult()), useDashboard: vi.fn(() => createDashboardResult()),
useMeta: vi.fn(() => ({ useMeta: vi.fn(() => ({
data: { checkerTypes: ["http", "cmd"] }, data: { checkerTypes: ["http", "cmd"], version: "0.1.0" },
})), })),
})); }));
@@ -208,4 +208,25 @@ describe("App", () => {
act(() => matchMediaController.setMatches(true)); act(() => matchMediaController.setMatches(true));
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark")); await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark"));
}); });
test("Header 展示版本号", () => {
render(<App />);
expect(screen.getByText("v0.1.0")).not.toBeNull();
});
test("缺失版本时不展示版本占位", () => {
const { useMeta } = require("../../../src/web/hooks/use-queries");
useMeta.mockReturnValue({
data: { checkerTypes: ["http", "cmd"] },
});
render(<App />);
expect(screen.queryByText(/v\d+\.\d+\.\d+/)).toBeNull();
});
test("复用 useMeta 查询结果", () => {
const { useMeta } = require("../../../src/web/hooks/use-queries");
render(<App />);
expect(useMeta).toHaveBeenCalled();
});
}); });