Compare commits
8 Commits
3b9006345e
...
a5cf6065c2
| Author | SHA1 | Date | |
|---|---|---|---|
| a5cf6065c2 | |||
| ce8baae3d1 | |||
| e1c33b4002 | |||
| f7facb7232 | |||
| 696db6ffb5 | |||
| c677b4f756 | |||
| 9f2b906063 | |||
| 3e8d01715f |
1
.husky/commit-msg
Executable file
1
.husky/commit-msg
Executable file
@@ -0,0 +1 @@
|
||||
bunx commitlint --edit $1
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
bunx lint-staged
|
||||
4
.lintstagedrc.json
Normal file
4
.lintstagedrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
|
||||
"*.{md,json,yaml,yml}": ["prettier --write"]
|
||||
}
|
||||
@@ -7,3 +7,6 @@ bun.lock
|
||||
.opencode/
|
||||
.claude/
|
||||
.codex/
|
||||
.agents/
|
||||
skills-lock.json
|
||||
data/
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"printWidth": 120
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
|
||||
707
DEVELOPMENT.md
Normal file
707
DEVELOPMENT.md
Normal file
@@ -0,0 +1,707 @@
|
||||
# DiAL 开发文档
|
||||
|
||||
本文档面向 DiAL 项目的开发者,介绍项目结构、构建流程、测试、代码规范等内容。
|
||||
|
||||
用户使用说明请参阅 [README.md](README.md)。
|
||||
|
||||
## 目录
|
||||
|
||||
- [项目结构](#项目结构)
|
||||
- [一、后端开发指引](#一后端开发指引)
|
||||
- [二、前端开发指引](#二前端开发指引)
|
||||
- [三、项目运行、集成与打包](#三项目运行集成与打包)
|
||||
- [代码质量](#代码质量)
|
||||
- [已知限制](#已知限制)
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
src/
|
||||
server/
|
||||
app.ts Bun HTTP 路由入口(路由分发 + API 汇聚)
|
||||
config.ts CLI 参数解析
|
||||
dev.ts 生产/开发启动入口
|
||||
server.ts HTTP server 启动工厂
|
||||
helpers.ts 共享响应格式化工具(jsonResponse、createHeaders 等)
|
||||
middleware.ts API 参数校验中间件(guardGetHead、validateTargetId 等)
|
||||
static.ts 静态资源服务与 SPA fallback
|
||||
routes/ API 路由 handler(按端点拆分)
|
||||
health.ts GET /health
|
||||
summary.ts GET /api/summary
|
||||
targets.ts GET /api/targets
|
||||
history.ts GET /api/targets/:id/history
|
||||
trend.ts GET /api/targets/:id/trend
|
||||
checker/
|
||||
types.ts 类型定义
|
||||
config-loader.ts YAML 配置解析与校验
|
||||
store.ts SQLite 数据存储
|
||||
engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制)
|
||||
size.ts 大小单位解析
|
||||
runner/ Checker 统一抽象与注册机制
|
||||
types.ts Checker 接口、CheckerContext、ResolveContext
|
||||
registry.ts CheckerRegistry 注册中心
|
||||
index.ts 注册入口(registerCheckers)
|
||||
shared/ 共享 expect 断言函数(跨 checker 复用)
|
||||
failure.ts 失败信息类型
|
||||
operator.ts 操作符系统(applyOperator、evaluateJsonPath)
|
||||
duration.ts 耗时断言
|
||||
text.ts 文本规则断言
|
||||
body.ts Body 规则断言(JSONPath/XPath/CSS/contains/regex)
|
||||
http/ HTTP Checker 子包
|
||||
runner.ts HttpChecker(resolve/execute/serialize)
|
||||
expect.ts HTTP 专用断言(status/headers)
|
||||
command/ Command Checker 子包
|
||||
runner.ts CommandChecker(resolve/execute/serialize)
|
||||
expect.ts Command 专用断言(exitCode)
|
||||
shared/
|
||||
api.ts 前后端共享 TypeScript 类型
|
||||
web/ Vite + React 前端 Dashboard
|
||||
components/ UI 组件(表格、分组、Drawer、状态条等)
|
||||
constants/ 常量定义(列配置、类型映射、排序/筛选/颜色阈值函数)
|
||||
hooks/ TanStack Query 数据层(useTargetDetail 集成轮询/条件查询)
|
||||
utils/ 前端工具函数
|
||||
scripts/ 开发、构建和 smoke test 脚本
|
||||
tests/ Bun test 测试
|
||||
openspec/ OpenSpec 变更与规格文档
|
||||
```
|
||||
|
||||
## 前后端边界
|
||||
|
||||
前端只通过 HTTP 调用后端,API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。
|
||||
|
||||
---
|
||||
|
||||
## 一、后端开发指引
|
||||
|
||||
### 1.1 架构概览
|
||||
|
||||
```
|
||||
启动流程:
|
||||
dev.ts → readRuntimeConfig(cli args) → loadConfig(yaml)
|
||||
→ ProbeStore(db) → ProbeEngine(store, targets) → startServer(store)
|
||||
|
||||
运行时:
|
||||
定时器(tick) → ProbeEngine.probeGroup()
|
||||
→ HTTP: fetcher.ts / Command: command-runner.ts
|
||||
→ runner/*/expect.ts 校验 → store.insertCheckResult()
|
||||
|
||||
HTTP 请求:
|
||||
Request → app.ts(路由分发) → routes/*.ts(handler)
|
||||
→ middleware.ts(参数校验) → helpers.ts(响应格式化) → Response
|
||||
```
|
||||
|
||||
### 1.2 库使用优先级
|
||||
|
||||
后端代码开发遵循严格的库选择顺序:
|
||||
|
||||
| 优先级 | 来源 | 典型用途 |
|
||||
| ------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Bun 内置 API | `Bun.serve`、`bun:sqlite`、`Bun.spawn`、`Bun.file`、`Bun.YAML` |
|
||||
| 2 | es-toolkit | 类型判断(`isPlainObject`/`isNil`/`isEmptyObject`)、深度比较(`isEqual`)、错误判断(`isError`)、并发控制(`Semaphore`)、集合操作(`groupBy`) |
|
||||
| 3 | 标准 Web API | `Object.fromEntries`、`Headers`、`fetch`、`AbortController` |
|
||||
| 4 | 主流三方库 | cheerio(HTML 解析)、xpath + @xmldom/xmldom(XML 解析) |
|
||||
| 5 | 自行实现 | 仅在以上都无法满足时(如 `parseDuration`、`parseSize`、`evaluateJsonPath` 等专项逻辑) |
|
||||
|
||||
**原则**:新增依赖前先检查上述每一层级是否已有可用方案。禁止随意引入新依赖。
|
||||
|
||||
### 1.3 API 路由开发
|
||||
|
||||
路由文件位于 `src/server/routes/`,每个端点一个文件。handler 函数签名统一为:
|
||||
|
||||
```typescript
|
||||
export function handleXxx(params, store: ProbeStore, method: string, mode: RuntimeMode): Response;
|
||||
```
|
||||
|
||||
**请求处理流程**:
|
||||
|
||||
1. `app.ts` 的 `createFetchHandler` 作为总入口,根据 URL pattern 匹配路由
|
||||
2. API 路由统一经过 `guardGetHead` 做方法检查(仅允许 GET/HEAD)
|
||||
3. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId`、`validateTimeRange`、`validatePagination` 做参数校验
|
||||
4. 校验函数返回 `Response` 表示校验失败(直接返回),返回数据对象表示通过
|
||||
5. 业务逻辑通过 `store` 查询数据,用 `helpers.ts` 的 `jsonResponse`、`mapCheckResult`、`formatDuration` 等格式化输出
|
||||
|
||||
**新增路由步骤**:
|
||||
|
||||
1. 在 `src/server/routes/` 下创建 `<name>.ts`
|
||||
2. 实现 handler 函数并 export
|
||||
3. 在 `app.ts` 的 `createFetchHandler` 中注册路径匹配和调用
|
||||
4. 在 `tests/server/app.test.ts` 中添加对应测试
|
||||
|
||||
### 1.4 共享工具
|
||||
|
||||
- **`helpers.ts`**:跨路由共用的响应工具函数(`jsonResponse`、`createHeaders`、`createApiError`、`mapCheckResult`、`formatDuration`、`createHealthResponse`)
|
||||
- **`middleware.ts`**:API 参数校验函数(`guardGetHead`、`validateTargetId`、`validateTimeRange`、`validatePagination`)
|
||||
- **`static.ts`**:生产模式下的静态资源服务与 SPA fallback
|
||||
|
||||
### 1.5 类型定义规范
|
||||
|
||||
- **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用
|
||||
- 前端不得 `import src/server/` 下的任何文件
|
||||
- **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string`
|
||||
- **后端内部扩展**:`checker/types.ts` 中 `CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段
|
||||
- 存储层类型(`StoredTarget`、`StoredCheckResult`)独立定义,与 API 类型分离
|
||||
- 配置类型(`ProbeConfig`、`TargetConfig`)支持 discriminated union,通过 `type` 字段区分 http/command
|
||||
|
||||
### 1.6 数据存储规范
|
||||
|
||||
基于 `bun:sqlite`,WAL 模式运行,数据库文件位于配置的 `dataDir` 下。
|
||||
|
||||
**Statement 使用规范**:
|
||||
|
||||
| 场景 | 方式 | 原因 |
|
||||
| -------------- | -------------------------------------- | ---------------------------------------- |
|
||||
| 单次读/写 | `this.db.query(sql).get()/all()/run()` | bun:sqlite 内置 statement 缓存,自动复用 |
|
||||
| 事务内多次复用 | `this.db.prepare(sql)` 缓存为局部变量 | 事务闭包中需要持有引用 |
|
||||
|
||||
**查询优化**:
|
||||
|
||||
- 避免 N+1 查询:批量场景优先用单次 SQL 聚合(GROUP BY、子查询 JOIN)+ 内存组装
|
||||
- 新增批量查询方法时必须编写对应单元测试
|
||||
- `getSummary()` 和 `GET /api/targets` 的响应组装已通过 `getLatestChecksMap` + `getAllTargetStats` 实现批量查询
|
||||
|
||||
**Schema**:
|
||||
|
||||
- `targets` 表:name(UNIQUE)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp
|
||||
- `check_results` 表:target_id(FK CASCADE)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON)
|
||||
- 复合索引:`(target_id, timestamp)`
|
||||
|
||||
### 1.7 拨测引擎
|
||||
|
||||
- **调度**:`ProbeEngine` 用 `es-toolkit/groupBy` 按 interval 分组,每组独立 `setInterval` 定时触发
|
||||
- **并发控制**:`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`),`acquire()` 阻塞等待
|
||||
- **Runner 选择**:`engine.runCheck()` 按 `target.type` 分发到 `runHttpCheck` 或 `runCommandCheck`
|
||||
- **超时控制**:HTTP 用 `AbortController`,Command 用 `setTimeout` + `proc.kill()`
|
||||
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 通过 `targetNameToId` 缓存 name→id 映射
|
||||
- **生命周期**:`start()`/`stop()` 管理定时器,`stop()` 清理所有 `setInterval`
|
||||
|
||||
### 1.8 expect 断言系统
|
||||
|
||||
两层模型:**观测值收集** → **规则校验**。
|
||||
|
||||
**HTTP 校验流程**:
|
||||
|
||||
```
|
||||
runHttpCheck → 收集观测(statusCode/headers/body/durationMs)
|
||||
→ checkHttpExpect → status → duration → headers → body(可选)
|
||||
→ 首个失败即停止,返回 CheckFailure
|
||||
```
|
||||
|
||||
**Command 校验流程**:
|
||||
|
||||
```
|
||||
runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
|
||||
→ checkCommandExpect → exitCode → duration → stdout → stderr
|
||||
→ 首个失败即停止
|
||||
```
|
||||
|
||||
**Body 规则类型**:
|
||||
|
||||
- `contains`:文本包含匹配
|
||||
- `regex`:正则表达式匹配
|
||||
- `json`:JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符)
|
||||
- `css`:cheerio CSS 选择器 + 操作符比较
|
||||
- `xpath`:XPath 节点提取 + 操作符比较
|
||||
|
||||
**操作符**:`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt`
|
||||
|
||||
### 1.9 错误模式
|
||||
|
||||
- **API 错误**:`{ error: "描述", status: <code> }`,状态码 400/404/405/503
|
||||
- **CheckFailure**:`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }`
|
||||
- **错误处理**:expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`
|
||||
- **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
|
||||
|
||||
### 1.10 测试规范
|
||||
|
||||
- 测试文件与源文件对应:`tests/server/checker/runner/shared/body.test.ts` ↔ `src/server/checker/runner/shared/body.ts`
|
||||
- 使用 `bun:test` 框架(`describe`/`test`/`expect`),测试数据库用临时目录 + `tmpdir()`
|
||||
- 新增 store 方法必须编写单元测试;新增 API 端点必须在 `app.test.ts` 中添加集成测试
|
||||
- 测试后清理:`afterAll` 中 `store.close()` + `rm(tempDir, { recursive: true })`
|
||||
|
||||
---
|
||||
|
||||
## 二、前端开发指引
|
||||
|
||||
### 2.1 技术栈概览
|
||||
|
||||
| 层面 | 技术 | 用途 |
|
||||
| ------ | ----------------------------------- | ---------------------------- |
|
||||
| 框架 | React 19 | UI 组件开发 |
|
||||
| 构建 | Vite 8 | 开发服务与生产构建 |
|
||||
| 语言 | TypeScript 6 | 类型安全 |
|
||||
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
|
||||
| 数据层 | TanStack Query (React Query) | 服务端状态管理与自动轮询 |
|
||||
| 图表 | Recharts | 拨测趋势折线图与状态环状图 |
|
||||
| 路由 | 无(单页面 Dashboard) | 仅需 Drawer/Tab 做页面内导航 |
|
||||
|
||||
**不引入的依赖**:React Router(单页面场景不需要)、状态管理库(TanStack Query 即服务端状态层,组件内用 `useState` 足够)
|
||||
|
||||
### 2.2 组件树与数据流
|
||||
|
||||
```
|
||||
main.tsx
|
||||
└── QueryClientProvider(TanStack Query 全局挂载)
|
||||
└── App(根组件)
|
||||
├── SummaryCards(总览统计卡片)
|
||||
│ └── useSummary() ─── GET /api/summary(8s 轮询)
|
||||
└── TargetBoard(目标列表)
|
||||
├── useTargets() ─── GET /api/targets(8s 轮询)
|
||||
└── TargetGroup[](按 group 字段分组)
|
||||
└── PrimaryTable ← TARGET_TABLE_COLUMNS(列定义:排序/筛选/渲染)
|
||||
└── TargetDetailDrawer(目标详情抽屉)
|
||||
└── useTargetDetail() ── 按需发起 trend + history 查询
|
||||
├── Tab: 概览 → Statistic + TrendChart + StatusDonut + Descriptions
|
||||
└── Tab: 记录 → PrimaryTable(分页历史记录)
|
||||
```
|
||||
|
||||
**数据层架构**:
|
||||
|
||||
```
|
||||
hooks/useTargetDetail.ts(唯一的数据层入口)
|
||||
├── queryKeys(结构化 query key,确保缓存粒度精确)
|
||||
├── useSummary() → /api/summary(8s 自动轮询)
|
||||
├── useTargets() → /api/targets(8s 自动轮询)
|
||||
└── useTargetDetail()(组合 hook,管理 Drawer 全部状态)
|
||||
├── 内部复用 useTargets() 的缓存来查找 selectedTarget
|
||||
├── useQuery(/api/targets/:id/trend)(条件查询:enabled 仅当 Drawer 打开且时间范围有效)
|
||||
└── useQuery(/api/targets/:id/history)(条件查询:含分页)
|
||||
```
|
||||
|
||||
### 2.3 TanStack Query 数据层
|
||||
|
||||
#### Query Key 规范
|
||||
|
||||
```typescript
|
||||
const queryKeys = {
|
||||
summary: () => ["summary"] as const,
|
||||
targets: () => ["targets"] as const,
|
||||
trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const,
|
||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||
};
|
||||
```
|
||||
|
||||
- Key 使用 **structured array**(非字符串),以便精确匹配和按 prefix 失效
|
||||
- 使用 `as const` 保持字面量类型
|
||||
- 排序:scope → id → 参数(粒度从粗到细)
|
||||
|
||||
#### 查询配置规范
|
||||
|
||||
```typescript
|
||||
// 全局面板级查询(需要持续刷新)
|
||||
useQuery({
|
||||
queryKey: queryKeys.summary(),
|
||||
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
||||
refetchInterval: 8000, // 自动轮询间隔
|
||||
refetchIntervalInBackground: false, // 切后台不轮询
|
||||
});
|
||||
|
||||
// 详情级查询(按需加载)
|
||||
useQuery({
|
||||
queryKey: selectedTargetId ? queryKeys.trend(id, from, to) : ["trend", "disabled"],
|
||||
queryFn: () => fetchJson(`/api/targets/${id}/trend?...`),
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo, // 条件查询
|
||||
});
|
||||
```
|
||||
|
||||
#### fetch 封装
|
||||
|
||||
```typescript
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
```
|
||||
|
||||
- 统一使用 `fetch`(不引入 axios),与后端共享 Web API 生态
|
||||
- 错误抛异常,由 TanStack Query 的 `error` 状态承接
|
||||
|
||||
#### QueryClient 全局配置
|
||||
|
||||
```typescript
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1, // 失败重试 1 次
|
||||
refetchOnWindowFocus: true, // 窗口聚焦时刷新
|
||||
staleTime: 5000, // 5s 内视为 fresh,避免重复请求
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 2.4 组件开发规范
|
||||
|
||||
#### 文件命名与导入
|
||||
|
||||
- 每个 React 组件一个 `.tsx` 文件,文件名使用 PascalCase(如 `StatusDot.tsx`)
|
||||
- 组件 props 定义为 `interface XxxProps`,紧邻组件函数声明
|
||||
- 类型从 `../../shared/api` 导入,使用 `type` 导入(`import type { ... }`)
|
||||
|
||||
```typescript
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
|
||||
interface TargetGroupProps {
|
||||
name: string;
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
}
|
||||
|
||||
export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 组件拆分原则
|
||||
|
||||
- **展示组件**(`components/`):纯渲染逻辑,通过 props 接收数据,通过回调返回事件
|
||||
- **容器逻辑**放在 hooks 中,组件只做数据消费
|
||||
- **常量数据**(列定义、排序器、筛选器)放在 `constants/`,不放在组件内部
|
||||
- **工具函数**(时间处理等)放在 `utils/`,保持纯函数无副作用
|
||||
|
||||
#### 现有组件清单
|
||||
|
||||
| 组件 | 文件 | 用途 |
|
||||
| -------------------- | ----------------------------------- | ---------------------------------- |
|
||||
| `App` | `app.tsx` | 根组件,编排全局状态与布局 |
|
||||
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) |
|
||||
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 |
|
||||
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组标题 + PrimaryTable |
|
||||
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(概览/记录 Tab) |
|
||||
| `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) |
|
||||
| `StatusDonut` | `components/StatusDonut.tsx` | Recharts 环状图(UP/DOWN 分布) |
|
||||
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
|
||||
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块) |
|
||||
| `GroupHeader` | `components/GroupHeader.tsx` | 分组标题(名称 + 统计) |
|
||||
|
||||
### 2.5 新增功能开发步骤
|
||||
|
||||
以"新增一个详情页面 Tab"为例:
|
||||
|
||||
1. **确认数据需求**:是已有 API 数据还是需要新端点?
|
||||
- 如有新端点,先在 `src/server/routes/` 添加,参考 [1.3 新增路由步骤](#13-api-路由开发)
|
||||
- 如有新字段,更新 `src/shared/api.ts` 类型定义
|
||||
2. **实现 hooks**:在 `src/web/hooks/useTargetDetail.ts` 中新增 `useQuery`(写好 `queryKey` 和 `enabled` 条件)
|
||||
3. **编写组件**:在 `src/web/components/` 创建组件文件
|
||||
- 在 `TargetDetailDrawer.tsx` 中新增 `<Tabs.TabPanel>` 引用
|
||||
4. **编写常量**:如有列定义/排序器/筛选器,放在 `src/web/constants/`
|
||||
5. **编写测试**:在 `tests/web/` 下添加对应的单元测试
|
||||
|
||||
### 2.6 样式开发规范
|
||||
|
||||
前端基于 TDesign React 构建 UI,样式开发遵循以下优先级(从高到低):
|
||||
|
||||
1. **使用 TDesign 组件**:布局、间距、排版优先使用 TDesign 组件(如 Space、Divider、Typography)
|
||||
2. **使用 TDesign 组件 props**:通过组件的 props 参数控制外观(如 `theme`、`variant`、`size`)
|
||||
3. **使用 TDesign CSS tokens**:颜色、间距、字体等使用 `--td-*` CSS 变量(如 `--td-success-color`、`--td-comp-margin-xxl`)
|
||||
4. **在 styles.css 中定义 CSS 类**:无法通过上述方式满足的样式需求,集中定义在 `styles.css` 中
|
||||
5. **自行开发组件**:仅在 TDesign 无法满足需求时自行开发
|
||||
|
||||
**红线**:
|
||||
|
||||
- **严禁在组件中使用 `style` 属性内联调整样式**
|
||||
- **严禁通过 CSS 覆盖 TDesign 组件内部类名**(如 `.t-tab-panel`),如需定制使用组件的 `className` prop
|
||||
- **严禁使用 `!important`**
|
||||
- 颜色统一使用 TDesign CSS tokens(`--td-success-color`、`--td-error-color`、`--td-warning-color` 等),不使用硬编码色值
|
||||
|
||||
**styles.css 组织**:
|
||||
|
||||
- 自定义 CSS 变量(如可用率渐变色 `--avail-0` ~ `--avail-9`)定义在 `:root` 中
|
||||
- 布局类(`.dashboard`、`.dashboard-header`)定义全局页面结构
|
||||
- 组件修饰类(`.status-dot--up`、`.latency-ok`)为自定义视觉组件提供样式变体
|
||||
- TDesign 表格行高亮(`.row-down`)通过 `rowClassName` prop 应用
|
||||
|
||||
### 2.7 前端测试规范
|
||||
|
||||
- 测试目录:`tests/web/`,结构对应 `src/web/`
|
||||
- 重点测试 **constants/** 中的纯函数(排序器、筛选器、颜色阈值等)
|
||||
- 使用 `bun:test` 框架
|
||||
|
||||
---
|
||||
|
||||
## 三、项目运行、集成与打包
|
||||
|
||||
### 3.1 开发期运行
|
||||
|
||||
#### 同时启动前后端
|
||||
|
||||
```bash
|
||||
bun run dev probes.yaml
|
||||
```
|
||||
|
||||
`scripts/dev.ts` 通过 `Bun.spawn` 同时启动两个子进程:
|
||||
|
||||
```
|
||||
bun run dev probes.yaml
|
||||
├── bun run dev:server probes.yaml → Bun HTTP 后端(默认 3000 端口)
|
||||
└── bun run dev:web → Vite 前端开发服务器(5173 端口)
|
||||
```
|
||||
|
||||
- 任一子进程退出会导致整体退出
|
||||
- `SIGINT`/`SIGTERM` 信号会同时终止两个子进程
|
||||
- `BACKEND_PORT` 环境变量可覆盖后端端口
|
||||
|
||||
#### 分别启动
|
||||
|
||||
```bash
|
||||
# 启动后端(含 watch 模式自动重启)
|
||||
bun run dev:server probes.yaml
|
||||
|
||||
# 另开终端启动前端
|
||||
bun run dev:web
|
||||
```
|
||||
|
||||
### 3.2 前后端集成方式
|
||||
|
||||
#### 开发期代理
|
||||
|
||||
Vite 配置了开发代理(`vite.config.ts`):
|
||||
|
||||
```typescript
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: `http://127.0.0.1:${backendPort}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
前端访问 `/api/*` 时,Vite 开发服务器自动转发到后端 `http://127.0.0.1:${backendPort}`,无需 CORS 配置。
|
||||
|
||||
前端开发地址为 `http://127.0.0.1:5173`(严格端口 `strictPort: true`)。
|
||||
|
||||
后端在开发模式下不提供静态资源服务,访问 `http://127.0.0.1:3000` 会提示"请通过 Vite 前端地址访问"。
|
||||
|
||||
#### 生产期集成
|
||||
|
||||
生产可执行文件是单体应用:前端静态资源嵌入 binary,后端同时提供 API 和静态文件服务。
|
||||
|
||||
```
|
||||
./dist/dial-server probes.yaml
|
||||
|
||||
启动后:
|
||||
访问 http://127.0.0.1:3000/ → 返回前端 SPA(index.html)
|
||||
访问 http://127.0.0.1:3000/api/* → 返回后端 API
|
||||
访问 /assets/* → 返回带不可变缓存的静态资源
|
||||
```
|
||||
|
||||
SPA fallback 逻辑(`src/server/static.ts`):
|
||||
|
||||
- `/` → index.html
|
||||
- 匹配 `/assets/*` → 返回对应文件(未匹配则 404)
|
||||
- 其他路径(如 `/dashboard`)→ fallback 到 index.html(SPA 路由)
|
||||
|
||||
### 3.3 构建打包
|
||||
|
||||
#### 构建命令
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
#### 构建流程详解
|
||||
|
||||
`scripts/build.ts` 执行以下步骤:
|
||||
|
||||
```
|
||||
1. vite build
|
||||
├── 入口:src/web/index.html
|
||||
└── 输出:dist/web/(index.html + assets/)
|
||||
|
||||
2. 生成 .build/static-assets.ts(临时文件)
|
||||
├── import Vite 产物为 Bun.file
|
||||
└── 导出 staticAssets: StaticAssets 对象
|
||||
|
||||
3. 生成 .build/server-entry.ts(临时文件)
|
||||
└── import 后端入口模块 + staticAssets,作为 Bun.build 入口
|
||||
|
||||
4. Bun.build({ compile, minify, sourcemap: "linked" })
|
||||
└── 输出:dist/dial-server(单文件可执行 binary)
|
||||
```
|
||||
|
||||
#### 产物
|
||||
|
||||
| 产物 | 用途 |
|
||||
| ------------------ | -------------------------- |
|
||||
| `dist/dial-server` | 生产可执行文件 |
|
||||
| `dist/web/` | Vite 构建产物(中间产物) |
|
||||
| `.build/` | 临时生成文件(构建后清理) |
|
||||
|
||||
#### 构建参数
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
| --------------------------- | -------------------------------------- |
|
||||
| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(如 `bun-linux-x64`) |
|
||||
|
||||
#### 运行可执行文件
|
||||
|
||||
```bash
|
||||
./dist/dial-server probes.yaml
|
||||
```
|
||||
|
||||
#### 清理
|
||||
|
||||
```bash
|
||||
bun run clean
|
||||
# 清理 .build/ 缓存和 *.bun-build 临时文件
|
||||
```
|
||||
|
||||
### 3.4 开发工作流
|
||||
|
||||
#### 日常开发循环
|
||||
|
||||
```bash
|
||||
bun run dev probes.yaml # 启动开发环境
|
||||
# 修改代码 → Vite HMR(前端)/ bun --watch(后端自动重启)
|
||||
bun run check # 提交前运行完整质量检查
|
||||
```
|
||||
|
||||
#### 完整验证流程
|
||||
|
||||
```bash
|
||||
bun run verify
|
||||
# = bun run check + bun run build + bun run test:smoke
|
||||
```
|
||||
|
||||
`verify` 适合 CI 或正式提交前,会完整验证类型检查、lint、格式、单元测试、构建、smoke test。
|
||||
|
||||
### 3.5 Smoke Test
|
||||
|
||||
```bash
|
||||
bun run test:smoke
|
||||
```
|
||||
|
||||
`scripts/smoke.ts` 构建后验证流程:
|
||||
|
||||
1. 动态分配空闲端口
|
||||
2. 用临时配置文件启动 `dist/dial-server`
|
||||
3. 等待健康检查通过
|
||||
4. 验证所有 API 端点返回正确数据
|
||||
5. 验证静态资源服务(含 SPA fallback 和 404 处理)
|
||||
6. 验证安全 headers
|
||||
7. 测试结束清理临时目录和进程
|
||||
|
||||
### 3.6 脚本说明
|
||||
|
||||
| 脚本 | 文件 | 说明 |
|
||||
| -------------------- | ------------------ | ------------------------------ |
|
||||
| `bun run dev` | `scripts/dev.ts` | 同时启动前后端开发服务 |
|
||||
| `bun run build` | `scripts/build.ts` | Vite 构建 + Bun 编译可执行文件 |
|
||||
| `bun run test:smoke` | `scripts/smoke.ts` | 构建后的端到端验证 |
|
||||
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
|
||||
|
||||
### 3.7 环境变量
|
||||
|
||||
| 变量 | 用途 | 默认值 |
|
||||
| --------------------------- | ---------------------------------------------------- | -------- |
|
||||
| `PORT`/`BACKEND_PORT` | 后端监听端口(开发期 Vite 代理目标、生产期监听端口) | `3000` |
|
||||
| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 |
|
||||
|
||||
### 3.8 项目配置文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --------------------- | ---------------------------------------------- |
|
||||
| `package.json` | 项目信息、脚本、依赖声明 |
|
||||
| `tsconfig.json` | TypeScript 配置(ESNext 模块、严格模式) |
|
||||
| `vite.config.ts` | Vite 开发代理与构建配置 |
|
||||
| `eslint.config.js` | ESLint 规则(含前端不得 import server 的检查) |
|
||||
| `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120`) |
|
||||
| `.prettierignore` | Prettier 排除路径 |
|
||||
| `probes.example.yaml` | 配置文件示例 |
|
||||
| `opencode.json` | OpenCode 工具配置(TDesign MCP server) |
|
||||
|
||||
### 3.9 依赖管理
|
||||
|
||||
- **包管理器**:仅使用 `bun`,禁止使用 npm、pnpm、yarn
|
||||
- **安装依赖**:`bun install`
|
||||
- **运行工具**:使用 `bunx`,禁止使用 `npx`、`pnpx`
|
||||
- **锁文件**:`bun.lock`
|
||||
|
||||
### 3.10 目录约定
|
||||
|
||||
| 目录 | 约定 |
|
||||
| ------------- | -------------------------------------------- |
|
||||
| `src/server/` | 后端代码,不能 import `src/web/` |
|
||||
| `src/web/` | 前端代码,不能 import `src/server/` |
|
||||
| `src/shared/` | 前后端共享类型,双向可引用 |
|
||||
| `scripts/` | 独立运行脚本,可 import 项目源码 |
|
||||
| `tests/` | 测试目录,结构镜像 src 目录 |
|
||||
| `dist/` | 构建产物(gitignore) |
|
||||
| `.build/` | 构建临时文件(gitignore) |
|
||||
| `openspec/` | OpenSpec 变更管理与规格文档 |
|
||||
| `data/` | 默认数据目录(gitignore,运行期生成 SQLite) |
|
||||
|
||||
---
|
||||
|
||||
## 代码质量
|
||||
|
||||
项目使用多层代码质量保障体系:ESLint 类型感知规则 + Perfectionist 导入排序 + Prettier 格式化 + TypeScript 严格模式 + Git hooks 自动化。
|
||||
|
||||
```bash
|
||||
bun run lint # ESLint 检查(含类型感知规则、导入排序、导入验证)
|
||||
bun run format:check # Prettier 格式检查
|
||||
bun run format # Prettier 自动格式化
|
||||
bun run typecheck # TypeScript 类型检查(含 noUnusedLocals、noPropertyAccessFromIndexSignature)
|
||||
bun test # 运行所有测试
|
||||
bun run check # 一键运行 typecheck + lint + format:check + test
|
||||
```
|
||||
|
||||
`check` 是日常开发推荐的质量检查命令。
|
||||
|
||||
### ESLint 规则
|
||||
|
||||
配置文件:`eslint.config.js`
|
||||
|
||||
| 配置来源 | 用途 |
|
||||
| ------------------------------------------------- | -------------------------------------------------- |
|
||||
| `@eslint/js` recommended | JavaScript 基础规则 |
|
||||
| `typescript-eslint` recommended-type-checked | TypeScript 类型感知规则(no-floating-promises 等) |
|
||||
| `typescript-eslint` stylistic-type-checked | TypeScript 风格规则(命名规范、语法选择等) |
|
||||
| `eslint-plugin-perfectionist` recommended-natural | 导入语句和命名导出自动排序 |
|
||||
| `eslint-plugin-import` | 导入路径验证、循环依赖检测、重复导入合并 |
|
||||
|
||||
### Prettier 配置
|
||||
|
||||
配置文件:`.prettierrc.json`
|
||||
|
||||
显式声明所有格式化参数(`printWidth: 120`、`semi: true`、`singleQuote: false`、`trailingComma: "all"`、`endOfLine: "lf"` 等),确保不同开发环境产出完全一致的格式化结果。
|
||||
|
||||
### TypeScript 严格标志
|
||||
|
||||
| 标志 | 值 | 说明 |
|
||||
| ------------------------------------ | ----- | -------------------------------------------------------------------------- |
|
||||
| `strict` | true | 全局严格模式 |
|
||||
| `noUnusedLocals` | true | 未使用局部变量视为错误 |
|
||||
| `noUnusedParameters` | false | 保留关闭(路由 handler 统一签名需要,如 `handleXxx(store, method, mode)`) |
|
||||
| `noPropertyAccessFromIndexSignature` | true | 禁止通过点号访问索引签名属性,强制使用括号语法 |
|
||||
| `noUncheckedIndexedAccess` | true | 数组/Map 访问必须运行时真值检查 |
|
||||
| `verbatimModuleSyntax` | true | 强制 `import type` 纯类型导入 |
|
||||
|
||||
### Git Hooks
|
||||
|
||||
通过 husky 在 commit 阶段自动执行检查:
|
||||
|
||||
| Hook | 行为 |
|
||||
| ------------ | -------------------------------------------------------------- |
|
||||
| `pre-commit` | lint-staged 对变更文件运行 `eslint --fix` + `prettier --write` |
|
||||
| `commit-msg` | commitlint 校验提交信息格式 `类型: 简短描述` |
|
||||
|
||||
提交类型限定:`feat`、`fix`、`refactor`、`docs`、`style`、`test`、`chore`。
|
||||
|
||||
`bun install` 时自动初始化 husky hooks,无需手动配置。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
bun run check # 日常开发(类型检查 + lint + 格式 + 单元测试)
|
||||
bun run verify # 完整验证(check + 构建 + smoke test)
|
||||
```
|
||||
|
||||
## 已知限制
|
||||
|
||||
当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。Command 类型拨测不支持 Windows 环境。
|
||||
82
README.md
82
README.md
@@ -2,40 +2,6 @@
|
||||
|
||||
基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
src/
|
||||
server/
|
||||
app.ts Bun HTTP 路由(API + 静态资源 + SPA fallback)
|
||||
config.ts CLI 参数解析
|
||||
dev.ts 开发期启动入口
|
||||
server.ts HTTP server 启动
|
||||
checker/
|
||||
types.ts 类型定义
|
||||
config-loader.ts YAML 配置解析与校验
|
||||
store.ts SQLite 数据存储
|
||||
fetcher.ts HTTP 拨测执行
|
||||
command-runner.ts 命令行拨测执行
|
||||
size.ts 大小单位解析
|
||||
engine.ts 调度引擎(按 interval 分组、组内并发)
|
||||
expect/
|
||||
http.ts HTTP 响应断言
|
||||
command.ts 命令行输出断言
|
||||
body.ts HTTP body 断言(JSONPath/XPath/CSS)
|
||||
failure.ts 失败信息类型
|
||||
shared/
|
||||
api.ts 前后端共享 TypeScript 类型
|
||||
web/ Vite + React 前端 Dashboard
|
||||
components/ UI 组件(表格、分组、Drawer、状态条等)
|
||||
constants/ 常量定义(列配置、类型映射、排序/筛选/颜色阈值函数)
|
||||
hooks/ TanStack Query 数据层(useTargetDetail 集成轮询/条件查询)
|
||||
utils/ 前端工具函数
|
||||
scripts/ 开发、构建和 smoke test 脚本
|
||||
tests/ Bun test 测试
|
||||
openspec/ OpenSpec 变更与规格文档
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
@@ -211,36 +177,6 @@ API 错误返回 `ApiErrorResponse` 格式:
|
||||
| 404 | 目标不存在、API 路由未匹配 |
|
||||
| 405 | 非 GET 方法请求 API 路由 |
|
||||
|
||||
## 代码质量
|
||||
|
||||
```bash
|
||||
bun run lint
|
||||
bun run format:check
|
||||
bun run format
|
||||
bun run check
|
||||
```
|
||||
|
||||
- `check` 依次运行 `typecheck`、`lint`、`format:check` 和单元测试。
|
||||
|
||||
## 构建 executable
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
构建流程:
|
||||
|
||||
1. 运行 `vite build`,输出前端资源到 `dist/web`
|
||||
2. 生成临时 `.build/static-assets.ts`,嵌入 Vite 产物
|
||||
3. 生成临时 `.build/server-entry.ts`,作为生产入口
|
||||
4. 运行 `Bun.build({ compile })`,输出 `dist/dial-server`
|
||||
|
||||
运行 executable:
|
||||
|
||||
```bash
|
||||
./dist/dial-server probes.yaml
|
||||
```
|
||||
|
||||
## 运行参数
|
||||
|
||||
CLI 只接受一个参数:YAML 配置文件路径。
|
||||
@@ -249,20 +185,6 @@ CLI 只接受一个参数:YAML 配置文件路径。
|
||||
./dist/dial-server ./probes.yaml
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
bun run check
|
||||
bun run verify
|
||||
```
|
||||
|
||||
- `check` 适合日常开发,包含类型检查、lint、格式检查和单元测试。
|
||||
- `verify` 先运行 `check`,再重新构建生产 executable 并运行 smoke test。
|
||||
|
||||
## 前后端边界
|
||||
|
||||
前端只通过 HTTP 调用后端,API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。
|
||||
|
||||
## 目标状态判定
|
||||
|
||||
单层判定模型,适用于 HTTP 和 Command 两种类型:
|
||||
@@ -273,6 +195,6 @@ bun run verify
|
||||
|
||||
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `matched=false`,通过 `failure.kind` 区分(`"error"` vs `"mismatch"`)。
|
||||
|
||||
## 已知限制
|
||||
---
|
||||
|
||||
当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。Command 类型拨测不支持 Windows 环境。
|
||||
> 开发相关文档(项目结构、构建、测试、代码规范等)请参阅 [DEVELOPMENT.md](DEVELOPMENT.md)。
|
||||
|
||||
492
bun.lock
492
bun.lock
@@ -8,6 +8,7 @@
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"cheerio": "^1.2.0",
|
||||
"es-toolkit": "^1.46.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"recharts": "^3.8.1",
|
||||
@@ -16,6 +17,8 @@
|
||||
"xpath": "^0.0.34",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.0.0",
|
||||
"@commitlint/config-conventional": "^21.0.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@types/bun": "^1.3.13",
|
||||
@@ -23,8 +26,13 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.4",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
@@ -67,6 +75,42 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@commitlint/cli": ["@commitlint/cli@21.0.0", "https://registry.npmmirror.com/@commitlint/cli/-/cli-21.0.0.tgz", { "dependencies": { "@commitlint/format": "^21.0.0", "@commitlint/lint": "^21.0.0", "@commitlint/load": "^21.0.0", "@commitlint/read": "^21.0.0", "@commitlint/types": "^21.0.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-p3y2oC0G2R45zaadMwBxCiSesS8digi5RDplP3Zrfpzm7xIgrgAj0W4fGzONjpHyg8obDVJDU45g5txzeMcblg=="],
|
||||
|
||||
"@commitlint/config-conventional": ["@commitlint/config-conventional@21.0.0", "https://registry.npmmirror.com/@commitlint/config-conventional/-/config-conventional-21.0.0.tgz", { "dependencies": { "@commitlint/types": "^21.0.0", "conventional-changelog-conventionalcommits": "^9.2.0" } }, "sha512-QJX/rPK4Yu3f5J4OCIBy5aXq2e0EEdwSDFZ3NQvFAXTm3gs12ipyZ+yjhZxm3hHn6DB8wuv3zhFTL1I2tYzUBA=="],
|
||||
|
||||
"@commitlint/config-validator": ["@commitlint/config-validator@21.0.0", "https://registry.npmmirror.com/@commitlint/config-validator/-/config-validator-21.0.0.tgz", { "dependencies": { "@commitlint/types": "^21.0.0", "ajv": "^8.11.0" } }, "sha512-v0UplTYryNUB463X5WrelzKq5/qyYm9/iUNk38S7ZLnd56Uuk2T9awhYKGlgD2/4L5YuN2gsKkyy4EHpRPPz2Q=="],
|
||||
|
||||
"@commitlint/ensure": ["@commitlint/ensure@21.0.0", "https://registry.npmmirror.com/@commitlint/ensure/-/ensure-21.0.0.tgz", { "dependencies": { "@commitlint/types": "^21.0.0", "es-toolkit": "^1.46.0" } }, "sha512-n+OYs0Ws9GKC2WlmAeLNoPz9CUg6n/ZyYMkFF8rJ0aMn2kDTDTG0VqK/2Dco0EB4fhuF3JPIllJmU9/LKTl4aw=="],
|
||||
|
||||
"@commitlint/execute-rule": ["@commitlint/execute-rule@21.0.0", "https://registry.npmmirror.com/@commitlint/execute-rule/-/execute-rule-21.0.0.tgz", {}, "sha512-3OhTq2gQX1tEheMsbDNqxfcNHsAM6g9cub9plf05I9jCxtbNfn8Y+mhClKyUwhX4dbtmC4OLZ9i+HNmoL1aksA=="],
|
||||
|
||||
"@commitlint/format": ["@commitlint/format@21.0.0", "https://registry.npmmirror.com/@commitlint/format/-/format-21.0.0.tgz", { "dependencies": { "@commitlint/types": "^21.0.0", "picocolors": "^1.1.1" } }, "sha512-RTfGSrueEgofs1piqwi42U05d85wfxiMH2ncMCZnltx1XqPR3N2S48oACBtTy4xRAhWlf5XlHkK2RaDzEQu3dA=="],
|
||||
|
||||
"@commitlint/is-ignored": ["@commitlint/is-ignored@21.0.0", "https://registry.npmmirror.com/@commitlint/is-ignored/-/is-ignored-21.0.0.tgz", { "dependencies": { "@commitlint/types": "^21.0.0", "semver": "^7.6.0" } }, "sha512-K3SaaOTVY9VKhge7vl0R3ng7GENRzJQ9MPV43Tu53kAwEgSx/E0HF4US3AcVqdvlvsDUbF2yXvED95dhela83w=="],
|
||||
|
||||
"@commitlint/lint": ["@commitlint/lint@21.0.0", "https://registry.npmmirror.com/@commitlint/lint/-/lint-21.0.0.tgz", { "dependencies": { "@commitlint/is-ignored": "^21.0.0", "@commitlint/parse": "^21.0.0", "@commitlint/rules": "^21.0.0", "@commitlint/types": "^21.0.0" } }, "sha512-dlUJA0Ka14R1YaR46JVRWE3m/8dOQAgE/D0heUfzYua5Jogtq/zzu2ITAIaB/u25DaKjtEO6kuvASzsFDyrPMw=="],
|
||||
|
||||
"@commitlint/load": ["@commitlint/load@21.0.0", "https://registry.npmmirror.com/@commitlint/load/-/load-21.0.0.tgz", { "dependencies": { "@commitlint/config-validator": "^21.0.0", "@commitlint/execute-rule": "^21.0.0", "@commitlint/resolve-extends": "^21.0.0", "@commitlint/types": "^21.0.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", "picocolors": "^1.1.1" } }, "sha512-l0nBfO/20PKcJXHZqDIgh7kw/TWVVwn8zZJOkVGBK/ig/h328jBu9jK7OiDl2oZr5mLphmKGjYDR2ffEyb2lIA=="],
|
||||
|
||||
"@commitlint/message": ["@commitlint/message@21.0.0", "https://registry.npmmirror.com/@commitlint/message/-/message-21.0.0.tgz", {}, "sha512-+daU92JaOHhI2En9KcH+2mvZGJ6D4YSxb/32QDwqkOwSj1Vanjio8PbAqX7dneACdg6B7RgQ7i3mpyYZAws4nw=="],
|
||||
|
||||
"@commitlint/parse": ["@commitlint/parse@21.0.0", "https://registry.npmmirror.com/@commitlint/parse/-/parse-21.0.0.tgz", { "dependencies": { "@commitlint/types": "^21.0.0", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" } }, "sha512-1dbvFBcQK79aTbpc2QCrgEDc6/MMkQ0Mdz4gGmYkN4AHMnAK9HesSewTHqGTrW5mALrMlYSgcWyvKjloY2w19A=="],
|
||||
|
||||
"@commitlint/read": ["@commitlint/read@21.0.0", "https://registry.npmmirror.com/@commitlint/read/-/read-21.0.0.tgz", { "dependencies": { "@commitlint/top-level": "^21.0.0", "@commitlint/types": "^21.0.0", "git-raw-commits": "^5.0.0", "tinyexec": "^1.0.0" } }, "sha512-8VKLKLl2vBSKoTMm1LwcySsyxrBeotnqcT5qJi9pPuPfqSapdAD870Ckgh79c41UFywL6kMqtiyY+kxtfcqZGg=="],
|
||||
|
||||
"@commitlint/resolve-extends": ["@commitlint/resolve-extends@21.0.0", "https://registry.npmmirror.com/@commitlint/resolve-extends/-/resolve-extends-21.0.0.tgz", { "dependencies": { "@commitlint/config-validator": "^21.0.0", "@commitlint/types": "^21.0.0", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" } }, "sha512-hrJYSZRpmecmSoxYrpuJ/1Q4J9JHt4AVVtr5/Ac6upLO/jJ1DnIm2AjD+38gru3KGOec4aHCVqETuWWLJhydWw=="],
|
||||
|
||||
"@commitlint/rules": ["@commitlint/rules@21.0.0", "https://registry.npmmirror.com/@commitlint/rules/-/rules-21.0.0.tgz", { "dependencies": { "@commitlint/ensure": "^21.0.0", "@commitlint/message": "^21.0.0", "@commitlint/to-lines": "^21.0.0", "@commitlint/types": "^21.0.0" } }, "sha512-NgQhX1qENA+rbrMw5KKyvVZpZG4D/0wgK8Z4INtcwKbfKtVDFMbn0oNc/Rs8wdyBPBj7ue8Lo/GllUL2Mqjwkg=="],
|
||||
|
||||
"@commitlint/to-lines": ["@commitlint/to-lines@21.0.0", "https://registry.npmmirror.com/@commitlint/to-lines/-/to-lines-21.0.0.tgz", {}, "sha512-qMwvrJK/x3dPcXsIAtQAMKV5Q0wTioyqyHKR06vVN4wmBF4cCrrLq5x81FDeY3Ba+GWgDt0/P3Zw/IHGM8lwgg=="],
|
||||
|
||||
"@commitlint/top-level": ["@commitlint/top-level@21.0.0", "https://registry.npmmirror.com/@commitlint/top-level/-/top-level-21.0.0.tgz", { "dependencies": { "escalade": "^3.2.0" } }, "sha512-8jPqyWZueuN4hU6/ArKVsZ6i8xWtjIrbzHEOaLaTGUfjhhbZNBfXef/DGjzxy55hAv3yFNxHLINfI1bCJ0/MzA=="],
|
||||
|
||||
"@commitlint/types": ["@commitlint/types@21.0.0", "https://registry.npmmirror.com/@commitlint/types/-/types-21.0.0.tgz", { "dependencies": { "conventional-commits-parser": "^6.3.0", "picocolors": "^1.1.1" } }, "sha512-6nEz+M7I90iix4sviA8NLwskOuyt0M98KUU2aYgiKbn46jMSxUm1l2ACtzRd9ec+y38aKyJhW4Fp6NW0z35kJQ=="],
|
||||
|
||||
"@conventional-changelog/git-client": ["@conventional-changelog/git-client@2.7.0", "https://registry.npmmirror.com/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", { "dependencies": { "@simple-libs/child-process-utils": "^1.0.0", "@simple-libs/stream-utils": "^1.2.0", "semver": "^7.5.2" }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.4.0" }, "optionalPeers": ["conventional-commits-filter", "conventional-commits-parser"] }, "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
@@ -109,7 +153,7 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.128.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.128.0.tgz", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="],
|
||||
|
||||
@@ -149,6 +193,12 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "https://registry.npmmirror.com/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="],
|
||||
|
||||
"@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "https://registry.npmmirror.com/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
@@ -189,6 +239,8 @@
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/json5": ["@types/json5@0.0.29", "https://registry.npmmirror.com/@types/json5/-/json5-0.0.29.tgz", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.2", "https://registry.npmmirror.com/@types/node/-/node-25.6.2.tgz", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
@@ -221,6 +273,44 @@
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="],
|
||||
|
||||
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
|
||||
|
||||
"@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="],
|
||||
|
||||
"@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="],
|
||||
|
||||
"@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="],
|
||||
|
||||
"@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="],
|
||||
|
||||
"@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="],
|
||||
|
||||
"@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="],
|
||||
|
||||
"@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="],
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
|
||||
@@ -231,6 +321,32 @@
|
||||
|
||||
"ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@7.3.0", "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||
|
||||
"array-ify": ["array-ify@1.0.0", "https://registry.npmmirror.com/array-ify/-/array-ify-1.0.0.tgz", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="],
|
||||
|
||||
"array-includes": ["array-includes@3.1.9", "https://registry.npmmirror.com/array-includes/-/array-includes-3.1.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
|
||||
|
||||
"array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "https://registry.npmmirror.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="],
|
||||
|
||||
"array.prototype.flat": ["array.prototype.flat@1.3.3", "https://registry.npmmirror.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
|
||||
|
||||
"array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "https://registry.npmmirror.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="],
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "https://registry.npmmirror.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="],
|
||||
@@ -243,6 +359,14 @@
|
||||
|
||||
"bun-types": ["bun-types@1.3.13", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.13.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.9", "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.9.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001792", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
|
||||
|
||||
"cheerio": ["cheerio@1.2.0", "https://registry.npmmirror.com/cheerio/-/cheerio-1.2.0.tgz", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
|
||||
@@ -251,10 +375,30 @@
|
||||
|
||||
"classnames": ["classnames@2.5.1", "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@5.0.0", "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-5.0.0.tgz", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"cli-truncate": ["cli-truncate@5.2.0", "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-5.2.0.tgz", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
|
||||
|
||||
"cliui": ["cliui@9.0.1", "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"compare-func": ["compare-func@2.0.0", "https://registry.npmmirror.com/compare-func/-/compare-func-2.0.0.tgz", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"conventional-changelog-angular": ["conventional-changelog-angular@8.3.1", "https://registry.npmmirror.com/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg=="],
|
||||
|
||||
"conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@9.3.1", "https://registry.npmmirror.com/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw=="],
|
||||
|
||||
"conventional-commits-parser": ["conventional-commits-parser@6.4.0", "https://registry.npmmirror.com/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@9.0.1", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.1.tgz", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
|
||||
|
||||
"cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.3.0", "https://registry.npmmirror.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", { "dependencies": { "jiti": "2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
@@ -285,6 +429,12 @@
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
|
||||
"data-view-byte-length": ["data-view-byte-length@1.0.2", "https://registry.npmmirror.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
|
||||
|
||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.10", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.10.tgz", {}, "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
@@ -293,8 +443,14 @@
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
|
||||
"define-properties": ["define-properties@1.2.1", "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
@@ -305,12 +461,38 @@
|
||||
|
||||
"domutils": ["domutils@3.2.2", "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"dot-prop": ["dot-prop@5.3.0", "https://registry.npmmirror.com/dot-prop/-/dot-prop-5.3.0.tgz", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.353", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"encoding-sniffer": ["encoding-sniffer@0.2.1", "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"environment": ["environment@1.1.0", "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
|
||||
"error-ex": ["error-ex@1.3.4", "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.4.tgz", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
|
||||
|
||||
"es-abstract": ["es-abstract@1.24.2", "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.2.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"es-shim-unscopables": ["es-shim-unscopables@1.1.0", "https://registry.npmmirror.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="],
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.0", "https://registry.npmmirror.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.46.1", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
@@ -319,6 +501,18 @@
|
||||
|
||||
"eslint": ["eslint@10.3.0", "https://registry.npmmirror.com/eslint/-/eslint-10.3.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
|
||||
|
||||
"eslint-import-context": ["eslint-import-context@0.1.9", "https://registry.npmmirror.com/eslint-import-context/-/eslint-import-context-0.1.9.tgz", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="],
|
||||
|
||||
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "https://registry.npmmirror.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="],
|
||||
|
||||
"eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@4.4.4", "https://registry.npmmirror.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", { "dependencies": { "debug": "^4.4.1", "eslint-import-context": "^0.1.8", "get-tsconfig": "^4.10.1", "is-bun-module": "^2.0.0", "stable-hash-x": "^0.2.0", "tinyglobby": "^0.2.14", "unrs-resolver": "^1.7.11" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw=="],
|
||||
|
||||
"eslint-module-utils": ["eslint-module-utils@2.12.1", "https://registry.npmmirror.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="],
|
||||
|
||||
"eslint-plugin-import": ["eslint-plugin-import@2.32.0", "https://registry.npmmirror.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
|
||||
|
||||
"eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@5.9.0", "https://registry.npmmirror.com/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.9.0.tgz", { "dependencies": { "@typescript-eslint/utils": "^8.58.2", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" } }, "sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
|
||||
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "https://registry.npmmirror.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
|
||||
@@ -345,6 +539,8 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
@@ -355,12 +551,54 @@
|
||||
|
||||
"flatted": ["flatted@3.4.2", "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"for-each": ["for-each@0.3.5", "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function.prototype.name": ["function.prototype.name@1.1.8", "https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
||||
|
||||
"functions-have-names": ["functions-have-names@1.2.3", "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||
|
||||
"generator-function": ["generator-function@2.0.1", "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-symbol-description": ["get-symbol-description@1.1.0", "https://registry.npmmirror.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.14.0", "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||
|
||||
"git-raw-commits": ["git-raw-commits@5.0.1", "https://registry.npmmirror.com/git-raw-commits/-/git-raw-commits-5.0.1.tgz", { "dependencies": { "@conventional-changelog/git-client": "^2.6.0", "meow": "^13.0.0" }, "bin": { "git-raw-commits": "src/cli.js" } }, "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"global-directory": ["global-directory@5.0.0", "https://registry.npmmirror.com/global-directory/-/global-directory-5.0.0.tgz", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
|
||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"has-proto": ["has-proto@1.2.0", "https://registry.npmmirror.com/has-proto/-/has-proto-1.2.0.tgz", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.3", "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
@@ -369,33 +607,103 @@
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"ini": ["ini@6.0.0", "https://registry.npmmirror.com/ini/-/ini-6.0.0.tgz", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.2.1", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||
|
||||
"is-async-function": ["is-async-function@2.1.1", "https://registry.npmmirror.com/is-async-function/-/is-async-function-2.1.1.tgz", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
||||
|
||||
"is-bigint": ["is-bigint@1.1.0", "https://registry.npmmirror.com/is-bigint/-/is-bigint-1.1.0.tgz", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
|
||||
|
||||
"is-boolean-object": ["is-boolean-object@1.2.2", "https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
|
||||
|
||||
"is-bun-module": ["is-bun-module@2.0.0", "https://registry.npmmirror.com/is-bun-module/-/is-bun-module-2.0.0.tgz", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="],
|
||||
|
||||
"is-callable": ["is-callable@1.2.7", "https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.2", "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.2.tgz", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
|
||||
|
||||
"is-data-view": ["is-data-view@1.0.2", "https://registry.npmmirror.com/is-data-view/-/is-data-view-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
|
||||
|
||||
"is-date-object": ["is-date-object@1.1.0", "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "https://registry.npmmirror.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"is-generator-function": ["is-generator-function@1.1.2", "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-map": ["is-map@2.0.3", "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
|
||||
|
||||
"is-negative-zero": ["is-negative-zero@2.0.3", "https://registry.npmmirror.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
|
||||
|
||||
"is-number-object": ["is-number-object@1.1.1", "https://registry.npmmirror.com/is-number-object/-/is-number-object-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
|
||||
|
||||
"is-obj": ["is-obj@2.0.0", "https://registry.npmmirror.com/is-obj/-/is-obj-2.0.0.tgz", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-set": ["is-set@2.0.3", "https://registry.npmmirror.com/is-set/-/is-set-2.0.3.tgz", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
|
||||
|
||||
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "https://registry.npmmirror.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
|
||||
|
||||
"is-string": ["is-string@1.1.1", "https://registry.npmmirror.com/is-string/-/is-string-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
|
||||
|
||||
"is-symbol": ["is-symbol@1.1.1", "https://registry.npmmirror.com/is-symbol/-/is-symbol-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
|
||||
|
||||
"is-typed-array": ["is-typed-array@1.1.15", "https://registry.npmmirror.com/is-typed-array/-/is-typed-array-1.1.15.tgz", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
|
||||
|
||||
"is-weakmap": ["is-weakmap@2.0.2", "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
|
||||
|
||||
"is-weakref": ["is-weakref@1.1.1", "https://registry.npmmirror.com/is-weakref/-/is-weakref-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
|
||||
|
||||
"is-weakset": ["is-weakset@2.0.4", "https://registry.npmmirror.com/is-weakset/-/is-weakset-2.0.4.tgz", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
|
||||
|
||||
"isarray": ["isarray@2.0.5", "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
"json5": ["json5@1.0.2", "https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
@@ -425,36 +733,80 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"lint-staged": ["lint-staged@17.0.4", "https://registry.npmmirror.com/lint-staged/-/lint-staged-17.0.4.tgz", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA=="],
|
||||
|
||||
"listr2": ["listr2@10.2.1", "https://registry.npmmirror.com/listr2/-/listr2-10.2.1.tgz", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"log-update": ["log-update@6.1.0", "https://registry.npmmirror.com/log-update/-/log-update-6.1.0.tgz", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"meow": ["meow@13.2.0", "https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"napi-postinstall": ["napi-postinstall@0.3.4", "https://registry.npmmirror.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"natural-orderby": ["natural-orderby@5.0.0", "https://registry.npmmirror.com/natural-orderby/-/natural-orderby-5.0.0.tgz", {}, "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg=="],
|
||||
|
||||
"node-exports-info": ["node-exports-info@1.6.0", "https://registry.npmmirror.com/node-exports-info/-/node-exports-info-1.6.0.tgz", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.38", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
"object.assign": ["object.assign@4.1.7", "https://registry.npmmirror.com/object.assign/-/object.assign-4.1.7.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
|
||||
|
||||
"object.entries": ["object.entries@1.1.9", "https://registry.npmmirror.com/object.entries/-/object.entries-1.1.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="],
|
||||
|
||||
"object.fromentries": ["object.fromentries@2.0.8", "https://registry.npmmirror.com/object.fromentries/-/object.fromentries-2.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="],
|
||||
|
||||
"object.groupby": ["object.groupby@1.0.3", "https://registry.npmmirror.com/object.groupby/-/object.groupby-1.0.3.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="],
|
||||
|
||||
"object.values": ["object.values@1.2.1", "https://registry.npmmirror.com/object.values/-/object.values-1.2.1.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
|
||||
|
||||
"onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
|
||||
@@ -465,12 +817,16 @@
|
||||
|
||||
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
@@ -501,48 +857,124 @@
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.14.1", "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resolve": ["resolve@2.0.0-next.6", "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.6.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
|
||||
|
||||
"resolve-from": ["resolve-from@5.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-5.1.0.tgz", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.18", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.18.tgz", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="],
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.4", "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.4.tgz", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"set-function-name": ["set-function-name@2.0.2", "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||
|
||||
"set-proto": ["set-proto@1.0.0", "https://registry.npmmirror.com/set-proto/-/set-proto-1.0.0.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@8.0.0", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-8.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
|
||||
|
||||
"sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stable-hash-x": ["stable-hash-x@0.2.0", "https://registry.npmmirror.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"string-argv": ["string-argv@0.3.2", "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"string.prototype.trim": ["string.prototype.trim@1.2.10", "https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="],
|
||||
|
||||
"string.prototype.trimend": ["string.prototype.trimend@1.0.9", "https://registry.npmmirror.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="],
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tdesign-icons-react": ["tdesign-icons-react@0.6.4", "https://registry.npmmirror.com/tdesign-icons-react/-/tdesign-icons-react-0.6.4.tgz", { "dependencies": { "@babel/runtime": "^7.16.5", "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-USAoi9vBWcwcJT45VqR3dRqX1MeAsn/RhHVx4bLwplhrlvE80ZQ1N9V+6F3HqE1Qe9mMDbtRM8Ul80+lALScww=="],
|
||||
|
||||
"tdesign-react": ["tdesign-react@1.16.9", "https://registry.npmmirror.com/tdesign-react/-/tdesign-react-1.16.9.tgz", { "dependencies": { "@babel/runtime": "~7.26.7", "@popperjs/core": "~2.11.2", "@types/sortablejs": "^1.10.7", "@types/validator": "^13.1.3", "classnames": "~2.5.1", "dayjs": "1.11.10", "hoist-non-react-statics": "~3.3.2", "lodash-es": "^4.17.21", "mitt": "^3.0.0", "raf": "~3.4.1", "react-fast-compare": "^3.2.2", "react-is": "^18.2.0", "react-transition-group": "~4.4.1", "sortablejs": "^1.15.0", "tdesign-icons-react": "^0.6.4", "tslib": "~2.3.1", "validator": "~13.15.0" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-C3uZRTkJ1iQ62BrMkuvqvBK+4HEuhl82rABxa6kAHGHL3eBI4DPfzAJGF0T3b+DKCBeJxb0x10elumT6NkQEaw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.1.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"tsconfig-paths": ["tsconfig-paths@3.15.0", "https://registry.npmmirror.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
||||
|
||||
"tslib": ["tslib@2.3.1", "https://registry.npmmirror.com/tslib/-/tslib-2.3.1.tgz", {}, "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "https://registry.npmmirror.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
|
||||
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "https://registry.npmmirror.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
|
||||
|
||||
"typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "https://registry.npmmirror.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="],
|
||||
|
||||
"typed-array-length": ["typed-array-length@1.0.7", "https://registry.npmmirror.com/typed-array-length/-/typed-array-length-1.0.7.tgz", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.59.2", "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="],
|
||||
|
||||
"unbox-primitive": ["unbox-primitive@1.1.0", "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
||||
|
||||
"undici": ["undici@7.25.0", "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"unrs-resolver": ["unrs-resolver@1.11.1", "https://registry.npmmirror.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
@@ -561,18 +993,44 @@
|
||||
|
||||
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
"which-builtin-type": ["which-builtin-type@1.2.1", "https://registry.npmmirror.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
|
||||
|
||||
"which-collection": ["which-collection@1.0.2", "https://registry.npmmirror.com/which-collection/-/which-collection-1.0.2.tgz", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.20", "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@10.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
|
||||
|
||||
"xpath": ["xpath@0.0.34", "https://registry.npmmirror.com/xpath/-/xpath-0.0.34.tgz", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"yargs": ["yargs@18.0.0", "https://registry.npmmirror.com/yargs/-/yargs-18.0.0.tgz", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@22.0.0", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"@babel/core/json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"@commitlint/config-validator/ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"@commitlint/is-ignored/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"@conventional-changelog/git-client/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"@emnapi/core/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@emnapi/runtime/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
@@ -583,16 +1041,38 @@
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.8", "https://registry.npmmirror.com/immer/-/immer-11.1.8.tgz", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@tybys/wasm-util/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"cli-truncate/string-width": ["string-width@8.2.1", "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
|
||||
|
||||
"cliui/wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-module-utils/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-plugin-import/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-plugin-import/minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"is-bun-module/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"log-update/slice-ansi": ["slice-ansi@7.1.2", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
|
||||
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
@@ -602,5 +1082,13 @@
|
||||
"tdesign-react/@babel/runtime": ["@babel/runtime@7.26.10", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.10.tgz", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="],
|
||||
|
||||
"tdesign-react/react-is": ["react-is@18.3.1", "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"wrap-ansi/string-width": ["string-width@8.2.1", "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
|
||||
|
||||
"@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.14", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
|
||||
|
||||
"eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
}
|
||||
}
|
||||
|
||||
8
commitlint.config.js
Normal file
8
commitlint.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
rules: {
|
||||
"subject-case": [0],
|
||||
"subject-full-stop": [0],
|
||||
"type-enum": [2, "always", ["feat", "fix", "refactor", "docs", "style", "test", "chore"]],
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import js from "@eslint/js";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
import perfectionist from "eslint-plugin-perfectionist";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
@@ -14,16 +16,47 @@ export default tseslint.config(
|
||||
".opencode/**",
|
||||
".claude/**",
|
||||
".codex/**",
|
||||
".agents/**",
|
||||
"bun.lock",
|
||||
"data/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
importPlugin.flatConfigs.recommended,
|
||||
importPlugin.flatConfigs.typescript,
|
||||
perfectionist.configs["recommended-natural"],
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
"import/resolver": { node: true, typescript: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"@typescript-eslint/array-type": ["error", { default: "array-simple" }],
|
||||
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }],
|
||||
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
||||
"@typescript-eslint/only-throw-error": "error",
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
||||
"@typescript-eslint/prefer-optional-chain": "error",
|
||||
"import/no-unresolved": ["error", { ignore: ["^bun:"] }],
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["eslint.config.js"],
|
||||
rules: {
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/web/**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
@@ -32,7 +65,6 @@ export default tseslint.config(
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
@@ -53,6 +85,7 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
],
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,14 +3,16 @@ schema: spec-driven
|
||||
context: |
|
||||
- 使用中文(注释、文档、交流),面向中文开发者
|
||||
- openspec文档的关键字按openspec规范使用,不要翻译为中文
|
||||
- **优先阅读README.md**获取项目结构与开发规范,所有代码风格、命名、注解、依赖、API等规范以README为准
|
||||
- **优先阅读README.md和DEVELOPMENT.md**获取项目概览与开发规范,所有代码风格、命名、注解、依赖、API等规范以DEVELOPMENT.md为准
|
||||
- 涉及模块结构、API、实体等变更时同步更新README.md
|
||||
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
||||
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
||||
- src/server目录下是基于bun实现的后端代码
|
||||
- src/web目录下是基于vite、react、TDesign实现的前端代码
|
||||
- 代码开发优先使用公共组件实现功能逻辑(优先级:官方库>主流三方库>项目公共工具>自行实现)
|
||||
- 后端库使用优先级:Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
|
||||
- 前端样式开发优先级:TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
|
||||
- 前端严禁:组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
|
||||
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||
- 禁止创建git操作task
|
||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||
@@ -26,4 +28,4 @@ rules:
|
||||
- 一行一个任务,严禁任务内容跨行
|
||||
- 如果是代码存在更新必须
|
||||
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
||||
- 更新README文档
|
||||
- 更新 README.md 和/或 DEVELOPMENT.md
|
||||
|
||||
78
openspec/specs/api-route-separation/spec.md
Normal file
78
openspec/specs/api-route-separation/spec.md
Normal file
@@ -0,0 +1,78 @@
|
||||
## Purpose
|
||||
|
||||
定义后端 API 路由的组织规范:按端点拆分为独立 handler、共享响应工具集中管理、参数校验逻辑抽取为中间件、静态资源服务独立维护。
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
### Requirement: 路由按职责拆分
|
||||
系统 SHALL 将 HTTP 路由处理逻辑按 API 端点拆分为独立模块,每个模块导出一个 handler 函数供 app.ts 统一注册。
|
||||
|
||||
#### Scenario: health 端点独立路由
|
||||
- **WHEN** 客户端请求 `GET /health`
|
||||
- **THEN** `routes/health.ts` 导出的 handler 负责处理,返回 HealthResponse JSON
|
||||
|
||||
#### Scenario: summary 端点独立路由
|
||||
- **WHEN** 客户端请求 `GET /api/summary`
|
||||
- **THEN** `routes/summary.ts` 导出的 handler 负责处理,委托 store 查询并返回 SummaryResponse JSON
|
||||
|
||||
#### Scenario: targets 端点独立路由
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** `routes/targets.ts` 导出的 handler 负责处理,委托 store 查询并返回 TargetStatus[] JSON
|
||||
|
||||
#### Scenario: history 端点独立路由
|
||||
- **WHEN** 客户端请求 `GET /api/targets/:id/history?from=ISO&to=ISO`
|
||||
- **THEN** `routes/history.ts` 导出的 handler 负责处理,包含参数校验、store 查询和 HistoryResponse 返回
|
||||
|
||||
#### Scenario: trend 端点独立路由
|
||||
- **WHEN** 客户端请求 `GET /api/targets/:id/trend?from=ISO&to=ISO`
|
||||
- **THEN** `routes/trend.ts` 导出的 handler 负责处理,包含参数校验、store 查询和 TrendPoint[] 返回
|
||||
|
||||
### Requirement: 共享辅助函数集中管理
|
||||
系统 SHALL 将跨路由共享的响应格式化函数抽取到 helpers.ts 模块,单一职责、集中管理。
|
||||
|
||||
#### Scenario: createApiError 集中定义
|
||||
- **WHEN** 任意路由需要返回 API 错误响应
|
||||
- **THEN** 从 `helpers.ts` 导入 `createApiError` 函数,提供错误信息和状态码
|
||||
|
||||
#### Scenario: jsonResponse 集中定义
|
||||
- **WHEN** 任意路由需要返回 JSON 响应
|
||||
- **THEN** 从 `helpers.ts` 导入 `jsonResponse` 函数,处理 HEAD 方法、Content-Type 和安全头
|
||||
|
||||
#### Scenario: mapCheckResult 集中定义
|
||||
- **WHEN** 需要将 StoredCheckResult 映射为 API CheckResult
|
||||
- **THEN** 从 `helpers.ts` 导入 `mapCheckResult` 函数,处理 failure JSON 解析和格式转换
|
||||
|
||||
### Requirement: 参数校验逻辑抽取为中间件
|
||||
系统 SHALL 将重复的参数校验逻辑(target ID 解析、时间范围校验、分页参数校验、方法检查)抽取到 middleware.ts 模块。
|
||||
|
||||
#### Scenario: 方法检查中间件
|
||||
- **WHEN** 请求方法不是 GET 或 HEAD
|
||||
- **THEN** `guardGetHead(request, mode)` SHALL 返回 405 Response,否则返回 null 表示放行
|
||||
|
||||
#### Scenario: Target ID 校验
|
||||
- **WHEN** URL 中的 id 参数不是正整数
|
||||
- **THEN** `validateTargetId(idStr)` SHALL 返回 400 ApiError
|
||||
|
||||
#### Scenario: 时间范围参数校验
|
||||
- **WHEN** from 或 to 参数缺失或格式无效
|
||||
- **THEN** `validateTimeRange(from, to)` SHALL 返回 400 ApiError
|
||||
|
||||
#### Scenario: 分页参数校验
|
||||
- **WHEN** page 或 pageSize 参数不是正整数
|
||||
- **THEN** `validatePagination(page, pageSize)` SHALL 返回 400 ApiError
|
||||
|
||||
### Requirement: 静态资源服务独立管理
|
||||
系统 SHALL 将静态资源服务、SPA fallback 和 Content-Type 映射逻辑抽取到 static.ts 模块。
|
||||
|
||||
#### Scenario: 根路径返回 index.html
|
||||
- **WHEN** 客户端请求 `/`
|
||||
- **THEN** `static.ts` 的 handler 返回 index.html,设置正确的 Content-Type 和 Cache-Control
|
||||
|
||||
#### Scenario: 资源文件返回正确 Content-Type
|
||||
- **WHEN** 客户端请求 `/assets/main.js`
|
||||
- **THEN** `static.ts` 的 handler 根据文件扩展名返回正确的 Content-Type(如 `.js` → `text/javascript`)
|
||||
|
||||
#### Scenario: SPA fallback
|
||||
- **WHEN** 客户端请求非 API、非资源的路径(如 `/dashboard`)
|
||||
- **THEN** `static.ts` 的 handler 返回 index.html 实现 SPA 的客户端路由
|
||||
102
openspec/specs/backend-code-quality/spec.md
Normal file
102
openspec/specs/backend-code-quality/spec.md
Normal file
@@ -0,0 +1,102 @@
|
||||
## Purpose
|
||||
|
||||
定义后端代码中 es-toolkit 和 Bun 内置 API 的使用规范:类型判断、空值检测、深度比较、错误判断、并发控制、集合分组和 Web API 标准方法,替代手写实现落实库使用优先级规则。
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
### Requirement: 使用 es-toolkit 进行类型判断
|
||||
系统 SHALL 使用 es-toolkit 的 `isPlainObject` 替代手写的对象类型判断函数,用于 expect 校验中区分纯值(原始值)和操作符对象。
|
||||
|
||||
#### Scenario: 识别纯对象为操作符
|
||||
- **WHEN** body 校验规则中 expected 配置为 `{ equals: "value" }`(纯对象操作符)
|
||||
- **THEN** `isPlainObject(expected)` SHALL 返回 true,系统按操作符语义处理
|
||||
|
||||
#### Scenario: 排除非纯对象作为操作符
|
||||
- **WHEN** body 校验规则中 expected 为原始值如 `"value"` 或数字 `200`
|
||||
- **THEN** `isPlainObject(expected)` SHALL 返回 false,系统按 equals 默认操作符处理
|
||||
|
||||
### Requirement: 使用 es-toolkit 进行空值检测
|
||||
系统 SHALL 使用 es-toolkit 的 `isNil` 替代手写的 `actual === null || actual === undefined` 检测,用于 expect 中 `empty` 操作符的空值判断。
|
||||
|
||||
#### Scenario: null 值判定为空
|
||||
- **WHEN** 校验值为 null
|
||||
- **THEN** `isNil(null)` SHALL 返回 true
|
||||
|
||||
#### Scenario: undefined 值判定为空
|
||||
- **WHEN** 校验值为 undefined
|
||||
- **THEN** `isNil(undefined)` SHALL 返回 true
|
||||
|
||||
#### Scenario: 非空值判定为非空
|
||||
- **WHEN** 校验值为 0、"false"、空数组 `[]` 等非 nil 值
|
||||
- **THEN** `isNil(value)` SHALL 返回 false
|
||||
|
||||
### Requirement: 使用 es-toolkit 进行空对象检测
|
||||
系统 SHALL 使用 es-toolkit 的 `isEmptyObject` 替代手写的 `typeof actual === "object" && Object.keys(actual).length === 0` 检测,用于 expect 中 `empty` 操作符的空对象判断。
|
||||
|
||||
#### Scenario: 空对象判定为空
|
||||
- **WHEN** 校验值为 `{}`
|
||||
- **THEN** `isEmptyObject({})` SHALL 返回 true
|
||||
|
||||
#### Scenario: 非空对象判定为非空
|
||||
- **WHEN** 校验值为 `{ key: "val" }`
|
||||
- **THEN** `isEmptyObject({ key: "val" })` SHALL 返回 false
|
||||
|
||||
#### Scenario: null 不是空对象
|
||||
- **WHEN** 校验值为 null
|
||||
- **THEN** `isEmptyObject(null)` SHALL 返回 false(空值由 isNil 前置处理)
|
||||
|
||||
### Requirement: 使用 es-toolkit 进行深度相等比较
|
||||
系统 SHALL 使用 es-toolkit 的 `isEqual` 替代 `!==` 浅比较,用于 expect 中 `equals` 操作符的值比较,支持对象和数组的深度比较。
|
||||
|
||||
#### Scenario: 原始值浅比较
|
||||
- **WHEN** expected 和 actual 均为原始值(字符串、数字、布尔值、null)
|
||||
- **THEN** `isEqual(actual, expected)` 的行为 SHALL 与 `actual === expected` 一致
|
||||
|
||||
#### Scenario: 对象深度比较
|
||||
- **WHEN** expected 和 actual 均为对象(如从 JSONPath 提取的结构化数据)
|
||||
- **THEN** `isEqual(actual, expected)` SHALL 递归比较所有属性值,而非引用比较
|
||||
|
||||
### Requirement: 使用 es-toolkit 进行错误类型判断
|
||||
系统 SHALL 使用 es-toolkit 的 `isError` 替代 `error instanceof Error`,用于 HTTP runner 和 command runner 中的错误类型判断。
|
||||
|
||||
#### Scenario: Error 实例识别
|
||||
- **WHEN** 错误对象为 `new Error("msg")`
|
||||
- **THEN** `isError(error)` SHALL 返回 true
|
||||
|
||||
#### Scenario: Error 子类识别
|
||||
- **WHEN** 错误对象为继承 Error 的自定义类型
|
||||
- **THEN** `isError(error)` SHALL 返回 true
|
||||
|
||||
#### Scenario: 非 Error 对象识别
|
||||
- **WHEN** 错误对象为字符串或普通对象
|
||||
- **THEN** `isError(error)` SHALL 返回 false
|
||||
|
||||
### Requirement: 使用 es-toolkit Semaphore 实现并发控制
|
||||
系统 SHALL 使用 es-toolkit 的 `Semaphore` 类替代手写的信号量实现(计数器 + Promise 队列),用于 ProbeEngine 中的组内并发拨测控制。
|
||||
|
||||
#### Scenario: 获取并发槽位
|
||||
- **WHEN** 当前并发数未达上限
|
||||
- **THEN** `semaphore.acquire()` SHALL 立即返回,不阻塞
|
||||
|
||||
#### Scenario: 等待并发槽位
|
||||
- **WHEN** 当前并发数已达上限 maxConcurrentChecks
|
||||
- **THEN** `semaphore.acquire()` SHALL 阻塞等待,直到其他任务调用 `semaphore.release()`
|
||||
|
||||
#### Scenario: 释放并发槽位
|
||||
- **WHEN** 调用 `semaphore.release()`
|
||||
- **THEN** 系统 SHALL 唤醒一个等待中的 acquire() 调用
|
||||
|
||||
### Requirement: 使用 es-toolkit groupBy 实现 target 分组
|
||||
系统 SHALL 使用 es-toolkit 的 `groupBy` 函数替代手写的 Map 循环分组,用于 ProbeEngine 中按 interval 分组拨测目标。
|
||||
|
||||
#### Scenario: 按 interval 分组
|
||||
- **WHEN** 输入包含不同 intervalMs 值的多个 target
|
||||
- **THEN** `groupBy(targets, t => t.intervalMs)` SHALL 返回 key 为 intervalMs 值的分组对象,值为对应 target 数组
|
||||
|
||||
### Requirement: 使用 Bun 内置 API 进行 Headers 转换
|
||||
系统 SHALL 使用 `Object.fromEntries(headers)` 标准 Web API 替代手写的 `headersToRecord` 函数,用于将 Fetch API 的 Headers 对象转换为键值对。
|
||||
|
||||
#### Scenario: 转换响应头
|
||||
- **WHEN** HTTP runner 获取到 response headers
|
||||
- **THEN** `Object.fromEntries(response.headers)` SHALL 返回以 header 名称为 key、header 值为 value 的对象
|
||||
73
openspec/specs/batch-data-queries/spec.md
Normal file
73
openspec/specs/batch-data-queries/spec.md
Normal file
@@ -0,0 +1,73 @@
|
||||
## Purpose
|
||||
|
||||
定义 ProbeStore 的批量查询方法:getLatestChecksMap、getAllTargetStats,以及 getSummary 和 createTargetsResponse 的 N+1 查询优化规范。同时约定单次查询操作使用 db.query() 利用内置缓存。
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
### Requirement: 批量查询最新检查结果
|
||||
系统 SHALL 提供 `getLatestChecksMap` 方法,通过单次 SQL 查询获取所有 target 的最新一次 check 结果,返回 Map 结构供调用方按 target_id 索引。
|
||||
|
||||
#### Scenario: 获取所有目标的最新检查
|
||||
- **WHEN** 调用 `getLatestChecksMap()`
|
||||
- **THEN** 系统 SHALL 执行子查询找到每个 target_id 的 MAX(timestamp),再 JOIN 回 check_results 获取完整行,返回 `Map<number, StoredCheckResult | null>`
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某 target 在 check_results 表中无任何记录
|
||||
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
|
||||
|
||||
### Requirement: 批量查询目标统计
|
||||
系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。
|
||||
|
||||
#### Scenario: 获取所有目标的聚合统计
|
||||
- **WHEN** 调用 `getAllTargetStats()`
|
||||
- **THEN** 系统 SHALL 执行 `SELECT target_id, COUNT(*), SUM(CASE WHEN matched=1 THEN 1 ELSE 0 END) FROM check_results GROUP BY target_id`,在内存中计算 availability 并返回 `Map<number, { totalChecks, availability }>`
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某 target 在 check_results 表中无任何记录
|
||||
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
|
||||
|
||||
#### Scenario: availability 精度
|
||||
- **WHEN** 计算 availability(upCount / totalChecks * 100)
|
||||
- **THEN** 结果 SHALL 四舍五入保留两位小数
|
||||
|
||||
### Requirement: summary 查询使用批量方法
|
||||
`getSummary` 方法 SHALL 使用 `getLatestChecksMap` 一次性获取所有 target 的最新检查结果,而非对每个 target 逐条查询。
|
||||
|
||||
#### Scenario: 统计总览使用批量查询
|
||||
- **WHEN** 调用 `store.getSummary()`
|
||||
- **THEN** 系统 SHALL 调用 `getLatestChecksMap()` 一次获取所有最新结果,在内存中遍历统计 up/down 数量,而非循环 N 次调用 `getLatestCheck()`
|
||||
|
||||
### Requirement: targets 列表使用批量方法
|
||||
`createTargetsResponse`(app.ts 中生成 TargetStatus[] 的逻辑)SHALL 使用 `getLatestChecksMap` 和 `getAllTargetStats` 替代逐目标查询 latest checkout、stats 和 samples。
|
||||
|
||||
#### Scenario: 目标列表使用批量查询
|
||||
- **WHEN** 处理 `GET /api/targets` 请求
|
||||
- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()`、`getAllTargetStats()` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库
|
||||
|
||||
### Requirement: prepared statement 使用 query() 缓存
|
||||
ProbeStore 中不涉及事务内复用的单次读/写操作 SHALL 使用 `this.db.query()` 而非 `this.db.prepare()`,利用 bun:sqlite 内置的 statement 缓存机制。
|
||||
|
||||
#### Scenario: insertCheckResult 使用 query
|
||||
- **WHEN** 写入一条检查结果
|
||||
- **THEN** `insertCheckResult` SHALL 使用 `this.db.query("INSERT INTO ...").run(...)` 而非 `this.db.prepare("INSERT INTO ...").run(...)`
|
||||
|
||||
#### Scenario: getHistory 查询使用 query
|
||||
- **WHEN** 查询历史记录(包括 COUNT 和分页查询)
|
||||
- **THEN** `getHistory` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
|
||||
|
||||
#### Scenario: getTargetStats 查询使用 query
|
||||
- **WHEN** 查询单目标统计
|
||||
- **THEN** `getTargetStats` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
|
||||
|
||||
#### Scenario: getTrend 查询使用 query
|
||||
- **WHEN** 查询趋势数据
|
||||
- **THEN** `getTrend` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
|
||||
|
||||
#### Scenario: getRecentSamples 查询使用 query
|
||||
- **WHEN** 查询采样数据
|
||||
- **THEN** `getRecentSamples` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
|
||||
|
||||
#### Scenario: syncTargets 事务保持 prepare(例外)
|
||||
- **WHEN** 同步 targets 配置(事务内多次复用 insertStmt/updateStmt/deleteStmt)
|
||||
- **THEN** `syncTargets` 方法 SHALL 保持使用 `this.db.prepare()`,因需要在事务闭包内持有引用
|
||||
@@ -1,81 +0,0 @@
|
||||
## Purpose
|
||||
|
||||
定义 Dashboard 的分组表格布局:按分组展示目标表格行、TDesign PrimaryTable 列定义、排序筛选、行交互和 DOWN 行视觉强化。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 分组卡片布局
|
||||
Dashboard SHALL 按 group 字段将目标分组,每个分组包含带统计的分组标题和独立 TDesign PrimaryTable 表格。
|
||||
|
||||
#### Scenario: 按分组展示目标
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组展示,"default" 分组排在最上面,其余分组按 YAML 配置顺序排列
|
||||
|
||||
#### Scenario: 分组标题带统计标签
|
||||
- **WHEN** 页面渲染某个分组
|
||||
- **THEN** 分组标题 SHALL 使用 TDesign Space + Tag 组件显示分组名称和三个标签:总数(theme=primary, variant=light)、正常数(theme=success, variant=light)、异常数(theme=danger, variant=light),标签仅显示数字
|
||||
|
||||
#### Scenario: 分组统计标签提示
|
||||
- **WHEN** 鼠标悬停在分组统计标签上
|
||||
- **THEN** 标签 SHALL 通过 TDesign Tag 的 title 属性显示提示文字("总数"、"正常"、"异常")
|
||||
|
||||
#### Scenario: "default" 分组显示名称
|
||||
- **WHEN** 分组名称为 "default"
|
||||
- **THEN** 分组标题 SHALL 显示 "默认分组"
|
||||
|
||||
### Requirement: 响应式卡片网格
|
||||
Dashboard SHALL 使用 TDesign PrimaryTable 展示每个分组的目标,表格宽度自适应容器。
|
||||
|
||||
#### Scenario: Dashboard 容器占满宽度
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** Dashboard 容器 SHALL 占满浏览器宽度,不设置 max-width 限制
|
||||
|
||||
#### Scenario: 表格自适应宽度
|
||||
- **WHEN** 视口宽度变化
|
||||
- **THEN** 每个分组的 PrimaryTable SHALL 自适应容器宽度,不设置固定宽度
|
||||
|
||||
#### Scenario: 分组间统一间距
|
||||
- **WHEN** 页面渲染多个分组
|
||||
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件(direction=vertical, size=32px)统一间距
|
||||
|
||||
### Requirement: 目标卡片内容
|
||||
每个分组的目标 SHALL 以 TDesign PrimaryTable 行的形式展示,包含状态、名称、类型、可用率、最近状态条、延迟和间隔 7 列。
|
||||
|
||||
#### Scenario: 状态列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染指示圆点,matched=true 显示绿色(--td-success-color),matched=false 显示红色(--td-error-color),宽度 80px,fixed="left",居中对齐
|
||||
|
||||
#### Scenario: 名称列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 名称列 SHALL 显示目标名称,超长名称自动省略(ellipsis)并通过 Tooltip 显示全名
|
||||
|
||||
#### Scenario: 类型列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 类型列 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)显示类型名称,宽度 80px
|
||||
|
||||
#### Scenario: 可用率列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件(theme=line, size=small)渲染,颜色按可用率数值每 10% 一档:0-10% 最红(#d54941),每升高 10% 色阶偏移一档,经过橙色区间,90-100% 最绿(#3dba60),宽度 160px
|
||||
|
||||
#### Scenario: 最近状态列渲染
|
||||
- **WHEN** 表格行渲染且 recentSamples 数据可用
|
||||
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件展示 30 个色块,色块颜色使用 TDesign tokens:UP 使用 --td-success-color、DOWN 使用 --td-error-color、无数据使用 --td-bg-color-component-disabled,宽度 220px
|
||||
|
||||
#### Scenario: 延迟列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐,颜色根据阈值变化:≤100ms 绿色、100-500ms 橙色、>500ms 红色,无数据显示"-",宽度 80px
|
||||
|
||||
#### Scenario: 间隔列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
|
||||
|
||||
### Requirement: 卡片交互
|
||||
表格行 SHALL 支持 hover 效果和点击打开 Drawer。
|
||||
|
||||
#### Scenario: 行 hover 效果
|
||||
- **WHEN** 鼠标悬停在表格行上
|
||||
- **THEN** 行 SHALL 显示 TDesign Table 内建的 hover 高亮效果
|
||||
|
||||
#### Scenario: 行点击打开详情
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 打开该目标的详情 Drawer
|
||||
113
openspec/specs/checker-runner-abstraction/spec.md
Normal file
113
openspec/specs/checker-runner-abstraction/spec.md
Normal file
@@ -0,0 +1,113 @@
|
||||
## Purpose
|
||||
|
||||
定义 Checker 接口规范、注册机制、CheckerContext 上下文注入,以及共享 expect 断言函数的职责边界。此 capability 是 checker 系统的架构基础,不定义任何具体 checker 类型的业务行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Checker 接口定义
|
||||
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义 `Checker` 接口,包含 `type`、`resolve`、`execute`、`serialize` 四个成员。`CheckerContext` SHALL 包含引擎注入的 `AbortSignal`。
|
||||
|
||||
#### Scenario: Checker 接口包含必要方法
|
||||
- **WHEN** 开发者实现一个新的 Checker
|
||||
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`resolve(target, context)`(解析配置并校验)、`execute(target, ctx)`(执行探测返回 CheckResult)和 `serialize(target)`(返回 target 展示文本和 config JSON)
|
||||
|
||||
#### Scenario: CheckerContext 注入 signal
|
||||
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
|
||||
- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort
|
||||
|
||||
### Requirement: CheckerRegistry 注册中心
|
||||
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。
|
||||
|
||||
#### Scenario: 注册并获取 Checker
|
||||
- **WHEN** 调用 `registry.register(new HttpChecker())` 后再调用 `registry.get("http")`
|
||||
- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例
|
||||
|
||||
#### Scenario: 获取未注册的 type
|
||||
- **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker
|
||||
- **THEN** 系统 SHALL 抛出错误,提示不支持的 probe type
|
||||
|
||||
#### Scenario: 重复注册
|
||||
- **WHEN** 同一 type 值被重复 `register()`
|
||||
- **THEN** 系统 SHALL 抛出错误,提示该 type 已注册
|
||||
|
||||
#### Scenario: 查询支持的 type 列表
|
||||
- **WHEN** 注册了 "http" 和 "command" 两个 checker 后查询 `registry.supportedTypes`
|
||||
- **THEN** 返回的数组 SHALL 包含 `["http", "command"]`(按注册顺序)
|
||||
|
||||
### Requirement: 引擎通过 registry 调度 checker
|
||||
系统 SHALL 在 `ProbeEngine.runCheck()` 中通过 `checkerRegistry.get(target.type).execute(target, ctx)` 调度检查,替代原有的 `switch/case` 分支。
|
||||
|
||||
#### Scenario: 引擎使用 registry 调度
|
||||
- **WHEN** engine 需要执行一个 type 为 "http" 的 target
|
||||
- **THEN** engine SHALL 从 `checkerRegistry` 中获取对应 checker 并调用其 `execute()` 方法,不再使用 `switch/case`
|
||||
|
||||
#### Scenario: 引擎注入超时 signal
|
||||
- **WHEN** engine 调度一次 checker 执行
|
||||
- **THEN** engine SHALL 创建 `AbortController`,设置超时定时器,将 `controller.signal` 注入 `CheckerContext`,执行完成后清理定时器
|
||||
|
||||
### Requirement: 配置解析通过 registry 委托 checker
|
||||
系统 SHALL 在 `config-loader.ts` 的 `resolveTarget()` 中通过 `checkerRegistry.get(target.type).resolve(target, context)` 委托解析,替代原有的 `if/else` 分支。`validateConfig()` SHALL 仅校验通用字段(name 非空、name 不重复、group 类型),不再包含 type 专属字段校验。
|
||||
|
||||
#### Scenario: 配置解析委托 checker
|
||||
- **WHEN** config-loader 解析一个 type 为 "command" 的 target
|
||||
- **THEN** config-loader SHALL 调用 `checkerRegistry.get("command").resolve()` 进行解析、校验和默认值填充
|
||||
|
||||
#### Scenario: 通用字段校验保留在 config-loader
|
||||
- **WHEN** YAML 配置中某个 target 缺少 name 或 type 字段
|
||||
- **THEN** config-loader 的 `validateConfig()` SHALL 仍负责校验这些通用字段
|
||||
|
||||
#### Scenario: type 专属校验下沉到 checker
|
||||
- **WHEN** YAML 配置中 HTTP target 缺少 `http.url`
|
||||
- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示缺少必填字段
|
||||
|
||||
### Requirement: 存储序列化通过 registry 获取展示格式
|
||||
系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON(`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。
|
||||
|
||||
#### Scenario: 序列化委托 checker
|
||||
- **WHEN** store 同步 targets 表
|
||||
- **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }`
|
||||
|
||||
### Requirement: 共享 expect 断言函数
|
||||
系统 SHALL 在 `src/server/checker/runner/shared/` 中提供可被多个 checker 复用的 expect 函数。checker 专用的 expect 函数 SHALL 保留在各自子包内。
|
||||
|
||||
#### Scenario: 共享 duration 断言
|
||||
- **WHEN** 任何 checker 需要校验执行耗时
|
||||
- **THEN** SHALL 调用 `runner/shared/duration.ts` 中的 `checkDuration(durationMs, maxDurationMs?)`,返回统一的 `ExpectResult`
|
||||
|
||||
#### Scenario: 共享 text 规则断言
|
||||
- **WHEN** 任何 checker 需要对文本输出执行有序规则校验
|
||||
- **THEN** SHALL 调用 `runner/shared/text.ts` 中的 `checkTextRules(text, rules, phase)`,返回统一的 `ExpectResult`
|
||||
|
||||
#### Scenario: 共享 body 规则断言
|
||||
- **WHEN** 任何 checker 需要对文本体执行 contains/regex/json/css/xpath 规则校验
|
||||
- **THEN** SHALL 调用 `runner/shared/body.ts` 中的 `checkBodyExpect(body, rules)`,返回统一的 `ExpectResult`
|
||||
|
||||
#### Scenario: HTTP 专用 expect
|
||||
- **WHEN** HTTP checker 需要校验响应状态码和响应头
|
||||
- **THEN** SHALL 调用 `runner/http/expect.ts` 中的 `checkStatus()` 和 `checkHeaders()`
|
||||
|
||||
#### Scenario: Command 专用 expect
|
||||
- **WHEN** Command checker 需要校验退出码
|
||||
- **THEN** SHALL 调用 `runner/command/expect.ts` 中的 `checkExitCode()`
|
||||
|
||||
### Requirement: 超时控制由引擎注入 signal
|
||||
Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自行创建 `AbortController` 或 `setTimeout` 用于超时控制。仅 command checker 可在 signal abort 时 `proc.kill()` 以确保子进程被终止。
|
||||
|
||||
#### Scenario: HTTP checker 使用 signal
|
||||
- **WHEN** HttpChecker 执行 HTTP 请求
|
||||
- **THEN** SHALL 将 `ctx.signal` 传入 `fetch()` 的 `signal` 选项,不自行创建 `AbortController`
|
||||
|
||||
#### Scenario: Command checker 响应 signal
|
||||
- **WHEN** CommandChecker 执行命令且 signal 被 abort
|
||||
- **THEN** SHALL 调用 `proc.kill()` 终止子进程,并在 CheckResult 中记录超时错误
|
||||
|
||||
### Requirement: CheckFailure.phase 使用 string 类型
|
||||
`shared/api.ts` 中 `CheckFailure.phase` 的类型 SHALL 定义为 `string`,替代原有的硬编码联合类型 `"status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr"`。
|
||||
|
||||
#### Scenario: phase 支持 checker 专用值
|
||||
- **WHEN** command checker 在执行失败(spawn error)时生成 failure
|
||||
- **THEN** `failure.phase` SHALL 可以是 `"spawn"` 等任意字符串值,类型系统 SHALL 不报错
|
||||
|
||||
#### Scenario: 前端展示 phase 不依赖硬编码类型
|
||||
- **WHEN** 前端收到任意 phase 字符串值
|
||||
- **THEN** 前端 SHALL 直接展示而不做类型判断
|
||||
@@ -5,7 +5,7 @@
|
||||
## Requirements
|
||||
|
||||
### Requirement: ESLint 代码质量门禁
|
||||
项目 SHALL 提供 ESLint 代码质量门禁,用于审查 TypeScript、React 前端、脚本和测试代码中的质量问题。
|
||||
项目 SHALL 提供 ESLint 代码质量门禁,用于审查 TypeScript、React 前端、脚本和测试代码中的质量问题。ESLint 配置 SHALL 包括 `@eslint/js` recommended 规则、`typescript-eslint` recommended-type-checked 和 stylistic-type-checked 规则、`eslint-plugin-perfectionist` 导入排序规则、`eslint-plugin-import` 导入验证规则,以及精选的单项类型安全和风格规则。
|
||||
|
||||
#### Scenario: 运行 lint 检查
|
||||
- **WHEN** 开发者运行文档化的 lint 命令
|
||||
@@ -19,8 +19,24 @@
|
||||
- **WHEN** `src/web` 前端代码导入 `src/server` 后端运行时实现
|
||||
- **THEN** lint 命令 MUST 失败并报告前后端边界违规
|
||||
|
||||
#### Scenario: 检测类型安全违规
|
||||
- **WHEN** 代码中存在浮动的 Promise 未 await、any 类型泄漏到明确类型位置、模板字符串中包含非字符串化对象等类型安全隐患
|
||||
- **THEN** lint 命令 MUST 失败并报告对应 `@typescript-eslint` 规则违规
|
||||
|
||||
#### Scenario: 检测导入路径错误
|
||||
- **WHEN** 代码中导入路径指向不存在的文件或已废弃的路径
|
||||
- **THEN** lint 命令 MUST 失败并报告 `import/no-unresolved` 或 `import/no-deprecated` 错误
|
||||
|
||||
#### Scenario: 排除第三方模板目录
|
||||
- **WHEN** ESLint 运行检查
|
||||
- **THEN** 系统 MUST 排除 `.agents/` 等第三方模板目录,不检查其中的代码
|
||||
|
||||
#### Scenario: 排除生成产物和锁文件
|
||||
- **WHEN** ESLint 运行检查
|
||||
- **THEN** 系统 MUST 排除 `dist/`、`.build/`、`node_modules/`、`openspec/`、`.opencode/`、`.claude/`、`.codex/`、`*.bun-build`、`bun.lock`、`data/` 等非源码目录
|
||||
|
||||
### Requirement: Prettier 代码格式门禁
|
||||
项目 SHALL 提供 Prettier 格式化和格式检查命令,用于统一代码风格。
|
||||
项目 SHALL 提供 Prettier 格式化和格式检查命令,用于统一代码风格。Prettier 配置 SHALL 显式声明 `printWidth`、`semi`、`singleQuote`、`trailingComma`、`bracketSpacing`、`arrowParens`、`endOfLine`、`tabWidth`、`useTabs` 全部格式化参数。
|
||||
|
||||
#### Scenario: 检查代码格式
|
||||
- **WHEN** 开发者运行文档化的格式检查命令
|
||||
@@ -32,7 +48,51 @@
|
||||
|
||||
#### Scenario: 排除 OpenSpec 文档和生成产物
|
||||
- **WHEN** Prettier 格式化或格式检查运行
|
||||
- **THEN** 系统 MUST 排除 `openspec/`、`dist/`、`.build/`、`node_modules/`、`bun.lock` 和临时构建产物
|
||||
- **THEN** 系统 MUST 排除 `openspec/`、`dist/`、`.build/`、`node_modules/`、`bun.lock`、`skills-lock.json`、`.agents/`、`data/`、`*.bun-build`、`.opencode/`、`.claude/`、`.codex/` 和临时构建产物
|
||||
|
||||
#### Scenario: 格式化配置一致性
|
||||
- **WHEN** 不同开发者在不同操作系统上运行 `prettier --write`
|
||||
- **THEN** 由于所有格式化参数均显式定义,产物 SHALL 完全一致
|
||||
|
||||
### Requirement: TypeScript 未使用变量检测
|
||||
项目 SHALL 启用 TypeScript `noUnusedLocals` 编译选项,将未使用的局部变量检测为编译错误。
|
||||
|
||||
#### Scenario: 存在未使用的局部变量
|
||||
- **WHEN** TypeScript 代码中存在声明但未被引用的局部变量
|
||||
- **THEN** `tsc --noEmit` MUST 以非零状态退出并报告未使用变量
|
||||
|
||||
### Requirement: TypeScript 索引签名属性访问检测
|
||||
项目 SHALL 启用 TypeScript `noPropertyAccessFromIndexSignature` 编译选项,禁止通过点号访问未显式声明的属性。
|
||||
|
||||
#### Scenario: 通过点号访问 Record 动态属性
|
||||
- **WHEN** 代码通过 `.property` 点号语法访问 `Record<string, T>` 类型或索引签名类型的属性
|
||||
- **THEN** `tsc --noEmit` MUST 以非零状态退出,强制使用 `["property"]` 括号语法显式访问
|
||||
|
||||
### Requirement: ESLint 导入自动排序
|
||||
项目 SHALL 通过 `eslint-plugin-perfectionist` 对导入语句进行自动排序,确保导入顺序一致性。
|
||||
|
||||
#### Scenario: 导入语句无序排列
|
||||
- **WHEN** 文件中导入语句未按要求排序
|
||||
- **THEN** `eslint --fix` SHALL 自动重排 import 声明和 named imports 内部顺序
|
||||
|
||||
#### Scenario: type import 与 value import 混合
|
||||
- **WHEN** 文件中同时存在 `import type` 和 `import` 语句
|
||||
- **THEN** perfectionist SHALL 正确识别并分别排序,不将 type 和 value 导入混淆
|
||||
|
||||
### Requirement: ESLint 导入路径验证
|
||||
项目 SHALL 通过 `eslint-plugin-import` 验证导入路径的有效性和一致性。
|
||||
|
||||
#### Scenario: 导入不存在的模块路径
|
||||
- **WHEN** 代码中导入了不存在或路径错误的模块
|
||||
- **THEN** lint 命令 MUST 失败并报告 `import/no-unresolved` 错误
|
||||
|
||||
#### Scenario: 存在重复导入
|
||||
- **WHEN** 同一个模块在同一文件中被多次导入
|
||||
- **THEN** `eslint --fix` SHALL 自动合并重复导入为目标模块的单条导入
|
||||
|
||||
#### Scenario: 存在循环依赖
|
||||
- **WHEN** 模块 A 导入模块 B,同时模块 B 导入模块 A
|
||||
- **THEN** lint 命令 MUST 报告 `import/no-cycle` 警告
|
||||
|
||||
### Requirement: 快速检查命令
|
||||
项目 SHALL 提供快速 `check` 命令,用于日常开发期间验证代码质量和基础行为。
|
||||
|
||||
50
openspec/specs/commit-quality-gates/spec.md
Normal file
50
openspec/specs/commit-quality-gates/spec.md
Normal file
@@ -0,0 +1,50 @@
|
||||
## Purpose
|
||||
|
||||
定义 Git hooks 自动化质量门禁行为,在 pre-commit 阶段自动运行代码检查和格式化,在 commit-msg 阶段校验提交信息格式。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: pre-commit 自动质量检查
|
||||
项目 SHALL 通过 husky 和 lint-staged 在 git commit 前自动对变更文件运行 ESLint 和 Prettier 检查。
|
||||
|
||||
#### Scenario: 变更 TypeScript 文件后提交
|
||||
- **WHEN** 开发者 stage 了 `.ts` 或 `.tsx` 文件并执行 `git commit`
|
||||
- **THEN** lint-staged SHALL 自动对变更文件运行 `eslint --fix` 和 `prettier --write`,修复后继续提交
|
||||
|
||||
#### Scenario: 变更 Markdown 或 JSON 文件后提交
|
||||
- **WHEN** 开发者 stage 了 `.md`、`.json`、`.yaml` 或 `.yml` 文件并执行 `git commit`
|
||||
- **THEN** lint-staged SHALL 自动对变更文件运行 `prettier --write`
|
||||
|
||||
#### Scenario: lint 检查失败阻止提交
|
||||
- **WHEN** 变更文件存在无法自动修复的 ESLint 错误
|
||||
- **THEN** pre-commit hook MUST 以非零状态退出,阻止提交
|
||||
|
||||
#### Scenario: 无变更文件提交
|
||||
- **WHEN** 开发者执行 `git commit` 但无 stage 文件
|
||||
- **THEN** lint-staged SHALL 正常通过,不阻止提交
|
||||
|
||||
### Requirement: 提交信息格式校验
|
||||
项目 SHALL 通过 commitlint 在 git commit 时校验提交信息必须符合 "类型: 简短描述" 格式,类型限定为 feat/fix/refactor/docs/style/test/chore。
|
||||
|
||||
#### Scenario: 有效的中文提交信息
|
||||
- **WHEN** 开发者提交信息为 "feat: 新增导入排序功能"
|
||||
- **THEN** commit-msg hook SHALL 通过校验
|
||||
|
||||
#### Scenario: 缺少类型前缀的提交信息
|
||||
- **WHEN** 开发者提交信息为 "新增导入排序功能"(无 "feat:" 前缀)
|
||||
- **THEN** commit-msg hook MUST 以非零状态退出,提示正确格式
|
||||
|
||||
#### Scenario: 无效的提交类型
|
||||
- **WHEN** 开发者提交信息使用不在允许列表中的类型(如 "update: 修改配置")
|
||||
- **THEN** commit-msg hook MUST 以非零状态退出,提示可用类型
|
||||
|
||||
### Requirement: husky 初始化自动化
|
||||
项目 SHALL 通过 `prepare` 生命周期脚本在 `bun install` 时自动初始化 husky。
|
||||
|
||||
#### Scenario: 首次安装依赖
|
||||
- **WHEN** 开发者运行 `bun install`
|
||||
- **THEN** husky SHALL 自动初始化,安装 pre-commit 和 commit-msg hooks
|
||||
|
||||
#### Scenario: 已有 husky 配置时安装
|
||||
- **WHEN** 开发者运行 `bun install` 且 husky 已初始化
|
||||
- **THEN** husky 初始化 SHALL 跳过,不覆盖已有配置
|
||||
69
openspec/specs/css-utility-classes/spec.md
Normal file
69
openspec/specs/css-utility-classes/spec.md
Normal file
@@ -0,0 +1,69 @@
|
||||
## Purpose
|
||||
|
||||
定义 styles.css 中集中管理的前端样式工具类和 CSS 自定义属性,供 TDesign 组件之外的自定义组件(StatusDot、StatusBar 等)引用。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 状态色 CSS 类
|
||||
styles.css SHALL 定义状态指示相关的 CSS 类,颜色使用 TDesign tokens。
|
||||
|
||||
#### Scenario: StatusDot 颜色类
|
||||
- **WHEN** StatusDot 组件渲染
|
||||
- **THEN** 组件 SHALL 使用 `.status-dot` 基础类 + `.status-dot--up`(background: `--td-success-color`)或 `.status-dot--down`(background: `--td-error-color`)修饰类,不使用内联 style
|
||||
|
||||
#### Scenario: StatusDot 发光阴影
|
||||
- **WHEN** StatusDot 组件渲染
|
||||
- **THEN** `.status-dot--up` SHALL 定义 `box-shadow` 使用 `--td-success-color`,`.status-dot--down` SHALL 定义 `box-shadow` 使用 `--td-error-color`
|
||||
|
||||
#### Scenario: StatusBar 色块类
|
||||
- **WHEN** StatusBar 组件渲染色块
|
||||
- **THEN** 组件 SHALL 使用 `.status-bar-block` 基础类 + `.status-bar-block--up`(background: `--td-success-color`)、`.status-bar-block--down`(background: `--td-error-color`)或 `.status-bar-block--empty`(background: `--td-bg-color-component-disabled`)修饰类,不使用内联 style
|
||||
|
||||
### Requirement: 可用率色阶 CSS 变量
|
||||
styles.css SHALL 定义 10 级可用率色阶 CSS 自定义属性,使用项目自定义色值。
|
||||
|
||||
#### Scenario: 色阶变量定义
|
||||
- **WHEN** 可用率进度条渲染
|
||||
- **THEN** 色阶 SHALL 通过 CSS 自定义属性 `--avail-0` 到 `--avail-9` 定义,值为项目自定义色值(`#d54941` 到 `#3dba60`)
|
||||
|
||||
#### Scenario: 色阶渐变方向
|
||||
- **WHEN** 色阶变量被引用
|
||||
- **THEN** 色阶 SHALL 从红色(0-30%)经橙色(30-60%)过渡到绿色(60-100%)
|
||||
|
||||
### Requirement: 辅助工具类
|
||||
styles.css SHALL 定义前端组件复用的工具类。
|
||||
|
||||
#### Scenario: 文本禁用色类
|
||||
- **WHEN** 延迟列无数据需要显示占位符
|
||||
- **THEN** 组件 SHALL 使用 `.text-disabled` 类(color: `--td-text-color-disabled`),不使用内联 style
|
||||
|
||||
#### Scenario: 等宽数字类
|
||||
- **WHEN** 数值需要等宽显示
|
||||
- **THEN** 组件 SHALL 使用 `.tabular-nums` 类(font-variant-numeric: tabular-nums)
|
||||
|
||||
#### Scenario: 延迟色值类
|
||||
- **WHEN** 延迟数值渲染
|
||||
- **THEN** 组件 SHALL 使用 `.latency-ok`(color: `--td-success-color`)、`.latency-warn`(color: `--td-warning-color`)或 `.latency-error`(color: `--td-error-color`)类,不使用内联 style
|
||||
|
||||
#### Scenario: 全宽布局类
|
||||
- **WHEN** 组件需要占满父容器宽度
|
||||
- **THEN** 组件 SHALL 使用 `.full-width` 类(width: 100%),不使用内联 style
|
||||
|
||||
#### Scenario: 可点击表格类
|
||||
- **WHEN** PrimaryTable 行支持点击交互
|
||||
- **THEN** 表格 SHALL 使用 `.clickable-table` 类(cursor: pointer),不使用内联 style
|
||||
|
||||
#### Scenario: Tab 面板内边距类
|
||||
- **WHEN** Drawer 内 Tabs 面板需要内边距
|
||||
- **THEN** TabPanel SHALL 使用 `className="tab-panel-padded"` prop 传入类名,不通过入侵 TDesign 内部类名覆盖
|
||||
|
||||
### Requirement: 异常行背景类
|
||||
styles.css SHALL 定义 DOWN 行的背景色,使用安全选择器且不使用 `!important`。
|
||||
|
||||
#### Scenario: DOWN 行背景色
|
||||
- **WHEN** 表格行标记为 DOWN 状态
|
||||
- **THEN** 行 SHALL 通过 `.t-table tr.row-down` 选择器获得浅红色背景(引用 `--td-error-color-light` token),不使用 `!important`
|
||||
|
||||
#### Scenario: DOWN 行 hover 状态
|
||||
- **WHEN** 鼠标悬停在 DOWN 行上
|
||||
- **THEN** 行背景 SHALL 通过 `.t-table--hoverable tbody tr.row-down:hover` 选择器显示 hover 状态色
|
||||
@@ -1,6 +1,6 @@
|
||||
## Purpose
|
||||
|
||||
定义拨测系统的 React 前端 Dashboard:TDesign Statistic 统计卡片、按分组表格布局、目标详情 Drawer、TanStack Query 数据轮询和页面加载/错误状态。
|
||||
定义拨测系统前端 Dashboard 页面:总览统计卡片、页面标题、加载和错误状态处理。分组表格布局见 `target-table`,目标详情 Drawer 见 `target-detail-drawer`,数据轮询和缓存见 `tanstack-query-data-layer`。
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -15,31 +15,12 @@ Dashboard SHALL 在页面顶部使用 TDesign Statistic 组件展示总览统计
|
||||
- **WHEN** 页面处于打开状态
|
||||
- **THEN** 统计卡片 SHALL 通过 TanStack Query 的 refetchInterval=8000 自动刷新数据
|
||||
|
||||
### Requirement: 卡片式分组布局
|
||||
Dashboard SHALL 使用按分组展示的表格布局,每个分组包含带统计的分组标题和独立 TDesign PrimaryTable。
|
||||
### Requirement: 页面标题
|
||||
Dashboard 页面 SHALL 使用 TDesign Typography 组件渲染标题和副标题。
|
||||
|
||||
> 表格列定义、排序、筛选、行交互的详细规范见 `target-table`。
|
||||
|
||||
#### Scenario: 按分组渲染表格
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组展示,每组一个 PrimaryTable,"default" 分组排在最上面
|
||||
|
||||
#### Scenario: 无分组时的展示
|
||||
- **WHEN** 所有目标均属于 "default" 分组
|
||||
- **THEN** 页面 SHALL 显示一个 "默认分组" 区域,表格正常展示
|
||||
|
||||
### Requirement: 目标详情 Drawer
|
||||
Dashboard SHALL 使用 TDesign Drawer 展示目标详情,包含时间范围筛选、Tabs 组织的统计图表和分页检查记录列表。
|
||||
|
||||
> Drawer 的时间范围筛选、Tabs 面板内容、检查结果列表的详细规范见 `target-detail-drawer`。
|
||||
|
||||
#### Scenario: 打开 Drawer
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right", size="60%"),展示该目标的详情
|
||||
|
||||
#### Scenario: 关闭 Drawer
|
||||
- **WHEN** 用户点击 Drawer 关闭按钮、ESC 键或遮罩层
|
||||
- **THEN** Drawer SHALL 关闭
|
||||
#### Scenario: 页面标题渲染
|
||||
- **WHEN** Dashboard 页面渲染
|
||||
- **THEN** 页面标题 SHALL 使用 TDesign Typography.Title 组件(level="h1")渲染"DiAL",副标题 SHALL 使用 Typography.Text 组件(theme="secondary")渲染"统一拨测平台"
|
||||
|
||||
### Requirement: 页面加载与错误状态
|
||||
Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。
|
||||
@@ -51,7 +32,3 @@ Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。
|
||||
#### Scenario: API 请求失败
|
||||
- **WHEN** 前端 API 请求失败
|
||||
- **THEN** 页面 SHALL 使用 TDesign Alert 组件(theme=error)显示错误提示
|
||||
|
||||
#### Scenario: Drawer 内部加载状态
|
||||
- **WHEN** Drawer 内趋势数据或历史记录正在加载
|
||||
- **THEN** 趋势面板 SHALL 显示 TDesign Skeleton 加载占位,记录表格 SHALL 显示 loading 状态
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
## Purpose
|
||||
|
||||
定义目标详情 Drawer:时间范围筛选(TDesign RadioGroup + DateRangePicker)、Tabs 组织概览/趋势/记录三个面板、统计图表和分页检查结果列表。
|
||||
定义目标详情 Drawer:时间范围筛选(TDesign RadioGroup + DateRangePicker)、Tabs 组织概览/记录两个面板、统计图表和分页检查结果列表。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 目标详情 Drawer
|
||||
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。
|
||||
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。
|
||||
|
||||
#### Scenario: 打开 Drawer
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
@@ -13,78 +13,107 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示
|
||||
|
||||
#### Scenario: Drawer 标题栏
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** 标题栏 SHALL 显示 StatusDot、目标名称和类型标签(TDesign Tag),以及内建关闭按钮
|
||||
- **THEN** 标题栏 SHALL 使用 TDesign Space 组件(align="center")布局,包含 StatusDot、目标名称(TDesign Typography.Text strong)和类型标签(TDesign Tag),以及内建关闭按钮。不使用内联 style 的 flex 布局
|
||||
|
||||
#### Scenario: 关闭 Drawer
|
||||
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
|
||||
- **THEN** Drawer SHALL 关闭
|
||||
|
||||
#### Scenario: Drawer 无底部按钮
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** Drawer SHALL 不显示底部操作栏(footer={false})
|
||||
|
||||
#### Scenario: Drawer 数据同步
|
||||
- **WHEN** Drawer 打开期间后台轮询刷新了 targets 数据
|
||||
- **THEN** Drawer 中 selectedTarget 的状态 SHALL 随之同步更新
|
||||
|
||||
#### Scenario: 切换目标重置 Tab
|
||||
- **WHEN** 用户从目标 A 切换到目标 B(点击不同的表格行)
|
||||
- **THEN** Drawer SHALL 重置为概览 Tab,使用 key={target.id} 确保组件状态不残留
|
||||
|
||||
#### Scenario: Drawer 内容区间距
|
||||
- **WHEN** Drawer 内容渲染
|
||||
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件(direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
|
||||
|
||||
### Requirement: 时间范围选择器
|
||||
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览/趋势/记录三个面板的数据。
|
||||
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。时间选择器 SHALL 分两行显示:第一行为快捷按钮,第二行为日期时间范围选择器。
|
||||
|
||||
#### Scenario: 快捷时间按钮
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** 时间选择区 SHALL 显示 TDesign RadioGroup(variant=default-filled)快捷按钮:1h、6h、24h、7d
|
||||
- **THEN** 时间选择区第一行 SHALL 显示 TDesign RadioGroup(variant=default-filled)快捷按钮:1小时、6小时、24小时、7天
|
||||
|
||||
#### Scenario: 点击快捷按钮
|
||||
- **WHEN** 用户点击快捷按钮(如 "24h")
|
||||
- **WHEN** 用户点击快捷按钮(如 "24小时")
|
||||
- **THEN** 系统 SHALL 自动设置对应的起止时间,DateRangePicker 显示对应的时间范围,该按钮高亮
|
||||
|
||||
#### Scenario: 自定义日期时间范围
|
||||
- **WHEN** 用户通过 TDesign DateRangePicker(mode=date, enableTimePicker)修改时间范围
|
||||
- **WHEN** 用户通过 TDesign DateRangePicker(mode=date, enableTimePicker, format="YYYY-MM-DD HH:mm")修改时间范围
|
||||
- **THEN** 快捷按钮 SHALL 取消高亮,系统重新请求对应时间范围的数据
|
||||
|
||||
#### Scenario: 时间精度为分钟级
|
||||
- **WHEN** 用户通过 DateRangePicker 选择时间
|
||||
- **THEN** 选择器 SHALL 仅精确到分钟(format="YYYY-MM-DD HH:mm"),秒列固定为 00
|
||||
|
||||
#### Scenario: DateRangePicker 全宽显示
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** DateRangePicker SHALL 通过 CSS 类 `.full-width` 占满时间选择区第二行的宽度,不使用内联 style 的 width: 100%
|
||||
|
||||
#### Scenario: 默认时间范围
|
||||
- **WHEN** Drawer 打开
|
||||
- **THEN** 时间选择器 SHALL 默认选中 "24h" 快捷按钮
|
||||
- **THEN** 时间选择器 SHALL 默认选中 "24小时" 快捷按钮
|
||||
|
||||
#### Scenario: 筛选触发数据刷新
|
||||
- **WHEN** 时间范围发生变化
|
||||
- **THEN** 系统 SHALL 重新请求趋势数据和历史记录
|
||||
|
||||
### Requirement: Tabs 内容组织
|
||||
Drawer 内部 SHALL 使用 TDesign Tabs 组织概览、趋势、记录三个面板。
|
||||
Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。TabPanel 内边距通过 className prop 控制。
|
||||
|
||||
#### Scenario: Tab 标签
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** Tabs SHALL 显示三个标签:概览、趋势、记录
|
||||
- **THEN** Tabs SHALL 显示两个标签:概览、记录
|
||||
|
||||
#### Scenario: Tab 面板内边距
|
||||
- **WHEN** TabPanel 渲染
|
||||
- **THEN** TabPanel SHALL 通过 `className` prop 传入自定义类名(`tab-panel-padded`)控制内边距,不通过入侵 TDesign 内部类名(`.t-tab-panel`)覆盖
|
||||
|
||||
### Requirement: 概览面板
|
||||
概览 Tab SHALL 展示目标统计摘要和基本信息。
|
||||
概览 Tab SHALL 按区域展示目标统计摘要、趋势图、状态分布和基本信息,每个区域使用 TDesign Divider 组件作为小标题分隔。
|
||||
|
||||
#### Scenario: 区域排列顺序
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 按以下顺序展示区域:统计 → 趋势 → 状态分布 → 基本信息,每个区域前 SHALL 显示 TDesign Divider(align="left")作为小标题,不使用内联 style 的 h4 标签
|
||||
|
||||
#### Scenario: 区域间距
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 各区域之间的间距 SHALL 通过 TDesign Space 组件(direction="vertical")统一管理,不使用内联 style 的 margin
|
||||
|
||||
#### Scenario: 统计数值卡片
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 使用 TDesign Statistic 组件展示 4 个统计值:总检查(color=blue)、正常(color=green)、异常(color=red)、可用率(color=green, suffix="%"),使用 TDesign Row/Col 横向排列
|
||||
- **THEN** 面板 SHALL 在"统计"区域使用 TDesign Statistic 组件展示 4 个统计值:总检查(color=blue)、正常(color=green)、异常(color=red)、可用率(color=green, suffix="%"),使用 TDesign Row/Col 横向排列。Row 的外层间距 SHALL 通过 TDesign Space 或 CSS 类控制,不使用内联 style
|
||||
|
||||
#### Scenario: 元信息展示
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 使用 TDesign Descriptions 组件展示目标元信息:目标地址、检查间隔、最新检查时间、状态详情
|
||||
#### Scenario: 趋势折线图
|
||||
- **WHEN** 概览面板渲染且趋势数据可用
|
||||
- **THEN** 面板 SHALL 在"趋势"区域展示 recharts 双 Y 轴折线图(TrendChart):耗时线(--td-brand-color)和可用率线(--td-success-color)
|
||||
|
||||
#### Scenario: 趋势数据加载中
|
||||
- **WHEN** 概览面板渲染且趋势数据正在加载
|
||||
- **THEN** "趋势"区域 SHALL 显示 TDesign Skeleton 加载占位
|
||||
|
||||
#### Scenario: 状态分布环形图
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 展示 recharts 环形图(StatusDonut),外圈显示 UP/DOWN 比例,中间显示可用率百分比
|
||||
- **THEN** 面板 SHALL 在"状态分布"区域展示 recharts 环形图(StatusDonut),外圈显示 UP/DOWN 比例,中间显示可用率百分比
|
||||
|
||||
### Requirement: 趋势面板
|
||||
趋势 Tab SHALL 展示可用率和耗时趋势折线图。
|
||||
|
||||
#### Scenario: 趋势折线图
|
||||
- **WHEN** 趋势面板渲染且数据可用
|
||||
- **THEN** 面板 SHALL 展示 recharts 双 Y 轴折线图:耗时线(--td-brand-color)和可用率线(--td-success-color)
|
||||
|
||||
#### Scenario: 趋势数据加载中
|
||||
- **WHEN** 趋势数据正在加载
|
||||
- **THEN** 面板 SHALL 显示 TDesign Skeleton 加载占位
|
||||
#### Scenario: 元信息展示
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 在"基本信息"区域使用 TDesign Descriptions 组件展示目标元信息:目标地址、检查间隔、最新检查时间、状态详情
|
||||
|
||||
### Requirement: 记录面板
|
||||
记录 Tab SHALL 展示分页检查结果列表,使用 TDesign PrimaryTable。
|
||||
|
||||
#### Scenario: 检查结果表格
|
||||
- **WHEN** 记录面板渲染且数据可用
|
||||
- **THEN** 面板 SHALL 使用 TDesign PrimaryTable 展示检查结果,列包含:状态(TDesign Tag theme=success/danger)、时间、详情、耗时、错误信息
|
||||
- **THEN** 面板 SHALL 使用 TDesign PrimaryTable 展示检查结果,列包含:状态(StatusDot 圆点)、时间(YYYY-MM-DD HH:mm:ss 格式)、耗时(标题含 ms 单位,单元格仅显示数值,居中对齐)、详情(statusDetail 和 failure.message 用冒号拼接)
|
||||
|
||||
#### Scenario: 服务端分页
|
||||
- **WHEN** 检查结果总数超过一页
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
## Purpose
|
||||
|
||||
定义目标详情 Drawer:时间范围筛选(TDesign RadioGroup 快捷按钮 + DateRangePicker)、Tabs 组织概览/趋势/记录三个面板、统计图表和分页检查结果列表。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 目标详情 Drawer
|
||||
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计图表和检查结果列表。
|
||||
|
||||
#### Scenario: 打开 Drawer
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right", size="60%"),展示该目标的详情
|
||||
|
||||
#### Scenario: Drawer 默认时间范围
|
||||
- **WHEN** Drawer 打开
|
||||
- **THEN** 筛选器 SHALL 默认选中 "24h" 快捷按钮
|
||||
|
||||
#### Scenario: 关闭 Drawer
|
||||
- **WHEN** 用户点击 Drawer 关闭按钮、ESC 键或遮罩层
|
||||
- **THEN** Drawer SHALL 关闭
|
||||
|
||||
### Requirement: 时间范围筛选
|
||||
Drawer SHALL 支持通过 TDesign RadioGroup 快捷按钮和 DateRangePicker 筛选数据的时间范围。
|
||||
|
||||
#### Scenario: 快捷时间范围按钮
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** 筛选栏 SHALL 显示 TDesign RadioGroup(variant=default-filled)快捷按钮:1h、6h、24h、7d,当前选中的按钮高亮显示
|
||||
|
||||
#### Scenario: 点击快捷按钮
|
||||
- **WHEN** 用户点击快捷按钮(如 "24h")
|
||||
- **THEN** 筛选器 SHALL 自动设置对应的起止时间,DateRangePicker 显示对应的时间范围,该按钮高亮
|
||||
|
||||
#### Scenario: 自定义日期时间选择
|
||||
- **WHEN** 用户通过 TDesign DateRangePicker(mode=date, enableTimePicker)修改起止时间
|
||||
- **THEN** 快捷按钮 SHALL 取消高亮,表示当前为自定义时间范围
|
||||
|
||||
#### Scenario: 筛选触发数据刷新
|
||||
- **WHEN** 时间范围发生变化(快捷按钮或自定义选择)
|
||||
- **THEN** 系统 SHALL 通过 TanStack Query 重新请求该时间范围内的趋势数据和历史记录
|
||||
|
||||
### Requirement: 统计图表展示
|
||||
Drawer 概览和趋势面板 SHALL 展示统计数值、目标元信息和可用率趋势折线图、状态分布环形图。
|
||||
|
||||
#### Scenario: 概览面板统计数值
|
||||
- **WHEN** 概览 Tab 加载完成
|
||||
- **THEN** 面板 SHALL 使用 TDesign Statistic 组件展示总检查、正常、异常、可用率四个数值,使用 TDesign Row/Col 横向排列
|
||||
|
||||
#### Scenario: 概览面板元信息
|
||||
- **WHEN** 概览 Tab 加载完成
|
||||
- **THEN** 面板 SHALL 使用 TDesign Descriptions 组件展示目标地址、检查间隔、最新检查时间、状态详情
|
||||
|
||||
#### Scenario: 可用率趋势折线图
|
||||
- **WHEN** 趋势 Tab 加载完成且数据可用
|
||||
- **THEN** 面板 SHALL 展示 recharts 双 Y 轴折线图:耗时线颜色使用 --td-brand-color,可用率线颜色使用 --td-success-color
|
||||
|
||||
#### Scenario: 状态分布环形图
|
||||
- **WHEN** 概览 Tab 加载完成
|
||||
- **THEN** 面板 SHALL 展示 recharts 环形图(Donut Chart),UP 颜色使用 --td-success-color,DOWN 颜色使用 --td-error-color,中间显示可用率百分比数字
|
||||
|
||||
### Requirement: 检查结果列表
|
||||
Drawer 记录面板 SHALL 使用 TDesign PrimaryTable 展示检查结果,支持服务端分页。
|
||||
|
||||
#### Scenario: 展示检查结果
|
||||
- **WHEN** 记录 Tab 加载完成且历史记录可用
|
||||
- **THEN** 表格 SHALL 展示检查结果,每条包含状态(TDesign Tag)、时间戳、statusDetail、耗时毫秒数和 failure 信息
|
||||
|
||||
#### Scenario: 分页导航
|
||||
- **WHEN** 检查结果总数超过一页
|
||||
- **THEN** 表格底部 SHALL 展示内建 pagination 分页器(disableDataPage=true)
|
||||
|
||||
#### Scenario: 翻页刷新
|
||||
- **WHEN** 用户切换分页页码
|
||||
- **THEN** 系统 SHALL 通过 TanStack Query 请求对应页码的历史记录数据,表格更新
|
||||
|
||||
### Requirement: 内容组织布局
|
||||
Drawer SHALL 使用 TDesign Tabs 组织概览、趋势、记录三个面板。
|
||||
|
||||
#### Scenario: Tabs 组织内容
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** 内容区域 SHALL 使用 TDesign Tabs 组件分为概览、趋势、记录三个标签页
|
||||
|
||||
### Requirement: 标题栏类型标签
|
||||
Drawer 标题栏 SHALL 显示目标类型标签,使用统一的类型显示映射系统。
|
||||
|
||||
#### Scenario: 类型标签显示
|
||||
- **WHEN** Drawer 标题栏渲染
|
||||
- **THEN** 标题栏 SHALL 在目标名称旁显示 TDesign Tag 类型标签(HTTP / CMD)
|
||||
|
||||
#### Scenario: 类型标签使用映射系统
|
||||
- **WHEN** Drawer 渲染类型标签
|
||||
- **THEN** 类型标签 SHALL 使用统一的类型显示映射函数,不硬编码映射逻辑
|
||||
@@ -23,12 +23,20 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
|
||||
- **WHEN** 分组名称为 "default"
|
||||
- **THEN** 分组标题 SHALL 显示 "默认分组"
|
||||
|
||||
#### Scenario: Dashboard 容器占满宽度
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** Dashboard 容器 SHALL 占满浏览器宽度,不设置 max-width 限制
|
||||
|
||||
#### Scenario: 分组间统一间距
|
||||
- **WHEN** 页面渲染多个分组
|
||||
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件(direction=vertical, size=32px)统一间距
|
||||
|
||||
### Requirement: 表格列定义
|
||||
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。
|
||||
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。
|
||||
|
||||
#### Scenario: 状态列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,fixed="left",宽度 80px,居中对齐,支持筛选(UP/DOWN/全部)
|
||||
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60px,fixed="left",居中对齐,支持筛选(UP/DOWN/全部)。StatusDot SHALL 通过 CSS 类(`.status-dot--up` / `.status-dot--down`)控制颜色,不使用内联 style
|
||||
|
||||
#### Scenario: 名称列
|
||||
- **WHEN** 表格渲染
|
||||
@@ -40,15 +48,15 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
|
||||
|
||||
#### Scenario: 可用率列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件(theme=line, size=small)渲染,颜色按可用率数值每 10% 一档:0-10% 最红(#d54941),每升高 10% 色阶偏移一档,经过橙色区间,90-100% 最绿(#3dba60),label 显示百分比数值,支持排序(升序优先,最差排最前)
|
||||
- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件(theme=line, size=small)渲染,颜色通过 CSS 自定义属性 `--avail-N`(基于项目自定义色值)控制,每 10% 一档,label 显示百分比数值,支持排序(升序优先,最差排最前)。color-threshold 函数 SHALL 返回 CSS 自定义属性引用而非硬编码色值
|
||||
|
||||
#### Scenario: 最近状态列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染 30 格采样色块,宽度 220px,色块使用 flex:1 自适应列宽
|
||||
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染 30 格采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style
|
||||
|
||||
#### Scenario: 延迟列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐,颜色根据阈值变化:≤100ms 使用 --td-success-color、100-500ms 使用 --td-warning-color、>500ms 使用 --td-error-color,无数据显示"-",支持数值排序
|
||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐。颜色 SHALL 通过 CSS 类实现:≤100ms 使用 `.latency-ok`、100-500ms 使用 `.latency-warn`、>500ms 使用 `.latency-error`。无数据 SHALL 使用 `.text-disabled` 类显示 "-",数值 SHALL 使用 `.tabular-nums` 类等宽显示。不使用内联 style
|
||||
|
||||
#### Scenario: 间隔列
|
||||
- **WHEN** 表格渲染
|
||||
@@ -62,11 +70,15 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
|
||||
- **THEN** 每个分组表格 SHALL 默认按状态降序排列,DOWN 目标排在同组最前面
|
||||
|
||||
### Requirement: DOWN 行视觉强化
|
||||
表格中状态为 DOWN 的行 SHALL 具有视觉区分。
|
||||
表格中状态为 DOWN 的行 SHALL 具有视觉区分,使用安全 CSS 选择器实现。
|
||||
|
||||
#### Scenario: DOWN 行背景色
|
||||
- **WHEN** 目标最近一次检查 matched=false
|
||||
- **THEN** 该行 SHALL 使用浅红色背景(--td-error-color-light),与正常行形成视觉区分
|
||||
- **THEN** 该行 SHALL 通过 `.t-table tr.row-down` CSS 选择器获得浅红色背景(`--td-error-color-light`),不使用 `!important`
|
||||
|
||||
#### Scenario: DOWN 行 hover 状态
|
||||
- **WHEN** 鼠标悬停在 DOWN 行上
|
||||
- **THEN** 行背景 SHALL 通过 `.t-table--hoverable tbody tr.row-down:hover` 选择器显示 hover 状态色,与正常行 hover 效果协调
|
||||
|
||||
### Requirement: 行点击交互
|
||||
表格行 SHALL 支持点击打开目标详情 Drawer。
|
||||
|
||||
11
package.json
11
package.json
@@ -17,9 +17,12 @@
|
||||
"test": "bun test",
|
||||
"test:smoke": "bun run scripts/smoke.ts",
|
||||
"clean": "bun run scripts/clean.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.0.0",
|
||||
"@commitlint/config-conventional": "^21.0.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@types/bun": "^1.3.13",
|
||||
@@ -27,8 +30,13 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.4",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
@@ -38,6 +46,7 @@
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"cheerio": "^1.2.0",
|
||||
"es-toolkit": "^1.46.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"recharts": "^3.8.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { $ } from "bun";
|
||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, relative, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { $ } from "bun";
|
||||
|
||||
const buildDir = fileURLToPath(new URL("../.build/", import.meta.url));
|
||||
const webDistDir = fileURLToPath(new URL("../dist/web/", import.meta.url));
|
||||
@@ -9,7 +9,7 @@ const executablePath = fileURLToPath(new URL("../dist/dial-server", import.meta.
|
||||
const generatedAssetsPath = fileURLToPath(new URL("../.build/static-assets.ts", import.meta.url));
|
||||
const generatedEntryPath = fileURLToPath(new URL("../.build/server-entry.ts", import.meta.url));
|
||||
|
||||
await rm(buildDir, { recursive: true, force: true });
|
||||
await rm(buildDir, { force: true, recursive: true });
|
||||
await rm(executablePath, { force: true });
|
||||
await mkdir(buildDir, { recursive: true });
|
||||
|
||||
@@ -26,21 +26,21 @@ const assetFiles = files.filter((file) => file !== indexPath);
|
||||
await writeGeneratedAssets(indexPath, assetFiles);
|
||||
await writeGeneratedEntry();
|
||||
|
||||
const target = process.env.BUN_TARGET ?? process.env.BUILD_TARGET;
|
||||
const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"];
|
||||
const result = await Bun.build({
|
||||
entrypoints: [generatedEntryPath],
|
||||
compile: target
|
||||
? {
|
||||
target: target as Bun.Build.CompileTarget,
|
||||
outfile: executablePath,
|
||||
autoloadDotenv: true,
|
||||
autoloadBunfig: true,
|
||||
autoloadDotenv: true,
|
||||
outfile: executablePath,
|
||||
target: target as Bun.Build.CompileTarget,
|
||||
}
|
||||
: {
|
||||
outfile: executablePath,
|
||||
autoloadDotenv: true,
|
||||
autoloadBunfig: true,
|
||||
autoloadDotenv: true,
|
||||
outfile: executablePath,
|
||||
},
|
||||
entrypoints: [generatedEntryPath],
|
||||
minify: true,
|
||||
sourcemap: "linked",
|
||||
});
|
||||
@@ -52,7 +52,7 @@ if (!result.success) {
|
||||
|
||||
console.log(`Built executable: ${executablePath}`);
|
||||
|
||||
await rm(buildDir, { recursive: true, force: true });
|
||||
await rm(buildDir, { force: true, recursive: true });
|
||||
|
||||
async function listFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
@@ -71,6 +71,15 @@ async function listFiles(directory: string): Promise<string[]> {
|
||||
return files.flat().sort((left, right) => normalize(left).localeCompare(normalize(right)));
|
||||
}
|
||||
|
||||
function normalize(path: string): string {
|
||||
return path.split(sep).join("/");
|
||||
}
|
||||
|
||||
function toImportPath(path: string): string {
|
||||
const rel = normalize(relative(buildDir, path));
|
||||
return rel.startsWith(".") ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
async function writeGeneratedAssets(indexPath: string, assetFiles: string[]) {
|
||||
const imports = [
|
||||
`import type { StaticAssets } from "../src/server/app";`,
|
||||
@@ -103,9 +112,12 @@ import { ProbeStore } from "../src/server/checker/store";
|
||||
import { ProbeEngine } from "../src/server/checker/engine";
|
||||
import { startServer } from "../src/server/server";
|
||||
import { readRuntimeConfig } from "../src/server/config";
|
||||
import { registerCheckers } from "../src/server/checker/runner";
|
||||
import { staticAssets } from "./static-assets";
|
||||
|
||||
async function main() {
|
||||
registerCheckers();
|
||||
|
||||
const { configPath } = readRuntimeConfig();
|
||||
const config = await loadConfig(configPath);
|
||||
|
||||
@@ -130,12 +142,3 @@ void main().catch((error) => {
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
function toImportPath(path: string): string {
|
||||
const rel = normalize(relative(buildDir, path));
|
||||
return rel.startsWith(".") ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function normalize(path: string): string {
|
||||
return path.split(sep).join("/");
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ import { resolve } from "node:path";
|
||||
|
||||
const root = resolve(import.meta.dir, "..");
|
||||
|
||||
const patterns: Array<{ glob: string; desc: string }> = [
|
||||
{ glob: ".build/", desc: "Bun 构建缓存" },
|
||||
{ glob: ".*.bun-build", desc: "Bun 构建临时文件" },
|
||||
const patterns: Array<{ desc: string; glob: string }> = [
|
||||
{ desc: "Bun 构建缓存", glob: ".build/" },
|
||||
{ desc: "Bun 构建临时文件", glob: ".*.bun-build" },
|
||||
];
|
||||
|
||||
for (const { glob, desc } of patterns) {
|
||||
for (const { desc, glob } of patterns) {
|
||||
const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: root, dot: true }));
|
||||
if (entries.length === 0) continue;
|
||||
for (const entry of entries) {
|
||||
const full = resolve(root, entry);
|
||||
await rm(full, { recursive: true, force: true });
|
||||
await rm(full, { force: true, recursive: true });
|
||||
console.log(`已清理 ${desc}: ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const configPath = process.argv[2];
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
BACKEND_PORT: process.env.PORT ?? "3000",
|
||||
BACKEND_PORT: process.env["PORT"] ?? "3000",
|
||||
};
|
||||
|
||||
const children: ChildProcessInfo[] = [
|
||||
@@ -15,16 +15,16 @@ const children: ChildProcessInfo[] = [
|
||||
name: "server",
|
||||
process: Bun.spawn(["bun", "run", "dev:server", ...(configPath ? [configPath] : [])], {
|
||||
env,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "web",
|
||||
process: Bun.spawn(["bun", "run", "dev:web"], {
|
||||
env,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -46,7 +46,7 @@ process.on("SIGTERM", () => {
|
||||
});
|
||||
|
||||
const firstExit = await Promise.race(
|
||||
children.map(async (child) => ({ name: child.name, code: await child.process.exited })),
|
||||
children.map(async (child) => ({ code: await child.process.exited, name: child.name })),
|
||||
);
|
||||
|
||||
stopChildren();
|
||||
|
||||
105
scripts/smoke.ts
105
scripts/smoke.ts
@@ -1,8 +1,9 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { access } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { HealthResponse, SummaryResponse } from "../src/shared/api";
|
||||
|
||||
const executablePath = process.argv[2] ?? fileURLToPath(new URL("../dist/dial-server", import.meta.url));
|
||||
@@ -12,7 +13,7 @@ await assertExecutableExists(executablePath);
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "dial-smoke-"));
|
||||
const configPath = join(tempDir, "probes.yaml");
|
||||
|
||||
const port = await getFreePort();
|
||||
const port = getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
writeFileSync(
|
||||
@@ -31,9 +32,9 @@ targets:
|
||||
`,
|
||||
);
|
||||
const app = Bun.spawn([executablePath, configPath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env },
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
const stdout = readStream(app.stdout);
|
||||
const stderr = readStream(app.stderr);
|
||||
@@ -49,10 +50,10 @@ try {
|
||||
assert(summary.total === 1, "总览统计: total 应为 1");
|
||||
assertSecurityHeaders(await fetch(`${baseUrl}/api/summary`), "/api/summary");
|
||||
|
||||
const { body: targets } = await expectJson(`${baseUrl}/api/targets`, 200);
|
||||
const { body: targets } = await expectJson<unknown[]>(`${baseUrl}/api/targets`, 200);
|
||||
assert(Array.isArray(targets), "/api/targets 应返回数组");
|
||||
assert(targets.length === 1, "/api/targets 应有 1 个目标");
|
||||
assert(targets[0].name === "httpbin", "目标名称应为 httpbin");
|
||||
assert((targets[0] as { name: string }).name === "httpbin", "目标名称应为 httpbin");
|
||||
|
||||
const missingApi = await fetch(`${baseUrl}/api/not-found`);
|
||||
assert(missingApi.status === 404, "未知 API 应返回 404");
|
||||
@@ -67,7 +68,7 @@ try {
|
||||
const { body: fallbackHtml } = await expectText(`${baseUrl}/dashboard`, 200);
|
||||
assert(fallbackHtml.includes("DiAL"), "SPA fallback 未返回前端入口页面");
|
||||
|
||||
const assetPath = rootHtml.match(/(?:src|href)="(\/assets\/[^"]+)"/)?.[1];
|
||||
const assetPath = /(?:src|href)="(\/assets\/[^"]+)"/.exec(rootHtml)?.[1];
|
||||
assert(assetPath !== undefined, "前端入口页面未引用 /assets/* 资源");
|
||||
|
||||
const asset = await fetch(`${baseUrl}${assetPath}`);
|
||||
@@ -85,7 +86,13 @@ try {
|
||||
throw new Error(`executable smoke test 失败: ${message}\nstdout:\n${out}\nstderr:\n${err}`, { cause: error });
|
||||
} finally {
|
||||
app.kill();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertExecutableExists(path: string) {
|
||||
@@ -96,37 +103,12 @@ async function assertExecutableExists(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch: () => new Response("ok"),
|
||||
});
|
||||
const port = server.port;
|
||||
|
||||
server.stop(true);
|
||||
|
||||
if (port === undefined) {
|
||||
throw new Error("无法分配 smoke test 端口");
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForServer(url: string) {
|
||||
const deadline = Date.now() + 8_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`服务未在超时时间内启动: ${url}`);
|
||||
function assertSecurityHeaders(response: Response, label: string) {
|
||||
assert(response.headers.get("x-content-type-options") === "nosniff", `${label} 缺少 nosniff 安全头`);
|
||||
assert(
|
||||
response.headers.get("referrer-policy") === "strict-origin-when-cross-origin",
|
||||
`${label} 缺少 Referrer-Policy 安全头`,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectJson<T = unknown>(url: string, status: number): Promise<{ body: T; response: Response }> {
|
||||
@@ -146,22 +128,41 @@ async function expectText(url: string, status: number): Promise<{ body: string;
|
||||
return { body: await response.text(), response };
|
||||
}
|
||||
|
||||
function assertSecurityHeaders(response: Response, label: string) {
|
||||
assert(response.headers.get("x-content-type-options") === "nosniff", `${label} 缺少 nosniff 安全头`);
|
||||
assert(
|
||||
response.headers.get("referrer-policy") === "strict-origin-when-cross-origin",
|
||||
`${label} 缺少 Referrer-Policy 安全头`,
|
||||
);
|
||||
}
|
||||
function getFreePort(): number {
|
||||
const server = Bun.serve({
|
||||
fetch: () => new Response("ok"),
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
const port = server.port;
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
void server.stop(true);
|
||||
|
||||
if (port === undefined) {
|
||||
throw new Error("无法分配 smoke test 端口");
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
|
||||
async function readStream(stream: null | ReadableStream<Uint8Array>): Promise<string> {
|
||||
if (!stream) return "";
|
||||
|
||||
return new Response(stream).text();
|
||||
}
|
||||
|
||||
async function waitForServer(url: string) {
|
||||
const deadline = Date.now() + 8_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`服务未在超时时间内启动: ${url}`);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
CheckFailure,
|
||||
CheckResult,
|
||||
HealthResponse,
|
||||
HistoryResponse,
|
||||
RuntimeMode,
|
||||
SummaryResponse,
|
||||
TargetStatus,
|
||||
TrendPoint,
|
||||
} from "../shared/api";
|
||||
import type { StoredCheckResult } from "./checker/types";
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { ProbeStore } from "./checker/store";
|
||||
|
||||
export interface StaticAssets {
|
||||
indexHtml: Blob;
|
||||
files: Record<string, Blob>;
|
||||
}
|
||||
import { createApiError, jsonResponse } from "./helpers";
|
||||
import { guardGetHead } from "./middleware";
|
||||
import { handleHealth } from "./routes/health";
|
||||
import { handleHistory } from "./routes/history";
|
||||
import { handleSummary } from "./routes/summary";
|
||||
import { handleTargets } from "./routes/targets";
|
||||
import { handleTrend } from "./routes/trend";
|
||||
import { serveStaticAsset } from "./static";
|
||||
|
||||
export interface AppOptions {
|
||||
mode: RuntimeMode;
|
||||
@@ -23,16 +16,17 @@ export interface AppOptions {
|
||||
store?: ProbeStore;
|
||||
}
|
||||
|
||||
export interface StaticAssets {
|
||||
files: Record<string, Blob>;
|
||||
indexHtml: Blob;
|
||||
}
|
||||
|
||||
export function createFetchHandler(options: AppOptions) {
|
||||
return (request: Request): Response => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
if (!allowsGetHead(request.method)) {
|
||||
return methodNotAllowedResponse(["GET", "HEAD"], options.mode);
|
||||
}
|
||||
|
||||
return jsonResponse(createHealthResponse(), { method: request.method, mode: options.mode });
|
||||
return handleHealth(request.method, options.mode);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/") && options.store) {
|
||||
@@ -52,293 +46,35 @@ export function createFetchHandler(options: AppOptions) {
|
||||
}
|
||||
|
||||
return new Response("开发期请通过 Vite 前端地址访问页面。", {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
status: 404,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const { method } = request;
|
||||
const guardResult = guardGetHead(request.method, mode);
|
||||
if (guardResult) return guardResult;
|
||||
|
||||
if (!allowsGetHead(method)) {
|
||||
return methodNotAllowedResponse(["GET", "HEAD"], mode);
|
||||
}
|
||||
const method = request.method;
|
||||
|
||||
if (url.pathname === "/api/summary") {
|
||||
return jsonResponse(createSummaryResponse(store), { method, mode });
|
||||
return handleSummary(store, method, mode);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/targets") {
|
||||
return jsonResponse(createTargetsResponse(store), { method, mode });
|
||||
return handleTargets(store, method, mode);
|
||||
}
|
||||
|
||||
const historyMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/history$/);
|
||||
const historyMatch = /^\/api\/targets\/([^/]+)\/history$/.exec(url.pathname);
|
||||
if (historyMatch) {
|
||||
return handleHistory(historyMatch[1]!, url, method, store, mode);
|
||||
}
|
||||
|
||||
const trendMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/trend$/);
|
||||
const trendMatch = /^\/api\/targets\/([^/]+)\/trend$/.exec(url.pathname);
|
||||
if (trendMatch) {
|
||||
return handleTrend(trendMatch[1]!, url, method, store, mode);
|
||||
}
|
||||
|
||||
return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const id = Number(idStr);
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const target = store.getTargetById(id);
|
||||
if (!target) {
|
||||
return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
let page = 1;
|
||||
let pageSize = 20;
|
||||
|
||||
if (pageParam !== null) {
|
||||
page = Number(pageParam);
|
||||
if (!Number.isInteger(page) || page <= 0) {
|
||||
return jsonResponse(createApiError("Invalid page parameter", 400), { method, mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSizeParam !== null) {
|
||||
pageSize = Number(pageSizeParam);
|
||||
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
||||
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { method, mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.getHistory(id, from, to, page, pageSize);
|
||||
const response: HistoryResponse = {
|
||||
items: result.items.map(mapCheckResult),
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { method, mode });
|
||||
}
|
||||
|
||||
function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const id = Number(idStr);
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const target = store.getTargetById(id);
|
||||
if (!target) {
|
||||
return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const trend: TrendPoint[] = store.getTrend(id, from, to).map((row) => ({
|
||||
hour: row.hour,
|
||||
avgDurationMs: row.avgDurationMs,
|
||||
availability: Math.round(row.availability * 100) / 100,
|
||||
totalChecks: row.totalChecks,
|
||||
}));
|
||||
|
||||
return jsonResponse(trend, { method, mode });
|
||||
}
|
||||
|
||||
function createSummaryResponse(store: ProbeStore): SummaryResponse {
|
||||
const summary = store.getSummary();
|
||||
return {
|
||||
total: summary.total,
|
||||
up: summary.up,
|
||||
down: summary.down,
|
||||
lastCheckTime: summary.lastCheckTime,
|
||||
};
|
||||
}
|
||||
|
||||
function createTargetsResponse(store: ProbeStore): TargetStatus[] {
|
||||
const targets = store.getTargets();
|
||||
|
||||
return targets.map((target) => {
|
||||
const latest = store.getLatestCheck(target.id);
|
||||
const stats = store.getTargetStats(target.id);
|
||||
const recentSamples = store.getRecentSamples(target.id, 30);
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
target: target.target,
|
||||
group: target.grp,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
recentSamples: recentSamples.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
durationMs: s.duration_ms,
|
||||
up: s.matched === 1,
|
||||
})),
|
||||
stats: {
|
||||
totalChecks: stats.totalChecks,
|
||||
availability: stats.availability,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
let failure: CheckFailure | null = null;
|
||||
if (row.failure) {
|
||||
try {
|
||||
failure = JSON.parse(row.failure) as CheckFailure;
|
||||
} catch {
|
||||
console.warn(`无法解析 failure 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
|
||||
failure = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: row.timestamp,
|
||||
matched: row.matched === 1,
|
||||
durationMs: row.duration_ms,
|
||||
statusDetail: row.status_detail,
|
||||
failure,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`;
|
||||
if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`;
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
function createHealthResponse(): HealthResponse {
|
||||
return {
|
||||
ok: true,
|
||||
service: "dial-server",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function createApiError(error: string, status: number): ApiErrorResponse {
|
||||
return { error, status };
|
||||
}
|
||||
|
||||
function allowsGetHead(method: string): boolean {
|
||||
return method === "GET" || method === "HEAD";
|
||||
}
|
||||
|
||||
function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response {
|
||||
return jsonResponse(createApiError("Method not allowed", 405), {
|
||||
mode,
|
||||
status: 405,
|
||||
headers: { Allow: allow.join(", ") },
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(
|
||||
body: unknown,
|
||||
options: { method?: string; mode: RuntimeMode; status?: number; headers?: HeadersInit },
|
||||
): Response {
|
||||
const headers = createHeaders(options.mode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
...options.headers,
|
||||
});
|
||||
const responseBody = options.method === "HEAD" ? null : JSON.stringify(body);
|
||||
|
||||
return new Response(responseBody, {
|
||||
status: options.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function serveStaticAsset(pathname: string, staticAssets: StaticAssets, mode: RuntimeMode): Response {
|
||||
if (pathname === "/") {
|
||||
return htmlResponse(staticAssets.indexHtml, mode);
|
||||
}
|
||||
|
||||
const asset = staticAssets.files[pathname];
|
||||
|
||||
if (asset) {
|
||||
return new Response(asset, {
|
||||
headers: createHeaders(mode, {
|
||||
"Content-Type": contentTypeFor(pathname),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) {
|
||||
return new Response("Not Found", {
|
||||
status: 404,
|
||||
headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }),
|
||||
});
|
||||
}
|
||||
|
||||
return htmlResponse(staticAssets.indexHtml, mode);
|
||||
}
|
||||
|
||||
function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response {
|
||||
return new Response(indexHtml, {
|
||||
headers: createHeaders(mode, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
|
||||
const headers = new Headers(init);
|
||||
|
||||
if (mode === "production") {
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function hasFileExtension(pathname: string): boolean {
|
||||
return /\/[^/]+\.[^/]+$/.test(pathname);
|
||||
}
|
||||
|
||||
function contentTypeFor(pathname: string): string {
|
||||
if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "text/javascript; charset=utf-8";
|
||||
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
|
||||
if (pathname.endsWith(".svg")) return "image/svg+xml";
|
||||
if (pathname.endsWith(".json")) return "application/json; charset=utf-8";
|
||||
if (pathname.endsWith(".png")) return "image/png";
|
||||
if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (pathname.endsWith(".ico")) return "image/x-icon";
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { CheckResult, ResolvedCommandTarget } from "./types";
|
||||
import { checkCommandExpect } from "./expect/command";
|
||||
import { errorFailure } from "./expect/failure";
|
||||
|
||||
async function readOutput(
|
||||
stdout: ReadableStream<Uint8Array>,
|
||||
stderr: ReadableStream<Uint8Array>,
|
||||
kill: () => void,
|
||||
maxBytes: number,
|
||||
): Promise<{ stdout: string; stderr: string; exceeded: boolean }> {
|
||||
let totalBytes = 0;
|
||||
let exceeded = false;
|
||||
let killed = false;
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
if (totalBytes > maxBytes && !killed) {
|
||||
exceeded = true;
|
||||
killed = true;
|
||||
try {
|
||||
kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* stream already closed */
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]);
|
||||
|
||||
return { stdout: out, stderr: err, exceeded };
|
||||
}
|
||||
|
||||
export async function runCommandCheck(target: ResolvedCommandTarget): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
|
||||
try {
|
||||
proc = Bun.spawn([target.command.exec, ...target.command.args], {
|
||||
cwd: target.command.cwd,
|
||||
env: target.command.env,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "spawn", error instanceof Error ? error.message : String(error)),
|
||||
};
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}, target.timeoutMs);
|
||||
|
||||
let outputResult: { stdout: string; stderr: string; exceeded: boolean };
|
||||
|
||||
try {
|
||||
outputResult = await readOutput(
|
||||
proc.stdout as ReadableStream<Uint8Array>,
|
||||
proc.stderr as ReadableStream<Uint8Array>,
|
||||
() => proc.kill(),
|
||||
target.command.maxOutputBytes,
|
||||
);
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
};
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${target.command.maxOutputBytes} 字节`),
|
||||
};
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${target.timeoutMs}ms)`),
|
||||
};
|
||||
}
|
||||
|
||||
const obs = { exitCode, stdout: outputResult.stdout, stderr: outputResult.stderr, durationMs };
|
||||
const expectResult = checkCommandExpect(obs, target.expect);
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: expectResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: expectResult.failure,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +1,22 @@
|
||||
import type {
|
||||
CommandDefaultsConfig,
|
||||
CommandTargetConfig,
|
||||
DefaultsConfig,
|
||||
HttpDefaultsConfig,
|
||||
HttpExpectConfig,
|
||||
HttpTargetConfig,
|
||||
ProbeConfig,
|
||||
ResolvedCommandTarget,
|
||||
ResolvedHttpTarget,
|
||||
ResolvedTarget,
|
||||
EngineRuntimeConfig,
|
||||
TargetConfig,
|
||||
TargetType,
|
||||
} from "./types";
|
||||
import { parseSize } from "./size";
|
||||
import { resolve } from "node:path";
|
||||
import { dirname } from "node:path";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import type { DefaultsConfig, EngineRuntimeConfig, ProbeConfig, ResolvedTarget, TargetConfig } from "./types";
|
||||
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DEFAULT_DATA_DIR = "./data";
|
||||
const DEFAULT_INTERVAL = "30s";
|
||||
const DEFAULT_TIMEOUT = "10s";
|
||||
const DEFAULT_HTTP_METHOD = "GET";
|
||||
const DEFAULT_MAX_BODY_BYTES = "100MB";
|
||||
const DEFAULT_MAX_OUTPUT_BYTES = "100MB";
|
||||
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
||||
const SUPPORTED_TYPES: TargetType[] = ["http", "command"];
|
||||
|
||||
export interface ResolvedConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
dataDir: string;
|
||||
configDir: string;
|
||||
dataDir: string;
|
||||
host: string;
|
||||
maxConcurrentChecks: number;
|
||||
port: number;
|
||||
targets: ResolvedTarget[];
|
||||
}
|
||||
|
||||
@@ -45,7 +28,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
}
|
||||
|
||||
const content = await file.text();
|
||||
const raw = Bun.YAML.parse(content) as ProbeConfig | null;
|
||||
const raw = Bun.YAML.parse(content) as null | ProbeConfig;
|
||||
|
||||
if (!raw) {
|
||||
throw new Error("配置文件内容为空或格式无效");
|
||||
@@ -74,7 +57,64 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
|
||||
);
|
||||
|
||||
return { host, port, dataDir, configDir, maxConcurrentChecks, targets };
|
||||
return { configDir, dataDir, host, maxConcurrentChecks, port, targets };
|
||||
}
|
||||
|
||||
function resolveTarget(
|
||||
target: TargetConfig,
|
||||
defaults: DefaultsConfig,
|
||||
defaultIntervalMs: number,
|
||||
defaultTimeoutMs: number,
|
||||
configDir: string,
|
||||
): ResolvedTarget {
|
||||
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
|
||||
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||
|
||||
const checker = checkerRegistry.get(target.type);
|
||||
const result = checker.resolve(target, { configDir, defaultIntervalMs, defaults, defaultTimeoutMs });
|
||||
|
||||
result.intervalMs = intervalMs;
|
||||
result.timeoutMs = timeoutMs;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateConfig(config: ProbeConfig): void {
|
||||
if (!config.targets || !Array.isArray(config.targets) || config.targets.length === 0) {
|
||||
throw new Error("配置文件必须包含至少一个 target");
|
||||
}
|
||||
|
||||
const names = new Set<string>();
|
||||
const supportedTypes = checkerRegistry.supportedTypes;
|
||||
|
||||
for (let i = 0; i < config.targets.length; i++) {
|
||||
const raw = config.targets[i] as unknown as Record<string, unknown>;
|
||||
|
||||
const name = raw["name"];
|
||||
if (!name || typeof name !== "string" || name.trim() === "") {
|
||||
throw new Error(`第 ${i + 1} 个 target 缺少 name 字段`);
|
||||
}
|
||||
|
||||
const type = raw["type"];
|
||||
if (!type || typeof type !== "string") {
|
||||
throw new Error(`target "${name}" 缺少 type 字段`);
|
||||
}
|
||||
|
||||
if (!supportedTypes.includes(type)) {
|
||||
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`);
|
||||
}
|
||||
|
||||
const group = raw["group"];
|
||||
if (group !== undefined && typeof group !== "string") {
|
||||
throw new Error(`target "${name}" 的 group 字段必须为字符串`);
|
||||
}
|
||||
|
||||
if (names.has(name)) {
|
||||
throw new Error(`target name 重复: "${name}"`);
|
||||
}
|
||||
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntime(runtime: EngineRuntimeConfig): number {
|
||||
@@ -91,135 +131,6 @@ function validateRuntime(runtime: EngineRuntimeConfig): number {
|
||||
return runtime.maxConcurrentChecks;
|
||||
}
|
||||
|
||||
function resolveTarget(
|
||||
target: TargetConfig,
|
||||
defaults: DefaultsConfig,
|
||||
defaultIntervalMs: number,
|
||||
defaultTimeoutMs: number,
|
||||
configDir: string,
|
||||
): ResolvedTarget {
|
||||
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
|
||||
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||
const group = target.group ?? "default";
|
||||
|
||||
if (target.type === "http") {
|
||||
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs, group);
|
||||
}
|
||||
|
||||
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir, group);
|
||||
}
|
||||
|
||||
function resolveHttpTarget(
|
||||
target: TargetConfig & { type: "http"; http: HttpTargetConfig },
|
||||
httpDefaults: HttpDefaultsConfig | undefined,
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
group: string,
|
||||
): ResolvedHttpTarget {
|
||||
const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
||||
|
||||
return {
|
||||
type: "http",
|
||||
name: target.name,
|
||||
group,
|
||||
http: {
|
||||
url: target.http.url,
|
||||
method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
|
||||
headers: { ...(httpDefaults?.headers ?? {}), ...(target.http.headers ?? {}) },
|
||||
body: target.http.body,
|
||||
maxBodyBytes,
|
||||
},
|
||||
intervalMs,
|
||||
timeoutMs,
|
||||
expect: target.expect as HttpExpectConfig | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCommandTarget(
|
||||
target: TargetConfig & { type: "command"; command: CommandTargetConfig },
|
||||
commandDefaults: CommandDefaultsConfig | undefined,
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
configDir: string,
|
||||
group: string,
|
||||
): ResolvedCommandTarget {
|
||||
const cwd = target.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(configDir, cwd);
|
||||
|
||||
const maxOutputBytes = parseSize(
|
||||
target.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
||||
);
|
||||
|
||||
const env = { ...process.env, ...(target.command.env ?? {}) } as Record<string, string>;
|
||||
|
||||
return {
|
||||
type: "command",
|
||||
name: target.name,
|
||||
group,
|
||||
command: {
|
||||
exec: target.command.exec,
|
||||
args: target.command.args ?? [],
|
||||
cwd: resolvedCwd,
|
||||
env,
|
||||
maxOutputBytes,
|
||||
},
|
||||
intervalMs,
|
||||
timeoutMs,
|
||||
expect: target.expect as import("./types").CommandExpectConfig | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function validateConfig(config: ProbeConfig): void {
|
||||
if (!config.targets || !Array.isArray(config.targets) || config.targets.length === 0) {
|
||||
throw new Error("配置文件必须包含至少一个 target");
|
||||
}
|
||||
|
||||
const names = new Set<string>();
|
||||
|
||||
for (let i = 0; i < config.targets.length; i++) {
|
||||
const raw = config.targets[i] as unknown as Record<string, unknown>;
|
||||
|
||||
const name = raw["name"];
|
||||
if (!name || typeof name !== "string" || (name as string).trim() === "") {
|
||||
throw new Error(`第 ${i + 1} 个 target 缺少 name 字段`);
|
||||
}
|
||||
|
||||
const type = raw["type"];
|
||||
if (!type || typeof type !== "string") {
|
||||
throw new Error(`target "${name}" 缺少 type 字段`);
|
||||
}
|
||||
|
||||
if (!SUPPORTED_TYPES.includes(type as TargetType)) {
|
||||
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (type === "http") {
|
||||
const http = raw["http"] as Record<string, unknown> | undefined;
|
||||
if (!http?.["url"] || typeof http["url"] !== "string" || (http["url"] as string).trim() === "") {
|
||||
throw new Error(`target "${name}" 缺少 http.url 字段`);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "command") {
|
||||
const cmd = raw["command"] as Record<string, unknown> | undefined;
|
||||
if (!cmd?.["exec"] || typeof cmd["exec"] !== "string" || (cmd["exec"] as string).trim() === "") {
|
||||
throw new Error(`target "${name}" 缺少 command.exec 字段`);
|
||||
}
|
||||
}
|
||||
|
||||
const group = raw["group"];
|
||||
if (group !== undefined && typeof group !== "string") {
|
||||
throw new Error(`target "${name}" 的 group 字段必须为字符串`);
|
||||
}
|
||||
|
||||
if (names.has(name as string)) {
|
||||
throw new Error(`target name 重复: "${name}"`);
|
||||
}
|
||||
|
||||
names.add(name as string);
|
||||
}
|
||||
}
|
||||
|
||||
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
|
||||
|
||||
export function parseDuration(value: string): number {
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import type { CheckResult, ResolvedTarget } from "./types";
|
||||
import { groupBy, Semaphore } from "es-toolkit";
|
||||
|
||||
import type { ProbeStore } from "./store";
|
||||
import { runHttpCheck } from "./fetcher";
|
||||
import { runCommandCheck } from "./command-runner";
|
||||
import type { CheckResult, ResolvedTarget } from "./types";
|
||||
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
export class ProbeEngine {
|
||||
private timers: ReturnType<typeof setInterval>[] = [];
|
||||
private semaphore: Semaphore;
|
||||
private store: ProbeStore;
|
||||
private targetNameToId = new Map<string, number>();
|
||||
private targets: ResolvedTarget[];
|
||||
private targetNameToId: Map<string, number> = new Map();
|
||||
private maxConcurrentChecks: number;
|
||||
private running = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
private timers: Array<ReturnType<typeof setInterval>> = [];
|
||||
|
||||
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
|
||||
this.store = store;
|
||||
this.targets = targets;
|
||||
this.maxConcurrentChecks = maxConcurrentChecks ?? 20;
|
||||
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
|
||||
this.refreshCache();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
const groups = this.groupByInterval(this.targets);
|
||||
const groups = groupBy(this.targets, (t) => t.intervalMs);
|
||||
|
||||
for (const [intervalMs, groupTargets] of groups) {
|
||||
for (const [intervalMs, groupTargets] of Object.entries(groups)) {
|
||||
void this.probeGroup(groupTargets);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void this.probeGroup(groupTargets);
|
||||
}, intervalMs);
|
||||
}, Number(intervalMs));
|
||||
|
||||
this.timers.push(timer);
|
||||
}
|
||||
@@ -40,45 +40,14 @@ export class ProbeEngine {
|
||||
this.timers = [];
|
||||
}
|
||||
|
||||
private groupByInterval(targets: ResolvedTarget[]): Map<number, ResolvedTarget[]> {
|
||||
const groups = new Map<number, ResolvedTarget[]>();
|
||||
|
||||
for (const target of targets) {
|
||||
const group = groups.get(target.intervalMs) ?? [];
|
||||
group.push(target);
|
||||
groups.set(target.intervalMs, group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private async acquire(): Promise<void> {
|
||||
if (this.running < this.maxConcurrentChecks) {
|
||||
this.running++;
|
||||
return;
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
this.running--;
|
||||
}
|
||||
}
|
||||
|
||||
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
targets.map(async (target) => {
|
||||
await this.acquire();
|
||||
await this.semaphore.acquire();
|
||||
try {
|
||||
return await this.runCheck(target);
|
||||
} finally {
|
||||
this.release();
|
||||
this.semaphore.release();
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -92,12 +61,22 @@ export class ProbeEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private refreshCache(): void {
|
||||
this.targetNameToId.clear();
|
||||
for (const target of this.store.getTargets()) {
|
||||
this.targetNameToId.set(target.name, target.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
|
||||
switch (target.type) {
|
||||
case "http":
|
||||
return runHttpCheck(target);
|
||||
case "command":
|
||||
return runCommandCheck(target);
|
||||
const checker = checkerRegistry.get(target.type);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
|
||||
|
||||
try {
|
||||
return await checker.execute(target, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,19 +85,12 @@ export class ProbeEngine {
|
||||
if (!targetId) return;
|
||||
|
||||
this.store.insertCheckResult({
|
||||
durationMs: result.durationMs,
|
||||
failure: result.failure,
|
||||
matched: result.matched,
|
||||
statusDetail: result.statusDetail,
|
||||
targetId,
|
||||
timestamp: result.timestamp,
|
||||
matched: result.matched,
|
||||
durationMs: result.durationMs,
|
||||
statusDetail: result.statusDetail,
|
||||
failure: result.failure,
|
||||
});
|
||||
}
|
||||
|
||||
private refreshCache(): void {
|
||||
this.targetNameToId.clear();
|
||||
for (const target of this.store.getTargets()) {
|
||||
this.targetNameToId.set(target.name, target.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { CheckFailure, CommandExpectConfig, TextRule } from "../types";
|
||||
import { applyOperator } from "./body";
|
||||
import { mismatchFailure } from "./failure";
|
||||
|
||||
export interface CommandObservation {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function checkExitCode(obs: CommandObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!allowed.includes(obs.exitCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"exitCode",
|
||||
"exitCode",
|
||||
allowed,
|
||||
obs.exitCode,
|
||||
`exitCode ${obs.exitCode} not in [${allowed}]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkDuration(
|
||||
obs: CommandObservation,
|
||||
maxDurationMs?: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||
if (obs.durationMs > maxDurationMs) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
obs.durationMs,
|
||||
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkTextRules(
|
||||
text: string,
|
||||
rules: TextRule[],
|
||||
phase: "stdout" | "stderr",
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]!;
|
||||
const path = `${phase}[${i}]`;
|
||||
if (!applyOperator(text, rule)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(phase, path, rule, text, `${phase} rule at index ${i} mismatch`),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
export function checkCommandExpect(
|
||||
obs: CommandObservation,
|
||||
expect?: CommandExpectConfig,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!expect) {
|
||||
return checkExitCode(obs, [0]);
|
||||
}
|
||||
|
||||
const exitCodeResult = checkExitCode(obs, expect.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) return exitCodeResult;
|
||||
|
||||
const durationResult = checkDuration(obs, expect.maxDurationMs);
|
||||
if (!durationResult.matched) return durationResult;
|
||||
|
||||
if (expect.stdout && expect.stdout.length > 0) {
|
||||
const stdoutResult = checkTextRules(obs.stdout, expect.stdout, "stdout");
|
||||
if (!stdoutResult.matched) return stdoutResult;
|
||||
}
|
||||
|
||||
if (expect.stderr && expect.stderr.length > 0) {
|
||||
const stderrResult = checkTextRules(obs.stderr, expect.stderr, "stderr");
|
||||
if (!stderrResult.matched) return stderrResult;
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { BodyRule, CheckFailure, HeaderExpect, HttpExpectConfig } from "../types";
|
||||
import { checkBodyExpect } from "./body";
|
||||
import { applyOperator } from "./body";
|
||||
import { mismatchFailure, errorFailure } from "./failure";
|
||||
|
||||
export interface HttpObservation {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function checkStatus(obs: HttpObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!allowed.includes(obs.statusCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"status",
|
||||
"status",
|
||||
allowed,
|
||||
obs.statusCode,
|
||||
`status ${obs.statusCode} not in [${allowed}]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkDuration(
|
||||
obs: HttpObservation,
|
||||
maxDurationMs?: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||
if (obs.durationMs > maxDurationMs) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
obs.durationMs,
|
||||
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkHeaders(
|
||||
obs: HttpObservation,
|
||||
headerExpects?: Record<string, HeaderExpect>,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!headerExpects) return { matched: true, failure: null };
|
||||
|
||||
for (const [key, expected] of Object.entries(headerExpects)) {
|
||||
const actualValue = obs.headers[key.toLowerCase()];
|
||||
const path = `headers.${key}`;
|
||||
|
||||
if (typeof expected === "string") {
|
||||
if (actualValue !== expected) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (actualValue === undefined) {
|
||||
if (expected.exists !== false) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, "defined", undefined, `header ${key} not found`),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!applyOperator(actualValue, expected)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkBody(obs: HttpObservation, bodyRules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!bodyRules || bodyRules.length === 0) return { matched: true, failure: null };
|
||||
|
||||
if (obs.body === null) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", "body", "body is null but body rules are configured"),
|
||||
};
|
||||
}
|
||||
|
||||
return checkBodyExpect(obs.body, bodyRules);
|
||||
}
|
||||
|
||||
export function checkHttpExpect(
|
||||
obs: HttpObservation,
|
||||
expect?: HttpExpectConfig,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!expect) {
|
||||
return checkStatus(obs, [200]);
|
||||
}
|
||||
|
||||
const statusResult = checkStatus(obs, expect.status ?? [200]);
|
||||
if (!statusResult.matched) return statusResult;
|
||||
|
||||
const durationResult = checkDuration(obs, expect.maxDurationMs);
|
||||
if (!durationResult.matched) return durationResult;
|
||||
|
||||
const headersResult = checkHeaders(obs, expect.headers);
|
||||
if (!headersResult.matched) return headersResult;
|
||||
|
||||
const bodyResult = checkBody(obs, expect.body);
|
||||
if (!bodyResult.matched) return bodyResult;
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { CheckResult, ResolvedHttpTarget } from "./types";
|
||||
import { checkHttpExpect } from "./expect/http";
|
||||
import { errorFailure } from "./expect/failure";
|
||||
|
||||
function headersToRecord(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
|
||||
|
||||
try {
|
||||
const start = performance.now();
|
||||
|
||||
const response = await fetch(target.http.url, {
|
||||
method: target.http.method,
|
||||
headers: target.http.headers,
|
||||
body: target.http.method !== "GET" && target.http.method !== "HEAD" ? target.http.body : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const statusCode = response.status;
|
||||
const responseHeaders = headersToRecord(response.headers);
|
||||
|
||||
const hasBodyRules = !!(target.expect?.body && target.expect.body.length > 0);
|
||||
|
||||
const preBodyExpect = target.expect
|
||||
? { status: target.expect.status, maxDurationMs: target.expect.maxDurationMs, headers: target.expect.headers }
|
||||
: undefined;
|
||||
|
||||
const preBodyObs = { statusCode, headers: responseHeaders, body: null as string | null, durationMs };
|
||||
const preBodyResult = checkHttpExpect(preBodyObs, preBodyExpect);
|
||||
|
||||
if (!hasBodyRules || !preBodyResult.matched) {
|
||||
clearTimeout(timeoutId);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: preBodyResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: preBodyResult.failure,
|
||||
};
|
||||
}
|
||||
|
||||
const bodyBuffer = await response.arrayBuffer();
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (bodyBuffer.byteLength > target.http.maxBodyBytes) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: errorFailure(
|
||||
"body",
|
||||
"body",
|
||||
`响应体大小 ${bodyBuffer.byteLength} 超过限制 ${target.http.maxBodyBytes}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const body = new TextDecoder().decode(bodyBuffer);
|
||||
const fullObs = { statusCode, headers: responseHeaders, body, durationMs };
|
||||
const fullResult = checkHttpExpect(fullObs, target.expect);
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: fullResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: fullResult.failure,
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
const isTimeout = error instanceof DOMException && error.name === "AbortError";
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure: errorFailure(
|
||||
"status",
|
||||
"request",
|
||||
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
19
src/server/checker/runner/command/expect.ts
Normal file
19
src/server/checker/runner/command/expect.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ExpectResult } from "../shared/duration";
|
||||
|
||||
import { mismatchFailure } from "../shared/failure";
|
||||
|
||||
export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult {
|
||||
if (!allowed.includes(exitCode)) {
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
"exitCode",
|
||||
"exitCode",
|
||||
allowed,
|
||||
exitCode,
|
||||
`exitCode ${exitCode} not in [${allowed.join(", ")}]`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
266
src/server/checker/runner/command/runner.ts
Normal file
266
src/server/checker/runner/command/runner.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { isError } from "es-toolkit";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type {
|
||||
CheckResult,
|
||||
CommandTargetConfig,
|
||||
ResolvedCommandTarget,
|
||||
ResolvedTarget,
|
||||
TargetConfig,
|
||||
} from "../../types";
|
||||
import type { Checker, CheckerContext, ResolveContext } from "../types";
|
||||
|
||||
import { parseSize } from "../../size";
|
||||
import { checkDuration } from "../shared/duration";
|
||||
import { errorFailure } from "../shared/failure";
|
||||
import { checkTextRules } from "../shared/text";
|
||||
import { checkExitCode } from "./expect";
|
||||
|
||||
export class CommandChecker implements Checker {
|
||||
readonly type = "command";
|
||||
|
||||
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const t = target as ResolvedCommandTarget;
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
|
||||
try {
|
||||
proc = Bun.spawn([t.command.exec, ...t.command.args], {
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
stderr: "pipe",
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
ctx.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
let outputResult: { exceeded: boolean; stderr: string; stdout: string };
|
||||
|
||||
try {
|
||||
outputResult = await readOutput(
|
||||
proc.stdout as ReadableStream<Uint8Array>,
|
||||
proc.stderr as ReadableStream<Uint8Array>,
|
||||
() => proc.kill(),
|
||||
t.command.maxOutputBytes,
|
||||
);
|
||||
} catch {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.command.maxOutputBytes} 字节`),
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: exitCodeResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (t.expect?.stdout && t.expect.stdout.length > 0) {
|
||||
const stdoutResult = checkTextRules(outputResult.stdout, t.expect.stdout, "stdout");
|
||||
if (!stdoutResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: stdoutResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (t.expect?.stderr && t.expect.stderr.length > 0) {
|
||||
const stderrResult = checkTextRules(outputResult.stderr, t.expect.stderr, "stderr");
|
||||
if (!stderrResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: stderrResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
|
||||
const t = target as TargetConfig & { command: CommandTargetConfig; type: "command" };
|
||||
const commandDefaults = context.defaults.command;
|
||||
|
||||
if (!t.command.exec || t.command.exec.trim() === "") {
|
||||
throw new Error(`target "${t.name}" 缺少 command.exec 字段`);
|
||||
}
|
||||
|
||||
const cwd = t.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(context.configDir, cwd);
|
||||
|
||||
const maxOutputBytes = parseSize(t.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? "100MB");
|
||||
|
||||
const env = { ...process.env, ...(t.command.env ?? {}) } as Record<string, string>;
|
||||
|
||||
return {
|
||||
command: {
|
||||
args: t.command.args ?? [],
|
||||
cwd: resolvedCwd,
|
||||
env,
|
||||
exec: t.command.exec,
|
||||
maxOutputBytes,
|
||||
},
|
||||
expect: target.expect,
|
||||
group: target.group ?? "default",
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "command",
|
||||
} satisfies ResolvedCommandTarget;
|
||||
}
|
||||
|
||||
serialize(target: ResolvedTarget): { config: string; target: string } {
|
||||
const t = target as ResolvedCommandTarget;
|
||||
const parts = [t.command.exec, ...t.command.args];
|
||||
return {
|
||||
config: JSON.stringify({
|
||||
args: t.command.args,
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
exec: t.command.exec,
|
||||
maxOutputBytes: t.command.maxOutputBytes,
|
||||
}),
|
||||
target: `exec ${parts.join(" ")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function readOutput(
|
||||
stdout: ReadableStream<Uint8Array>,
|
||||
stderr: ReadableStream<Uint8Array>,
|
||||
kill: () => void,
|
||||
maxBytes: number,
|
||||
): Promise<{ exceeded: boolean; stderr: string; stdout: string }> {
|
||||
let totalBytes = 0;
|
||||
let exceeded = false;
|
||||
let killed = false;
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
if (totalBytes > maxBytes && !killed) {
|
||||
exceeded = true;
|
||||
killed = true;
|
||||
try {
|
||||
kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* stream already closed */
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]);
|
||||
|
||||
return { exceeded, stderr: err, stdout: out };
|
||||
}
|
||||
96
src/server/checker/runner/http/expect.ts
Normal file
96
src/server/checker/runner/http/expect.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { HeaderExpect, HttpExpectConfig } from "../../types";
|
||||
import type { ExpectResult } from "../shared/duration";
|
||||
|
||||
import { checkBodyExpect } from "../shared/body";
|
||||
import { checkDuration } from "../shared/duration";
|
||||
import { errorFailure, mismatchFailure } from "../shared/failure";
|
||||
import { applyOperator } from "../shared/operator";
|
||||
|
||||
export function checkHeaders(
|
||||
headers: Record<string, string>,
|
||||
headerExpects?: Record<string, HeaderExpect>,
|
||||
): ExpectResult {
|
||||
if (!headerExpects) return { failure: null, matched: true };
|
||||
|
||||
for (const [key, expected] of Object.entries(headerExpects)) {
|
||||
const actualValue = headers[key.toLowerCase()];
|
||||
const path = `headers.${key}`;
|
||||
|
||||
if (typeof expected === "string") {
|
||||
if (actualValue !== expected) {
|
||||
return {
|
||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (actualValue === undefined) {
|
||||
if (expected.exists !== false) {
|
||||
return {
|
||||
failure: mismatchFailure("headers", path, "defined", undefined, `header ${key} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!applyOperator(actualValue, expected)) {
|
||||
return {
|
||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function checkHttpExpect(
|
||||
statusCode: number,
|
||||
headers: Record<string, string>,
|
||||
body: null | string,
|
||||
durationMs: number,
|
||||
expect?: HttpExpectConfig,
|
||||
): ExpectResult {
|
||||
if (!expect) {
|
||||
return checkStatus(statusCode, [200]);
|
||||
}
|
||||
|
||||
const statusResult = checkStatus(statusCode, expect.status ?? [200]);
|
||||
if (!statusResult.matched) return statusResult;
|
||||
|
||||
const durationResult = checkDuration(durationMs, expect.maxDurationMs);
|
||||
if (!durationResult.matched) return durationResult;
|
||||
|
||||
const headersResult = checkHeaders(headers, expect.headers);
|
||||
if (!headersResult.matched) return headersResult;
|
||||
|
||||
if (expect.body && expect.body.length > 0) {
|
||||
if (body === null) {
|
||||
return {
|
||||
failure: errorFailure("body", "body", "body is null but body rules are configured"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
const bodyResult = checkBodyExpect(body, expect.body);
|
||||
if (!bodyResult.matched) return bodyResult;
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function checkStatus(statusCode: number, allowed: number[]): ExpectResult {
|
||||
if (!allowed.includes(statusCode)) {
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
"status",
|
||||
"status",
|
||||
allowed,
|
||||
statusCode,
|
||||
`status ${statusCode} not in [${allowed.join(", ")}]`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
132
src/server/checker/runner/http/runner.ts
Normal file
132
src/server/checker/runner/http/runner.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, HttpTargetConfig, ResolvedHttpTarget, ResolvedTarget, TargetConfig } from "../../types";
|
||||
import type { Checker, CheckerContext, ResolveContext } from "../types";
|
||||
|
||||
import { parseSize } from "../../size";
|
||||
import { errorFailure } from "../shared/failure";
|
||||
import { checkHttpExpect } from "./expect";
|
||||
|
||||
export class HttpChecker implements Checker {
|
||||
readonly type = "http";
|
||||
|
||||
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const t = target as ResolvedHttpTarget;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const start = performance.now();
|
||||
|
||||
const response = await fetch(t.http.url, {
|
||||
body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined,
|
||||
headers: t.http.headers,
|
||||
method: t.http.method,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const statusCode = response.status;
|
||||
const responseHeaders = Object.fromEntries(response.headers);
|
||||
|
||||
const hasBodyRules = !!(t.expect?.body && t.expect.body.length > 0);
|
||||
|
||||
const preBodyExpect = t.expect
|
||||
? { headers: t.expect.headers, maxDurationMs: t.expect.maxDurationMs, status: t.expect.status }
|
||||
: undefined;
|
||||
|
||||
const preBodyResult = checkHttpExpect(statusCode, responseHeaders, null, durationMs, preBodyExpect);
|
||||
|
||||
if (!hasBodyRules || !preBodyResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: preBodyResult.failure,
|
||||
matched: preBodyResult.matched,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const bodyBuffer = await response.arrayBuffer();
|
||||
|
||||
if (bodyBuffer.byteLength > t.http.maxBodyBytes) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("body", "body", `响应体大小 ${bodyBuffer.byteLength} 超过限制 ${t.http.maxBodyBytes}`),
|
||||
matched: false,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const body = new TextDecoder().decode(bodyBuffer);
|
||||
const fullResult = checkHttpExpect(statusCode, responseHeaders, body, durationMs, t.expect);
|
||||
|
||||
return {
|
||||
durationMs,
|
||||
failure: fullResult.failure,
|
||||
matched: fullResult.matched,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
} catch (error) {
|
||||
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
||||
|
||||
return {
|
||||
durationMs: null,
|
||||
failure: errorFailure(
|
||||
"status",
|
||||
"request",
|
||||
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
|
||||
const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" };
|
||||
const httpDefaults = context.defaults.http;
|
||||
|
||||
if (!t.http.url || t.http.url.trim() === "") {
|
||||
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
|
||||
}
|
||||
|
||||
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
|
||||
|
||||
return {
|
||||
expect: target.expect,
|
||||
group: target.group ?? "default",
|
||||
http: {
|
||||
body: t.http.body,
|
||||
headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) },
|
||||
maxBodyBytes,
|
||||
method: t.http.method ?? httpDefaults?.method ?? "GET",
|
||||
url: t.http.url,
|
||||
},
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "http",
|
||||
} satisfies ResolvedHttpTarget;
|
||||
}
|
||||
|
||||
serialize(target: ResolvedTarget): { config: string; target: string } {
|
||||
const t = target as ResolvedHttpTarget;
|
||||
return {
|
||||
config: JSON.stringify({
|
||||
body: t.http.body,
|
||||
headers: t.http.headers,
|
||||
maxBodyBytes: t.http.maxBodyBytes,
|
||||
method: t.http.method,
|
||||
url: t.http.url,
|
||||
}),
|
||||
target: t.http.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
10
src/server/checker/runner/index.ts
Normal file
10
src/server/checker/runner/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { CommandChecker } from "./command/runner";
|
||||
import { HttpChecker } from "./http/runner";
|
||||
import { checkerRegistry } from "./registry";
|
||||
|
||||
export function registerCheckers(): void {
|
||||
checkerRegistry.register(new HttpChecker());
|
||||
checkerRegistry.register(new CommandChecker());
|
||||
}
|
||||
|
||||
export { checkerRegistry } from "./registry";
|
||||
26
src/server/checker/runner/registry.ts
Normal file
26
src/server/checker/runner/registry.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Checker } from "./types";
|
||||
|
||||
export class CheckerRegistry {
|
||||
get supportedTypes(): string[] {
|
||||
return [...this.checkers.keys()];
|
||||
}
|
||||
|
||||
private checkers = new Map<string, Checker>();
|
||||
|
||||
get(type: string): Checker {
|
||||
const checker = this.checkers.get(type);
|
||||
if (!checker) {
|
||||
throw new Error(`不支持的 probe type: "${type}"`);
|
||||
}
|
||||
return checker;
|
||||
}
|
||||
|
||||
register(checker: Checker): void {
|
||||
if (this.checkers.has(checker.type)) {
|
||||
throw new Error(`Checker type "${checker.type}" 已注册`);
|
||||
}
|
||||
this.checkers.set(checker.type, checker);
|
||||
}
|
||||
}
|
||||
|
||||
export const checkerRegistry = new CheckerRegistry();
|
||||
@@ -1,136 +1,26 @@
|
||||
import type { BodyRule, CheckFailure, CssRule, ExpectOperator, ExpectValue, JsonRule, XpathRule } from "../types";
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import * as cheerio from "cheerio";
|
||||
import * as xpath from "xpath";
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import { mismatchFailure, errorFailure } from "./failure";
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
import type { BodyRule, CssRule, JsonRule, XpathRule } from "../../types";
|
||||
import type { ExpectResult } from "./duration";
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
import { errorFailure, mismatchFailure } from "./failure";
|
||||
import { applyOperator, evaluateJsonPath } from "./operator";
|
||||
|
||||
const segments = path.slice(2).split(".");
|
||||
let current: unknown = json;
|
||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
|
||||
if (!rules || rules.length === 0) return { failure: null, matched: true };
|
||||
|
||||
for (const seg of segments) {
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch) {
|
||||
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
||||
const idx = parseInt(bracketMatch[2]!, 10);
|
||||
if (!Array.isArray(current) || idx >= current.length) return undefined;
|
||||
current = current[idx];
|
||||
} else {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const result = checkSingleBodyRule(body, rules[i]!, i);
|
||||
if (!result.matched) return result;
|
||||
}
|
||||
|
||||
return current;
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "equals":
|
||||
if (actual !== expected) return false;
|
||||
break;
|
||||
case "contains":
|
||||
if (!String(actual).includes(expected as string)) return false;
|
||||
break;
|
||||
case "match":
|
||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const isEmpty =
|
||||
actual === null ||
|
||||
actual === undefined ||
|
||||
actual === "" ||
|
||||
(Array.isArray(actual) && actual.length === 0) ||
|
||||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
|
||||
if (expected !== isEmpty) return false;
|
||||
break;
|
||||
}
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gte":
|
||||
if (!(Number(actual) >= (expected as number))) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!(Number(actual) <= (expected as number))) return false;
|
||||
break;
|
||||
case "gt":
|
||||
if (!(Number(actual) > (expected as number))) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!(Number(actual) < (expected as number))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isObject(expected)) {
|
||||
return applyOperator(actual, expected as ExpectOperator);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
||||
}
|
||||
|
||||
function checkJsonRule(
|
||||
body: string,
|
||||
rule: JsonRule,
|
||||
rulePath: string,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
const { path, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.json(${path})`;
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", fullPath, "body is not valid JSON"),
|
||||
};
|
||||
}
|
||||
|
||||
const actual = evaluateJsonPath(json, path);
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
if (actual === undefined) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "defined", actual, `path ${path} is undefined`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
const matched = applyOperator(actual, operators);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `json path ${path} mismatch`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkCssRule(
|
||||
body: string,
|
||||
rule: CssRule,
|
||||
rulePath: string,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
const { selector, attr, ...operators } = rule;
|
||||
function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResult {
|
||||
const { attr, selector, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
|
||||
|
||||
let $: cheerio.CheerioAPI;
|
||||
@@ -138,8 +28,8 @@ function checkCssRule(
|
||||
$ = cheerio.load(body);
|
||||
} catch {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", fullPath, "failed to parse HTML"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,44 +40,44 @@ function checkCssRule(
|
||||
if (attr !== undefined) {
|
||||
if (el.attr(attr) === undefined) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
if (el.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
if (operators.exists === true) {
|
||||
if (el.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
if (operators.exists === false) {
|
||||
if (el.length > 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, false, true, `selector ${selector} exists`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
if (el.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,84 +85,73 @@ function checkCssRule(
|
||||
const matched = applyOperator(actual ?? "", operators);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `css selector ${selector} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkXpathRule(
|
||||
body: string,
|
||||
rule: XpathRule,
|
||||
rulePath: string,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectResult {
|
||||
const { path, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.xpath(${path})`;
|
||||
const fullPath = `${rulePath}.json(${path})`;
|
||||
|
||||
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||
let json: unknown;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(body, "text/xml");
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
return {
|
||||
failure: errorFailure("body", fullPath, "body is not valid JSON"),
|
||||
matched: false,
|
||||
failure: errorFailure("body", fullPath, "failed to parse XML/HTML"),
|
||||
};
|
||||
}
|
||||
|
||||
const nodes = xpath.select(path, doc as unknown as Node);
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`),
|
||||
};
|
||||
}
|
||||
|
||||
const node = nodes[0]!;
|
||||
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
|
||||
const actual = evaluateJsonPath(json, path);
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
return { matched: true, failure: null };
|
||||
if (actual === undefined) {
|
||||
return {
|
||||
failure: mismatchFailure("body", fullPath, "defined", actual, `path ${path} is undefined`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
const matched = applyOperator(actual, operators);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `json path ${path} mismatch`),
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `xpath ${path} mismatch`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkSingleBodyRule(
|
||||
body: string,
|
||||
rule: BodyRule,
|
||||
index: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
function checkSingleBodyRule(body: string, rule: BodyRule, index: number): ExpectResult {
|
||||
const rulePath = `body[${index}]`;
|
||||
|
||||
if ("contains" in rule) {
|
||||
const matched = body.includes(rule.contains);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", rulePath, rule.contains, body, `body does not contain "${rule.contains}"`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
if ("regex" in rule) {
|
||||
const matched = new RegExp(rule.regex).test(body);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", rulePath, `/${rule.regex}/`, body, `body does not match /${rule.regex}/`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
if ("json" in rule) {
|
||||
@@ -287,16 +166,45 @@ function checkSingleBodyRule(
|
||||
return checkXpathRule(body, rule.xpath, rulePath);
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!rules || rules.length === 0) return { matched: true, failure: null };
|
||||
function checkXpathRule(body: string, rule: XpathRule, rulePath: string): ExpectResult {
|
||||
const { path, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.xpath(${path})`;
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const result = checkSingleBodyRule(body, rules[i]!, i);
|
||||
if (!result.matched) return result;
|
||||
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(body, "text/xml");
|
||||
} catch {
|
||||
return {
|
||||
failure: errorFailure("body", fullPath, "failed to parse XML/HTML"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
const nodes = xpath.select(path, doc as unknown as Node);
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
|
||||
return {
|
||||
failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
const node = nodes[0]!;
|
||||
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
const matched = applyOperator(actual, operators);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `xpath ${path} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
25
src/server/checker/runner/shared/duration.ts
Normal file
25
src/server/checker/runner/shared/duration.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { CheckFailure } from "../../types";
|
||||
|
||||
import { mismatchFailure } from "./failure";
|
||||
|
||||
export interface ExpectResult {
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
|
||||
if (maxDurationMs === undefined) return { failure: null, matched: true };
|
||||
if (durationMs > maxDurationMs) {
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
durationMs,
|
||||
`duration ${durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { CheckFailure } from "../types";
|
||||
import type { CheckFailure } from "../../types";
|
||||
|
||||
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||
if (value === undefined || value === null) return value;
|
||||
const str = String(value);
|
||||
if (str.length <= maxLen) return value;
|
||||
return str.slice(0, maxLen) + "...";
|
||||
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
|
||||
return {
|
||||
kind: "error",
|
||||
message,
|
||||
path,
|
||||
phase,
|
||||
};
|
||||
}
|
||||
|
||||
export function mismatchFailure(
|
||||
@@ -15,20 +17,18 @@ export function mismatchFailure(
|
||||
message: string,
|
||||
): CheckFailure {
|
||||
return {
|
||||
kind: "mismatch",
|
||||
phase,
|
||||
path,
|
||||
expected,
|
||||
actual: truncateActual(actual),
|
||||
expected,
|
||||
kind: "mismatch",
|
||||
message,
|
||||
path,
|
||||
phase,
|
||||
};
|
||||
}
|
||||
|
||||
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
|
||||
return {
|
||||
kind: "error",
|
||||
phase,
|
||||
path,
|
||||
message,
|
||||
};
|
||||
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||
if (value === undefined || value === null) return value;
|
||||
const str = typeof value === "string" ? value : JSON.stringify(value);
|
||||
if (str.length <= maxLen) return value;
|
||||
return str.slice(0, maxLen) + "...";
|
||||
}
|
||||
77
src/server/checker/runner/shared/operator.ts
Normal file
77
src/server/checker/runner/shared/operator.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
|
||||
|
||||
import type { ExpectOperator, ExpectValue } from "../../types";
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "contains":
|
||||
if (!String(actual).includes(expected as string)) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const isEmpty =
|
||||
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
|
||||
if (expected !== isEmpty) return false;
|
||||
break;
|
||||
}
|
||||
case "equals":
|
||||
if (!isEqual(actual, expected)) return false;
|
||||
break;
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gt":
|
||||
if (!(Number(actual) > (expected as number))) return false;
|
||||
break;
|
||||
case "gte":
|
||||
if (!(Number(actual) >= (expected as number))) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!(Number(actual) < (expected as number))) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!(Number(actual) <= (expected as number))) return false;
|
||||
break;
|
||||
case "match":
|
||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isPlainObject(expected)) {
|
||||
return applyOperator(actual, expected);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected });
|
||||
}
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
const segments = path.slice(2).split(".");
|
||||
let current: unknown = json;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch) {
|
||||
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
||||
const idx = parseInt(bracketMatch[2]!, 10);
|
||||
if (!Array.isArray(current) || idx >= current.length) return undefined;
|
||||
current = current[idx];
|
||||
} else {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
19
src/server/checker/runner/shared/text.ts
Normal file
19
src/server/checker/runner/shared/text.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { TextRule } from "../../types";
|
||||
import type { ExpectResult } from "./duration";
|
||||
|
||||
import { mismatchFailure } from "./failure";
|
||||
import { applyOperator } from "./operator";
|
||||
|
||||
export function checkTextRules(text: string, rules: TextRule[], phase: string): ExpectResult {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]!;
|
||||
const path = `${phase}[${i}]`;
|
||||
if (!applyOperator(text, rule)) {
|
||||
return {
|
||||
failure: mismatchFailure(phase, path, rule, text, `${phase} rule at index ${i} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
19
src/server/checker/runner/types.ts
Normal file
19
src/server/checker/runner/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CheckResult, DefaultsConfig, ResolvedTarget, TargetConfig } from "../types";
|
||||
|
||||
export interface Checker {
|
||||
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
|
||||
serialize(target: ResolvedTarget): { config: string; target: string };
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
export interface CheckerContext {
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ResolveContext {
|
||||
configDir: string;
|
||||
defaultIntervalMs: number;
|
||||
defaults: DefaultsConfig;
|
||||
defaultTimeoutMs: number;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
|
||||
|
||||
export function parseSize(value: string | number): number {
|
||||
export function parseSize(value: number | string): number {
|
||||
if (typeof value === "number") return value;
|
||||
|
||||
const match = SIZE_REGEX.exec(value);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync as fsMkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
|
||||
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
const CREATE_TARGETS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -36,8 +39,8 @@ ON check_results (target_id, timestamp)
|
||||
`;
|
||||
|
||||
export class ProbeStore {
|
||||
private db: Database;
|
||||
private closed = false;
|
||||
private db: Database;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
ensureDir(dirname(dbPath));
|
||||
@@ -49,6 +52,211 @@ export class ProbeStore {
|
||||
this.db.run(CREATE_INDEX);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
getAllTargetStats(): Map<number, { availability: number; totalChecks: number }> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`SELECT target_id, COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||
FROM check_results
|
||||
GROUP BY target_id`,
|
||||
)
|
||||
.all() as Array<{ target_id: number; totalChecks: number; upCount: number }>;
|
||||
|
||||
const result = new Map<number, { availability: number; totalChecks: number }>();
|
||||
for (const row of rows) {
|
||||
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 10000) / 100 : 0;
|
||||
result.set(row.target_id, { availability, totalChecks: row.totalChecks });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getHistory(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): { items: StoredCheckResult[]; page: number; pageSize: number; total: number } {
|
||||
const countRow = this.db
|
||||
.query("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
|
||||
.get(targetId, from, to) as { total: number };
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const items = this.db
|
||||
.query(
|
||||
"SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.all(targetId, from, to, pageSize, offset) as StoredCheckResult[];
|
||||
|
||||
return { items, page, pageSize, total: countRow.total };
|
||||
}
|
||||
|
||||
getLatestCheck(targetId: number): null | StoredCheckResult {
|
||||
return this.db
|
||||
.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1")
|
||||
.get(targetId) as null | StoredCheckResult;
|
||||
}
|
||||
|
||||
getLatestChecksMap(): Map<number, StoredCheckResult> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`SELECT cr.* FROM check_results cr
|
||||
INNER JOIN (
|
||||
SELECT target_id, MAX(timestamp) as max_ts
|
||||
FROM check_results
|
||||
GROUP BY target_id
|
||||
) latest ON cr.target_id = latest.target_id AND cr.timestamp = latest.max_ts`,
|
||||
)
|
||||
.all() as StoredCheckResult[];
|
||||
return new Map(rows.map((r) => [r.target_id, r]));
|
||||
}
|
||||
|
||||
getRecentSamples(
|
||||
targetId: number,
|
||||
limit: number,
|
||||
): Array<{ duration_ms: null | number; matched: number; timestamp: string }> {
|
||||
return this.db
|
||||
.query(
|
||||
"SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
|
||||
)
|
||||
.all(targetId, limit) as Array<{
|
||||
duration_ms: null | number;
|
||||
matched: number;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
getSummary(): {
|
||||
down: number;
|
||||
lastCheckTime: null | string;
|
||||
total: number;
|
||||
up: number;
|
||||
} {
|
||||
const targets = this.getTargets();
|
||||
const latestChecksMap = this.getLatestChecksMap();
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let lastCheckTime: null | string = null;
|
||||
|
||||
for (const target of targets) {
|
||||
const latest = latestChecksMap.get(target.id);
|
||||
|
||||
if (latest) {
|
||||
if (latest.matched) {
|
||||
up++;
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
|
||||
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
|
||||
lastCheckTime = latest.timestamp;
|
||||
}
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
down,
|
||||
lastCheckTime,
|
||||
total: targets.length,
|
||||
up,
|
||||
};
|
||||
}
|
||||
|
||||
getTargetById(id: number): null | StoredTarget {
|
||||
if (this.closed) return null;
|
||||
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as null | StoredTarget;
|
||||
}
|
||||
|
||||
getTargets(): StoredTarget[] {
|
||||
if (this.closed) return [];
|
||||
return this.db
|
||||
.query("SELECT * FROM targets ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id")
|
||||
.all() as StoredTarget[];
|
||||
}
|
||||
|
||||
getTargetStats(targetId: number): {
|
||||
availability: number;
|
||||
totalChecks: number;
|
||||
} {
|
||||
const row = this.db
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||
FROM check_results
|
||||
WHERE target_id = ?`,
|
||||
)
|
||||
.get(targetId) as { totalChecks: number; upCount: number };
|
||||
|
||||
const totalChecks = row.totalChecks;
|
||||
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
|
||||
|
||||
return {
|
||||
availability: Math.round(availability * 100) / 100,
|
||||
totalChecks,
|
||||
};
|
||||
}
|
||||
|
||||
getTrend(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
): Array<{
|
||||
availability: number;
|
||||
avgDurationMs: null | number;
|
||||
hour: string;
|
||||
totalChecks: number;
|
||||
}> {
|
||||
return this.db
|
||||
.query(
|
||||
`SELECT
|
||||
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
|
||||
AVG(CASE WHEN matched = 1 THEN duration_ms END) as avgDurationMs,
|
||||
CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability,
|
||||
COUNT(*) as totalChecks
|
||||
FROM check_results
|
||||
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY hour
|
||||
ORDER BY hour`,
|
||||
)
|
||||
.all(targetId, from, to) as Array<{
|
||||
availability: number;
|
||||
avgDurationMs: null | number;
|
||||
hour: string;
|
||||
totalChecks: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
insertCheckResult(result: {
|
||||
durationMs: null | number;
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
statusDetail: null | string;
|
||||
targetId: number;
|
||||
timestamp: string;
|
||||
}): void {
|
||||
if (this.closed) return;
|
||||
this.db
|
||||
.query(
|
||||
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
result.targetId,
|
||||
result.timestamp,
|
||||
result.matched ? 1 : 0,
|
||||
result.durationMs,
|
||||
result.statusDetail,
|
||||
result.failure ? JSON.stringify(result.failure) : null,
|
||||
);
|
||||
}
|
||||
|
||||
syncTargets(targets: ResolvedTarget[]): void {
|
||||
if (this.closed) return;
|
||||
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{
|
||||
@@ -89,205 +297,14 @@ export class ProbeStore {
|
||||
|
||||
tx();
|
||||
}
|
||||
|
||||
getTargets(): StoredTarget[] {
|
||||
if (this.closed) return [];
|
||||
return this.db
|
||||
.query("SELECT * FROM targets ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id")
|
||||
.all() as StoredTarget[];
|
||||
}
|
||||
|
||||
getTargetById(id: number): StoredTarget | null {
|
||||
if (this.closed) return null;
|
||||
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as StoredTarget | null;
|
||||
}
|
||||
|
||||
insertCheckResult(result: {
|
||||
targetId: number;
|
||||
timestamp: string;
|
||||
matched: boolean;
|
||||
durationMs: number | null;
|
||||
statusDetail: string | null;
|
||||
failure: CheckFailure | null;
|
||||
}): void {
|
||||
if (this.closed) return;
|
||||
this.db
|
||||
.prepare(
|
||||
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
result.targetId,
|
||||
result.timestamp,
|
||||
result.matched ? 1 : 0,
|
||||
result.durationMs,
|
||||
result.statusDetail,
|
||||
result.failure ? JSON.stringify(result.failure) : null,
|
||||
);
|
||||
}
|
||||
|
||||
getLatestCheck(targetId: number): StoredCheckResult | null {
|
||||
return this.db
|
||||
.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1")
|
||||
.get(targetId) as StoredCheckResult | null;
|
||||
}
|
||||
|
||||
getHistory(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): { items: StoredCheckResult[]; total: number; page: number; pageSize: number } {
|
||||
const countRow = this.db
|
||||
.prepare("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
|
||||
.get(targetId, from, to) as { total: number };
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const items = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.all(targetId, from, to, pageSize, offset) as StoredCheckResult[];
|
||||
|
||||
return { items, total: countRow.total, page, pageSize };
|
||||
}
|
||||
|
||||
getTargetStats(targetId: number): {
|
||||
totalChecks: number;
|
||||
availability: number;
|
||||
} {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||
FROM check_results
|
||||
WHERE target_id = ?`,
|
||||
)
|
||||
.get(targetId) as { totalChecks: number; upCount: number };
|
||||
|
||||
const totalChecks = row.totalChecks;
|
||||
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalChecks,
|
||||
availability: Math.round(availability * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
getTrend(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
): Array<{
|
||||
hour: string;
|
||||
avgDurationMs: number | null;
|
||||
availability: number;
|
||||
totalChecks: number;
|
||||
}> {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT
|
||||
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
|
||||
AVG(CASE WHEN matched = 1 THEN duration_ms END) as avgDurationMs,
|
||||
CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability,
|
||||
COUNT(*) as totalChecks
|
||||
FROM check_results
|
||||
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY hour
|
||||
ORDER BY hour`,
|
||||
)
|
||||
.all(targetId, from, to) as Array<{
|
||||
hour: string;
|
||||
avgDurationMs: number | null;
|
||||
availability: number;
|
||||
totalChecks: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
getSummary(): {
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
lastCheckTime: string | null;
|
||||
} {
|
||||
const targets = this.getTargets();
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let lastCheckTime: string | null = null;
|
||||
|
||||
for (const target of targets) {
|
||||
const latest = this.getLatestCheck(target.id);
|
||||
|
||||
if (latest) {
|
||||
if (latest.matched) {
|
||||
up++;
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
|
||||
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
|
||||
lastCheckTime = latest.timestamp;
|
||||
}
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: targets.length,
|
||||
up,
|
||||
down,
|
||||
lastCheckTime,
|
||||
};
|
||||
}
|
||||
|
||||
getRecentSamples(
|
||||
targetId: number,
|
||||
limit: number,
|
||||
): Array<{ timestamp: string; duration_ms: number | null; matched: number }> {
|
||||
return this.db
|
||||
.prepare(
|
||||
"SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
|
||||
)
|
||||
.all(targetId, limit) as Array<{
|
||||
timestamp: string;
|
||||
duration_ms: number | null;
|
||||
matched: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function buildTargetDisplay(t: ResolvedTarget): string {
|
||||
if (t.type === "http") {
|
||||
return t.http.url;
|
||||
}
|
||||
const parts = [t.command.exec, ...t.command.args];
|
||||
return `exec ${parts.join(" ")}`;
|
||||
}
|
||||
|
||||
function buildTargetConfig(t: ResolvedTarget): string {
|
||||
if (t.type === "http") {
|
||||
return JSON.stringify({
|
||||
url: t.http.url,
|
||||
method: t.http.method,
|
||||
headers: t.http.headers,
|
||||
body: t.http.body,
|
||||
maxBodyBytes: t.http.maxBodyBytes,
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
exec: t.command.exec,
|
||||
args: t.command.args,
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
maxOutputBytes: t.command.maxOutputBytes,
|
||||
});
|
||||
return checkerRegistry.get(t.type).serialize(t).config;
|
||||
}
|
||||
|
||||
function buildTargetDisplay(t: ResolvedTarget): string {
|
||||
return checkerRegistry.get(t.type).serialize(t).target;
|
||||
}
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
export type TargetType = "http" | "command";
|
||||
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api";
|
||||
|
||||
export interface ProbeConfig {
|
||||
server?: ServerConfig;
|
||||
runtime?: EngineRuntimeConfig;
|
||||
defaults?: DefaultsConfig;
|
||||
targets: TargetConfig[];
|
||||
}
|
||||
export type BodyRule =
|
||||
| { contains: string }
|
||||
| { css: CssRule }
|
||||
| { json: JsonRule }
|
||||
| { regex: string }
|
||||
| { xpath: XpathRule };
|
||||
|
||||
export interface ServerConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
dataDir?: string;
|
||||
}
|
||||
|
||||
export interface EngineRuntimeConfig {
|
||||
maxConcurrentChecks?: number;
|
||||
}
|
||||
|
||||
export interface HttpDefaultsConfig {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
maxBodyBytes?: string;
|
||||
export interface CheckResult extends ApiCheckResult {
|
||||
targetName: string;
|
||||
}
|
||||
|
||||
export interface CommandDefaultsConfig {
|
||||
@@ -28,161 +16,162 @@ export interface CommandDefaultsConfig {
|
||||
maxOutputBytes?: string;
|
||||
}
|
||||
|
||||
export interface DefaultsConfig {
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
http?: HttpDefaultsConfig;
|
||||
command?: CommandDefaultsConfig;
|
||||
}
|
||||
|
||||
export interface HttpTargetConfig {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
maxBodyBytes?: string;
|
||||
}
|
||||
|
||||
export interface CommandTargetConfig {
|
||||
exec: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
maxOutputBytes?: string;
|
||||
}
|
||||
|
||||
export type TargetConfig = BaseTargetConfig &
|
||||
({ type: "http"; http: HttpTargetConfig } | { type: "command"; command: CommandTargetConfig });
|
||||
|
||||
interface BaseTargetConfig {
|
||||
name: string;
|
||||
group?: string;
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
expect?: ExpectConfig;
|
||||
}
|
||||
|
||||
export interface ExpectOperator {
|
||||
equals?: string | number | boolean | null;
|
||||
contains?: string;
|
||||
match?: string;
|
||||
empty?: boolean;
|
||||
exists?: boolean;
|
||||
gte?: number;
|
||||
lte?: number;
|
||||
gt?: number;
|
||||
lt?: number;
|
||||
}
|
||||
|
||||
export type ExpectValue = string | number | boolean | null | ExpectOperator;
|
||||
|
||||
export type TextRule = ExpectOperator;
|
||||
|
||||
export type JsonRule = { path: string } & ExpectOperator;
|
||||
|
||||
export type CssRule = { selector: string; attr?: string } & ExpectOperator;
|
||||
|
||||
export type XpathRule = { path: string } & ExpectOperator;
|
||||
|
||||
export type BodyRule =
|
||||
| { contains: string }
|
||||
| { regex: string }
|
||||
| { json: JsonRule }
|
||||
| { css: CssRule }
|
||||
| { xpath: XpathRule };
|
||||
|
||||
export type HeaderExpect = string | ExpectOperator;
|
||||
|
||||
export interface HttpExpectConfig {
|
||||
status?: number[];
|
||||
maxDurationMs?: number;
|
||||
headers?: Record<string, HeaderExpect>;
|
||||
body?: BodyRule[];
|
||||
}
|
||||
|
||||
export interface CommandExpectConfig {
|
||||
exitCode?: number[];
|
||||
maxDurationMs?: number;
|
||||
stdout?: TextRule[];
|
||||
stderr?: TextRule[];
|
||||
stdout?: TextRule[];
|
||||
}
|
||||
|
||||
export type ExpectConfig = HttpExpectConfig | CommandExpectConfig;
|
||||
|
||||
export interface ResolvedHttpTarget {
|
||||
type: "http";
|
||||
name: string;
|
||||
group: string;
|
||||
http: ResolvedHttpConfig;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
expect?: HttpExpectConfig;
|
||||
export interface CommandTargetConfig {
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
exec: string;
|
||||
maxOutputBytes?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedHttpConfig {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
export type CssRule = ExpectOperator & { attr?: string; selector: string };
|
||||
|
||||
export interface DefaultsConfig {
|
||||
command?: CommandDefaultsConfig;
|
||||
http?: HttpDefaultsConfig;
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
}
|
||||
|
||||
export interface EngineRuntimeConfig {
|
||||
maxConcurrentChecks?: number;
|
||||
}
|
||||
|
||||
export type ExpectConfig = CommandExpectConfig | HttpExpectConfig;
|
||||
|
||||
export interface ExpectOperator {
|
||||
contains?: string;
|
||||
empty?: boolean;
|
||||
equals?: boolean | null | number | string;
|
||||
exists?: boolean;
|
||||
gt?: number;
|
||||
gte?: number;
|
||||
lt?: number;
|
||||
lte?: number;
|
||||
match?: string;
|
||||
}
|
||||
|
||||
export type ExpectValue = boolean | ExpectOperator | null | number | string;
|
||||
|
||||
export type HeaderExpect = ExpectOperator | string;
|
||||
|
||||
export interface HttpDefaultsConfig {
|
||||
headers?: Record<string, string>;
|
||||
maxBodyBytes?: string;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export interface HttpExpectConfig {
|
||||
body?: BodyRule[];
|
||||
headers?: Record<string, HeaderExpect>;
|
||||
maxDurationMs?: number;
|
||||
status?: number[];
|
||||
}
|
||||
|
||||
export interface HttpTargetConfig {
|
||||
body?: string;
|
||||
maxBodyBytes: number;
|
||||
headers?: Record<string, string>;
|
||||
maxBodyBytes?: string;
|
||||
method?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedCommandTarget {
|
||||
type: "command";
|
||||
name: string;
|
||||
group: string;
|
||||
command: ResolvedCommandConfig;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
expect?: CommandExpectConfig;
|
||||
export type JsonRule = ExpectOperator & { path: string };
|
||||
|
||||
export interface ProbeConfig {
|
||||
defaults?: DefaultsConfig;
|
||||
runtime?: EngineRuntimeConfig;
|
||||
server?: ServerConfig;
|
||||
targets: TargetConfig[];
|
||||
}
|
||||
|
||||
export interface ResolvedCommandConfig {
|
||||
exec: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
exec: string;
|
||||
maxOutputBytes: number;
|
||||
}
|
||||
|
||||
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget;
|
||||
|
||||
export interface CheckFailure {
|
||||
kind: "error" | "mismatch";
|
||||
phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr";
|
||||
path: string;
|
||||
expected?: unknown;
|
||||
actual?: unknown;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
targetName: string;
|
||||
timestamp: string;
|
||||
matched: boolean;
|
||||
durationMs: number | null;
|
||||
statusDetail: string | null;
|
||||
failure: CheckFailure | null;
|
||||
}
|
||||
|
||||
export interface StoredTarget {
|
||||
id: number;
|
||||
export interface ResolvedCommandTarget {
|
||||
command: ResolvedCommandConfig;
|
||||
expect?: CommandExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: string;
|
||||
type: TargetType;
|
||||
target: string;
|
||||
config: string;
|
||||
interval_ms: number;
|
||||
timeout_ms: number;
|
||||
expect: string | null;
|
||||
grp: string;
|
||||
timeoutMs: number;
|
||||
type: "command";
|
||||
}
|
||||
|
||||
export interface ResolvedHttpConfig {
|
||||
body?: string;
|
||||
headers: Record<string, string>;
|
||||
maxBodyBytes: number;
|
||||
method: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedHttpTarget {
|
||||
expect?: HttpExpectConfig;
|
||||
group: string;
|
||||
http: ResolvedHttpConfig;
|
||||
intervalMs: number;
|
||||
name: string;
|
||||
timeoutMs: number;
|
||||
type: "http";
|
||||
}
|
||||
|
||||
export type ResolvedTarget = ResolvedCommandTarget | ResolvedHttpTarget;
|
||||
|
||||
export interface ServerConfig {
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface StoredCheckResult {
|
||||
duration_ms: null | number;
|
||||
failure: null | string;
|
||||
id: number;
|
||||
matched: number;
|
||||
status_detail: null | string;
|
||||
target_id: number;
|
||||
timestamp: string;
|
||||
matched: number;
|
||||
duration_ms: number | null;
|
||||
status_detail: string | null;
|
||||
failure: string | null;
|
||||
}
|
||||
|
||||
export interface StoredTarget {
|
||||
config: string;
|
||||
expect: null | string;
|
||||
grp: string;
|
||||
id: number;
|
||||
interval_ms: number;
|
||||
name: string;
|
||||
target: string;
|
||||
timeout_ms: number;
|
||||
type: TargetType;
|
||||
}
|
||||
|
||||
export type TargetConfig = BaseTargetConfig &
|
||||
({ command: CommandTargetConfig; type: "command" } | { http: HttpTargetConfig; type: "http" });
|
||||
|
||||
export type TargetType = "command" | "http";
|
||||
|
||||
export type { CheckFailure };
|
||||
export type TextRule = ExpectOperator;
|
||||
|
||||
export type XpathRule = ExpectOperator & { path: string };
|
||||
|
||||
interface BaseTargetConfig {
|
||||
expect?: ExpectConfig;
|
||||
group?: string;
|
||||
interval?: string;
|
||||
name: string;
|
||||
timeout?: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export interface RuntimeConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function readRuntimeConfig(argv: string[] = process.argv.slice(2)): { configPath: string } {
|
||||
if (argv.length === 0) {
|
||||
throw new Error("需要指定 YAML 配置文件路径\n用法: dial-server <config.yaml>");
|
||||
@@ -5,8 +10,3 @@ export function readRuntimeConfig(argv: string[] = process.argv.slice(2)): { con
|
||||
|
||||
return { configPath: argv[0]! };
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { loadConfig } from "./checker/config-loader";
|
||||
import { ProbeStore } from "./checker/store";
|
||||
import { ProbeEngine } from "./checker/engine";
|
||||
import { startServer } from "./server";
|
||||
import { registerCheckers } from "./checker/runner";
|
||||
import { ProbeStore } from "./checker/store";
|
||||
import { readRuntimeConfig } from "./config";
|
||||
import { startServer } from "./server";
|
||||
|
||||
async function main() {
|
||||
registerCheckers();
|
||||
|
||||
const { configPath } = readRuntimeConfig();
|
||||
const config = await loadConfig(configPath);
|
||||
|
||||
|
||||
79
src/server/helpers.ts
Normal file
79
src/server/helpers.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
|
||||
import type { StoredCheckResult } from "./checker/types";
|
||||
|
||||
export function allowsGetHead(method: string): boolean {
|
||||
return method === "GET" || method === "HEAD";
|
||||
}
|
||||
|
||||
export function createApiError(error: string, status: number): ApiErrorResponse {
|
||||
return { error, status };
|
||||
}
|
||||
|
||||
export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
|
||||
const headers = new Headers(init);
|
||||
|
||||
if (mode === "production") {
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function createHealthResponse(): HealthResponse {
|
||||
return {
|
||||
ok: true,
|
||||
service: "dial-server",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`;
|
||||
if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`;
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
export function jsonResponse(
|
||||
body: unknown,
|
||||
options: { headers?: HeadersInit; method?: string; mode: RuntimeMode; status?: number },
|
||||
): Response {
|
||||
const headers = createHeaders(options.mode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
...options.headers,
|
||||
});
|
||||
const responseBody = options.method === "HEAD" ? null : JSON.stringify(body);
|
||||
|
||||
return new Response(responseBody, {
|
||||
headers,
|
||||
status: options.status,
|
||||
});
|
||||
}
|
||||
|
||||
export function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
let failure: CheckFailure | null = null;
|
||||
if (row.failure) {
|
||||
try {
|
||||
failure = JSON.parse(row.failure) as CheckFailure;
|
||||
} catch {
|
||||
console.warn(`无法解析 failure 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
|
||||
failure = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationMs: row.duration_ms,
|
||||
failure,
|
||||
matched: row.matched === 1,
|
||||
statusDetail: row.status_detail,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response {
|
||||
return jsonResponse(createApiError("Method not allowed", 405), {
|
||||
headers: { Allow: allow.join(", ") },
|
||||
mode,
|
||||
status: 405,
|
||||
});
|
||||
}
|
||||
59
src/server/middleware.ts
Normal file
59
src/server/middleware.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
|
||||
import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers";
|
||||
|
||||
export function guardGetHead(method: string, mode: RuntimeMode): null | Response {
|
||||
if (!allowsGetHead(method)) {
|
||||
return methodNotAllowedResponse(["GET", "HEAD"], mode);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validatePagination(
|
||||
pageParam: null | string,
|
||||
pageSizeParam: null | string,
|
||||
mode: RuntimeMode,
|
||||
): Response | { page: number; pageSize: number } {
|
||||
let page = 1;
|
||||
let pageSize = 20;
|
||||
|
||||
if (pageParam !== null) {
|
||||
page = Number(pageParam);
|
||||
if (!Number.isInteger(page) || page <= 0) {
|
||||
return jsonResponse(createApiError("Invalid page parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSizeParam !== null) {
|
||||
pageSize = Number(pageSizeParam);
|
||||
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
||||
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
return { page, pageSize };
|
||||
}
|
||||
|
||||
export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: number } {
|
||||
const id = Number(idStr);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return jsonResponse(createApiError("Invalid target ID", 400), { mode, status: 400 });
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
export function validateTimeRange(
|
||||
from: null | string,
|
||||
to: null | string,
|
||||
mode: RuntimeMode,
|
||||
): Response | { from: string; to: string } {
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (isNaN(new Date(from).getTime()) || isNaN(new Date(to).getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
11
src/server/routes/health.ts
Normal file
11
src/server/routes/health.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RuntimeMode } from "../../shared/api";
|
||||
|
||||
import { allowsGetHead, createHealthResponse, jsonResponse, methodNotAllowedResponse } from "../helpers";
|
||||
|
||||
export function handleHealth(method: string, mode: RuntimeMode): Response {
|
||||
if (!allowsGetHead(method)) {
|
||||
return methodNotAllowedResponse(["GET", "HEAD"], mode);
|
||||
}
|
||||
|
||||
return jsonResponse(createHealthResponse(), { method, mode });
|
||||
}
|
||||
31
src/server/routes/history.ts
Normal file
31
src/server/routes/history.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { HistoryResponse, RuntimeMode } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { jsonResponse, mapCheckResult } from "../helpers";
|
||||
import { validatePagination, validateTargetId, validateTimeRange } from "../middleware";
|
||||
|
||||
export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const idResult = validateTargetId(idStr, mode);
|
||||
if (idResult instanceof Response) return idResult;
|
||||
|
||||
const target = store.getTargetById(idResult.id);
|
||||
if (!target) {
|
||||
return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
|
||||
if (timeResult instanceof Response) return timeResult;
|
||||
|
||||
const pageResult = validatePagination(url.searchParams.get("page"), url.searchParams.get("pageSize"), mode);
|
||||
if (pageResult instanceof Response) return pageResult;
|
||||
|
||||
const result = store.getHistory(idResult.id, timeResult.from, timeResult.to, pageResult.page, pageResult.pageSize);
|
||||
const response: HistoryResponse = {
|
||||
items: result.items.map(mapCheckResult),
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { method, mode });
|
||||
}
|
||||
16
src/server/routes/summary.ts
Normal file
16
src/server/routes/summary.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { RuntimeMode, SummaryResponse } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { jsonResponse } from "../helpers";
|
||||
|
||||
export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response {
|
||||
const summary = store.getSummary();
|
||||
const response: SummaryResponse = {
|
||||
down: summary.down,
|
||||
lastCheckTime: summary.lastCheckTime,
|
||||
total: summary.total,
|
||||
up: summary.up,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { method, mode });
|
||||
}
|
||||
37
src/server/routes/targets.ts
Normal file
37
src/server/routes/targets.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RuntimeMode, TargetStatus } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { formatDuration, jsonResponse, mapCheckResult } from "../helpers";
|
||||
|
||||
export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response {
|
||||
const targets = store.getTargets();
|
||||
const latestChecksMap = store.getLatestChecksMap();
|
||||
const allStats = store.getAllTargetStats();
|
||||
|
||||
const result: TargetStatus[] = targets.map((target) => {
|
||||
const latest = latestChecksMap.get(target.id) ?? null;
|
||||
const stats = allStats.get(target.id) ?? { availability: 0, totalChecks: 0 };
|
||||
const recentSamples = store.getRecentSamples(target.id, 30);
|
||||
|
||||
return {
|
||||
group: target.grp,
|
||||
id: target.id,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
name: target.name,
|
||||
recentSamples: recentSamples.map((s) => ({
|
||||
durationMs: s.duration_ms,
|
||||
timestamp: s.timestamp,
|
||||
up: s.matched === 1,
|
||||
})),
|
||||
stats: {
|
||||
availability: stats.availability,
|
||||
totalChecks: stats.totalChecks,
|
||||
},
|
||||
target: target.target,
|
||||
type: target.type,
|
||||
};
|
||||
});
|
||||
|
||||
return jsonResponse(result, { method, mode });
|
||||
}
|
||||
27
src/server/routes/trend.ts
Normal file
27
src/server/routes/trend.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { RuntimeMode, TrendPoint } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { jsonResponse } from "../helpers";
|
||||
import { validateTargetId, validateTimeRange } from "../middleware";
|
||||
|
||||
export function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const idResult = validateTargetId(idStr, mode);
|
||||
if (idResult instanceof Response) return idResult;
|
||||
|
||||
const target = store.getTargetById(idResult.id);
|
||||
if (!target) {
|
||||
return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
|
||||
if (timeResult instanceof Response) return timeResult;
|
||||
|
||||
const trend: TrendPoint[] = store.getTrend(idResult.id, timeResult.from, timeResult.to).map((row) => ({
|
||||
availability: Math.round(row.availability * 100) / 100,
|
||||
avgDurationMs: row.avgDurationMs,
|
||||
hour: row.hour,
|
||||
totalChecks: row.totalChecks,
|
||||
}));
|
||||
|
||||
return jsonResponse(trend, { method, mode });
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { StaticAssets } from "./app";
|
||||
import type { ProbeStore } from "./checker/store";
|
||||
import { createFetchHandler } from "./app";
|
||||
import type { RuntimeConfig } from "./config";
|
||||
|
||||
import { createFetchHandler } from "./app";
|
||||
|
||||
export interface StartServerOptions {
|
||||
config: RuntimeConfig;
|
||||
mode: RuntimeMode;
|
||||
@@ -14,13 +15,13 @@ export interface StartServerOptions {
|
||||
export function startServer(options: StartServerOptions) {
|
||||
const { config, mode, staticAssets, store } = options;
|
||||
const server = Bun.serve({
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
fetch: createFetchHandler({
|
||||
mode,
|
||||
staticAssets,
|
||||
store,
|
||||
}),
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
});
|
||||
|
||||
console.log(`DiAL listening on ${server.url}`);
|
||||
|
||||
55
src/server/static.ts
Normal file
55
src/server/static.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { StaticAssets } from "./app";
|
||||
|
||||
import { createHeaders } from "./helpers";
|
||||
|
||||
export function contentTypeFor(pathname: string): string {
|
||||
if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "text/javascript; charset=utf-8";
|
||||
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
|
||||
if (pathname.endsWith(".svg")) return "image/svg+xml";
|
||||
if (pathname.endsWith(".json")) return "application/json; charset=utf-8";
|
||||
if (pathname.endsWith(".png")) return "image/png";
|
||||
if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (pathname.endsWith(".ico")) return "image/x-icon";
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
export function hasFileExtension(pathname: string): boolean {
|
||||
return /\/[^/]+\.[^/]+$/.test(pathname);
|
||||
}
|
||||
|
||||
export function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response {
|
||||
return new Response(indexHtml, {
|
||||
headers: createHeaders(mode, {
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function serveStaticAsset(pathname: string, staticAssets: StaticAssets, mode: RuntimeMode): Response {
|
||||
if (pathname === "/") {
|
||||
return htmlResponse(staticAssets.indexHtml, mode);
|
||||
}
|
||||
|
||||
const asset = staticAssets.files[pathname];
|
||||
|
||||
if (asset) {
|
||||
return new Response(asset, {
|
||||
headers: createHeaders(mode, {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": contentTypeFor(pathname),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) {
|
||||
return new Response("Not Found", {
|
||||
headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }),
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return htmlResponse(staticAssets.indexHtml, mode);
|
||||
}
|
||||
@@ -1,4 +1,24 @@
|
||||
export type RuntimeMode = "development" | "production" | "test";
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface CheckFailure {
|
||||
actual?: unknown;
|
||||
expected?: unknown;
|
||||
kind: "error" | "mismatch";
|
||||
message: string;
|
||||
path: string;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
durationMs: null | number;
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
statusDetail: null | string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
ok: true;
|
||||
@@ -6,68 +26,48 @@ export interface HealthResponse {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface SummaryResponse {
|
||||
export interface HistoryResponse {
|
||||
items: CheckResult[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
lastCheckTime: string | null;
|
||||
}
|
||||
|
||||
export interface RecentSample {
|
||||
durationMs: null | number;
|
||||
timestamp: string;
|
||||
durationMs: number | null;
|
||||
up: boolean;
|
||||
}
|
||||
|
||||
export interface TargetStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
target: string;
|
||||
group: string;
|
||||
interval: string;
|
||||
latestCheck: CheckResult | null;
|
||||
stats: TargetStats;
|
||||
recentSamples: RecentSample[];
|
||||
export type RuntimeMode = "development" | "production" | "test";
|
||||
|
||||
export interface SummaryResponse {
|
||||
down: number;
|
||||
lastCheckTime: null | string;
|
||||
total: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
export interface TargetStats {
|
||||
totalChecks: number;
|
||||
availability: number;
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
items: CheckResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
timestamp: string;
|
||||
matched: boolean;
|
||||
durationMs: number | null;
|
||||
statusDetail: string | null;
|
||||
failure: CheckFailure | null;
|
||||
}
|
||||
|
||||
export interface CheckFailure {
|
||||
kind: "error" | "mismatch";
|
||||
phase: string;
|
||||
path: string;
|
||||
expected?: unknown;
|
||||
actual?: unknown;
|
||||
message: string;
|
||||
export interface TargetStatus {
|
||||
group: string;
|
||||
id: number;
|
||||
interval: string;
|
||||
latestCheck: CheckResult | null;
|
||||
name: string;
|
||||
recentSamples: RecentSample[];
|
||||
stats: TargetStats;
|
||||
target: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
hour: string;
|
||||
avgDurationMs: number | null;
|
||||
availability: number;
|
||||
avgDurationMs: null | number;
|
||||
hour: string;
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,59 @@
|
||||
import { Alert, Loading } from "tdesign-react";
|
||||
import { useSummary, useTargets, useTargetDetail } from "./hooks/useTargetDetail";
|
||||
import { Alert, Loading, Typography } from "tdesign-react";
|
||||
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetBoard } from "./components/TargetBoard";
|
||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||
import { useSummary, useTargetDetail, useTargets } from "./hooks/useTargetDetail";
|
||||
|
||||
export function App() {
|
||||
const { data: summary, isLoading: summaryLoading, error: summaryError } = useSummary();
|
||||
const { data: targets, isLoading: targetsLoading, error: targetsError } = useTargets();
|
||||
const { data: summary, error: summaryError, isLoading: summaryLoading } = useSummary();
|
||||
const { data: targets, error: targetsError, isLoading: targetsLoading } = useTargets();
|
||||
const {
|
||||
selectedTarget,
|
||||
trendData,
|
||||
trendLoading,
|
||||
closeDrawer,
|
||||
handlePageChange,
|
||||
handleTimeChange,
|
||||
historyData,
|
||||
historyLoading,
|
||||
openDrawer,
|
||||
selectedTarget,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
trendData,
|
||||
trendLoading,
|
||||
} = useTargetDetail();
|
||||
|
||||
const error = summaryError || targetsError;
|
||||
const error = summaryError ?? targetsError;
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<h1>DiAL</h1>
|
||||
<p className="dashboard-subtitle">统一拨测平台</p>
|
||||
<Typography.Title level="h1">DiAL</Typography.Title>
|
||||
<Typography.Text theme="secondary">统一拨测平台</Typography.Text>
|
||||
</header>
|
||||
|
||||
{error && <Alert theme="error" message={`请求失败: ${error.message}`} closeBtn />}
|
||||
{error && <Alert closeBtn message={`请求失败: ${error.message}`} theme="error" />}
|
||||
|
||||
{summaryLoading && targetsLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCards summary={summary ?? null} />
|
||||
<TargetBoard targets={targets ?? []} onTargetClick={openDrawer} />
|
||||
<TargetBoard onTargetClick={openDrawer} targets={targets ?? []} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TargetDetailDrawer
|
||||
target={selectedTarget}
|
||||
trendData={trendData}
|
||||
trendLoading={trendLoading}
|
||||
historyData={historyData}
|
||||
historyLoading={historyLoading}
|
||||
key={selectedTarget?.id}
|
||||
onClose={closeDrawer}
|
||||
onPageChange={handlePageChange}
|
||||
onTimeChange={handleTimeChange}
|
||||
target={selectedTarget}
|
||||
timeFrom={timeFrom}
|
||||
timeTo={timeTo}
|
||||
onTimeChange={handleTimeChange}
|
||||
onPageChange={handlePageChange}
|
||||
onClose={closeDrawer}
|
||||
trendData={trendData}
|
||||
trendLoading={trendLoading}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { Space, Tag } from "tdesign-react";
|
||||
import { Tag, Typography } from "tdesign-react";
|
||||
|
||||
interface GroupHeaderProps {
|
||||
down: number;
|
||||
name: string;
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
|
||||
export function GroupHeader({ down, name, total, up }: GroupHeaderProps) {
|
||||
const displayName = name === "default" ? "默认分组" : name;
|
||||
|
||||
return (
|
||||
<Space align="center" size={8} style={{ marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0, fontSize: "1.1rem", fontWeight: 600 }}>{displayName}</h2>
|
||||
<Tag theme="primary" variant="light" title="总数">
|
||||
<div className="group-header">
|
||||
<Typography.Title level="h4">{displayName}</Typography.Title>
|
||||
<Tag theme="primary" title="总数" variant="light">
|
||||
{total}
|
||||
</Tag>
|
||||
<Tag theme="success" variant="light" title="正常">
|
||||
<Tag theme="success" title="正常" variant="light">
|
||||
{up}
|
||||
</Tag>
|
||||
<Tag theme="danger" variant="light" title="异常">
|
||||
<Tag theme="danger" title="异常" variant="light">
|
||||
{down}
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,15 +9,12 @@ export function StatusBar({ samples }: StatusBarProps) {
|
||||
if (sample) {
|
||||
blocks.push(
|
||||
<span
|
||||
className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`}
|
||||
key={i}
|
||||
className="status-bar-block"
|
||||
style={{ background: sample.up ? "var(--td-success-color)" : "var(--td-error-color)" }}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
blocks.push(
|
||||
<span key={i} className="status-bar-block" style={{ background: "var(--td-bg-color-component-disabled)" }} />,
|
||||
);
|
||||
blocks.push(<span className="status-bar-block status-bar-block--empty" key={i} />);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface StatusDonutProps {
|
||||
up: number;
|
||||
down: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
const UP_COLOR = "var(--td-success-color)";
|
||||
const DOWN_COLOR = "var(--td-error-color)";
|
||||
const EMPTY_COLOR = "var(--td-bg-color-component-disabled)";
|
||||
|
||||
export function StatusDonut({ up, down }: StatusDonutProps) {
|
||||
export function StatusDonut({ down, up }: StatusDonutProps) {
|
||||
const total = up + down;
|
||||
const availability = total > 0 ? ((up / total) * 100).toFixed(1) : "-";
|
||||
|
||||
@@ -25,11 +25,11 @@ export function StatusDonut({ up, down }: StatusDonutProps) {
|
||||
|
||||
return (
|
||||
<div className="status-donut">
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<ResponsiveContainer height={180} width="100%">
|
||||
<PieChart>
|
||||
<Pie data={data} cx="50%" cy="50%" innerRadius={50} outerRadius={70} dataKey="value" stroke="none">
|
||||
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
|
||||
{data.map((_, index) => (
|
||||
<Cell key={index} fill={colors[index % colors.length]!} />
|
||||
<Cell fill={colors[index % colors.length]} key={index} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
|
||||
@@ -3,12 +3,5 @@ interface StatusDotProps {
|
||||
}
|
||||
|
||||
export function StatusDot({ up }: StatusDotProps) {
|
||||
const color = up ? "var(--td-success-color)" : "var(--td-error-color)";
|
||||
const shadow = up ? "var(--td-success-color)" : "var(--td-error-color)";
|
||||
return (
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{ background: color, boxShadow: `0 0 0 6px color-mix(in srgb, ${shadow} 14%, transparent)` }}
|
||||
/>
|
||||
);
|
||||
return <span className={`status-dot ${up ? "status-dot--up" : "status-dot--down"}`} />;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { Row, Col, Card, Statistic } from "tdesign-react";
|
||||
import { Card, Col, Row, Statistic } from "tdesign-react";
|
||||
|
||||
import type { SummaryResponse } from "../../shared/api";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: SummaryResponse | null;
|
||||
summary: null | SummaryResponse;
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
if (!summary) return null;
|
||||
|
||||
const cards = [
|
||||
{ label: "全部目标", value: summary.total, color: "blue" as const },
|
||||
{ label: "正常", value: summary.up, color: "green" as const },
|
||||
{ label: "异常", value: summary.down, color: "red" as const },
|
||||
{ color: "blue" as const, label: "全部目标", value: summary.total },
|
||||
{ color: "green" as const, label: "正常", value: summary.up },
|
||||
{ color: "red" as const, label: "异常", value: summary.down },
|
||||
];
|
||||
|
||||
return (
|
||||
<Row gutter={16} style={{ marginBottom: 32 }}>
|
||||
<Row className="summary-cards-row" gutter={16}>
|
||||
{cards.map((card) => (
|
||||
<Col key={card.label} span={4}>
|
||||
<Card bordered>
|
||||
<Statistic title={card.label} value={card.value} color={card.color} />
|
||||
<Statistic color={card.color} title={card.label} value={card.value} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Space } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { TargetGroup } from "./TargetGroup";
|
||||
|
||||
interface TargetBoardProps {
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
targets: TargetStatus[];
|
||||
}
|
||||
|
||||
export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
|
||||
export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
||||
const groups = new Map<string, TargetStatus[]>();
|
||||
for (const target of targets) {
|
||||
const group = target.group;
|
||||
@@ -25,9 +27,9 @@ export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={32} style={{ width: "100%" }}>
|
||||
<Space className="full-width" direction="vertical" size={32}>
|
||||
{sortedGroups.map(([name, groupTargets]) => (
|
||||
<TargetGroup key={name} name={name} targets={groupTargets} onTargetClick={onTargetClick} />
|
||||
<TargetGroup key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
|
||||
@@ -1,96 +1,99 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Drawer,
|
||||
Tabs,
|
||||
RadioGroup,
|
||||
DateRangePicker,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Descriptions,
|
||||
Skeleton,
|
||||
PrimaryTable,
|
||||
} from "tdesign-react";
|
||||
import type { TabValue } from "tdesign-react";
|
||||
import type { CheckResult, TargetStatus, TrendPoint, HistoryResponse } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Col,
|
||||
DateRangePicker,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Drawer,
|
||||
PrimaryTable,
|
||||
RadioGroup,
|
||||
Row,
|
||||
Skeleton,
|
||||
Space,
|
||||
Statistic,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "tdesign-react";
|
||||
|
||||
import type { CheckResult, HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
import { subtractHours } from "../utils/time";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface TargetDetailDrawerProps {
|
||||
target: TargetStatus | null;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
historyData: HistoryResponse;
|
||||
historyLoading: boolean;
|
||||
onClose: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onTimeChange: (from: string, to: string) => void;
|
||||
target: null | TargetStatus;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
onTimeChange: (from: string, to: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onClose: () => void;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
}
|
||||
|
||||
const TIME_SHORTCUTS = [
|
||||
{ label: "1h", hours: 1, value: "1h" },
|
||||
{ label: "6h", hours: 6, value: "6h" },
|
||||
{ label: "24h", hours: 24, value: "24h" },
|
||||
{ label: "7d", hours: 168, value: "7d" },
|
||||
{ hours: 1, label: "1小时", value: "1h" },
|
||||
{ hours: 6, label: "6小时", value: "6h" },
|
||||
{ hours: 24, label: "24小时", value: "24h" },
|
||||
{ hours: 168, label: "7天", value: "7d" },
|
||||
] as const;
|
||||
|
||||
const HISTORY_COLUMNS = [
|
||||
{
|
||||
colKey: "matched",
|
||||
title: "状态",
|
||||
width: 72,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => (
|
||||
<Tag theme={row.matched ? "success" : "danger"} size="small">
|
||||
{row.matched ? "UP" : "DOWN"}
|
||||
</Tag>
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => (
|
||||
<StatusDot up={!!row.matched} />
|
||||
),
|
||||
colKey: "matched",
|
||||
title: "#",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => {
|
||||
const d = new Date(row.timestamp);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
},
|
||||
colKey: "timestamp",
|
||||
title: "时间",
|
||||
width: 170,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
|
||||
new Date(row.timestamp).toLocaleString("zh-CN"),
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
align: "center" as const,
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) =>
|
||||
row.durationMs !== null ? Math.round(row.durationMs) : "-",
|
||||
colKey: "durationMs",
|
||||
title: "耗时(ms)",
|
||||
width: 96,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => {
|
||||
const parts = [row.statusDetail, row.failure?.message].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(":") : "-";
|
||||
},
|
||||
colKey: "statusDetail",
|
||||
title: "详情",
|
||||
width: 100,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => row.statusDetail ?? "-",
|
||||
},
|
||||
{
|
||||
colKey: "durationMs",
|
||||
title: "耗时",
|
||||
width: 80,
|
||||
align: "right" as const,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
|
||||
row.durationMs !== null ? `${Math.round(row.durationMs)}ms` : "-",
|
||||
},
|
||||
{
|
||||
colKey: "failure",
|
||||
title: "错误信息",
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
|
||||
row.failure?.message ?? "",
|
||||
},
|
||||
];
|
||||
|
||||
export function TargetDetailDrawer({
|
||||
target,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
onClose,
|
||||
onPageChange,
|
||||
onTimeChange,
|
||||
target,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
onTimeChange,
|
||||
onPageChange,
|
||||
onClose,
|
||||
trendData,
|
||||
trendLoading,
|
||||
}: TargetDetailDrawerProps) {
|
||||
const [activeShortcut, setActiveShortcut] = useState<string>("24h");
|
||||
const [activeTab, setActiveTab] = useState<TabValue>("overview");
|
||||
@@ -108,8 +111,8 @@ export function TargetDetailDrawer({
|
||||
);
|
||||
|
||||
const handleDateRangeChange = useCallback(
|
||||
(value: Array<string | number | Date>) => {
|
||||
if (value && value.length === 2) {
|
||||
(value: Array<Date | number | string>) => {
|
||||
if (value?.length === 2) {
|
||||
onTimeChange(new Date(value[0]!).toISOString(), new Date(value[1]!).toISOString());
|
||||
setActiveShortcut("");
|
||||
}
|
||||
@@ -126,87 +129,97 @@ export function TargetDetailDrawer({
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
visible={!!target}
|
||||
placement="right"
|
||||
size="60%"
|
||||
onClose={onClose}
|
||||
footer={false}
|
||||
header={
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Space align="center" size={8}>
|
||||
<StatusDot up={!!isUp} />
|
||||
<span style={{ fontWeight: 600 }}>{target.name}</span>
|
||||
<Typography.Text strong>{target.name}</Typography.Text>
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(target.type)}
|
||||
</Tag>
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
onClose={onClose}
|
||||
placement="right"
|
||||
size="60%"
|
||||
visible={!!target}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 16 }}>
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<RadioGroup
|
||||
variant="default-filled"
|
||||
value={activeShortcut}
|
||||
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
|
||||
onChange={handleShortcut}
|
||||
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
|
||||
theme="button"
|
||||
value={activeShortcut}
|
||||
variant="default-filled"
|
||||
/>
|
||||
<DateRangePicker
|
||||
mode="date"
|
||||
className="full-width"
|
||||
defaultTime={["00:00:00", "23:59:00"]}
|
||||
enableTimePicker
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
mode="date"
|
||||
onChange={handleDateRangeChange}
|
||||
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
valueType="YYYY-MM-DD HH:mm"
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Tabs value={activeTab} onChange={(val: TabValue) => setActiveTab(val)}>
|
||||
<Tabs.TabPanel value="overview" label="概览">
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={3}>
|
||||
<Statistic title="总检查" value={totalChecks} color="blue" />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="正常" value={upChecks} color="green" />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="异常" value={downChecks} color="red" />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="可用率" value={target.stats?.availability ?? 0} color="green" suffix="%" />
|
||||
</Col>
|
||||
</Row>
|
||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Divider align="left">统计</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic color="blue" title="总检查" value={totalChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" title="正常" value={upChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="red" title="异常" value={downChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Descriptions
|
||||
items={[
|
||||
{ label: "目标地址", content: target.target },
|
||||
{ label: "检查间隔", content: target.interval },
|
||||
{
|
||||
label: "最新检查时间",
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
},
|
||||
{ label: "状态详情", content: target.latestCheck?.statusDetail ?? "-" },
|
||||
]}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Divider align="left">趋势</Divider>
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
|
||||
|
||||
<StatusDonut up={upChecks} down={downChecks} />
|
||||
<Divider align="left">状态分布</Divider>
|
||||
<StatusDonut down={downChecks} up={upChecks} />
|
||||
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Descriptions
|
||||
items={[
|
||||
{ content: target.target, label: "目标地址" },
|
||||
{ content: target.interval, label: "检查间隔" },
|
||||
{
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
label: "最新检查时间",
|
||||
},
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel value="trend" label="趋势">
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel value="history" label="记录">
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
||||
<PrimaryTable
|
||||
columns={HISTORY_COLUMNS}
|
||||
data={historyData.items}
|
||||
rowKey="timestamp"
|
||||
loading={historyLoading}
|
||||
disableDataPage
|
||||
loading={historyLoading}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
pagination={{
|
||||
current: historyData.page,
|
||||
pageSize: historyData.pageSize,
|
||||
total: historyData.total,
|
||||
}}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
rowKey="timestamp"
|
||||
/>
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
import { PrimaryTable } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { TARGET_TABLE_COLUMNS } from "../constants/target-table-columns";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
|
||||
interface TargetGroupProps {
|
||||
name: string;
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
targets: TargetStatus[];
|
||||
}
|
||||
|
||||
export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) {
|
||||
export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps) {
|
||||
const up = targets.filter((t) => t.latestCheck?.matched).length;
|
||||
const down = targets.length - up;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GroupHeader name={name} total={targets.length} up={up} down={down} />
|
||||
<GroupHeader down={down} name={name} total={targets.length} up={up} />
|
||||
<PrimaryTable
|
||||
bordered
|
||||
className="clickable-table"
|
||||
columns={TARGET_TABLE_COLUMNS}
|
||||
data={targets}
|
||||
defaultSort={[{ descending: true, sortBy: "latestCheck.matched" }]}
|
||||
hover
|
||||
onRowClick={({ row }) => onTargetClick(row)}
|
||||
rowClassName={({ row }) => {
|
||||
const target = row;
|
||||
return target.latestCheck?.matched === false ? "row-down" : "";
|
||||
}}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
stripe
|
||||
hover
|
||||
bordered
|
||||
defaultSort={[{ sortBy: "latestCheck.matched", descending: true }]}
|
||||
onRowClick={({ row }) => onTargetClick(row as TargetStatus)}
|
||||
rowClassName={({ row }) => {
|
||||
const target = row as TargetStatus;
|
||||
return target.latestCheck?.matched === false ? "row-down" : "";
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Line, LineChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
interface TrendChartProps {
|
||||
@@ -22,23 +23,23 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
|
||||
return (
|
||||
<div className="trend-chart">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<ResponsiveContainer height={240} width="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--td-border-level-2-color)" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="var(--td-text-color-secondary)" />
|
||||
<CartesianGrid stroke="var(--td-border-level-2-color)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" stroke="var(--td-text-color-secondary)" tick={{ fontSize: 12 }} />
|
||||
<YAxis
|
||||
yAxisId="duration"
|
||||
tick={{ fontSize: 12 }}
|
||||
label={{ fontSize: 11, position: "insideTopRight", value: "ms" }}
|
||||
stroke="var(--td-text-color-secondary)"
|
||||
label={{ value: "ms", position: "insideTopRight", fontSize: 11 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
yAxisId="duration"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="availability"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12 }}
|
||||
label={{ fontSize: 11, position: "insideTopLeft", value: "%" }}
|
||||
orientation="right"
|
||||
stroke="var(--td-text-color-secondary)"
|
||||
label={{ value: "%", position: "insideTopLeft", fontSize: 11 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
yAxisId="availability"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: unknown, name: unknown) => {
|
||||
@@ -50,22 +51,22 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="duration"
|
||||
type="monotone"
|
||||
dataKey="avgDurationMs"
|
||||
stroke="var(--td-brand-color)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="avgDurationMs"
|
||||
stroke="var(--td-brand-color)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="duration"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="availability"
|
||||
type="monotone"
|
||||
dataKey="availability"
|
||||
stroke="var(--td-success-color)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="availability"
|
||||
stroke="var(--td-success-color)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="availability"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
const AVAILABILITY_COLORS = [
|
||||
"#d54941", // 0-10%
|
||||
"#d96241", // 10-20%
|
||||
"#e37318", // 20-30%
|
||||
"#e89318", // 30-40%
|
||||
"#d9a818", // 40-50%
|
||||
"#b8b020", // 50-60%
|
||||
"#8dba30", // 60-70%
|
||||
"#6dba3f", // 70-80%
|
||||
"#4dba50", // 80-90%
|
||||
"#3dba60", // 90-100%
|
||||
];
|
||||
|
||||
export function getAvailabilityProgressColor(availability: number): string {
|
||||
const index = Math.min(Math.floor(availability / 10), 9);
|
||||
return AVAILABILITY_COLORS[index]!;
|
||||
}
|
||||
|
||||
|
||||
export function getLatencyColor(ms: number): string {
|
||||
if (ms <= 100) return "var(--td-success-color)";
|
||||
if (ms <= 500) return "var(--td-warning-color)";
|
||||
return "var(--td-error-color)";
|
||||
return `var(--avail-${index})`;
|
||||
}
|
||||
|
||||
@@ -1,89 +1,92 @@
|
||||
import type { PrimaryTableCol, PrimaryTableCellParams } from "tdesign-react";
|
||||
import { Tag, Progress } from "tdesign-react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "../components/StatusDot";
|
||||
import { StatusBar } from "../components/StatusBar";
|
||||
import { getTargetTypeDisplay } from "./target-type-display";
|
||||
import { getAvailabilityProgressColor, getLatencyColor } from "./color-threshold";
|
||||
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||
import { statusFilter, typeFilter } from "./target-table-filters";
|
||||
import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
export const TARGET_TABLE_COLUMNS: PrimaryTableCol<TargetStatus>[] = [
|
||||
import { Progress, Tag } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { StatusBar } from "../components/StatusBar";
|
||||
import { StatusDot } from "../components/StatusDot";
|
||||
import { getAvailabilityProgressColor } from "./color-threshold";
|
||||
import { statusFilter, typeFilter } from "./target-table-filters";
|
||||
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||
import { getTargetTypeDisplay } from "./target-type-display";
|
||||
|
||||
export const TARGET_TABLE_COLUMNS: Array<PrimaryTableCol<TargetStatus>> = [
|
||||
{
|
||||
colKey: "latestCheck.matched",
|
||||
title: "状态",
|
||||
width: 80,
|
||||
fixed: "left",
|
||||
align: "center",
|
||||
filter: statusFilter,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
|
||||
colKey: "latestCheck.matched",
|
||||
filter: statusFilter,
|
||||
fixed: "left",
|
||||
title: "#",
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
colKey: "name",
|
||||
title: "名称",
|
||||
ellipsis: true,
|
||||
sorter: nameSorter,
|
||||
sortType: "all",
|
||||
title: "名称",
|
||||
},
|
||||
{
|
||||
colKey: "type",
|
||||
title: "类型",
|
||||
width: 80,
|
||||
filter: typeFilter,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(row.type)}
|
||||
</Tag>
|
||||
),
|
||||
colKey: "type",
|
||||
filter: typeFilter,
|
||||
title: "类型",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
colKey: "stats.availability",
|
||||
title: "可用率",
|
||||
width: 160,
|
||||
sorter: availabilitySorter,
|
||||
sortType: "all",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const availability = row.stats?.availability;
|
||||
if (availability === undefined || availability === null) return "-";
|
||||
const color = getAvailabilityProgressColor(availability);
|
||||
return (
|
||||
<Progress
|
||||
theme="line"
|
||||
size="small"
|
||||
percentage={availability}
|
||||
color={color}
|
||||
label={`${availability.toFixed(1)}%`}
|
||||
percentage={availability}
|
||||
size="small"
|
||||
theme="line"
|
||||
/>
|
||||
);
|
||||
},
|
||||
colKey: "stats.availability",
|
||||
sorter: availabilitySorter,
|
||||
sortType: "all",
|
||||
title: "可用率",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusBar samples={row.recentSamples} />,
|
||||
colKey: "recentSamples",
|
||||
title: "最近状态",
|
||||
width: 220,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusBar samples={row.recentSamples} />,
|
||||
},
|
||||
{
|
||||
colKey: "latestCheck.durationMs",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
align: "right",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const ms = row.latestCheck?.durationMs;
|
||||
if (ms === null || ms === undefined) return <span style={{ color: "var(--td-text-color-disabled)" }}>-</span>;
|
||||
const color = getLatencyColor(ms);
|
||||
return <span style={{ color, fontVariantNumeric: "tabular-nums" }}>{Math.round(ms)}ms</span>;
|
||||
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
|
||||
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
|
||||
return <span className={`${colorClass} tabular-nums`}>{Math.round(ms)}ms</span>;
|
||||
},
|
||||
colKey: "latestCheck.durationMs",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
colKey: "interval",
|
||||
title: "间隔",
|
||||
width: 72,
|
||||
align: "center",
|
||||
},
|
||||
];
|
||||
|
||||
export { statusSorter, availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||
export { statusFilter, typeFilter } from "./target-table-filters";
|
||||
export { availabilitySorter, latencySorter, nameSorter, statusSorter } from "./target-table-sorters";
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
export const statusFilter: PrimaryTableCol["filter"] = {
|
||||
type: "single",
|
||||
list: [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "UP", value: "up" },
|
||||
{ label: "DOWN", value: "down" },
|
||||
],
|
||||
type: "single",
|
||||
};
|
||||
|
||||
export const typeFilter: PrimaryTableCol["filter"] = {
|
||||
type: "single",
|
||||
list: [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
{ label: "CMD", value: "command" },
|
||||
],
|
||||
type: "single",
|
||||
};
|
||||
|
||||
@@ -5,15 +5,6 @@ const STATUS_ORDER: Record<string, number> = {
|
||||
up: 1,
|
||||
};
|
||||
|
||||
function getStatusRank(target: TargetStatus): number {
|
||||
if (!target.latestCheck) return 2;
|
||||
return target.latestCheck.matched ? STATUS_ORDER["up"]! : STATUS_ORDER["down"]!;
|
||||
}
|
||||
|
||||
export function statusSorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return getStatusRank(a) - getStatusRank(b);
|
||||
}
|
||||
|
||||
export function availabilitySorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return (a.stats?.availability ?? 0) - (b.stats?.availability ?? 0);
|
||||
}
|
||||
@@ -25,3 +16,12 @@ export function latencySorter(a: TargetStatus, b: TargetStatus): number {
|
||||
export function nameSorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return a.name.localeCompare(b.name, "zh-CN");
|
||||
}
|
||||
|
||||
export function statusSorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return getStatusRank(a) - getStatusRank(b);
|
||||
}
|
||||
|
||||
function getStatusRank(target: TargetStatus): number {
|
||||
if (!target.latestCheck) return 2;
|
||||
return target.latestCheck.matched ? STATUS_ORDER["up"]! : STATUS_ORDER["down"]!;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const TARGET_TYPE_DISPLAY = {
|
||||
http: "HTTP",
|
||||
command: "CMD",
|
||||
http: "HTTP",
|
||||
} as const;
|
||||
|
||||
export type TargetType = keyof typeof TARGET_TYPE_DISPLAY;
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { HistoryResponse, SummaryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
|
||||
import { subtractHours } from "../utils/time";
|
||||
|
||||
const queryKeys = {
|
||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||
summary: () => ["summary"] as const,
|
||||
targets: () => ["targets"] as const,
|
||||
trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const,
|
||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||
};
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function useSummary() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.summary(),
|
||||
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.targets(),
|
||||
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
||||
queryKey: queryKeys.summary(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
@@ -36,7 +23,7 @@ export function useTargets() {
|
||||
|
||||
export function useTargetDetail() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
|
||||
const [timeFrom, setTimeFrom] = useState("");
|
||||
const [timeTo, setTimeTo] = useState("");
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
@@ -47,27 +34,27 @@ export function useTargetDetail() {
|
||||
selectedTargetId !== null ? (targetsData?.find((t) => t.id === selectedTargetId) ?? null) : null;
|
||||
|
||||
const trend = useQuery({
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
||||
: ["trend", "disabled"],
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
queryFn: () =>
|
||||
fetchJson<TrendPoint[]>(
|
||||
`/api/targets/${selectedTargetId}/trend?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}`,
|
||||
),
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
||||
: ["trend", "disabled"],
|
||||
});
|
||||
|
||||
const history = useQuery({
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
||||
: ["history", "disabled"],
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
queryFn: () =>
|
||||
fetchJson<HistoryResponse>(
|
||||
`/api/targets/${selectedTargetId}/history?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}&page=${historyPage}&pageSize=20`,
|
||||
),
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
||||
: ["history", "disabled"],
|
||||
});
|
||||
|
||||
const openDrawer = useCallback((target: TargetStatus) => {
|
||||
@@ -96,16 +83,31 @@ export function useTargetDetail() {
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedTarget,
|
||||
trendData: trend.data ?? [],
|
||||
trendLoading: trend.isLoading,
|
||||
historyData: history.data ?? { items: [], total: 0, page: 1, pageSize: 20 },
|
||||
closeDrawer,
|
||||
handlePageChange,
|
||||
handleTimeChange,
|
||||
historyData: history.data ?? { items: [], page: 1, pageSize: 20, total: 0 },
|
||||
historyLoading: history.isLoading,
|
||||
openDrawer,
|
||||
selectedTarget,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
trendData: trend.data ?? [],
|
||||
trendLoading: trend.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
||||
queryKey: queryKeys.targets(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./app";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
staleTime: 5000,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
:root {
|
||||
--avail-0: #d54941;
|
||||
--avail-1: #d96241;
|
||||
--avail-2: #e37318;
|
||||
--avail-3: #e89318;
|
||||
--avail-4: #d9a818;
|
||||
--avail-5: #b8b020;
|
||||
--avail-6: #8dba30;
|
||||
--avail-7: #6dba3f;
|
||||
--avail-8: #4dba50;
|
||||
--avail-9: #3dba60;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
padding: 32px 24px;
|
||||
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: var(--td-comp-margin-l);
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
.dashboard-header .t-typography {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -27,6 +33,16 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot--up {
|
||||
background: var(--td-success-color);
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--td-success-color) 14%, transparent);
|
||||
}
|
||||
|
||||
.status-dot--down {
|
||||
background: var(--td-error-color);
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--td-error-color) 14%, transparent);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
@@ -41,10 +57,25 @@
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.status-bar-block--up {
|
||||
background: var(--td-success-color);
|
||||
}
|
||||
|
||||
.status-bar-block--down {
|
||||
background: var(--td-error-color);
|
||||
}
|
||||
|
||||
.status-bar-block--empty {
|
||||
background: var(--td-bg-color-component-disabled);
|
||||
}
|
||||
|
||||
.status-donut {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.donut-center-label {
|
||||
@@ -68,6 +99,60 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.row-down {
|
||||
background: color-mix(in srgb, var(--td-error-color) 6%, transparent) !important;
|
||||
.tab-panel-padded {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.t-table tr.row-down {
|
||||
background: color-mix(in srgb, var(--td-error-color) 6%, transparent);
|
||||
}
|
||||
|
||||
.t-table--hoverable tbody tr.row-down:hover {
|
||||
background: color-mix(in srgb, var(--td-error-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: var(--td-text-color-disabled);
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.latency-ok {
|
||||
color: var(--td-success-color);
|
||||
}
|
||||
|
||||
.latency-warn {
|
||||
color: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.latency-error {
|
||||
color: var(--td-error-color);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clickable-table {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
margin-bottom: var(--td-comp-margin-m);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-header .t-typography {
|
||||
margin: 0;
|
||||
font-size: var(--td-font-size-title-medium);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.summary-cards-row {
|
||||
margin-bottom: var(--td-comp-margin-xl);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
|
||||
import { ProbeStore } from "../../src/server/checker/store";
|
||||
import type { HistoryResponse, SummaryResponse, TargetStatus, HealthResponse } from "../../src/shared/api";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { HealthResponse, HistoryResponse, SummaryResponse, TargetStatus } from "../../src/shared/api";
|
||||
|
||||
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
|
||||
import { checkerRegistry } from "../../src/server/checker/runner";
|
||||
import { CommandChecker } from "../../src/server/checker/runner/command/runner";
|
||||
import { HttpChecker } from "../../src/server/checker/runner/http/runner";
|
||||
import { ProbeStore } from "../../src/server/checker/store";
|
||||
|
||||
function ensureRegistered() {
|
||||
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||
checkerRegistry.register(new HttpChecker());
|
||||
checkerRegistry.register(new CommandChecker());
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
ensureRegistered();
|
||||
});
|
||||
|
||||
const staticAssets: StaticAssets = {
|
||||
indexHtml: new Blob(['<!doctype html><title>DiAL</title><div id="root"></div>'], {
|
||||
type: "text/html",
|
||||
}),
|
||||
files: {
|
||||
"/assets/app.js": new Blob(["console.log('app');"], { type: "text/javascript" }),
|
||||
},
|
||||
indexHtml: new Blob(['<!doctype html><title>DiAL</title><div id="root"></div>'], {
|
||||
type: "text/html",
|
||||
}),
|
||||
};
|
||||
|
||||
describe("API 路由", () => {
|
||||
@@ -26,57 +42,57 @@ describe("API 路由", () => {
|
||||
store = new ProbeStore(join(tempDir, "test.db"));
|
||||
store.syncTargets([
|
||||
{
|
||||
type: "http",
|
||||
name: "test-a",
|
||||
group: "default",
|
||||
http: {
|
||||
url: "http://a.com",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
maxBodyBytes: 104857600,
|
||||
method: "GET",
|
||||
url: "http://a.com",
|
||||
},
|
||||
intervalMs: 30000,
|
||||
name: "test-a",
|
||||
timeoutMs: 10000,
|
||||
type: "http",
|
||||
},
|
||||
{
|
||||
type: "command",
|
||||
name: "test-b",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
exec: "echo",
|
||||
maxOutputBytes: 104857600,
|
||||
},
|
||||
group: "default",
|
||||
intervalMs: 60000,
|
||||
name: "test-b",
|
||||
timeoutMs: 5000,
|
||||
type: "command",
|
||||
},
|
||||
]);
|
||||
|
||||
const targets = store.getTargets();
|
||||
store.insertCheckResult({
|
||||
durationMs: 150,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
matched: true,
|
||||
durationMs: 150,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
store.insertCheckResult({
|
||||
durationMs: null,
|
||||
failure: {
|
||||
actual: 500,
|
||||
expected: 200,
|
||||
kind: "error",
|
||||
message: "状态码不匹配",
|
||||
path: "$.status",
|
||||
phase: "status",
|
||||
},
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure: {
|
||||
kind: "error",
|
||||
phase: "status",
|
||||
path: "$.status",
|
||||
expected: 200,
|
||||
actual: 500,
|
||||
message: "状态码不匹配",
|
||||
},
|
||||
});
|
||||
|
||||
fetchHandler = createFetchHandler({ mode: "test", staticAssets, store });
|
||||
@@ -84,11 +100,11 @@ describe("API 路由", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
store.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
test("/health 返回健康检查", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/health"));
|
||||
const response = fetchHandler(new Request("http://localhost/health"));
|
||||
const body = (await response.json()) as HealthResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -97,7 +113,7 @@ describe("API 路由", () => {
|
||||
});
|
||||
|
||||
test("/api/summary 返回总览统计", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/summary"));
|
||||
const response = fetchHandler(new Request("http://localhost/api/summary"));
|
||||
const body = (await response.json()) as SummaryResponse;
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.total).toBe(2);
|
||||
@@ -108,7 +124,7 @@ describe("API 路由", () => {
|
||||
});
|
||||
|
||||
test("/api/targets 返回目标列表", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/targets"));
|
||||
const response = fetchHandler(new Request("http://localhost/api/targets"));
|
||||
const body = (await response.json()) as TargetStatus[];
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -136,7 +152,7 @@ describe("API 路由", () => {
|
||||
const targets = store.getTargets();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
const response = fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}`),
|
||||
);
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
@@ -154,7 +170,7 @@ describe("API 路由", () => {
|
||||
const targets = store.getTargets();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
const response = fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=1`),
|
||||
);
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
@@ -168,90 +184,92 @@ describe("API 路由", () => {
|
||||
const targets = store.getTargets();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
const response = fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/trend?from=${from}&to=${to}`),
|
||||
);
|
||||
const body = await response.json();
|
||||
const body = (await response.json()) as unknown[];
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
});
|
||||
|
||||
test("查询不存在的目标返回 404", async () => {
|
||||
const response = await fetchHandler(
|
||||
const response = fetchHandler(
|
||||
new Request(
|
||||
"http://localhost/api/targets/99999/history?from=2024-01-01T00:00:00.000Z&to=2026-12-31T23:59:59.999Z",
|
||||
),
|
||||
);
|
||||
const body = await response.json();
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe("Target not found");
|
||||
expect(body["error"]).toBe("Target not found");
|
||||
});
|
||||
|
||||
test("history 缺少 from/to 参数返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`));
|
||||
const body = await response.json();
|
||||
const response = fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`));
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain("from and to");
|
||||
expect(body["error"]).toContain("from and to");
|
||||
});
|
||||
|
||||
test("trend 缺少 from/to 参数返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`));
|
||||
const body = await response.json();
|
||||
const response = fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`));
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain("from and to");
|
||||
expect(body["error"]).toContain("from and to");
|
||||
});
|
||||
|
||||
test("无效目标 ID 返回 400", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/targets/abc/history"));
|
||||
const body = await response.json();
|
||||
test("trend 无效 targetId 返回 400", async () => {
|
||||
const response = fetchHandler(
|
||||
new Request("http://localhost/api/targets/invalid/trend?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z"),
|
||||
);
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe("Invalid target ID");
|
||||
expect(body["error"]).toBe("Invalid target ID");
|
||||
});
|
||||
|
||||
test("未知 /api/* 返回 404", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/missing"));
|
||||
test("未知 /api/* 返回 404", () => {
|
||||
const response = fetchHandler(new Request("http://localhost/api/missing"));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("HEAD 请求返回 headers 无 body", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "HEAD" }));
|
||||
const response = fetchHandler(new Request("http://localhost/api/summary", { method: "HEAD" }));
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toBe("");
|
||||
});
|
||||
|
||||
test("不支持的 method 返回 405", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "POST" }));
|
||||
test("不支持的 method 返回 405", () => {
|
||||
const response = fetchHandler(new Request("http://localhost/api/summary", { method: "POST" }));
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
});
|
||||
|
||||
test("生产响应包含安全 headers", async () => {
|
||||
test("生产响应包含安全 headers", () => {
|
||||
const prodHandler = createFetchHandler({ mode: "production", staticAssets, store });
|
||||
const response = await prodHandler(new Request("http://localhost/api/summary"));
|
||||
const response = prodHandler(new Request("http://localhost/api/summary"));
|
||||
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
||||
});
|
||||
|
||||
test("静态资源和 SPA fallback 正常工作", async () => {
|
||||
const root = await fetchHandler(new Request("http://localhost/"));
|
||||
test("静态资源和 SPA fallback 正常工作", () => {
|
||||
const root = fetchHandler(new Request("http://localhost/"));
|
||||
expect(root.status).toBe(200);
|
||||
|
||||
const fallback = await fetchHandler(new Request("http://localhost/dashboard"));
|
||||
const fallback = fetchHandler(new Request("http://localhost/dashboard"));
|
||||
expect(fallback.status).toBe(200);
|
||||
|
||||
const asset = await fetchHandler(new Request("http://localhost/assets/app.js"));
|
||||
const asset = fetchHandler(new Request("http://localhost/assets/app.js"));
|
||||
expect(asset.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -260,12 +278,12 @@ describe("API 路由", () => {
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
store.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: { kind: "error", message: "test", path: "$", phase: "body" },
|
||||
matched: false,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-06-01T00:00:00.000Z",
|
||||
matched: false,
|
||||
durationMs: 100,
|
||||
statusDetail: "200 OK",
|
||||
failure: { kind: "error", phase: "body", path: "$", message: "test" },
|
||||
});
|
||||
|
||||
(store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db
|
||||
@@ -274,9 +292,7 @@ describe("API 路由", () => {
|
||||
|
||||
const from = "2025-06-01T00:00:00.000Z";
|
||||
const to = "2025-06-01T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${t1Id}/history?from=${from}&to=${to}`),
|
||||
);
|
||||
const response = fetchHandler(new Request(`http://localhost/api/targets/${t1Id}/history?from=${from}&to=${to}`));
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { beforeAll, afterAll, describe, expect, test } from "bun:test";
|
||||
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
|
||||
import { readRuntimeConfig } from "../../../src/server/config";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
|
||||
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
|
||||
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||
import { readRuntimeConfig } from "../../../src/server/config";
|
||||
|
||||
function ensureRegistered() {
|
||||
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||
checkerRegistry.register(new HttpChecker());
|
||||
checkerRegistry.register(new CommandChecker());
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
ensureRegistered();
|
||||
});
|
||||
|
||||
describe("parseDuration", () => {
|
||||
test("解析秒", () => {
|
||||
@@ -52,7 +67,7 @@ describe("loadConfig", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
test("解析最简 HTTP 配置", async () => {
|
||||
@@ -111,7 +126,7 @@ describe("loadConfig", () => {
|
||||
expect(t.command.args).toEqual(["nginx"]);
|
||||
expect(t.command.cwd).toBe(subdir);
|
||||
expect(t.command.maxOutputBytes).toBe(104857600);
|
||||
expect(t.command.env.PATH).toBeDefined();
|
||||
expect(t.command.env["PATH"]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -216,6 +231,7 @@ targets:
|
||||
});
|
||||
|
||||
test("配置文件不存在抛出错误", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在");
|
||||
});
|
||||
|
||||
@@ -229,6 +245,7 @@ targets:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段");
|
||||
});
|
||||
|
||||
@@ -242,6 +259,7 @@ targets:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 type 字段");
|
||||
});
|
||||
|
||||
@@ -255,6 +273,7 @@ targets:
|
||||
http: {}
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
|
||||
});
|
||||
|
||||
@@ -268,6 +287,7 @@ targets:
|
||||
command: {}
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 command.exec 字段");
|
||||
});
|
||||
|
||||
@@ -280,6 +300,7 @@ targets:
|
||||
type: dns
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
|
||||
});
|
||||
|
||||
@@ -298,12 +319,14 @@ targets:
|
||||
url: "http://b.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
|
||||
});
|
||||
|
||||
test("targets 为空数组抛出错误", async () => {
|
||||
const configPath = join(tempDir, "empty-targets.yaml");
|
||||
await writeFile(configPath, `targets: []`);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target");
|
||||
});
|
||||
|
||||
@@ -320,6 +343,7 @@ targets:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
|
||||
});
|
||||
|
||||
@@ -336,6 +360,7 @@ targets:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("maxConcurrentChecks 必须为正整数");
|
||||
});
|
||||
|
||||
@@ -353,6 +378,7 @@ targets:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效的 size 格式");
|
||||
});
|
||||
|
||||
@@ -368,6 +394,7 @@ targets:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效的时长格式");
|
||||
});
|
||||
|
||||
@@ -395,9 +422,9 @@ targets:
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "http") {
|
||||
expect(t.expect).toEqual({
|
||||
status: [200, 201],
|
||||
body: [{ contains: "ok" }, { json: { path: "$.status", equals: "ok" } }],
|
||||
body: [{ contains: "ok" }, { json: { equals: "ok", path: "$.status" } }],
|
||||
maxDurationMs: 3000,
|
||||
status: [200, 201],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -427,9 +454,9 @@ targets:
|
||||
if (t.type === "command") {
|
||||
expect(t.expect).toEqual({
|
||||
exitCode: [0, 2],
|
||||
stdout: [{ contains: "ok" }, { match: "done" }],
|
||||
stderr: [{ empty: true }],
|
||||
maxDurationMs: 5000,
|
||||
stderr: [{ empty: true }],
|
||||
stdout: [{ contains: "ok" }, { match: "done" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -474,9 +501,9 @@ targets:
|
||||
const config = await loadConfig(configPath);
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "command") {
|
||||
expect(t.command.env.LANG).toBe("C");
|
||||
expect(t.command.env.CUSTOM_VAR).toBe("test");
|
||||
expect(t.command.env.PATH).toBeDefined();
|
||||
expect(t.command.env["LANG"]).toBe("C");
|
||||
expect(t.command.env["CUSTOM_VAR"]).toBe("test");
|
||||
expect(t.command.env["PATH"]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -526,6 +553,7 @@ targets:
|
||||
`,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ProbeEngine } from "../../../src/server/checker/engine";
|
||||
|
||||
import type { ProbeStore } from "../../../src/server/checker/store";
|
||||
import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types";
|
||||
|
||||
import { ProbeEngine } from "../../../src/server/checker/engine";
|
||||
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
|
||||
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||
|
||||
function createMockStore(targetNames: string[]) {
|
||||
let nextId = 1;
|
||||
const targets = targetNames.map((name) => ({ id: nextId++, name }));
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
|
||||
return {
|
||||
_results: results,
|
||||
getTargets() {
|
||||
return targets.map(({ id, name }) => ({
|
||||
id,
|
||||
name,
|
||||
type: "command" as const,
|
||||
target: "",
|
||||
config: "",
|
||||
interval_ms: 60000,
|
||||
timeout_ms: 5000,
|
||||
expect: null,
|
||||
grp: "default",
|
||||
id,
|
||||
interval_ms: 60000,
|
||||
name,
|
||||
target: "",
|
||||
timeout_ms: 5000,
|
||||
type: "command" as const,
|
||||
}));
|
||||
},
|
||||
insertCheckResult(result: Record<string, unknown>) {
|
||||
results.push(result);
|
||||
},
|
||||
_results: results,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureRegistered() {
|
||||
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||
checkerRegistry.register(new HttpChecker());
|
||||
checkerRegistry.register(new CommandChecker());
|
||||
}
|
||||
}
|
||||
|
||||
function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarget>): ResolvedCommandTarget {
|
||||
return {
|
||||
type: "command",
|
||||
name,
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
exec: "echo",
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
},
|
||||
group: "default",
|
||||
intervalMs: 60000,
|
||||
name,
|
||||
timeoutMs: 5000,
|
||||
type: "command",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProbeEngine", () => {
|
||||
test("start/stop 不抛错", () => {
|
||||
ensureRegistered();
|
||||
const mockStore = createMockStore(["test"]) as unknown as ProbeStore;
|
||||
const targets: ResolvedTarget[] = [makeCommandTarget("test")];
|
||||
const engine = new ProbeEngine(mockStore, targets);
|
||||
@@ -69,16 +82,16 @@ describe("ProbeEngine", () => {
|
||||
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!.matched).toBe(true);
|
||||
expect(results[0]!.statusDetail).toBe("exitCode=0");
|
||||
expect(results[0]!["matched"]).toBe(true);
|
||||
expect(results[0]!["statusDetail"]).toBe("exitCode=0");
|
||||
});
|
||||
|
||||
test("多个目标并发执行", async () => {
|
||||
const targetA = makeCommandTarget("echo-a", {
|
||||
command: { exec: "echo", args: ["a"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
command: { args: ["a"], cwd: "/tmp", env: {}, exec: "echo", maxOutputBytes: 1024 * 1024 },
|
||||
});
|
||||
const targetB = makeCommandTarget("echo-b", {
|
||||
command: { exec: "echo", args: ["b"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
command: { args: ["b"], cwd: "/tmp", env: {}, exec: "echo", maxOutputBytes: 1024 * 1024 },
|
||||
});
|
||||
|
||||
const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore;
|
||||
@@ -95,7 +108,7 @@ describe("ProbeEngine", () => {
|
||||
|
||||
test("失败目标不阻塞其他目标", async () => {
|
||||
const badTarget = makeCommandTarget("bad-cmd", {
|
||||
command: { exec: "false", args: [], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
command: { args: [], cwd: "/tmp", env: {}, exec: "false", maxOutputBytes: 1024 * 1024 },
|
||||
});
|
||||
const goodTarget = makeCommandTarget("good-cmd");
|
||||
|
||||
@@ -110,8 +123,8 @@ describe("ProbeEngine", () => {
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(2);
|
||||
|
||||
const badResult = results.find((r) => r.matched === false);
|
||||
const goodResult = results.find((r) => r.matched === true);
|
||||
const badResult = results.find((r) => r["matched"] === false);
|
||||
const goodResult = results.find((r) => r["matched"] === true);
|
||||
expect(badResult).toBeDefined();
|
||||
expect(goodResult).toBeDefined();
|
||||
});
|
||||
@@ -119,7 +132,7 @@ describe("ProbeEngine", () => {
|
||||
test("并发限制 maxConcurrentChecks", async () => {
|
||||
const targets = Array.from({ length: 5 }, (_, i) =>
|
||||
makeCommandTarget(`cmd-${i}`, {
|
||||
command: { exec: "echo", args: [String(i)], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
command: { args: [String(i)], cwd: "/tmp", env: {}, exec: "echo", maxOutputBytes: 1024 * 1024 },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -134,7 +147,7 @@ describe("ProbeEngine", () => {
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(5);
|
||||
for (const r of results) {
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r["matched"]).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -166,25 +179,25 @@ describe("ProbeEngine", () => {
|
||||
|
||||
test("HTTP 目标运行", async () => {
|
||||
const httpServer = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response("ok");
|
||||
},
|
||||
port: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const httpTarget: ResolvedHttpTarget = {
|
||||
type: "http",
|
||||
name: "http-test",
|
||||
group: "default",
|
||||
http: {
|
||||
url: `http://localhost:${httpServer.port}/`,
|
||||
method: "GET",
|
||||
headers: {},
|
||||
maxBodyBytes: 1024 * 1024,
|
||||
method: "GET",
|
||||
url: `http://localhost:${httpServer.port}/`,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
name: "http-test",
|
||||
timeoutMs: 5000,
|
||||
type: "http",
|
||||
};
|
||||
|
||||
const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore;
|
||||
@@ -197,10 +210,10 @@ describe("ProbeEngine", () => {
|
||||
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!.matched).toBe(true);
|
||||
expect(results[0]!.statusDetail).toBe("HTTP 200");
|
||||
expect(results[0]!["matched"]).toBe(true);
|
||||
expect(results[0]!["statusDetail"]).toBe("HTTP 200");
|
||||
} finally {
|
||||
httpServer.stop();
|
||||
void httpServer.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
applyOperator,
|
||||
checkBodyExpect,
|
||||
checkExpectValue,
|
||||
evaluateJsonPath,
|
||||
} from "../../../../src/server/checker/expect/body";
|
||||
|
||||
describe("evaluateJsonPath", () => {
|
||||
const obj = {
|
||||
status: "ok",
|
||||
code: 0,
|
||||
active: true,
|
||||
error: null,
|
||||
data: {
|
||||
count: 42,
|
||||
items: [{ name: "a" }, { name: "b" }],
|
||||
nested: { deep: "value" },
|
||||
},
|
||||
emptyObj: {},
|
||||
emptyArr: [],
|
||||
};
|
||||
|
||||
test("简单字段访问", () => {
|
||||
expect(evaluateJsonPath(obj, "$.status")).toBe("ok");
|
||||
expect(evaluateJsonPath(obj, "$.code")).toBe(0);
|
||||
expect(evaluateJsonPath(obj, "$.active")).toBe(true);
|
||||
expect(evaluateJsonPath(obj, "$.error")).toBeNull();
|
||||
});
|
||||
|
||||
test("嵌套对象访问", () => {
|
||||
expect(evaluateJsonPath(obj, "$.data.count")).toBe(42);
|
||||
expect(evaluateJsonPath(obj, "$.data.nested.deep")).toBe("value");
|
||||
});
|
||||
|
||||
test("数组索引访问", () => {
|
||||
expect(evaluateJsonPath(obj, "$.data.items[0].name")).toBe("a");
|
||||
expect(evaluateJsonPath(obj, "$.data.items[1].name")).toBe("b");
|
||||
});
|
||||
|
||||
test("路径不存在返回 undefined", () => {
|
||||
expect(evaluateJsonPath(obj, "$.notExist")).toBeUndefined();
|
||||
expect(evaluateJsonPath(obj, "$.data.notExist")).toBeUndefined();
|
||||
expect(evaluateJsonPath(obj, "$.data.items[99]")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("空对象和空数组", () => {
|
||||
expect(evaluateJsonPath(obj, "$.emptyObj")).toEqual({});
|
||||
expect(evaluateJsonPath(obj, "$.emptyArr")).toEqual([]);
|
||||
});
|
||||
|
||||
test("非 $ 开头路径返回 undefined", () => {
|
||||
expect(evaluateJsonPath(obj, "status")).toBeUndefined();
|
||||
expect(evaluateJsonPath(obj, ".status")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("null 对象上访问", () => {
|
||||
expect(evaluateJsonPath(null, "$.any")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOperator", () => {
|
||||
test("equals 操作符", () => {
|
||||
expect(applyOperator("ok", { equals: "ok" })).toBe(true);
|
||||
expect(applyOperator("ok", { equals: "error" })).toBe(false);
|
||||
expect(applyOperator(42, { equals: 42 })).toBe(true);
|
||||
expect(applyOperator(42, { equals: 41 })).toBe(false);
|
||||
expect(applyOperator(null, { equals: null })).toBe(true);
|
||||
expect(applyOperator(true, { equals: true })).toBe(true);
|
||||
});
|
||||
|
||||
test("contains 操作符", () => {
|
||||
expect(applyOperator("hello world", { contains: "hello" })).toBe(true);
|
||||
expect(applyOperator("hello world", { contains: "missing" })).toBe(false);
|
||||
expect(applyOperator(12345, { contains: "23" })).toBe(true);
|
||||
});
|
||||
|
||||
test("match 操作符", () => {
|
||||
expect(applyOperator("v2.1.0", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(true);
|
||||
expect(applyOperator("v2.1", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(false);
|
||||
expect(applyOperator("abc123", { match: "^\\w+\\d+$" })).toBe(true);
|
||||
});
|
||||
|
||||
test("empty 操作符", () => {
|
||||
expect(applyOperator("", { empty: true })).toBe(true);
|
||||
expect(applyOperator(null, { empty: true })).toBe(true);
|
||||
expect(applyOperator(undefined, { empty: true })).toBe(true);
|
||||
expect(applyOperator([], { empty: true })).toBe(true);
|
||||
expect(applyOperator({}, { empty: true })).toBe(true);
|
||||
expect(applyOperator("ok", { empty: true })).toBe(false);
|
||||
expect(applyOperator([1, 2], { empty: false })).toBe(true);
|
||||
expect(applyOperator([], { empty: false })).toBe(false);
|
||||
});
|
||||
|
||||
test("exists 操作符", () => {
|
||||
expect(applyOperator("ok", { exists: true })).toBe(true);
|
||||
expect(applyOperator(null, { exists: true })).toBe(true);
|
||||
expect(applyOperator(undefined, { exists: true })).toBe(false);
|
||||
expect(applyOperator(undefined, { exists: false })).toBe(true);
|
||||
expect(applyOperator("ok", { exists: false })).toBe(false);
|
||||
});
|
||||
|
||||
test("gte 操作符", () => {
|
||||
expect(applyOperator(10, { gte: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { gte: 5 })).toBe(true);
|
||||
expect(applyOperator(3, { gte: 5 })).toBe(false);
|
||||
expect(applyOperator("10", { gte: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
test("lte 操作符", () => {
|
||||
expect(applyOperator(3, { lte: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { lte: 5 })).toBe(true);
|
||||
expect(applyOperator(10, { lte: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("gt 操作符", () => {
|
||||
expect(applyOperator(10, { gt: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { gt: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("lt 操作符", () => {
|
||||
expect(applyOperator(3, { lt: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { lt: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("多操作符 AND 组合", () => {
|
||||
expect(applyOperator(7, { gte: 5, lte: 10 })).toBe(true);
|
||||
expect(applyOperator(3, { gte: 5, lte: 10 })).toBe(false);
|
||||
expect(applyOperator(15, { gte: 5, lte: 10 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkExpectValue", () => {
|
||||
test("原始值直接比较", () => {
|
||||
expect(checkExpectValue("ok", "ok")).toBe(true);
|
||||
expect(checkExpectValue("ok", "error")).toBe(false);
|
||||
expect(checkExpectValue(42, 42)).toBe(true);
|
||||
expect(checkExpectValue(null, null)).toBe(true);
|
||||
});
|
||||
|
||||
test("对象作为操作符", () => {
|
||||
expect(checkExpectValue(42, { gte: 10 })).toBe(true);
|
||||
expect(checkExpectValue(42, { gte: 100 })).toBe(false);
|
||||
expect(checkExpectValue("hello", { contains: "ell" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkBodyExpect (BodyRule[])", () => {
|
||||
test("无规则返回匹配成功", () => {
|
||||
const r = checkBodyExpect("anything");
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("空规则数组返回匹配成功", () => {
|
||||
const r = checkBodyExpect("anything", []);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("contains 规则匹配成功", () => {
|
||||
const r = checkBodyExpect("hello world", [{ contains: "hello" }]);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("contains 规则匹配失败", () => {
|
||||
const r = checkBodyExpect("hello world", [{ contains: "missing" }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure).not.toBeNull();
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
expect(r.failure!.phase).toBe("body");
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("regex 规则匹配成功", () => {
|
||||
const r = checkBodyExpect("status: ok", [{ regex: "ok" }]);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("regex 规则匹配失败", () => {
|
||||
const r = checkBodyExpect("status: error", [{ regex: "^ok$" }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("json 等值匹配成功", () => {
|
||||
const body = JSON.stringify({ status: "ok", code: 0 });
|
||||
const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "ok" } }]);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("json 等值匹配失败", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "error" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
});
|
||||
|
||||
test("json 操作符匹配", () => {
|
||||
const body = JSON.stringify({ count: 42, version: "v2.1.0" });
|
||||
expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 10 } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(body, [{ json: { path: "$.version", match: "\\d+\\.\\d+\\.\\d+" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 100 } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("json 路径不存在", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ json: { path: "$.notExist", equals: "value" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("json 解析失败", () => {
|
||||
const r = checkBodyExpect("not json", [{ json: { path: "$.status", equals: "ok" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("error");
|
||||
});
|
||||
|
||||
test("css 文本内容匹配", () => {
|
||||
const html = "<div id='health'>OK</div><span class='ver'>1.0</span>";
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "OK" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "span.ver", equals: "1.0" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "ERROR" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("css 选择器无匹配元素", () => {
|
||||
const html = "<div>OK</div>";
|
||||
const r = checkBodyExpect(html, [{ css: { selector: "span.missing", equals: "OK" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("css attr 提取", () => {
|
||||
const html = '<meta name="version" content="2.0.1">';
|
||||
expect(
|
||||
checkBodyExpect(html, [{ css: { selector: 'meta[name="version"]', attr: "content", equals: "2.0.1" } }]).matched,
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkBodyExpect(html, [
|
||||
{ css: { selector: 'meta[name="version"]', attr: "content", match: "\\d+\\.\\d+\\.\\d+" } },
|
||||
]).matched,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("css exists 检查", () => {
|
||||
const html = "<div id='test'>OK</div>";
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: true } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "span#missing", exists: false } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: false } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 节点文本匹配", () => {
|
||||
const xml = "<root><status>ok</status><code>200</code></root>";
|
||||
expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "ok" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "error" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 无匹配节点", () => {
|
||||
const xml = "<root><status>ok</status></root>";
|
||||
const r = checkBodyExpect(xml, [{ xpath: { path: "/root/missing/text()", equals: "ok" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("规则数组按顺序检查,第一条失败立即返回", () => {
|
||||
const body = JSON.stringify({ status: "error" });
|
||||
const r = checkBodyExpect(body, [{ contains: "healthy" }, { json: { path: "$.status", equals: "error" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("多条规则全部通过", () => {
|
||||
const body = JSON.stringify({ status: "healthy", count: 5 });
|
||||
const r = checkBodyExpect(body, [
|
||||
{ contains: "healthy" },
|
||||
{ json: { path: "$.status", equals: "healthy" } },
|
||||
{ json: { path: "$.count", gte: 1 } },
|
||||
]);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("第二条规则失败返回正确索引", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { path: "$.status", equals: "error" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toContain("body[1]");
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { checkCommandExpect } from "../../../../src/server/checker/expect/command";
|
||||
import type { CommandObservation } from "../../../../src/server/checker/expect/command";
|
||||
import type { CommandExpectConfig } from "../../../../src/server/checker/types";
|
||||
|
||||
function obs(overrides: Partial<CommandObservation> = {}): CommandObservation {
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
durationMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkCommandExpect", () => {
|
||||
test("无 expect 配置时默认检查 exitCode [0] 匹配成功", () => {
|
||||
const r = checkCommandExpect(obs());
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("无 expect 配置时 exitCode 非 0 匹配失败", () => {
|
||||
const r = checkCommandExpect(obs({ exitCode: 1 }));
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure).not.toBeNull();
|
||||
expect(r.failure!.phase).toBe("exitCode");
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
});
|
||||
|
||||
test("exitCode 匹配指定退出码", () => {
|
||||
const cfg: CommandExpectConfig = { exitCode: [0, 1] };
|
||||
expect(checkCommandExpect(obs({ exitCode: 0 }), cfg).matched).toBe(true);
|
||||
expect(checkCommandExpect(obs({ exitCode: 1 }), cfg).matched).toBe(true);
|
||||
expect(checkCommandExpect(obs({ exitCode: 2 }), cfg).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("exitCode 不匹配返回 phase=exitCode 的失败", () => {
|
||||
const r = checkCommandExpect(obs({ exitCode: 2 }), { exitCode: [0] });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("exitCode");
|
||||
expect(r.failure!.expected).toEqual([0]);
|
||||
expect(r.failure!.actual).toBe(2);
|
||||
});
|
||||
|
||||
test("duration 在限制内匹配成功", () => {
|
||||
const r = checkCommandExpect(obs({ durationMs: 50 }), { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("duration 超过限制匹配失败", () => {
|
||||
const r = checkCommandExpect(obs({ durationMs: 200 }), { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("stdout TextRule 数组匹配", () => {
|
||||
const o = obs({ stdout: "build completed successfully" });
|
||||
expect(checkCommandExpect(o, { stdout: [{ contains: "completed" }] }).matched).toBe(true);
|
||||
expect(checkCommandExpect(o, { stdout: [{ contains: "failed" }] }).matched).toBe(false);
|
||||
expect(checkCommandExpect(o, { stdout: [{ match: "completed.*successfully$" }] }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("stdout 多条规则全部通过", () => {
|
||||
const o = obs({ stdout: "version: 3.2.1, build: ok" });
|
||||
const r = checkCommandExpect(o, {
|
||||
stdout: [{ contains: "version" }, { match: "\\d+\\.\\d+\\.\\d+" }],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("stdout 第一条规则失败立即返回", () => {
|
||||
const o = obs({ stdout: "error occurred" });
|
||||
const r = checkCommandExpect(o, {
|
||||
stdout: [{ contains: "success" }, { contains: "error" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stdout");
|
||||
expect(r.failure!.path).toBe("stdout[0]");
|
||||
});
|
||||
|
||||
test("stderr TextRule 数组匹配", () => {
|
||||
const o = obs({ stderr: "warning: deprecated" });
|
||||
expect(checkCommandExpect(o, { stderr: [{ contains: "warning" }] }).matched).toBe(true);
|
||||
expect(checkCommandExpect(o, { stderr: [{ contains: "error" }] }).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("stdout 失败阻止 stderr 检查", () => {
|
||||
const o = obs({ stdout: "bad output", stderr: "warning message" });
|
||||
const r = checkCommandExpect(o, {
|
||||
exitCode: [0],
|
||||
stdout: [{ contains: "success" }],
|
||||
stderr: [{ contains: "warning" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stdout");
|
||||
});
|
||||
|
||||
test("stdout 通过但 stderr 失败", () => {
|
||||
const o = obs({ stdout: "ok", stderr: "fatal error" });
|
||||
const r = checkCommandExpect(o, {
|
||||
stdout: [{ contains: "ok" }],
|
||||
stderr: [{ equals: "clean" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stderr");
|
||||
});
|
||||
|
||||
test("完整流水线 exitCode->duration->stdout->stderr 全部通过", () => {
|
||||
const o = obs({
|
||||
exitCode: 0,
|
||||
durationMs: 50,
|
||||
stdout: "build success",
|
||||
stderr: "",
|
||||
});
|
||||
const r = checkCommandExpect(o, {
|
||||
exitCode: [0],
|
||||
maxDurationMs: 100,
|
||||
stdout: [{ contains: "success" }],
|
||||
stderr: [{ empty: true }],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("完整流水线 exitCode 通过但 duration 失败", () => {
|
||||
const o = obs({ exitCode: 0, durationMs: 500 });
|
||||
const r = checkCommandExpect(o, {
|
||||
exitCode: [0],
|
||||
maxDurationMs: 100,
|
||||
stdout: [{ contains: "ok" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("完整流水线 exitCode/duration 通过但 stdout 失败", () => {
|
||||
const o = obs({ exitCode: 0, durationMs: 50, stdout: "error" });
|
||||
const r = checkCommandExpect(o, {
|
||||
exitCode: [0],
|
||||
maxDurationMs: 100,
|
||||
stdout: [{ contains: "success" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stdout");
|
||||
});
|
||||
|
||||
test("完整流水线 exitCode/duration/stdout 通过但 stderr 失败", () => {
|
||||
const o = obs({ exitCode: 0, durationMs: 50, stdout: "ok", stderr: "warning" });
|
||||
const r = checkCommandExpect(o, {
|
||||
exitCode: [0],
|
||||
maxDurationMs: 100,
|
||||
stdout: [{ contains: "ok" }],
|
||||
stderr: [{ empty: true }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stderr");
|
||||
});
|
||||
|
||||
test("stdout 操作符组合", () => {
|
||||
const o = obs({ stdout: "count: 42" });
|
||||
expect(
|
||||
checkCommandExpect(o, {
|
||||
stdout: [{ contains: "count" }, { match: "\\d+" }],
|
||||
}).matched,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { checkHttpExpect } from "../../../../src/server/checker/expect/http";
|
||||
import type { HttpObservation } from "../../../../src/server/checker/expect/http";
|
||||
import type { HttpExpectConfig } from "../../../../src/server/checker/types";
|
||||
|
||||
function obs(overrides: Partial<HttpObservation> = {}): HttpObservation {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: "",
|
||||
durationMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkHttpExpect", () => {
|
||||
test("无 expect 配置时默认检查 status [200] 匹配成功", () => {
|
||||
const r = checkHttpExpect(obs());
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("无 expect 配置时 status 非 200 匹配失败", () => {
|
||||
const r = checkHttpExpect(obs({ statusCode: 500 }));
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure).not.toBeNull();
|
||||
expect(r.failure!.phase).toBe("status");
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
});
|
||||
|
||||
test("status 匹配指定状态码", () => {
|
||||
const cfg: HttpExpectConfig = { status: [200, 301] };
|
||||
expect(checkHttpExpect(obs({ statusCode: 200 }), cfg).matched).toBe(true);
|
||||
expect(checkHttpExpect(obs({ statusCode: 301 }), cfg).matched).toBe(true);
|
||||
expect(checkHttpExpect(obs({ statusCode: 404 }), cfg).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("status 不匹配返回 phase=status 的失败", () => {
|
||||
const r = checkHttpExpect(obs({ statusCode: 503 }), { status: [200] });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("status");
|
||||
expect(r.failure!.expected).toEqual([200]);
|
||||
expect(r.failure!.actual).toBe(503);
|
||||
});
|
||||
|
||||
test("duration 在限制内匹配成功", () => {
|
||||
const r = checkHttpExpect(obs({ durationMs: 50 }), { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("duration 超过限制匹配失败", () => {
|
||||
const r = checkHttpExpect(obs({ durationMs: 200 }), { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("duration 恰好等于限制匹配成功", () => {
|
||||
const r = checkHttpExpect(obs({ durationMs: 100 }), { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 字符串格式检查(等于)", () => {
|
||||
const o = obs({ headers: { "content-type": "application/json", "x-api": "v1" } });
|
||||
expect(checkHttpExpect(o, { headers: { "content-type": "application/json" } }).matched).toBe(true);
|
||||
expect(checkHttpExpect(o, { headers: { "content-type": "text/html" } }).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("headers 操作符格式检查", () => {
|
||||
const o = obs({ headers: { "content-type": "application/json" } });
|
||||
expect(checkHttpExpect(o, { headers: { "content-type": { contains: "json" } } }).matched).toBe(true);
|
||||
expect(checkHttpExpect(o, { headers: { "content-type": { match: "^application/" } } }).matched).toBe(true);
|
||||
expect(checkHttpExpect(o, { headers: { "content-type": { contains: "xml" } } }).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("headers 大小写不敏感匹配", () => {
|
||||
const o = obs({ headers: { "content-type": "application/json" } });
|
||||
expect(checkHttpExpect(o, { headers: { "Content-Type": "application/json" } }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 不存在时返回失败", () => {
|
||||
const o = obs({ headers: {} });
|
||||
const r = checkHttpExpect(o, { headers: { "x-missing": "value" } });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("body 规则数组按顺序检查", () => {
|
||||
const o = obs({ body: JSON.stringify({ status: "ok", count: 5 }) });
|
||||
const r = checkHttpExpect(o, {
|
||||
body: [{ contains: "ok" }, { json: { path: "$.count", gte: 1 } }],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("body 第一条规则失败立即返回", () => {
|
||||
const o = obs({ body: "hello world" });
|
||||
const r = checkHttpExpect(o, {
|
||||
body: [{ contains: "missing" }, { contains: "hello" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("body 为 null 但有 body 规则时报错", () => {
|
||||
const o = obs({ body: null });
|
||||
const r = checkHttpExpect(o, { body: [{ contains: "test" }] });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("error");
|
||||
});
|
||||
|
||||
test("完整流水线 status->duration->headers->body 全部通过", () => {
|
||||
const o = obs({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ status: "healthy" }),
|
||||
durationMs: 50,
|
||||
});
|
||||
const r = checkHttpExpect(o, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
headers: { "content-type": { contains: "json" } },
|
||||
body: [{ json: { path: "$.status", equals: "healthy" } }],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("完整流水线 status 通过但 duration 失败", () => {
|
||||
const o = obs({ statusCode: 200, durationMs: 500 });
|
||||
const r = checkHttpExpect(o, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("完整流水线 status 和 duration 通过但 headers 失败", () => {
|
||||
const o = obs({ statusCode: 200, durationMs: 50, headers: { "x-api": "v1" } });
|
||||
const r = checkHttpExpect(o, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
headers: { "x-api": "v2" },
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("完整流水线 status/duration/headers 通过但 body 失败", () => {
|
||||
const o = obs({
|
||||
statusCode: 200,
|
||||
durationMs: 50,
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "error occurred",
|
||||
});
|
||||
const r = checkHttpExpect(o, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: [{ contains: "success" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("body");
|
||||
});
|
||||
});
|
||||
26
tests/server/checker/runner/command/expect.test.ts
Normal file
26
tests/server/checker/runner/command/expect.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkExitCode } from "../../../../../src/server/checker/runner/command/expect";
|
||||
|
||||
describe("checkExitCode", () => {
|
||||
test("exitCode 在允许列表中匹配成功", () => {
|
||||
const r = checkExitCode(0, [0]);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("exitCode 不在允许列表中匹配失败", () => {
|
||||
const r = checkExitCode(1, [0]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("exitCode");
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
expect(r.failure!.expected).toEqual([0]);
|
||||
expect(r.failure!.actual).toBe(1);
|
||||
});
|
||||
|
||||
test("多个允许退出码", () => {
|
||||
expect(checkExitCode(0, [0, 1]).matched).toBe(true);
|
||||
expect(checkExitCode(1, [0, 1]).matched).toBe(true);
|
||||
expect(checkExitCode(2, [0, 1]).matched).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,127 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { runCommandCheck } from "../../../src/server/checker/command-runner";
|
||||
import type { ResolvedCommandTarget } from "../../../src/server/checker/types";
|
||||
|
||||
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||||
import type { ResolvedCommandTarget } from "../../../../../src/server/checker/types";
|
||||
|
||||
import { CommandChecker } from "../../../../../src/server/checker/runner/command/runner";
|
||||
|
||||
const checker = new CommandChecker();
|
||||
|
||||
function makeCtx(timeoutMs = 5000): CheckerContext {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), timeoutMs);
|
||||
return { signal: controller.signal };
|
||||
}
|
||||
|
||||
function makeTarget(
|
||||
command: Partial<ResolvedCommandTarget["command"]>,
|
||||
overrides?: Partial<ResolvedCommandTarget>,
|
||||
): ResolvedCommandTarget {
|
||||
return {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
exec: "echo",
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
...command,
|
||||
},
|
||||
group: "default",
|
||||
intervalMs: 60000,
|
||||
name: "test-cmd",
|
||||
timeoutMs: 5000,
|
||||
type: "command",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runCommandCheck", () => {
|
||||
describe("CommandChecker", () => {
|
||||
test("exitCode=0 成功", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "true", args: [] }));
|
||||
const result = await checker.execute(makeTarget({ args: [], exec: "true" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=0");
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("exitCode=1 不匹配默认 [0]", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "false", args: [] }));
|
||||
const result = await checker.execute(makeTarget({ args: [], exec: "false" }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.phase).toBe("exitCode");
|
||||
});
|
||||
|
||||
test("exitCode=1 匹配自定义 [1]", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "false", args: [] }, { expect: { exitCode: [1] } }));
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: [], exec: "false" }, { expect: { exitCode: [1] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
});
|
||||
|
||||
test("命令不存在返回 spawn 错误", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "/nonexistent/command/xyz" }));
|
||||
const result = await checker.execute(makeTarget({ exec: "/nonexistent/command/xyz" }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.phase).toBe("exitCode");
|
||||
expect(result.failure!.message).toBeTruthy();
|
||||
});
|
||||
|
||||
test("超时返回错误", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "sleep", args: ["10"] }, { timeoutMs: 100 }));
|
||||
const result = await checker.execute(makeTarget({ args: ["10"], exec: "sleep" }, { timeoutMs: 100 }), makeCtx(100));
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.message).toContain("超时");
|
||||
});
|
||||
|
||||
test("stdout 输出捕获", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "echo", args: ["hello world"] }));
|
||||
const result = await checker.execute(makeTarget({ args: ["hello world"], exec: "echo" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("stdout 匹配 expect", async () => {
|
||||
const result = await runCommandCheck(
|
||||
makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "hello" }] } }),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: ["hello"], exec: "echo" }, { expect: { stdout: [{ contains: "hello" }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("stdout 不匹配 expect", async () => {
|
||||
const result = await runCommandCheck(
|
||||
makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "nonexistent" }] } }),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: ["hello"], exec: "echo" }, { expect: { stdout: [{ contains: "nonexistent" }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("stdout");
|
||||
});
|
||||
|
||||
test("stderr 匹配 expect", async () => {
|
||||
const result = await runCommandCheck(
|
||||
makeTarget({ exec: "bash", args: ["-c", "echo error >&2"] }, { expect: { stderr: [{ contains: "error" }] } }),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: ["-c", "echo error >&2"], exec: "bash" }, { expect: { stderr: [{ contains: "error" }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("输出超过 maxOutputBytes", async () => {
|
||||
const result = await runCommandCheck(
|
||||
makeTarget({
|
||||
exec: "bash",
|
||||
args: ["-c", "yes | head -1000"],
|
||||
maxOutputBytes: 10,
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: ["-c", "yes | head -1000"], exec: "bash", maxOutputBytes: 10 }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.message).toContain("超过限制");
|
||||
});
|
||||
|
||||
test("durationMs 非空", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "true", args: [] }));
|
||||
const result = await checker.execute(makeTarget({ args: [], exec: "true" }), makeCtx());
|
||||
expect(result.durationMs).not.toBeNull();
|
||||
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("ls 命令执行成功", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "ls", args: ["/tmp"] }));
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=0");
|
||||
});
|
||||
|
||||
test("不使用 shell,通配符不被展开", async () => {
|
||||
const result = await runCommandCheck(
|
||||
makeTarget({ exec: "echo", args: ["*"] }, { expect: { stdout: [{ contains: "*" }] } }),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: ["*"], exec: "echo" }, { expect: { stdout: [{ contains: "*" }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("不提供 stdin,等待输入的命令会阻塞超时", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "bash", args: ["-c", "read line"] }, { timeoutMs: 500 }));
|
||||
expect(result.matched).toBe(false);
|
||||
test("serialize 返回命令摘要和 config JSON", () => {
|
||||
const target = makeTarget({ args: ["hello"], exec: "echo" });
|
||||
const s = checker.serialize(target);
|
||||
expect(s.target).toBe("exec echo hello");
|
||||
const config = JSON.parse(s.config) as { args: string[]; exec: string };
|
||||
expect(config.exec).toBe("echo");
|
||||
expect(config.args).toEqual(["hello"]);
|
||||
});
|
||||
});
|
||||
147
tests/server/checker/runner/http/expect.test.ts
Normal file
147
tests/server/checker/runner/http/expect.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkHttpExpect } from "../../../../../src/server/checker/runner/http/expect";
|
||||
|
||||
function obs(
|
||||
overrides: { body?: null | string; durationMs?: number; headers?: Record<string, string>; statusCode?: number } = {},
|
||||
) {
|
||||
return {
|
||||
body: overrides.body ?? "",
|
||||
durationMs: overrides.durationMs ?? 100,
|
||||
headers: overrides.headers ?? {},
|
||||
statusCode: overrides.statusCode ?? 200,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkHttpExpect", () => {
|
||||
test("无 expect 配置时默认检查 status [200] 匹配成功", () => {
|
||||
const r = checkHttpExpect(obs().statusCode, obs().headers, obs().body, obs().durationMs);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("无 expect 配置时 status 非 200 匹配失败", () => {
|
||||
const r = checkHttpExpect(500, {}, "", 100);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure).not.toBeNull();
|
||||
expect(r.failure!.phase).toBe("status");
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
});
|
||||
|
||||
test("status 匹配指定状态码", () => {
|
||||
const cfg = { status: [200, 301] };
|
||||
expect(checkHttpExpect(200, {}, "", 100, cfg).matched).toBe(true);
|
||||
expect(checkHttpExpect(301, {}, "", 100, cfg).matched).toBe(true);
|
||||
expect(checkHttpExpect(404, {}, "", 100, cfg).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("status 不匹配返回 phase=status 的失败", () => {
|
||||
const r = checkHttpExpect(503, {}, "", 100, { status: [200] });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("status");
|
||||
expect(r.failure!.expected).toEqual([200]);
|
||||
expect(r.failure!.actual).toBe(503);
|
||||
});
|
||||
|
||||
test("duration 在限制内匹配成功", () => {
|
||||
const r = checkHttpExpect(200, {}, "", 50, { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("duration 超过限制匹配失败", () => {
|
||||
const r = checkHttpExpect(200, {}, "", 200, { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("duration 恰好等于限制匹配成功", () => {
|
||||
const r = checkHttpExpect(200, {}, "", 100, { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 字符串格式检查(等于)", () => {
|
||||
const h = { "content-type": "application/json", "x-api": "v1" };
|
||||
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": "application/json" } }).matched).toBe(true);
|
||||
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": "text/html" } }).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("headers 操作符格式检查", () => {
|
||||
const h = { "content-type": "application/json" };
|
||||
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { contains: "json" } } }).matched).toBe(true);
|
||||
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { match: "^application/" } } }).matched).toBe(
|
||||
true,
|
||||
);
|
||||
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { contains: "xml" } } }).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("headers 大小写不敏感匹配", () => {
|
||||
const h = { "content-type": "application/json" };
|
||||
expect(checkHttpExpect(200, h, "", 100, { headers: { "Content-Type": "application/json" } }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 不存在时返回失败", () => {
|
||||
const r = checkHttpExpect(200, {}, "", 100, { headers: { "x-missing": "value" } });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("body 规则数组按顺序检查", () => {
|
||||
const body = JSON.stringify({ count: 5, status: "ok" });
|
||||
const r = checkHttpExpect(200, {}, body, 100, {
|
||||
body: [{ contains: "ok" }, { json: { gte: 1, path: "$.count" } }],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("body 第一条规则失败立即返回", () => {
|
||||
const r = checkHttpExpect(200, {}, "hello world", 100, {
|
||||
body: [{ contains: "missing" }, { contains: "hello" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("body 为 null 但有 body 规则时报错", () => {
|
||||
const r = checkHttpExpect(200, {}, null, 100, { body: [{ contains: "test" }] });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("error");
|
||||
});
|
||||
|
||||
test("完整流水线 status->duration->headers->body 全部通过", () => {
|
||||
const r = checkHttpExpect(200, { "content-type": "application/json" }, JSON.stringify({ status: "healthy" }), 50, {
|
||||
body: [{ json: { equals: "healthy", path: "$.status" } }],
|
||||
headers: { "content-type": { contains: "json" } },
|
||||
maxDurationMs: 100,
|
||||
status: [200],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("完整流水线 status 通过但 duration 失败", () => {
|
||||
const r = checkHttpExpect(200, {}, "", 500, { maxDurationMs: 100, status: [200] });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("完整流水线 status 和 duration 通过但 headers 失败", () => {
|
||||
const r = checkHttpExpect(200, { "x-api": "v1" }, "", 50, {
|
||||
headers: { "x-api": "v2" },
|
||||
maxDurationMs: 100,
|
||||
status: [200],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("完整流水线 status/duration/headers 通过但 body 失败", () => {
|
||||
const r = checkHttpExpect(200, { "content-type": "text/plain" }, "error occurred", 50, {
|
||||
body: [{ contains: "success" }],
|
||||
headers: { "content-type": "text/plain" },
|
||||
maxDurationMs: 100,
|
||||
status: [200],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("body");
|
||||
});
|
||||
});
|
||||
@@ -1,82 +1,84 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { runHttpCheck } from "../../../src/server/checker/fetcher";
|
||||
|
||||
describe("runHttpCheck", () => {
|
||||
test("checkExpect 已移除", async () => {
|
||||
const mod = await import("../../../src/server/checker/fetcher");
|
||||
expect((mod as Record<string, unknown>).checkExpect).toBeUndefined();
|
||||
expect((mod as Record<string, unknown>).fetchTarget).toBeUndefined();
|
||||
});
|
||||
});
|
||||
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||||
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types";
|
||||
|
||||
describe("runHttpCheck 集成", () => {
|
||||
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
|
||||
|
||||
const checker = new HttpChecker();
|
||||
|
||||
describe("HttpChecker", () => {
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
switch (url.pathname) {
|
||||
case "/ok":
|
||||
return new Response("hello world", {
|
||||
headers: { "content-type": "text/plain", "x-custom": "test-value" },
|
||||
case "/echo":
|
||||
return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
case "/json":
|
||||
return new Response(JSON.stringify({ status: "ok" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
case "/echo":
|
||||
return new Response(JSON.stringify({ method: req.method, body: req.body ? "present" : "empty" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
case "/large":
|
||||
return new Response("x".repeat(2000));
|
||||
case "/notfound":
|
||||
return new Response("not found", { status: 404 });
|
||||
case "/slow":
|
||||
return new Response("slow", { status: 200 });
|
||||
case "/ok":
|
||||
return new Response("hello world", {
|
||||
headers: { "content-type": "text/plain", "x-custom": "test-value" },
|
||||
});
|
||||
default:
|
||||
return new Response("ok");
|
||||
}
|
||||
},
|
||||
port: 0,
|
||||
});
|
||||
baseUrl = `http://localhost:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop();
|
||||
void server.stop();
|
||||
});
|
||||
|
||||
function makeTarget(overrides: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
expect?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
maxBodyBytes?: number;
|
||||
method?: string;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
url?: string;
|
||||
}): ResolvedHttpTarget {
|
||||
return {
|
||||
type: "http" as const,
|
||||
name: "test-http",
|
||||
expect: overrides.expect,
|
||||
group: "default",
|
||||
http: {
|
||||
url: overrides.url ?? `${baseUrl}/ok`,
|
||||
method: overrides.method ?? "GET",
|
||||
headers: overrides.headers ?? ({} as Record<string, string>),
|
||||
body: overrides.body,
|
||||
headers: overrides.headers ?? {},
|
||||
maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024,
|
||||
method: overrides.method ?? "GET",
|
||||
url: overrides.url ?? `${baseUrl}/ok`,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
name: "test-http",
|
||||
timeoutMs: overrides.timeoutMs ?? 5000,
|
||||
expect: overrides.expect as import("../../../src/server/checker/types").HttpExpectConfig | undefined,
|
||||
type: "http",
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(timeoutMs = 5000): CheckerContext {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), timeoutMs);
|
||||
return { signal: controller.signal };
|
||||
}
|
||||
|
||||
test("成功请求 200", async () => {
|
||||
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok` }));
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.durationMs).not.toBeNull();
|
||||
@@ -84,171 +86,136 @@ describe("runHttpCheck 集成", () => {
|
||||
});
|
||||
|
||||
test("404 不匹配默认 status [200]", async () => {
|
||||
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/notfound` }));
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound` }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 404");
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.phase).toBe("status");
|
||||
});
|
||||
|
||||
test("404 匹配自定义 status [404]", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/notfound`,
|
||||
expect: { status: [404] },
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { status: [404] }, url: `${baseUrl}/notfound` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 检查通过", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { headers: { "x-custom": "test-value" } },
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { headers: { "x-custom": "test-value" } }, url: `${baseUrl}/ok` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 检查失败", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { headers: { "x-custom": "wrong-value" } },
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { headers: { "x-custom": "wrong-value" } }, url: `${baseUrl}/ok` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("body contains 检查", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { body: [{ contains: "hello" }] },
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ contains: "hello" }] }, url: `${baseUrl}/ok` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("body contains 失败", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { body: [{ contains: "nonexistent" }] },
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ contains: "nonexistent" }] }, url: `${baseUrl}/ok` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("body");
|
||||
});
|
||||
|
||||
test("body json 检查", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/json`,
|
||||
expect: { body: [{ json: { path: "$.status", equals: "ok" } }] },
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ json: { equals: "ok", path: "$.status" } }] }, url: `${baseUrl}/json` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("响应体超过 maxBodyBytes", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/large`,
|
||||
maxBodyBytes: 100,
|
||||
expect: { body: [{ contains: "x" }] },
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 100, url: `${baseUrl}/large` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.phase).toBe("body");
|
||||
expect(result.failure!.message).toContain("超过限制");
|
||||
});
|
||||
|
||||
test("请求超时", async () => {
|
||||
const timeoutServer = Bun.serve({
|
||||
port: 0,
|
||||
async fetch() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
return new Response("late");
|
||||
},
|
||||
port: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `http://localhost:${timeoutServer.port}/`,
|
||||
timeoutMs: 100,
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ timeoutMs: 100, url: `http://localhost:${timeoutServer.port}/` }),
|
||||
makeCtx(100),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.message).toContain("超时");
|
||||
} finally {
|
||||
timeoutServer.stop();
|
||||
void timeoutServer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("快速失败:status 失败时不读取 body", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/notfound`,
|
||||
expect: { status: [200], body: [{ contains: "something" }] },
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ contains: "something" }], status: [200] }, url: `${baseUrl}/notfound` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("status");
|
||||
});
|
||||
|
||||
test("快速失败:headers 失败时不读取 body", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { headers: { "x-missing": "value" }, body: [{ contains: "hello" }] },
|
||||
}),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("status 通过但 body 失败", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { status: [200], body: [{ contains: "not-in-body" }] },
|
||||
}),
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ contains: "not-in-body" }], status: [200] }, url: `${baseUrl}/ok` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("body");
|
||||
});
|
||||
|
||||
test("无 expect 时默认检查 status 200", async () => {
|
||||
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok`, expect: undefined }));
|
||||
const result = await checker.execute(makeTarget({ expect: undefined, url: `${baseUrl}/ok` }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("POST 请求携带 body", async () => {
|
||||
const result = await runHttpCheck(
|
||||
const result = await checker.execute(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/echo`,
|
||||
method: "POST",
|
||||
body: "test-body",
|
||||
expect: { body: [{ json: { equals: "present", path: "$.body" } }], status: [200] },
|
||||
headers: { "content-type": "text/plain" },
|
||||
expect: { status: [200], body: [{ json: { path: "$.body", equals: "present" } }] },
|
||||
method: "POST",
|
||||
url: `${baseUrl}/echo`,
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("仅 contains 规则时不解析 JSON", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { body: [{ contains: "hello world" }] },
|
||||
}),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
test("serialize 返回 URL 和 config JSON", () => {
|
||||
const target = makeTarget({});
|
||||
const s = checker.serialize(target);
|
||||
expect(s.target).toBe(target.http.url);
|
||||
const config = JSON.parse(s.config) as { method: string; url: string };
|
||||
expect(config.url).toBe(target.http.url);
|
||||
expect(config.method).toBe("GET");
|
||||
});
|
||||
});
|
||||
42
tests/server/checker/runner/registry.test.ts
Normal file
42
tests/server/checker/runner/registry.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { Checker } from "../../../../src/server/checker/runner/types";
|
||||
import type { CheckResult, ResolvedTarget } from "../../../../src/server/checker/types";
|
||||
|
||||
import { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
|
||||
|
||||
function createChecker(type: string): Checker {
|
||||
return {
|
||||
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
|
||||
resolve: () => ({}) as unknown as ResolvedTarget,
|
||||
serialize: () => ({ config: "", target: "" }),
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CheckerRegistry", () => {
|
||||
test("注册并获取 Checker", () => {
|
||||
const registry = new CheckerRegistry();
|
||||
const checker = createChecker("http");
|
||||
registry.register(checker);
|
||||
expect(registry.get("http")).toBe(checker);
|
||||
});
|
||||
|
||||
test("获取未注册的 type 抛出错误", () => {
|
||||
const registry = new CheckerRegistry();
|
||||
expect(() => registry.get("unknown")).toThrow("不支持的 probe type");
|
||||
});
|
||||
|
||||
test("重复注册同一 type 抛出错误", () => {
|
||||
const registry = new CheckerRegistry();
|
||||
registry.register(createChecker("http"));
|
||||
expect(() => registry.register(createChecker("http"))).toThrow("已注册");
|
||||
});
|
||||
|
||||
test("查询支持的 type 列表", () => {
|
||||
const registry = new CheckerRegistry();
|
||||
registry.register(createChecker("http"));
|
||||
registry.register(createChecker("command"));
|
||||
expect(registry.supportedTypes).toEqual(["http", "command"]);
|
||||
});
|
||||
});
|
||||
139
tests/server/checker/runner/shared/body.test.ts
Normal file
139
tests/server/checker/runner/shared/body.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkBodyExpect } from "../../../../../src/server/checker/runner/shared/body";
|
||||
|
||||
describe("checkBodyExpect (BodyRule[])", () => {
|
||||
test("无规则返回匹配成功", () => {
|
||||
const r = checkBodyExpect("anything");
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("空规则数组返回匹配成功", () => {
|
||||
const r = checkBodyExpect("anything", []);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("contains 规则匹配成功", () => {
|
||||
const r = checkBodyExpect("hello world", [{ contains: "hello" }]);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("contains 规则匹配失败", () => {
|
||||
const r = checkBodyExpect("hello world", [{ contains: "missing" }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure).not.toBeNull();
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
expect(r.failure!.phase).toBe("body");
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("regex 规则匹配成功", () => {
|
||||
const r = checkBodyExpect("status: ok", [{ regex: "ok" }]);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("regex 规则匹配失败", () => {
|
||||
const r = checkBodyExpect("status: error", [{ regex: "^ok$" }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("json 等值匹配成功", () => {
|
||||
const body = JSON.stringify({ code: 0, status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ json: { equals: "ok", path: "$.status" } }]);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("json 等值匹配失败", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ json: { equals: "error", path: "$.status" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
});
|
||||
|
||||
test("json 操作符匹配", () => {
|
||||
const body = JSON.stringify({ count: 42, version: "v2.1.0" });
|
||||
expect(checkBodyExpect(body, [{ json: { gte: 10, path: "$.count" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(body, [{ json: { match: "\\d+\\.\\d+\\.\\d+", path: "$.version" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(body, [{ json: { gte: 100, path: "$.count" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("json 路径不存在", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ json: { equals: "value", path: "$.notExist" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("json 解析失败", () => {
|
||||
const r = checkBodyExpect("not json", [{ json: { equals: "ok", path: "$.status" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("error");
|
||||
});
|
||||
|
||||
test("css 文本内容匹配", () => {
|
||||
const html = "<div id='health'>OK</div><span class='ver'>1.0</span>";
|
||||
expect(checkBodyExpect(html, [{ css: { equals: "OK", selector: "div#health" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { equals: "1.0", selector: "span.ver" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { equals: "ERROR", selector: "div#health" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("css 选择器无匹配元素", () => {
|
||||
const html = "<div>OK</div>";
|
||||
const r = checkBodyExpect(html, [{ css: { equals: "OK", selector: "span.missing" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("css attr 提取", () => {
|
||||
const html = '<meta name="version" content="2.0.1">';
|
||||
expect(
|
||||
checkBodyExpect(html, [{ css: { attr: "content", equals: "2.0.1", selector: 'meta[name="version"]' } }]).matched,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("css exists 检查", () => {
|
||||
const html = "<div id='test'>OK</div>";
|
||||
expect(checkBodyExpect(html, [{ css: { exists: true, selector: "div#test" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { exists: false, selector: "span#missing" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { exists: false, selector: "div#test" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 节点文本匹配", () => {
|
||||
const xml = "<root><status>ok</status><code>200</code></root>";
|
||||
expect(checkBodyExpect(xml, [{ xpath: { equals: "ok", path: "/root/status/text()" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(xml, [{ xpath: { equals: "error", path: "/root/status/text()" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 无匹配节点", () => {
|
||||
const xml = "<root><status>ok</status></root>";
|
||||
const r = checkBodyExpect(xml, [{ xpath: { equals: "ok", path: "/root/missing/text()" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("规则数组按顺序检查,第一条失败立即返回", () => {
|
||||
const body = JSON.stringify({ status: "error" });
|
||||
const r = checkBodyExpect(body, [{ contains: "healthy" }, { json: { equals: "error", path: "$.status" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("多条规则全部通过", () => {
|
||||
const body = JSON.stringify({ count: 5, status: "healthy" });
|
||||
const r = checkBodyExpect(body, [
|
||||
{ contains: "healthy" },
|
||||
{ json: { equals: "healthy", path: "$.status" } },
|
||||
{ json: { gte: 1, path: "$.count" } },
|
||||
]);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("第二条规则失败返回正确索引", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { equals: "error", path: "$.status" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toContain("body[1]");
|
||||
});
|
||||
});
|
||||
30
tests/server/checker/runner/shared/duration.test.ts
Normal file
30
tests/server/checker/runner/shared/duration.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkDuration } from "../../../../../src/server/checker/runner/shared/duration";
|
||||
|
||||
describe("checkDuration", () => {
|
||||
test("未配置 maxDurationMs 返回匹配成功", () => {
|
||||
const r = checkDuration(100);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("duration 在限制内匹配成功", () => {
|
||||
const r = checkDuration(50, 100);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("duration 等于限制匹配成功", () => {
|
||||
const r = checkDuration(100, 100);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("duration 超过限制匹配失败", () => {
|
||||
const r = checkDuration(200, 100);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure).not.toBeNull();
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { truncateActual, mismatchFailure, errorFailure } from "../../../../src/server/checker/expect/failure";
|
||||
|
||||
import { errorFailure, mismatchFailure, truncateActual } from "../../../../../src/server/checker/runner/shared/failure";
|
||||
|
||||
describe("truncateActual", () => {
|
||||
test("短字符串不截断", () => {
|
||||
@@ -43,12 +44,12 @@ describe("mismatchFailure", () => {
|
||||
test("返回正确的 mismatch 结构", () => {
|
||||
const f = mismatchFailure("status", "status", [200], 500, "status mismatch");
|
||||
expect(f).toEqual({
|
||||
kind: "mismatch",
|
||||
phase: "status",
|
||||
path: "status",
|
||||
expected: [200],
|
||||
actual: 500,
|
||||
expected: [200],
|
||||
kind: "mismatch",
|
||||
message: "status mismatch",
|
||||
path: "status",
|
||||
phase: "status",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,9 +66,9 @@ describe("errorFailure", () => {
|
||||
const f = errorFailure("body", "body[0].json($.x)", "body is not valid JSON");
|
||||
expect(f).toEqual({
|
||||
kind: "error",
|
||||
phase: "body",
|
||||
path: "body[0].json($.x)",
|
||||
message: "body is not valid JSON",
|
||||
path: "body[0].json($.x)",
|
||||
phase: "body",
|
||||
});
|
||||
});
|
||||
|
||||
146
tests/server/checker/runner/shared/operator.test.ts
Normal file
146
tests/server/checker/runner/shared/operator.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
applyOperator,
|
||||
checkExpectValue,
|
||||
evaluateJsonPath,
|
||||
} from "../../../../../src/server/checker/runner/shared/operator";
|
||||
|
||||
describe("evaluateJsonPath", () => {
|
||||
const obj = {
|
||||
active: true,
|
||||
code: 0,
|
||||
data: {
|
||||
count: 42,
|
||||
items: [{ name: "a" }, { name: "b" }],
|
||||
nested: { deep: "value" },
|
||||
},
|
||||
emptyArr: [],
|
||||
emptyObj: {},
|
||||
error: null,
|
||||
status: "ok",
|
||||
};
|
||||
|
||||
test("简单字段访问", () => {
|
||||
expect(evaluateJsonPath(obj, "$.status")).toBe("ok");
|
||||
expect(evaluateJsonPath(obj, "$.code")).toBe(0);
|
||||
expect(evaluateJsonPath(obj, "$.active")).toBe(true);
|
||||
expect(evaluateJsonPath(obj, "$.error")).toBeNull();
|
||||
});
|
||||
|
||||
test("嵌套对象访问", () => {
|
||||
expect(evaluateJsonPath(obj, "$.data.count")).toBe(42);
|
||||
expect(evaluateJsonPath(obj, "$.data.nested.deep")).toBe("value");
|
||||
});
|
||||
|
||||
test("数组索引访问", () => {
|
||||
expect(evaluateJsonPath(obj, "$.data.items[0].name")).toBe("a");
|
||||
expect(evaluateJsonPath(obj, "$.data.items[1].name")).toBe("b");
|
||||
});
|
||||
|
||||
test("路径不存在返回 undefined", () => {
|
||||
expect(evaluateJsonPath(obj, "$.notExist")).toBeUndefined();
|
||||
expect(evaluateJsonPath(obj, "$.data.notExist")).toBeUndefined();
|
||||
expect(evaluateJsonPath(obj, "$.data.items[99]")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("空对象和空数组", () => {
|
||||
expect(evaluateJsonPath(obj, "$.emptyObj")).toEqual({});
|
||||
expect(evaluateJsonPath(obj, "$.emptyArr")).toEqual([]);
|
||||
});
|
||||
|
||||
test("非 $ 开头路径返回 undefined", () => {
|
||||
expect(evaluateJsonPath(obj, "status")).toBeUndefined();
|
||||
expect(evaluateJsonPath(obj, ".status")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("null 对象上访问", () => {
|
||||
expect(evaluateJsonPath(null, "$.any")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOperator", () => {
|
||||
test("equals 操作符", () => {
|
||||
expect(applyOperator("ok", { equals: "ok" })).toBe(true);
|
||||
expect(applyOperator("ok", { equals: "error" })).toBe(false);
|
||||
expect(applyOperator(42, { equals: 42 })).toBe(true);
|
||||
expect(applyOperator(42, { equals: 41 })).toBe(false);
|
||||
expect(applyOperator(null, { equals: null })).toBe(true);
|
||||
expect(applyOperator(true, { equals: true })).toBe(true);
|
||||
});
|
||||
|
||||
test("contains 操作符", () => {
|
||||
expect(applyOperator("hello world", { contains: "hello" })).toBe(true);
|
||||
expect(applyOperator("hello world", { contains: "missing" })).toBe(false);
|
||||
expect(applyOperator(12345, { contains: "23" })).toBe(true);
|
||||
});
|
||||
|
||||
test("match 操作符", () => {
|
||||
expect(applyOperator("v2.1.0", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(true);
|
||||
expect(applyOperator("v2.1", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(false);
|
||||
expect(applyOperator("abc123", { match: "^\\w+\\d+$" })).toBe(true);
|
||||
});
|
||||
|
||||
test("empty 操作符", () => {
|
||||
expect(applyOperator("", { empty: true })).toBe(true);
|
||||
expect(applyOperator(null, { empty: true })).toBe(true);
|
||||
expect(applyOperator(undefined, { empty: true })).toBe(true);
|
||||
expect(applyOperator([], { empty: true })).toBe(true);
|
||||
expect(applyOperator({}, { empty: true })).toBe(true);
|
||||
expect(applyOperator("ok", { empty: true })).toBe(false);
|
||||
expect(applyOperator([1, 2], { empty: false })).toBe(true);
|
||||
expect(applyOperator([], { empty: false })).toBe(false);
|
||||
});
|
||||
|
||||
test("exists 操作符", () => {
|
||||
expect(applyOperator("ok", { exists: true })).toBe(true);
|
||||
expect(applyOperator(null, { exists: true })).toBe(true);
|
||||
expect(applyOperator(undefined, { exists: true })).toBe(false);
|
||||
expect(applyOperator(undefined, { exists: false })).toBe(true);
|
||||
expect(applyOperator("ok", { exists: false })).toBe(false);
|
||||
});
|
||||
|
||||
test("gte 操作符", () => {
|
||||
expect(applyOperator(10, { gte: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { gte: 5 })).toBe(true);
|
||||
expect(applyOperator(3, { gte: 5 })).toBe(false);
|
||||
expect(applyOperator("10", { gte: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
test("lte 操作符", () => {
|
||||
expect(applyOperator(3, { lte: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { lte: 5 })).toBe(true);
|
||||
expect(applyOperator(10, { lte: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("gt 操作符", () => {
|
||||
expect(applyOperator(10, { gt: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { gt: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("lt 操作符", () => {
|
||||
expect(applyOperator(3, { lt: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { lt: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("多操作符 AND 组合", () => {
|
||||
expect(applyOperator(7, { gte: 5, lte: 10 })).toBe(true);
|
||||
expect(applyOperator(3, { gte: 5, lte: 10 })).toBe(false);
|
||||
expect(applyOperator(15, { gte: 5, lte: 10 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkExpectValue", () => {
|
||||
test("原始值直接比较", () => {
|
||||
expect(checkExpectValue("ok", "ok")).toBe(true);
|
||||
expect(checkExpectValue("ok", "error")).toBe(false);
|
||||
expect(checkExpectValue(42, 42)).toBe(true);
|
||||
expect(checkExpectValue(null, null)).toBe(true);
|
||||
});
|
||||
|
||||
test("对象作为操作符", () => {
|
||||
expect(checkExpectValue(42, { gte: 10 })).toBe(true);
|
||||
expect(checkExpectValue(42, { gte: 100 })).toBe(false);
|
||||
expect(checkExpectValue("hello", { contains: "ell" })).toBe(true);
|
||||
});
|
||||
});
|
||||
50
tests/server/checker/runner/shared/text.test.ts
Normal file
50
tests/server/checker/runner/shared/text.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkTextRules } from "../../../../../src/server/checker/runner/shared/text";
|
||||
|
||||
describe("checkTextRules", () => {
|
||||
test("无规则返回匹配成功", () => {
|
||||
const r = checkTextRules("hello", [], "stdout");
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("单条 contains 规则匹配成功", () => {
|
||||
const r = checkTextRules("build completed successfully", [{ contains: "completed" }], "stdout");
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("单条 contains 规则匹配失败", () => {
|
||||
const r = checkTextRules("build completed successfully", [{ contains: "failed" }], "stdout");
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stdout");
|
||||
expect(r.failure!.path).toBe("stdout[0]");
|
||||
});
|
||||
|
||||
test("多条规则全部通过", () => {
|
||||
const r = checkTextRules(
|
||||
"version: 3.2.1, build: ok",
|
||||
[{ contains: "version" }, { match: "\\d+\\.\\d+\\.\\d+" }],
|
||||
"stdout",
|
||||
);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("第一条规则失败立即返回", () => {
|
||||
const r = checkTextRules("error occurred", [{ contains: "success" }, { contains: "error" }], "stdout");
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stdout");
|
||||
expect(r.failure!.path).toBe("stdout[0]");
|
||||
});
|
||||
|
||||
test("stderr phase", () => {
|
||||
const r = checkTextRules("warning: deprecated", [{ contains: "warning" }], "stderr");
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("empty 操作符", () => {
|
||||
const r = checkTextRules("", [{ empty: true }], "stderr");
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { parseSize } from "../../../src/server/checker/size";
|
||||
|
||||
describe("parseSize", () => {
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ProbeStore } from "../../../src/server/checker/store";
|
||||
import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/types";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/types";
|
||||
|
||||
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
|
||||
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||
import { ProbeStore } from "../../../src/server/checker/store";
|
||||
|
||||
function ensureRegistered() {
|
||||
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||
checkerRegistry.register(new HttpChecker());
|
||||
checkerRegistry.register(new CommandChecker());
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
ensureRegistered();
|
||||
});
|
||||
|
||||
const httpTarget: ResolvedTarget = {
|
||||
type: "http",
|
||||
name: "test-http",
|
||||
expect: { maxDurationMs: 3000, status: [200] },
|
||||
group: "default",
|
||||
http: {
|
||||
url: "https://example.com/health",
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
maxBodyBytes: 104857600,
|
||||
method: "GET",
|
||||
url: "https://example.com/health",
|
||||
},
|
||||
intervalMs: 30000,
|
||||
name: "test-http",
|
||||
timeoutMs: 10000,
|
||||
expect: { status: [200], maxDurationMs: 3000 },
|
||||
type: "http",
|
||||
};
|
||||
|
||||
const commandTarget: ResolvedTarget = {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "ping",
|
||||
args: ["-c", "1", "localhost"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
exec: "ping",
|
||||
maxOutputBytes: 104857600,
|
||||
},
|
||||
group: "default",
|
||||
intervalMs: 60000,
|
||||
name: "test-cmd",
|
||||
timeoutMs: 5000,
|
||||
type: "command",
|
||||
};
|
||||
|
||||
describe("ProbeStore", () => {
|
||||
@@ -47,7 +63,7 @@ describe("ProbeStore", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
store.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
test("初始化后无 targets", () => {
|
||||
@@ -66,21 +82,26 @@ describe("ProbeStore", () => {
|
||||
const t = store.getTargets().find((t) => t.name === "test-http")!;
|
||||
expect(t.type).toBe("http");
|
||||
expect(t.target).toBe("https://example.com/health");
|
||||
const config = JSON.parse(t.config);
|
||||
const config = JSON.parse(t.config) as {
|
||||
headers: Record<string, string>;
|
||||
maxBodyBytes: number;
|
||||
method: string;
|
||||
url: string;
|
||||
};
|
||||
expect(config.url).toBe("https://example.com/health");
|
||||
expect(config.method).toBe("GET");
|
||||
expect(config.headers).toEqual({ Accept: "application/json" });
|
||||
expect(config.maxBodyBytes).toBe(104857600);
|
||||
expect(t.interval_ms).toBe(30000);
|
||||
expect(t.timeout_ms).toBe(10000);
|
||||
expect(JSON.parse(t.expect!)).toEqual({ status: [200], maxDurationMs: 3000 });
|
||||
expect(JSON.parse(t.expect!)).toEqual({ maxDurationMs: 3000, status: [200] });
|
||||
});
|
||||
|
||||
test("command target 字段正确", () => {
|
||||
const t = store.getTargets().find((t) => t.name === "test-cmd")!;
|
||||
expect(t.type).toBe("command");
|
||||
expect(t.target).toBe("exec ping -c 1 localhost");
|
||||
const config = JSON.parse(t.config);
|
||||
const config = JSON.parse(t.config) as { args: string[]; cwd: string; exec: string; maxOutputBytes: number };
|
||||
expect(config.exec).toBe("ping");
|
||||
expect(config.args).toEqual(["-c", "1", "localhost"]);
|
||||
expect(config.cwd).toBe("/tmp");
|
||||
@@ -130,39 +151,39 @@ describe("ProbeStore", () => {
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
store.insertCheckResult({
|
||||
durationMs: 150.5,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
matched: true,
|
||||
durationMs: 150.5,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
|
||||
store.insertCheckResult({
|
||||
durationMs: 300,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
matched: true,
|
||||
durationMs: 300,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
|
||||
const failure: CheckFailure = {
|
||||
kind: "error",
|
||||
phase: "duration",
|
||||
path: "$.maxDurationMs",
|
||||
expected: 3000,
|
||||
actual: 5000,
|
||||
expected: 3000,
|
||||
kind: "error",
|
||||
message: "请求耗时 5000ms 超过限制 3000ms",
|
||||
path: "$.maxDurationMs",
|
||||
phase: "duration",
|
||||
};
|
||||
|
||||
store.insertCheckResult({
|
||||
durationMs: null,
|
||||
failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure,
|
||||
});
|
||||
|
||||
const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z", 1, 10);
|
||||
@@ -184,12 +205,12 @@ describe("ProbeStore", () => {
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
store.insertCheckResult({
|
||||
durationMs: 100 + i,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
matched: true,
|
||||
durationMs: 100 + i,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -260,32 +281,32 @@ describe("ProbeStore", () => {
|
||||
test("删除 target 级联删除 check_results", () => {
|
||||
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
|
||||
const cascadeTarget: ResolvedTarget = {
|
||||
type: "http",
|
||||
name: "cascade-test",
|
||||
group: "default",
|
||||
http: { url: "http://cascade.test", method: "GET", headers: {}, maxBodyBytes: 104857600 },
|
||||
http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://cascade.test" },
|
||||
intervalMs: 30000,
|
||||
name: "cascade-test",
|
||||
timeoutMs: 10000,
|
||||
type: "http",
|
||||
};
|
||||
|
||||
cascadeStore.syncTargets([cascadeTarget]);
|
||||
const t = cascadeStore.getTargets()[0]!;
|
||||
|
||||
cascadeStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
matched: true,
|
||||
durationMs: 100,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
cascadeStore.insertCheckResult({
|
||||
durationMs: null,
|
||||
failure: { kind: "error", message: "fail", path: "$", phase: "status" },
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure: { kind: "error", phase: "status", path: "$", message: "fail" },
|
||||
});
|
||||
|
||||
expect(cascadeStore.getLatestCheck(t.id)).not.toBeNull();
|
||||
@@ -297,4 +318,75 @@ describe("ProbeStore", () => {
|
||||
|
||||
cascadeStore.close();
|
||||
});
|
||||
|
||||
test("getLatestChecksMap 返回所有 target 的最新 check", () => {
|
||||
const targets = store.getTargets();
|
||||
const map = store.getLatestChecksMap();
|
||||
expect(map).toBeInstanceOf(Map);
|
||||
|
||||
for (const target of targets) {
|
||||
const latest = map.get(target.id);
|
||||
if (latest) {
|
||||
expect(latest.target_id).toBe(target.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("getLatestChecksMap 对无记录的 target 不包含 key", () => {
|
||||
const freshStore = new ProbeStore(join(tempDir, "fresh-map.db"));
|
||||
freshStore.syncTargets([
|
||||
{
|
||||
group: "default",
|
||||
http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://no.records" },
|
||||
intervalMs: 30000,
|
||||
name: "no-records",
|
||||
timeoutMs: 10000,
|
||||
type: "http",
|
||||
},
|
||||
]);
|
||||
|
||||
const map = freshStore.getLatestChecksMap();
|
||||
expect(map.size).toBe(0);
|
||||
|
||||
freshStore.close();
|
||||
});
|
||||
|
||||
test("getAllTargetStats 返回所有 target 的聚合统计", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
const t2Id = targets[1]!.id;
|
||||
|
||||
const stats = store.getAllTargetStats();
|
||||
expect(stats).toBeInstanceOf(Map);
|
||||
|
||||
const stats1 = stats.get(t1Id);
|
||||
expect(stats1).toBeDefined();
|
||||
expect(stats1!.totalChecks).toBeGreaterThan(0);
|
||||
expect(stats1!.availability).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const stats2 = stats.get(t2Id);
|
||||
if (stats2) {
|
||||
expect(stats2.totalChecks).toBe(0);
|
||||
expect(stats2.availability).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("getAllTargetStats 对无记录的 target 不包含 key", () => {
|
||||
const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db"));
|
||||
freshStore.syncTargets([
|
||||
{
|
||||
group: "default",
|
||||
http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://no.stats" },
|
||||
intervalMs: 30000,
|
||||
name: "no-stats",
|
||||
timeoutMs: 10000,
|
||||
type: "http",
|
||||
},
|
||||
]);
|
||||
|
||||
const stats = freshStore.getAllTargetStats();
|
||||
expect(stats.size).toBe(0);
|
||||
|
||||
freshStore.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { readRuntimeConfig } from "../../src/server/config";
|
||||
|
||||
describe("runtime config", () => {
|
||||
|
||||
@@ -1,95 +1,70 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { getAvailabilityProgressColor, getLatencyColor } from "../../../src/web/constants/color-threshold";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { getAvailabilityProgressColor } from "../../../src/web/constants/color-threshold";
|
||||
|
||||
describe("color-threshold", () => {
|
||||
describe("getAvailabilityProgressColor", () => {
|
||||
test("0-10% 返回第一档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(0)).toBe("#d54941");
|
||||
expect(getAvailabilityProgressColor(5)).toBe("#d54941");
|
||||
expect(getAvailabilityProgressColor(9.99)).toBe("#d54941");
|
||||
test("0-10% 返回第一档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(0)).toBe("var(--avail-0)");
|
||||
expect(getAvailabilityProgressColor(5)).toBe("var(--avail-0)");
|
||||
expect(getAvailabilityProgressColor(9.99)).toBe("var(--avail-0)");
|
||||
});
|
||||
|
||||
test("10-20% 返回第二档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(10)).toBe("#d96241");
|
||||
expect(getAvailabilityProgressColor(15)).toBe("#d96241");
|
||||
expect(getAvailabilityProgressColor(19.99)).toBe("#d96241");
|
||||
test("10-20% 返回第二档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(10)).toBe("var(--avail-1)");
|
||||
expect(getAvailabilityProgressColor(15)).toBe("var(--avail-1)");
|
||||
expect(getAvailabilityProgressColor(19.99)).toBe("var(--avail-1)");
|
||||
});
|
||||
|
||||
test("20-30% 返回第三档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(20)).toBe("#e37318");
|
||||
expect(getAvailabilityProgressColor(25)).toBe("#e37318");
|
||||
test("20-30% 返回第三档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(20)).toBe("var(--avail-2)");
|
||||
expect(getAvailabilityProgressColor(25)).toBe("var(--avail-2)");
|
||||
});
|
||||
|
||||
test("30-40% 返回第四档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(30)).toBe("#e89318");
|
||||
expect(getAvailabilityProgressColor(35)).toBe("#e89318");
|
||||
test("30-40% 返回第四档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(30)).toBe("var(--avail-3)");
|
||||
expect(getAvailabilityProgressColor(35)).toBe("var(--avail-3)");
|
||||
});
|
||||
|
||||
test("40-50% 返回第五档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(40)).toBe("#d9a818");
|
||||
expect(getAvailabilityProgressColor(45)).toBe("#d9a818");
|
||||
test("40-50% 返回第五档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(40)).toBe("var(--avail-4)");
|
||||
expect(getAvailabilityProgressColor(45)).toBe("var(--avail-4)");
|
||||
});
|
||||
|
||||
test("50-60% 返回第六档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(50)).toBe("#b8b020");
|
||||
expect(getAvailabilityProgressColor(55)).toBe("#b8b020");
|
||||
test("50-60% 返回第六档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(50)).toBe("var(--avail-5)");
|
||||
expect(getAvailabilityProgressColor(55)).toBe("var(--avail-5)");
|
||||
});
|
||||
|
||||
test("60-70% 返回第七档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(60)).toBe("#8dba30");
|
||||
expect(getAvailabilityProgressColor(65)).toBe("#8dba30");
|
||||
test("60-70% 返回第七档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(60)).toBe("var(--avail-6)");
|
||||
expect(getAvailabilityProgressColor(65)).toBe("var(--avail-6)");
|
||||
});
|
||||
|
||||
test("70-80% 返回第八档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(70)).toBe("#6dba3f");
|
||||
expect(getAvailabilityProgressColor(75)).toBe("#6dba3f");
|
||||
test("70-80% 返回第八档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(70)).toBe("var(--avail-7)");
|
||||
expect(getAvailabilityProgressColor(75)).toBe("var(--avail-7)");
|
||||
});
|
||||
|
||||
test("80-90% 返回第九档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(80)).toBe("#4dba50");
|
||||
expect(getAvailabilityProgressColor(85)).toBe("#4dba50");
|
||||
test("80-90% 返回第九档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(80)).toBe("var(--avail-8)");
|
||||
expect(getAvailabilityProgressColor(85)).toBe("var(--avail-8)");
|
||||
});
|
||||
|
||||
test("90-100% 返回第十档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(90)).toBe("#3dba60");
|
||||
expect(getAvailabilityProgressColor(95)).toBe("#3dba60");
|
||||
expect(getAvailabilityProgressColor(99.9)).toBe("#3dba60");
|
||||
expect(getAvailabilityProgressColor(100)).toBe("#3dba60");
|
||||
test("90-100% 返回第十档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(90)).toBe("var(--avail-9)");
|
||||
expect(getAvailabilityProgressColor(95)).toBe("var(--avail-9)");
|
||||
expect(getAvailabilityProgressColor(99.9)).toBe("var(--avail-9)");
|
||||
expect(getAvailabilityProgressColor(100)).toBe("var(--avail-9)");
|
||||
});
|
||||
|
||||
test("边界值", () => {
|
||||
expect(getAvailabilityProgressColor(9.999)).toBe("#d54941");
|
||||
expect(getAvailabilityProgressColor(10)).toBe("#d96241");
|
||||
expect(getAvailabilityProgressColor(19.999)).toBe("#d96241");
|
||||
expect(getAvailabilityProgressColor(20)).toBe("#e37318");
|
||||
expect(getAvailabilityProgressColor(89.999)).toBe("#4dba50");
|
||||
expect(getAvailabilityProgressColor(90)).toBe("#3dba60");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLatencyColor", () => {
|
||||
test("<=100ms 返回 success 色", () => {
|
||||
expect(getLatencyColor(0)).toBe("var(--td-success-color)");
|
||||
expect(getLatencyColor(50)).toBe("var(--td-success-color)");
|
||||
expect(getLatencyColor(100)).toBe("var(--td-success-color)");
|
||||
});
|
||||
|
||||
test("100-500ms 返回 warning 色", () => {
|
||||
expect(getLatencyColor(101)).toBe("var(--td-warning-color)");
|
||||
expect(getLatencyColor(250)).toBe("var(--td-warning-color)");
|
||||
expect(getLatencyColor(500)).toBe("var(--td-warning-color)");
|
||||
});
|
||||
|
||||
test(">500ms 返回 error 色", () => {
|
||||
expect(getLatencyColor(501)).toBe("var(--td-error-color)");
|
||||
expect(getLatencyColor(1000)).toBe("var(--td-error-color)");
|
||||
});
|
||||
|
||||
test("边界值", () => {
|
||||
expect(getLatencyColor(100)).toBe("var(--td-success-color)");
|
||||
expect(getLatencyColor(100.01)).toBe("var(--td-warning-color)");
|
||||
expect(getLatencyColor(500)).toBe("var(--td-warning-color)");
|
||||
expect(getLatencyColor(500.01)).toBe("var(--td-error-color)");
|
||||
expect(getAvailabilityProgressColor(9.999)).toBe("var(--avail-0)");
|
||||
expect(getAvailabilityProgressColor(10)).toBe("var(--avail-1)");
|
||||
expect(getAvailabilityProgressColor(19.999)).toBe("var(--avail-1)");
|
||||
expect(getAvailabilityProgressColor(20)).toBe("var(--avail-2)");
|
||||
expect(getAvailabilityProgressColor(89.999)).toBe("var(--avail-8)");
|
||||
expect(getAvailabilityProgressColor(90)).toBe("var(--avail-9)");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user