feat: 版本管理,package.json 唯一版本源、/api/meta 返回版本、Dashboard Header 展示版本号
This commit is contained in:
@@ -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
|
||||
@@ -27,6 +55,7 @@ src/
|
||||
server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由 + fetch fallback 静态资源服务)
|
||||
helpers.ts 共享响应格式化工具(见下方函数清单)
|
||||
middleware.ts API 参数校验中间件(validateTargetId、validateTimeRange、validatePagination)
|
||||
version.ts 应用版本读取与校验
|
||||
routes/ API 路由 handler(按端点拆分)
|
||||
health.ts GET /health(无 store 参数)
|
||||
meta.ts GET /api/meta
|
||||
|
||||
17
README.md
17
README.md
@@ -17,10 +17,25 @@ DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**
|
||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、ICMP(存活检测、延迟、丢包率)、LLM(大模型服务应用层健康检查)
|
||||
- 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
||||
- 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation,便于排障和后续分析
|
||||
- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新
|
||||
- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新、版本号展示
|
||||
- 多主题支持:系统、明亮、黑暗三种主题模式
|
||||
- 零外部依赖:数据存储使用 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。
|
||||
|
||||
## 应用截图
|
||||
|
||||
| | 亮色 | 暗色 |
|
||||
|
||||
@@ -34,3 +34,18 @@ Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶
|
||||
#### Scenario: 页面背景色
|
||||
- **WHEN** Dashboard 页面渲染
|
||||
- **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 内部类名
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
## Purpose
|
||||
|
||||
定义系统运行时元数据 API:checker 类型列表等元信息的对外暴露方式。
|
||||
定义系统运行时元数据 API:checker 类型列表、应用版本号等元信息的对外暴露方式。
|
||||
|
||||
## Requirements
|
||||
|
||||
### 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`
|
||||
- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[] }`,包含所有已注册的 checker 类型标识符(如 `["http", "cmd"]`)
|
||||
- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[], version: string }`,其中 `checkerTypes` 包含所有已注册的 checker 类型标识符(如 `["http", "cmd"]`),`version` 为当前运行实例的 `MAJOR.MINOR.PATCH` 应用版本
|
||||
|
||||
#### Scenario: 类型列表来源
|
||||
- **WHEN** 系统启动并注册了 checker
|
||||
- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致
|
||||
|
||||
#### Scenario: 版本号来源
|
||||
- **WHEN** 系统启动并确定应用版本
|
||||
- **THEN** `/api/meta` 返回的 `version` SHALL 与启动时注入的应用版本完全一致
|
||||
|
||||
#### Scenario: 不支持的 method 请求
|
||||
- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta`
|
||||
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应
|
||||
@@ -24,4 +28,4 @@
|
||||
|
||||
#### Scenario: MetaResponse 类型定义
|
||||
- **WHEN** 前后端引用 `MetaResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段
|
||||
- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 和 `version: string` 字段
|
||||
|
||||
@@ -66,3 +66,18 @@ executable MUST 将环境相关运行时配置保留在嵌入的前端和 server
|
||||
#### Scenario: 验证失败
|
||||
- **WHEN** 质量检查或构建阶段失败
|
||||
- **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 返回升迁后的版本号
|
||||
|
||||
@@ -47,7 +47,18 @@
|
||||
|
||||
#### Scenario: 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 文件拆分
|
||||
数据层 hook SHALL 按职责拆分为独立文件。
|
||||
|
||||
54
openspec/specs/version-management/spec.md
Normal file
54
openspec/specs/version-management/spec.md
Normal 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>` 的用途
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "dial-server",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -16,7 +17,11 @@
|
||||
"test": "bun test",
|
||||
"clean": "bun run scripts/clean.ts",
|
||||
"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": {
|
||||
"@commitlint/cli": "^21.0.1",
|
||||
|
||||
@@ -2,10 +2,13 @@ import { readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { validateVersion } from "./bump-version-logic";
|
||||
|
||||
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
const distWebDir = join(projectRoot, "dist/web");
|
||||
const buildDir = join(projectRoot, ".build");
|
||||
const executablePath = join(projectRoot, "dist/dial-server");
|
||||
const packageJsonPath = join(projectRoot, "package.json");
|
||||
|
||||
async function build() {
|
||||
try {
|
||||
@@ -60,6 +63,14 @@ async function codeGeneration() {
|
||||
await rm(buildDir, { force: true, recursive: true });
|
||||
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 importLines: string[] = [];
|
||||
const fileEntries: string[] = [];
|
||||
@@ -104,9 +115,11 @@ async function codeGeneration() {
|
||||
`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 });`,
|
||||
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||
`}`,
|
||||
"",
|
||||
`void main().catch((error) => {`,
|
||||
|
||||
40
scripts/bump-version-logic.ts
Normal file
40
scripts/bump-version-logic.ts
Normal 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
45
scripts/bump-version.ts
Normal 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();
|
||||
@@ -28,6 +28,7 @@ export interface BootstrapOptions {
|
||||
configPath: string;
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StaticAssets;
|
||||
version: string;
|
||||
}
|
||||
|
||||
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
|
||||
@@ -73,6 +74,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
|
||||
mode: options.mode,
|
||||
staticAssets: options.staticAssets,
|
||||
store,
|
||||
version: options.version,
|
||||
});
|
||||
} catch (error) {
|
||||
engine?.stop();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { readRuntimeConfig } from "./config";
|
||||
import { readAppVersion } from "./version";
|
||||
|
||||
async function main() {
|
||||
const { configPath } = readRuntimeConfig();
|
||||
await bootstrap({ configPath, mode: "development" });
|
||||
const version = await readAppVersion();
|
||||
await bootstrap({ configPath, mode: "development", version });
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { readRuntimeConfig } from "./config";
|
||||
import { readAppVersion } from "./version";
|
||||
|
||||
async function main() {
|
||||
const { configPath } = readRuntimeConfig();
|
||||
await bootstrap({ configPath, mode: "production" });
|
||||
const version = await readAppVersion();
|
||||
await bootstrap({ configPath, mode: "production", version });
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { MetaResponse, RuntimeMode } from "../../shared/api";
|
||||
import { checkerRegistry } from "../checker/runner";
|
||||
import { jsonResponse } from "../helpers";
|
||||
|
||||
export function handleMeta(mode: RuntimeMode): Response {
|
||||
export function handleMeta(mode: RuntimeMode, version: string): Response {
|
||||
const response: MetaResponse = {
|
||||
checkerTypes: checkerRegistry.supportedTypes,
|
||||
version,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { mode });
|
||||
|
||||
@@ -16,10 +16,11 @@ export interface StartServerOptions {
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StaticAssets;
|
||||
store: ProbeStore;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function startServer(options: StartServerOptions) {
|
||||
const { config, mode, staticAssets, store } = options;
|
||||
const { config, mode, staticAssets, store, version } = options;
|
||||
|
||||
const server = Bun.serve({
|
||||
fetch(req) {
|
||||
@@ -36,7 +37,7 @@ export function startServer(options: StartServerOptions) {
|
||||
GET: (req) => handleDashboard(new URL(req.url), store, mode),
|
||||
},
|
||||
"/api/meta": {
|
||||
GET: () => handleMeta(mode),
|
||||
GET: () => handleMeta(mode, version),
|
||||
},
|
||||
"/api/targets/:id/history": {
|
||||
GET: (req) => handleHistory(req.params.id, new URL(req.url), store, mode),
|
||||
|
||||
17
src/server/version.ts
Normal file
17
src/server/version.ts
Normal 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;
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export interface HistoryResponse {
|
||||
|
||||
export interface MetaResponse {
|
||||
checkerTypes: string[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface RecentSample {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Alert, Layout, Menu, RadioGroup, Skeleton } from "tdesign-react";
|
||||
import { RefreshCountdown } from "./components/RefreshCountdown";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
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 { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
|
||||
|
||||
@@ -46,6 +46,7 @@ export function App() {
|
||||
isLoading: dashboardLoading,
|
||||
refetch: refetchDashboard,
|
||||
} = useDashboard(dashboardRefetchInterval);
|
||||
const { data: meta } = useMeta();
|
||||
const {
|
||||
activeTab,
|
||||
closeDrawer,
|
||||
@@ -62,6 +63,7 @@ export function App() {
|
||||
timeTo,
|
||||
} = useTargetDetail();
|
||||
const isManualRefresh = refreshInterval === 0;
|
||||
const versionDisplay = meta?.version ? `v${meta.version}` : null;
|
||||
|
||||
const handleIntervalChange = (value: number) => {
|
||||
void refetchDashboard();
|
||||
@@ -80,6 +82,7 @@ export function App() {
|
||||
<span className="dashboard-brand">
|
||||
<span className="dashboard-logo">DiAL</span>
|
||||
<span className="dashboard-subtitle">统一拨测平台</span>
|
||||
{versionDisplay && <span className="dashboard-version">{versionDisplay}</span>}
|
||||
</span>
|
||||
}
|
||||
operations={
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
70
tests/scripts/build.test.ts
Normal file
70
tests/scripts/build.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
94
tests/scripts/bump-version.test.ts
Normal file
94
tests/scripts/bump-version.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,7 @@ describe("API 路由", () => {
|
||||
config: { host: "127.0.0.1", port: 0 },
|
||||
mode: "test",
|
||||
store,
|
||||
version: "0.1.0",
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
@@ -235,7 +236,7 @@ describe("API 路由", () => {
|
||||
expect(invalidLimit.status).toBe(400);
|
||||
});
|
||||
|
||||
test("/api/meta 返回 checker 类型列表", async () => {
|
||||
test("/api/meta 返回 checker 类型列表和版本号", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/meta`);
|
||||
const body = (await response.json()) as MetaResponse;
|
||||
|
||||
@@ -243,6 +244,7 @@ describe("API 路由", () => {
|
||||
expect(body.checkerTypes).toEqual(checkerRegistry.supportedTypes);
|
||||
expect(body.checkerTypes).toContain("http");
|
||||
expect(body.checkerTypes).toContain("cmd");
|
||||
expect(body.version).toBe("0.1.0");
|
||||
});
|
||||
|
||||
test("不支持的 method 在有 API 通配符时返回 404", async () => {
|
||||
@@ -410,6 +412,7 @@ describe("API 路由", () => {
|
||||
config: { host: "127.0.0.1", port: 0 },
|
||||
mode: "production",
|
||||
store,
|
||||
version: "0.1.0",
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${prodServer.port}/api/dashboard`);
|
||||
|
||||
@@ -77,6 +77,7 @@ function createHarness(overrides: BootstrapDependencies = {}) {
|
||||
startServer(options) {
|
||||
expect(options.config).toEqual({ host: config.host, port: config.port });
|
||||
expect(options.store).toBe(store);
|
||||
expect(options.version).toBe("0.1.0");
|
||||
calls.push(`startServer:${options.mode}`);
|
||||
},
|
||||
...overrides,
|
||||
@@ -89,7 +90,7 @@ describe("bootstrap", () => {
|
||||
test("开发模式执行完整启动序列", async () => {
|
||||
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([
|
||||
"loadConfig:/tmp/probes.yaml",
|
||||
@@ -106,7 +107,7 @@ describe("bootstrap", () => {
|
||||
test("收到退出信号时停止 engine 并关闭 store", async () => {
|
||||
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");
|
||||
|
||||
@@ -122,7 +123,7 @@ describe("bootstrap", () => {
|
||||
|
||||
let error: unknown;
|
||||
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) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ function installMatchMedia(initialMatches: boolean) {
|
||||
void vi.mock("../../../src/web/hooks/use-queries", () => ({
|
||||
useDashboard: vi.fn(() => createDashboardResult()),
|
||||
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));
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user