diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e85f035..0375a2b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 ` | 显式设置版本号 | + +**版本展示:** + +- 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 diff --git a/README.md b/README.md index 311edcf..7f6f7f2 100644 --- a/README.md +++ b/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。 + ## 应用截图 | | 亮色 | 暗色 | diff --git a/openspec/specs/dashboard-layout/spec.md b/openspec/specs/dashboard-layout/spec.md index 929fa6d..1dc2b34 100644 --- a/openspec/specs/dashboard-layout/spec.md +++ b/openspec/specs/dashboard-layout/spec.md @@ -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 内部类名 diff --git a/openspec/specs/meta-api/spec.md b/openspec/specs/meta-api/spec.md index f8c1993..0ffefc7 100644 --- a/openspec/specs/meta-api/spec.md +++ b/openspec/specs/meta-api/spec.md @@ -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` 字段 diff --git a/openspec/specs/single-executable-packaging/spec.md b/openspec/specs/single-executable-packaging/spec.md index 54e625f..69ea171 100644 --- a/openspec/specs/single-executable-packaging/spec.md +++ b/openspec/specs/single-executable-packaging/spec.md @@ -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 返回升迁后的版本号 diff --git a/openspec/specs/tanstack-query-data-layer/spec.md b/openspec/specs/tanstack-query-data-layer/spec.md index e61d01b..f72ed45 100644 --- a/openspec/specs/tanstack-query-data-layer/spec.md +++ b/openspec/specs/tanstack-query-data-layer/spec.md @@ -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 按职责拆分为独立文件。 diff --git a/openspec/specs/version-management/spec.md b/openspec/specs/version-management/spec.md new file mode 100644 index 0000000..a4147f4 --- /dev/null +++ b/openspec/specs/version-management/spec.md @@ -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 ` 的用途 diff --git a/package.json b/package.json index 2e94a0b..ee80d7c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build.ts b/scripts/build.ts index ee39a88..66e197c 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -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) => {`, diff --git a/scripts/bump-version-logic.ts b/scripts/bump-version-logic.ts new file mode 100644 index 0000000..a8fab63 --- /dev/null +++ b/scripts/bump-version-logic.ts @@ -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)`); + } +} diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts new file mode 100644 index 0000000..f4e868d --- /dev/null +++ b/scripts/bump-version.ts @@ -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 [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 "); + 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(); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 8f29bd8..1a981e2 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -28,6 +28,7 @@ export interface BootstrapOptions { configPath: string; mode: RuntimeMode; staticAssets?: StaticAssets; + version: string; } type BootstrapEngine = Pick; @@ -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(); diff --git a/src/server/dev.ts b/src/server/dev.ts index a88019c..10ac5cc 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -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) => { diff --git a/src/server/main.ts b/src/server/main.ts index a6f3d24..d2999a8 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -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) => { diff --git a/src/server/routes/meta.ts b/src/server/routes/meta.ts index 60350fc..a83952b 100644 --- a/src/server/routes/meta.ts +++ b/src/server/routes/meta.ts @@ -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 }); diff --git a/src/server/server.ts b/src/server/server.ts index e95eae8..e05c79c 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -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), diff --git a/src/server/version.ts b/src/server/version.ts new file mode 100644 index 0000000..15de640 --- /dev/null +++ b/src/server/version.ts @@ -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 { + 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; +} diff --git a/src/shared/api.ts b/src/shared/api.ts index f2c98d0..4e262bc 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -58,6 +58,7 @@ export interface HistoryResponse { export interface MetaResponse { checkerTypes: string[]; + version: string; } export interface RecentSample { diff --git a/src/web/app.tsx b/src/web/app.tsx index 82f0669..ad726fb 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -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() { DiAL 统一拨测平台 + {versionDisplay && {versionDisplay}} } operations={ diff --git a/src/web/styles.css b/src/web/styles.css index 38f952b..cf54165 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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; diff --git a/tests/scripts/build.test.ts b/tests/scripts/build.test.ts new file mode 100644 index 0000000..c03fa58 --- /dev/null +++ b/tests/scripts/build.test.ts @@ -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"'); + }); +}); diff --git a/tests/scripts/bump-version.test.ts b/tests/scripts/bump-version.test.ts new file mode 100644 index 0000000..af2d4c8 --- /dev/null +++ b/tests/scripts/bump-version.test.ts @@ -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); + }); +}); diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 9558f48..b407097 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -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`); diff --git a/tests/server/bootstrap.test.ts b/tests/server/bootstrap.test.ts index 905240d..464e68b 100644 --- a/tests/server/bootstrap.test.ts +++ b/tests/server/bootstrap.test.ts @@ -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; } diff --git a/tests/web/components/App.test.tsx b/tests/web/components/App.test.tsx index bbc6f03..3bfda48 100644 --- a/tests/web/components/App.test.tsx +++ b/tests/web/components/App.test.tsx @@ -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(); + expect(screen.getByText("v0.1.0")).not.toBeNull(); + }); + + test("缺失版本时不展示版本占位", () => { + const { useMeta } = require("../../../src/web/hooks/use-queries"); + useMeta.mockReturnValue({ + data: { checkerTypes: ["http", "cmd"] }, + }); + + render(); + expect(screen.queryByText(/v\d+\.\d+\.\d+/)).toBeNull(); + }); + + test("复用 useMeta 查询结果", () => { + const { useMeta } = require("../../../src/web/hooks/use-queries"); + render(); + expect(useMeta).toHaveBeenCalled(); + }); });