Compare commits
105 Commits
9267f6585c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f8fd8bd9c | |||
| 3390eb5e8d | |||
| 145bb8fd04 | |||
| 358f8d011a | |||
| c2dcfab80c | |||
| f38286d74d | |||
| 08b61cbf47 | |||
| c120690cf1 | |||
| 77c6015b3a | |||
| c1db793073 | |||
| 714b635aef | |||
| a6504d5a62 | |||
| 483cdc596b | |||
| 4f33fba793 | |||
| 6601ab458d | |||
| cfca03b4d6 | |||
| cf847ccd7a | |||
| 6e53c8130d | |||
| 6ca8b36542 | |||
| 79358ba50d | |||
| e448cb4654 | |||
| 5238dbe77d | |||
| 007d74934d | |||
| 0d709c7681 | |||
| b432581444 | |||
| ccd16a583e | |||
| 8eac814cc6 | |||
| f3df3a203b | |||
| 60a54b483f | |||
| 6098be2d9e | |||
| b591dcca97 | |||
| 9b53c746f6 | |||
| 375dd3492b | |||
| 22c06820fa | |||
| 12cd05b04e | |||
| 8d8549d07f | |||
| 7a635a0a9f | |||
| 349896bd02 | |||
| 52262a31f6 | |||
| 550c427814 | |||
| c51bc5a0d8 | |||
| 393e8da5fd | |||
| 0a9a9016be | |||
| 31fd3a2a43 | |||
| f7193e98ff | |||
| 7926514986 | |||
| 366b3211c8 | |||
| e924732a02 | |||
| 04c24e6796 | |||
| 146cef982e | |||
| c36df94e59 | |||
| f8d563c668 | |||
| 88f4119a4e | |||
| c46ab14cce | |||
| 8793fbd786 | |||
| 2b08f81a0d | |||
| 86b8cf1950 | |||
| d6a77b2c6e | |||
| 28e46b8431 | |||
| 9904f198aa | |||
| c61a4a6091 | |||
| 1c5cfafda6 | |||
| e983e5d75d | |||
| 0fa2c0c811 | |||
| 6e485cc991 | |||
| bcfac52112 | |||
| 31aeee6d60 | |||
| a62007083d | |||
| 76b47006fe | |||
| 147a2559ae | |||
| 6ea185315f | |||
| ecd47748d2 | |||
| bcfb907bd3 | |||
| 26f0bfe104 | |||
| bb6b2bc20b | |||
| c396c29402 | |||
| aade0bbff7 | |||
| 7b20b59b79 | |||
| bce0f8e7a8 | |||
| 2fd0f206be | |||
| 87d946a441 | |||
| ad87be6956 | |||
| 9a71b7967c | |||
| a5cf6065c2 | |||
| ce8baae3d1 | |||
| e1c33b4002 | |||
| f7facb7232 | |||
| 696db6ffb5 | |||
| c677b4f756 | |||
| 9f2b906063 | |||
| 3e8d01715f | |||
| 3b9006345e | |||
| f48e39a615 | |||
| 48b40238b8 | |||
| 3fa1b3957e | |||
| 767f26617e | |||
| 48a9e96ec2 | |||
| d873484938 | |||
| 80d5f4cdf4 | |||
| 0ee10b47c9 | |||
| 35ba56888b | |||
| 548b44d28e | |||
| b8810f1182 | |||
| 599d973cbd | |||
| 57d3a5cfb4 |
8
.claude/settings.json
Normal file
8
.claude/settings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"tdesign-mcp-server": {
|
||||||
|
"command": "bunx",
|
||||||
|
"args": ["tdesign-mcp-server@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.build
|
||||||
|
.claude
|
||||||
|
.codex
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.git
|
||||||
|
.opencode
|
||||||
|
.agents
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
data
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
*.bun-build
|
||||||
32
.gitattributes
vendored
Normal file
32
.gitattributes
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 跨平台行尾规范
|
||||||
|
# 所有文本文件统一用 LF(Unix 风格),避免 CRLF/LF 混用
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# 二进制文件不转换行尾
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.svg binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.gz binary
|
||||||
|
*.tar binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.mov binary
|
||||||
|
*.mp3 binary
|
||||||
|
|
||||||
|
# Shell 脚本必须 LF
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Windows 批处理必须 CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# 锁定文件(如 package-lock.json)保持 LF
|
||||||
|
*.lock text eol=lf
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -403,11 +403,15 @@ cython_debug/
|
|||||||
!.claude/settings.json
|
!.claude/settings.json
|
||||||
.opencode
|
.opencode
|
||||||
.codex
|
.codex
|
||||||
|
.pi/*
|
||||||
|
!.pi/mcp.json
|
||||||
|
!.pi/extensions
|
||||||
openspec/changes/archive
|
openspec/changes/archive
|
||||||
temp
|
temp
|
||||||
.agents
|
.agents
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
.worktrees
|
.worktrees
|
||||||
|
data/
|
||||||
!scripts/build/
|
!scripts/build/
|
||||||
backend/bin
|
backend/bin
|
||||||
backend/server
|
backend/server
|
||||||
@@ -421,3 +425,4 @@ backend/cmd/desktop/rsrc_windows_*.syso
|
|||||||
# Bun
|
# Bun
|
||||||
.build/
|
.build/
|
||||||
*.bun-build
|
*.bun-build
|
||||||
|
dist/release/
|
||||||
|
|||||||
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"],
|
||||||
|
"*.{md,json,yaml,yml}": ["prettier --write"]
|
||||||
|
}
|
||||||
19
.pi/extensions/pi-permission-system/config.json
Normal file
19
.pi/extensions/pi-permission-system/config.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
|
||||||
|
"permission": {
|
||||||
|
"*": "allow",
|
||||||
|
"write": "allow",
|
||||||
|
"edit": "allow",
|
||||||
|
"bash": {
|
||||||
|
"*": "allow",
|
||||||
|
"npm *": "deny",
|
||||||
|
"npx *": "deny",
|
||||||
|
"pnpm *": "deny",
|
||||||
|
"pnpx *": "deny"
|
||||||
|
},
|
||||||
|
"external_directory": {
|
||||||
|
"*": "ask",
|
||||||
|
"/tmp/*": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.pi/mcp.json
Normal file
8
.pi/mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"tdesign-mcp-server": {
|
||||||
|
"command": "bunx",
|
||||||
|
"args": ["tdesign-mcp-server@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,3 +7,7 @@ bun.lock
|
|||||||
.opencode/
|
.opencode/
|
||||||
.claude/
|
.claude/
|
||||||
.codex/
|
.codex/
|
||||||
|
.agents/
|
||||||
|
skills-lock.json
|
||||||
|
data/
|
||||||
|
probe-config.schema.json
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
{
|
{
|
||||||
"printWidth": 120
|
"printWidth": 120,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false
|
||||||
}
|
}
|
||||||
|
|||||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"editor.insertSpaces": true,
|
||||||
|
"editor.detectIndentation": false,
|
||||||
|
|
||||||
|
"files.eol": "\n",
|
||||||
|
"files.encoding": "utf8",
|
||||||
|
"files.insertFinalNewline": true,
|
||||||
|
"files.trimTrailingWhitespace": true
|
||||||
|
}
|
||||||
45
Dockerfile
Normal file
45
Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
ARG BUN_IMAGE=oven/bun:1-alpine
|
||||||
|
ARG ALPINE_IMAGE=alpine:3.22
|
||||||
|
|
||||||
|
FROM ${BUN_IMAGE} AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN set -eux; \
|
||||||
|
case "${TARGETARCH:-$(uname -m)}" in \
|
||||||
|
amd64|x86_64) export BUN_TARGET=bun-linux-x64-musl ;; \
|
||||||
|
arm64|aarch64) export BUN_TARGET=bun-linux-arm64-musl ;; \
|
||||||
|
*) echo "不支持的架构: ${TARGETARCH:-$(uname -m)}" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
FROM ${ALPINE_IMAGE} AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates iputils-ping libgcc libstdc++ tzdata \
|
||||||
|
&& addgroup -S dial \
|
||||||
|
&& adduser -S -G dial -h /nonexistent -s /sbin/nologin dial \
|
||||||
|
&& mkdir -p /etc/dial /data/dial \
|
||||||
|
&& chown -R dial:dial /data/dial
|
||||||
|
|
||||||
|
COPY --from=build --chmod=0755 /app/dist/dial-server /usr/local/bin/dial-server
|
||||||
|
COPY --chmod=0644 docker/probes.yaml /etc/dial/probes.yaml
|
||||||
|
|
||||||
|
USER dial
|
||||||
|
WORKDIR /data/dial
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -q -O - "http://127.0.0.1:3000/health" >/dev/null || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/dial-server"]
|
||||||
|
CMD ["/etc/dial/probes.yaml"]
|
||||||
184
LICENSE
Normal file
184
LICENSE
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction, and
|
||||||
|
distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by the copyright
|
||||||
|
owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all other entities
|
||||||
|
that control, are controlled by, or are under common control with that entity.
|
||||||
|
For the purposes of this definition, "control" means (i) the power, direct or
|
||||||
|
indirect, to cause the direction or management of such entity, whether by
|
||||||
|
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity exercising
|
||||||
|
permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications, including
|
||||||
|
but not limited to software source code, documentation source, and configuration
|
||||||
|
files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical transformation or
|
||||||
|
translation of a Source form, including but not limited to compiled object code,
|
||||||
|
generated documentation, and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or Object form,
|
||||||
|
made available under the License, as indicated by a copyright notice that is
|
||||||
|
included in or attached to the work (an example is provided in the Appendix
|
||||||
|
below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object form, that
|
||||||
|
is based on (or derived from) the Work and for which the editorial revisions,
|
||||||
|
annotations, elaborations, or other modifications represent, as a whole, an
|
||||||
|
original work of authorship. For the purposes of this License, Derivative Works
|
||||||
|
shall not include works that remain separable from, or merely link (or bind by
|
||||||
|
name) to the interfaces of, the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including the original version
|
||||||
|
of the Work and any modifications or additions to that Work or Derivative Works
|
||||||
|
thereof, that is intentionally submitted to Licensor for inclusion in the Work
|
||||||
|
by the copyright owner or by an individual or Legal Entity authorized to submit
|
||||||
|
on behalf of the copyright owner. For the purposes of this definition,
|
||||||
|
"submitted" means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems, and
|
||||||
|
issue tracking systems that are managed by, or on behalf of, the Licensor for
|
||||||
|
the purpose of discussing and improving the Work, but excluding communication
|
||||||
|
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||||
|
owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
|
||||||
|
of whom a Contribution has been received by Licensor and subsequently
|
||||||
|
incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of this
|
||||||
|
License, each Contributor hereby grants to You a perpetual, worldwide,
|
||||||
|
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
|
||||||
|
reproduce, prepare Derivative Works of, publicly display, publicly perform,
|
||||||
|
sublicense, and distribute the Work and such Derivative Works in Source or
|
||||||
|
Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of this License,
|
||||||
|
each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
|
||||||
|
no-charge, royalty-free, irrevocable (except as stated in this section) patent
|
||||||
|
license to make, have made, use, offer to sell, sell, import, and otherwise
|
||||||
|
transfer the Work, where such license applies only to those patent claims
|
||||||
|
licensable by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s) with the Work
|
||||||
|
to which such Contribution(s) was submitted. If You institute patent litigation
|
||||||
|
against any entity (including a cross-claim or counterclaim in a lawsuit)
|
||||||
|
alleging that the Work or a Contribution incorporated within the Work
|
||||||
|
constitutes direct or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate as of the date
|
||||||
|
such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the Work or
|
||||||
|
Derivative Works thereof in any medium, with or without modifications, and in
|
||||||
|
Source or Object form, provided that You meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or Derivative Works a copy of
|
||||||
|
this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices stating that
|
||||||
|
You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works that You
|
||||||
|
distribute, all copyright, patent, trademark, and attribution notices from the
|
||||||
|
Source form of the Work, excluding those notices that do not pertain to any part
|
||||||
|
of the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its distribution, then
|
||||||
|
any Derivative Works that You distribute must include a readable copy of the
|
||||||
|
attribution notices contained within such NOTICE file, excluding those notices
|
||||||
|
that do not pertain to any part of the Derivative Works, in at least one of the
|
||||||
|
following places: within a NOTICE text file distributed as part of the
|
||||||
|
Derivative Works; within the Source form or documentation, if provided along
|
||||||
|
with the Derivative Works; or, within a display generated by the Derivative
|
||||||
|
Works, if and wherever such third-party notices normally appear. The contents of
|
||||||
|
the NOTICE file are for informational purposes only and do not modify the
|
||||||
|
License. You may add Your own attribution notices within Derivative Works that
|
||||||
|
You distribute, alongside or as an addendum to the NOTICE text from the Work,
|
||||||
|
provided that such additional attribution notices cannot be construed as
|
||||||
|
modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and may provide
|
||||||
|
additional or different license terms and conditions for use, reproduction, or
|
||||||
|
distribution of Your modifications, or for any such Derivative Works as a whole,
|
||||||
|
provided Your use, reproduction, and distribution of the Work otherwise complies
|
||||||
|
with the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise, any
|
||||||
|
Contribution intentionally submitted for inclusion in the Work by You to the
|
||||||
|
Licensor shall be under the terms and conditions of this License, without any
|
||||||
|
additional terms or conditions. Notwithstanding the above, nothing herein shall
|
||||||
|
supersede or modify the terms of any separate license agreement you may have
|
||||||
|
executed with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade names,
|
||||||
|
trademarks, service marks, or product names of the Licensor, except as required
|
||||||
|
for reasonable and customary use in describing the origin of the Work and
|
||||||
|
reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
|
||||||
|
writing, Licensor provides the Work (and each Contributor provides its
|
||||||
|
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied, including, without limitation, any warranties
|
||||||
|
or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any risks
|
||||||
|
associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory, whether in
|
||||||
|
tort (including negligence), contract, or otherwise, unless required by
|
||||||
|
applicable law (such as deliberate and grossly negligent acts) or agreed to in
|
||||||
|
writing, shall any Contributor be liable to You for damages, including any
|
||||||
|
direct, indirect, special, incidental, or consequential damages of any character
|
||||||
|
arising as a result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill, work stoppage,
|
||||||
|
computer failure or malfunction, or any and all other commercial damages or
|
||||||
|
losses), even if such Contributor has been advised of the possibility of such
|
||||||
|
damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing the Work or
|
||||||
|
Derivative Works thereof, You may choose to offer, and charge a fee for,
|
||||||
|
acceptance of support, warranty, indemnity, or other liability obligations
|
||||||
|
and/or rights consistent with this License. However, in accepting such
|
||||||
|
obligations, You may act only on Your own behalf and on Your sole
|
||||||
|
responsibility, not on behalf of any other Contributor, and only if You agree to
|
||||||
|
indemnify, defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason of your
|
||||||
|
accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following boilerplate
|
||||||
|
notice, with the fields enclosed by brackets "[]" replaced with your own
|
||||||
|
identifying information. (Don't include the brackets!) The text should be
|
||||||
|
enclosed in the appropriate comment syntax for the file format. We also
|
||||||
|
recommend that a file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier identification within
|
||||||
|
third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
175
README.md
175
README.md
@@ -1,132 +1,99 @@
|
|||||||
# Gateway Checker
|
<h1 align="center">DiAL</h1>
|
||||||
|
|
||||||
基于 Bun + TypeScript 的前后端一体化 demo。开发期使用 Vite + React 提供前端 HMR,后端由 Bun 提供 API;生产期先构建前端静态资源,再将前端资源和 Bun 后端打包为单个 executable。
|
<p align="center">
|
||||||
|
<strong>轻量级多类型拨测监控工具</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
## 项目结构
|
<p align="center">
|
||||||
|
基于 Bun + TypeScript 构建 · YAML 配置驱动 · 内置 Dashboard
|
||||||
|
</p>
|
||||||
|
|
||||||
```text
|
---
|
||||||
src/
|
|
||||||
server/ Bun 后端运行时、API、静态资源 fallback
|
|
||||||
shared/ 前后端共享 TypeScript 类型
|
|
||||||
web/ Vite + React 前端 demo
|
|
||||||
scripts/ 开发、构建和 smoke test 脚本
|
|
||||||
tests/ Bun test 测试
|
|
||||||
openspec/ OpenSpec 变更与规格文档
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发命令
|
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**DNS**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
|
||||||
|
|
||||||
|
## 功能亮点
|
||||||
|
|
||||||
|
- 多类型拨测:HTTP、Cmd、DB、TCP、UDP、DNS、ICMP、LLM
|
||||||
|
- 丰富校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
||||||
|
- 结构化观测数据:HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出、LLM token 用量等
|
||||||
|
- 内置 Dashboard:实时状态、可用率统计、趋势图、最近状态条、手动/自动刷新、版本号展示
|
||||||
|
- 多主题支持:系统、明亮、黑暗三种主题模式
|
||||||
|
- 自托管部署:本地 SQLite 存储,无需额外数据库服务
|
||||||
|
|
||||||
|
## 应用截图
|
||||||
|
|
||||||
|
| | 亮色 | 暗色 |
|
||||||
|
| ------ | --------------------------------------------------- | ------------------------------------------------- |
|
||||||
|
| 主页 |  |  |
|
||||||
|
| 详情页 |  |  |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
**前置条件:** [Bun](https://bun.sh/) >= 1.0
|
||||||
|
|
||||||
|
ICMP checker 依赖系统 `ping` 命令。精简容器镜像需额外安装,例如 Alpine 可安装 `iputils-ping`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone https://github.com/your-org/DiAL.git
|
||||||
|
cd DiAL
|
||||||
bun install
|
bun install
|
||||||
bun run dev
|
cp probes.example.yaml probes.yaml
|
||||||
|
bun run dev probes.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
`bun run dev` 会同时启动:
|
`bun run dev` 会同时启动 Vite 开发服务器(`http://127.0.0.1:5173`)和 API 服务器(`http://127.0.0.1:3000`),访问前端地址即可使用 Dashboard。
|
||||||
|
|
||||||
- Bun 后端:默认 `http://127.0.0.1:3000`
|
## 最小配置示例
|
||||||
- Vite 前端:默认 `http://127.0.0.1:5173`
|
|
||||||
|
|
||||||
开发期请打开 Vite 前端地址。前端通过相对路径 `/api/demo` 调用后端,Vite 会把 `/api/*` 代理到 Bun 后端,因此浏览器不需要 CORS 配置。
|
```yaml
|
||||||
|
# yaml-language-server: $schema=./probe-config.schema.json
|
||||||
|
|
||||||
全栈开发命令使用 `PORT` 作为后端端口覆盖来源,并将同一端口传给 Vite proxy:
|
targets:
|
||||||
|
- id: "baidu-home"
|
||||||
```bash
|
name: "Baidu"
|
||||||
PORT=4000 bun run dev
|
type: http
|
||||||
|
http:
|
||||||
|
url: "https://www.baidu.com"
|
||||||
|
expect:
|
||||||
|
status: [200]
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以分别运行:
|
完整配置、checker、expect 和部署说明参见 [用户文档](docs/user/README.md)、[配置文件](docs/user/configuration.md)、[Checker 参考](docs/user/checkers/README.md) 和 [校验规则](docs/user/expectations.md)。
|
||||||
|
|
||||||
```bash
|
## 生产运行
|
||||||
bun run dev:server
|
|
||||||
bun run dev:web
|
|
||||||
```
|
|
||||||
|
|
||||||
分别运行时,若后端不是默认 `3000` 端口,需要为 Vite 指定同一个后端端口:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PORT=4000 bun run dev:server
|
|
||||||
BACKEND_PORT=4000 bun run dev:web
|
|
||||||
```
|
|
||||||
|
|
||||||
## 代码质量
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run lint
|
|
||||||
bun run format:check
|
|
||||||
bun run format
|
|
||||||
bun run check
|
|
||||||
```
|
|
||||||
|
|
||||||
- `lint` 使用 ESLint 检查 TypeScript、React Hooks 和前后端边界。
|
|
||||||
- `format:check` 使用 Prettier 检查代码格式。
|
|
||||||
- `format` 使用 Prettier 重写受管理文件格式。
|
|
||||||
- `check` 依次运行 `typecheck`、`lint`、`format:check` 和单元测试,适合日常开发期间快速验证。
|
|
||||||
|
|
||||||
Prettier 不格式化 `openspec/`、`dist/`、`.build/`、`node_modules/`、`bun.lock` 和临时构建产物。
|
|
||||||
|
|
||||||
## Demo 验证
|
|
||||||
|
|
||||||
开发期打开 `http://127.0.0.1:5173`,页面应显示 `/api/demo` 返回的后端 message、Bun 版本、平台和响应时间。
|
|
||||||
|
|
||||||
直接验证 API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://127.0.0.1:3000/api/demo
|
|
||||||
curl http://127.0.0.1:3000/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## 构建 executable
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run build
|
bun run build
|
||||||
|
./dist/dial-server ./probes.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
构建流程:
|
Docker、跨平台发布包和运行时注意事项参见 [部署文档](docs/user/deployment.md)。
|
||||||
|
|
||||||
- 运行 `vite build`,输出前端资源到 `dist/web`
|
## 文档导航
|
||||||
- 生成临时 `.build/static-assets.ts`,用 Bun file import 嵌入 Vite 产物
|
|
||||||
- 生成临时 `.build/server-entry.ts`,作为生产 executable 的 server 入口
|
|
||||||
- 运行 `Bun.build({ compile })`,输出 `dist/gateway-checker`
|
|
||||||
|
|
||||||
运行 executable:
|
| 入口 | 内容 |
|
||||||
|
| -------------------------------------------- | ------------------------------------------- |
|
||||||
|
| [文档总览](docs/README.md) | 全部文档入口和文档归属矩阵 |
|
||||||
|
| [用户文档](docs/user/README.md) | 配置、部署、expect、排障和 checker 使用入口 |
|
||||||
|
| [配置文件](docs/user/configuration.md) | YAML 结构、变量、server、targets 通用字段 |
|
||||||
|
| [Checker 参考](docs/user/checkers/README.md) | 所有 checker 的配置、expect 和示例 |
|
||||||
|
| [校验规则](docs/user/expectations.md) | expect 规则、状态判定、failure、observation |
|
||||||
|
| [部署文档](docs/user/deployment.md) | 构建、Docker、发布包和容器运行边界 |
|
||||||
|
| [故障排查](docs/user/troubleshooting.md) | 常见运行问题和排查入口 |
|
||||||
|
| [开发文档](docs/development/README.md) | 开发入口、常用命令、质量门禁和专题索引 |
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./dist/gateway-checker
|
bun run check # schema:check + typecheck + lint + test
|
||||||
|
bun run verify # check + build
|
||||||
```
|
```
|
||||||
|
|
||||||
生产期默认访问 `http://127.0.0.1:3000`。同一个 executable 会服务 `/api/demo`、`/health`、`/assets/*` 和前端 SPA fallback。
|
开发入口参见 [开发文档](docs/development/README.md)。新增或修改 checker 前请先阅读 [Checker 开发](docs/development/checker.md)。
|
||||||
|
|
||||||
## 运行参数
|
## License
|
||||||
|
|
||||||
默认配置:
|
Apache-2.0
|
||||||
|
|
||||||
- `HOST=127.0.0.1`
|
|
||||||
- `PORT=3000`
|
|
||||||
|
|
||||||
可以通过环境变量或 CLI 参数覆盖:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PORT=4000 ./dist/gateway-checker
|
|
||||||
./dist/gateway-checker --host 0.0.0.0 --port 4000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run check
|
|
||||||
bun run verify
|
|
||||||
```
|
|
||||||
|
|
||||||
- `check` 适合日常开发,包含类型检查、lint、格式检查和单元测试。
|
|
||||||
- `verify` 适合提交前或发布前,先运行 `check`,再重新构建生产 executable 并运行 smoke test。
|
|
||||||
- `test:smoke` 会启动生成的 executable,并检查 `/api/demo`、`/health`、前端根路径、静态资源、未知 API、未知静态资源、生产模式、缓存响应头、低风险安全响应头和 SPA fallback。
|
|
||||||
|
|
||||||
## 前后端边界
|
|
||||||
|
|
||||||
前端只通过 HTTP 调用后端,默认 API 路径为相对 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。
|
|
||||||
|
|
||||||
这保证了当前可以单文件部署,也保留未来将前端拆到 CDN 或独立静态站点的路径。
|
|
||||||
|
|
||||||
## 已知限制
|
|
||||||
|
|
||||||
当前 demo 不包含数据库、认证、SSR、React Router 或 UI 组件库。单 executable 是按目标平台构建的产物,不是一个文件同时覆盖 macOS、Linux 和 Windows。
|
|
||||||
|
|||||||
BIN
assets/screenshot/dark_detail.png
Normal file
BIN
assets/screenshot/dark_detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 340 KiB |
BIN
assets/screenshot/dark_index.png
Normal file
BIN
assets/screenshot/dark_index.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 349 KiB |
BIN
assets/screenshot/light_detail.png
Normal file
BIN
assets/screenshot/light_detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
BIN
assets/screenshot/light_index.png
Normal file
BIN
assets/screenshot/light_index.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 484 KiB |
3
bunfig.toml
Normal file
3
bunfig.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./tests/setup.ts"]
|
||||||
|
exclude = ["./tests/e2e/**"]
|
||||||
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"]],
|
||||||
|
},
|
||||||
|
};
|
||||||
17
docker/probes.yaml
Normal file
17
docker/probes.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# yaml-language-server: $schema=../probe-config.schema.json
|
||||||
|
|
||||||
|
server:
|
||||||
|
listen:
|
||||||
|
host: "${DIAL_HOST|0.0.0.0}"
|
||||||
|
port: "${DIAL_PORT|3000}"
|
||||||
|
storage:
|
||||||
|
dataDir: "${DIAL_DATA_DIR|/data/dial}"
|
||||||
|
|
||||||
|
targets:
|
||||||
|
- id: "self-health"
|
||||||
|
name: "DiAL 自检"
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "http://127.0.0.1:${DIAL_PORT|3000}/health"
|
||||||
|
expect:
|
||||||
|
status: [200]
|
||||||
127
docs/README.md
Normal file
127
docs/README.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# DiAL 文档
|
||||||
|
|
||||||
|
本文档是 DiAL 的文档路由入口。AI 工具和开发者应先阅读本文件判断本次任务需要读取和更新哪些专题文档,再按任务类型读取最小必要上下文。
|
||||||
|
|
||||||
|
## 目录索引
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/
|
||||||
|
README.md
|
||||||
|
development/
|
||||||
|
README.md
|
||||||
|
architecture.md
|
||||||
|
backend.md
|
||||||
|
frontend.md
|
||||||
|
release.md
|
||||||
|
checker.md
|
||||||
|
user/
|
||||||
|
README.md
|
||||||
|
configuration.md
|
||||||
|
deployment.md
|
||||||
|
expectations.md
|
||||||
|
troubleshooting.md
|
||||||
|
checkers/
|
||||||
|
README.md
|
||||||
|
http.md
|
||||||
|
cmd.md
|
||||||
|
db.md
|
||||||
|
tcp.md
|
||||||
|
udp.md
|
||||||
|
icmp.md
|
||||||
|
dns.md
|
||||||
|
llm.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`docs/prompts/` 是提示词资产目录,不属于常规开发流程和用户使用文档。代码、配置、部署或 checker 变更不需要更新该目录,除非任务明确要求维护提示词资产。
|
||||||
|
|
||||||
|
## 入口文档
|
||||||
|
|
||||||
|
| 入口 | 定位 |
|
||||||
|
| ------------------------------------------- | ------------------------------------------ |
|
||||||
|
| [项目 README](../README.md) | 项目整体介绍、快速开始、核心能力、文档引导 |
|
||||||
|
| [开发文档](development/README.md) | 开发入口、全局规则、常用命令、质量门禁 |
|
||||||
|
| [用户文档](user/README.md) | 用户使用入口、配置、部署、expect、排障 |
|
||||||
|
| [Checker 用户参考](user/checkers/README.md) | 各 checker 的配置项、expect 字段和示例 |
|
||||||
|
|
||||||
|
## 按任务阅读路径
|
||||||
|
|
||||||
|
| 任务 | 必读文档 |
|
||||||
|
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
|
||||||
|
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
||||||
|
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
|
||||||
|
| 修改后端 API、store、engine、logger | [开发文档](development/README.md)、[后端开发](development/backend.md) |
|
||||||
|
| 修改前端 | [开发文档](development/README.md)、[前端开发](development/frontend.md) |
|
||||||
|
| 新增或修改 checker | [Checker 开发](development/checker.md)、[Checker 用户参考](user/checkers/README.md)、相近 checker 文档 |
|
||||||
|
| 修改配置 schema | [配置文件](user/configuration.md)、[后端开发](development/backend.md)、相关 checker 文档 |
|
||||||
|
| 修改 expect 或状态模型 | [校验规则](user/expectations.md)、[后端开发](development/backend.md)、[Checker 开发](development/checker.md) |
|
||||||
|
| 修改构建、Docker、release | [构建与发布](development/release.md)、[部署文档](user/deployment.md) |
|
||||||
|
| 修改故障处理或运行依赖 | [故障排查](user/troubleshooting.md)、相关用户文档 |
|
||||||
|
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
||||||
|
|
||||||
|
## 文档归属矩阵
|
||||||
|
|
||||||
|
| 变更类型 | 默认更新位置 |
|
||||||
|
| -------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||||
|
| 项目定位、核心能力、快速开始、顶层文档导航 | `README.md` |
|
||||||
|
| 文档路由、文档更新规则、文档归属矩阵 | `docs/README.md`、`openspec/config.yaml` |
|
||||||
|
| 开发入口、常用命令、质量门禁、全局工程规则、OpenSpec 约定 | `docs/development/README.md` |
|
||||||
|
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
|
||||||
|
| 后端 API、共享类型、store、engine、logger、expect 基础设施 | `docs/development/backend.md` |
|
||||||
|
| 前端技术栈、组件、样式、数据层、前端测试 | `docs/development/frontend.md` |
|
||||||
|
| checker 开发机制、schema/validate/resolve/execute/expect 约定 | `docs/development/checker.md` |
|
||||||
|
| 构建、发布、Dockerfile、脚本、前后端静态资源集成 | `docs/development/release.md` |
|
||||||
|
| YAML 顶层结构、server、variables、targets 通用字段 | `docs/user/configuration.md` |
|
||||||
|
| checker 配置、expect 字段、示例、用户可见 checker 行为 | `docs/user/checkers/<type>.md`、`docs/user/checkers/README.md` |
|
||||||
|
| ValueMatcher、ContentExpectations、KeyedExpectations、状态模型 | `docs/user/expectations.md` |
|
||||||
|
| 构建产物运行、Docker 参数、发布包、运行时依赖 | `docs/user/deployment.md` |
|
||||||
|
| 常见运行问题、依赖命令、容器权限、配置校验问题 | `docs/user/troubleshooting.md` |
|
||||||
|
|
||||||
|
## development 文档如何更新
|
||||||
|
|
||||||
|
开发文档解释“如何实现和维护”。代码变更影响开发者理解、开发流程、测试方式或架构边界时,必须更新 `docs/development/` 对应文档。
|
||||||
|
|
||||||
|
- 全局规则、常用命令、质量门禁、目录边界、OpenSpec 约定更新到 `docs/development/README.md`。
|
||||||
|
- 架构图、启动链路、运行时流程、前后端边界更新到 `docs/development/architecture.md`。
|
||||||
|
- 后端 API、配置加载、store、engine、logger、expect 基础设施和后端测试规范更新到 `docs/development/backend.md`。
|
||||||
|
- 前端技术栈、组件边界、数据流、样式规则和前端测试规范更新到 `docs/development/frontend.md`。
|
||||||
|
- checker 开发机制、文件结构、schema、validate、resolve、execute、expect、测试 checklist 更新到 `docs/development/checker.md`。
|
||||||
|
- 构建、Docker、release、脚本和发布验证更新到 `docs/development/release.md`。
|
||||||
|
- 不新增“杂项”开发文档;优先把内容放入上述最贴近的专题,确需新增专题时先更新本文档和 `openspec/config.yaml`。
|
||||||
|
|
||||||
|
## user 文档如何更新
|
||||||
|
|
||||||
|
用户文档解释“如何使用”和“用户能观察到什么”。变更影响用户配置、运行、部署、checker 行为、expect 规则、状态结果或排障方式时,必须更新 `docs/user/` 对应文档。
|
||||||
|
|
||||||
|
- 配置事实来源是 TypeBox schema、`probe-config.schema.json`、语义校验器和测试;`docs/user/configuration.md` 负责解释顶层结构和通用字段。
|
||||||
|
- checker 专属字段和示例只在 `docs/user/checkers/<type>.md` 完整展开,`docs/user/checkers/README.md` 只维护类型索引和选择建议。
|
||||||
|
- expect 断言模型、UP/DOWN、`failure`、`observation`、快速失败顺序更新到 `docs/user/expectations.md`。
|
||||||
|
- Docker、生产运行、发布包和运行时依赖更新到 `docs/user/deployment.md`。
|
||||||
|
- 常见错误和排查路径更新到 `docs/user/troubleshooting.md`。
|
||||||
|
- 用户文档避免解释内部实现细节,需要实现细节时链接到 `docs/development/`。
|
||||||
|
|
||||||
|
## 文档影响分析
|
||||||
|
|
||||||
|
每次代码变更都必须执行文档影响分析。
|
||||||
|
|
||||||
|
```text
|
||||||
|
代码或配置变更
|
||||||
|
-> 用户能感知吗?更新 docs/user/ 或 README.md
|
||||||
|
-> 开发者需要知道吗?更新 docs/development/
|
||||||
|
-> 文档规则变化吗?更新 docs/README.md 和 openspec/config.yaml
|
||||||
|
-> 都不是?收尾说明写明无需更新文档及原因
|
||||||
|
```
|
||||||
|
|
||||||
|
同一事实只在最贴近读者的文档中完整展开,其他文档使用链接引用。根目录 README 保持轻量,不承载完整配置参考、checker 表或实现教程。
|
||||||
|
|
||||||
|
## 收尾说明示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
文档影响分析:本次修改了 HTTP checker 的配置字段,已更新 docs/user/checkers/http.md、docs/user/configuration.md 和 probe-config.schema.json。
|
||||||
|
```
|
||||||
|
|
||||||
|
无需更新文档时:
|
||||||
|
|
||||||
|
```text
|
||||||
|
文档影响分析:本次仅调整内部测试 helper,未改变用户可见行为、配置、架构边界或开发流程,因此无需更新文档。
|
||||||
|
```
|
||||||
115
docs/development/README.md
Normal file
115
docs/development/README.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# 开发文档
|
||||||
|
|
||||||
|
本文档是 DiAL 的开发入口。AI 工具和开发者应先阅读 [`../README.md`](../README.md) 判断文档归属,再阅读本文和最小必要专题。
|
||||||
|
|
||||||
|
适用场景:修改源码、测试、构建脚本、开发流程、架构边界、checker 开发机制或项目工程规则。
|
||||||
|
|
||||||
|
## 专题索引
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
| ---------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
|
| [architecture.md](architecture.md) | 项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界 |
|
||||||
|
| [backend.md](backend.md) | 后端库优先级、API 路由、共享 helpers、类型规范、配置契约、store、engine、logger、expect、错误模型 |
|
||||||
|
| [frontend.md](frontend.md) | React、TDesign、TanStack Query、组件、样式和前端测试规范 |
|
||||||
|
| [checker.md](checker.md) | 新增或修改 checker 的实现机制、测试要求、文档同步和 checklist |
|
||||||
|
| [release.md](release.md) | 开发服务、前后端集成、构建、Docker、release、脚本、环境变量 |
|
||||||
|
| [../README.md](../README.md) | 文档路由、文档归属矩阵、development/user 文档更新规则 |
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
| -------------------------------- | ---------------------------------------- |
|
||||||
|
| `bun install` | 安装依赖 |
|
||||||
|
| `bun run dev probes.yaml` | 启动双进程开发环境 |
|
||||||
|
| `bun run dev:server probes.yaml` | 仅启动后端 API server |
|
||||||
|
| `bun run dev:web` | 仅启动 Vite dev server |
|
||||||
|
| `bun run schema` | 生成 `probe-config.schema.json` |
|
||||||
|
| `bun run schema:check` | 检查导出 schema 是否同步 |
|
||||||
|
| `bun run typecheck` | TypeScript 类型检查 |
|
||||||
|
| `bun run lint` | ESLint 和 Prettier 格式检查 |
|
||||||
|
| `bun run format` | Prettier 自动格式化 |
|
||||||
|
| `bun test` | 运行全部测试 |
|
||||||
|
| `bun run check` | `schema:check + typecheck + lint + test` |
|
||||||
|
| `bun run build` | 构建生产可执行文件 |
|
||||||
|
| `bun run verify` | `check + build` 完整验证 |
|
||||||
|
| `bun run release` | 跨平台发布打包 |
|
||||||
|
| `bun run clean` | 清理构建缓存与产物 |
|
||||||
|
|
||||||
|
## 质量门禁
|
||||||
|
|
||||||
|
代码变更必须按影响范围执行验证。
|
||||||
|
|
||||||
|
| 变更类型 | 必跑命令 |
|
||||||
|
| -------------------------------- | --------------------------------------------------------- |
|
||||||
|
| 常规代码变更 | `bun run check` |
|
||||||
|
| 构建、部署、发布、前后端集成变更 | `bun run verify` |
|
||||||
|
| 配置 schema 变化 | `bun run schema`、`bun run schema:check`、`bun run check` |
|
||||||
|
| checker 新增或修改 | `bun run schema`、`bun run schema:check`、`bun run check` |
|
||||||
|
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
|
||||||
|
|
||||||
|
正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
|
||||||
|
|
||||||
|
## 全局工程规则
|
||||||
|
|
||||||
|
- 使用中文编写注释、文档和项目内交流内容。
|
||||||
|
- 仅使用 `bun` 作为包管理器,禁止使用 npm、pnpm、yarn。
|
||||||
|
- 运行工具使用 `bunx`,禁止使用 npx、pnpx。
|
||||||
|
- 新增代码优先复用已有组件、工具和依赖库,不引入新依赖;确需新增依赖时先说明原因。
|
||||||
|
- 后端优先使用 Bun 内置 API,其次是 es-toolkit、标准 Web API、主流三方库,最后才自行实现。
|
||||||
|
- 前端样式优先使用 TDesign 组件、组件 props、TDesign CSS tokens、`styles.css` CSS 类,最后才自行开发组件。
|
||||||
|
- 前端禁止组件内联 `style`、覆盖 TDesign 内部类名、使用 `!important`、硬编码色值。
|
||||||
|
- 当前项目未上线,不需要为旧行为做向前兼容,除非用户明确要求。
|
||||||
|
|
||||||
|
## 包管理、依赖与提交
|
||||||
|
|
||||||
|
- 仅使用 `bun` 安装依赖和运行项目脚本,锁文件为 `bun.lock`。
|
||||||
|
- 新增依赖前先确认 Bun 内置 API、es-toolkit、标准 Web API、现有三方库和项目公共工具是否已满足需求。
|
||||||
|
- Git 提交信息使用中文,格式为 `类型: 简短描述`。
|
||||||
|
- 提交类型限定为 `feat`、`fix`、`refactor`、`docs`、`style`、`test`、`chore`。
|
||||||
|
- 多行提交描述时,标题和正文之间空一行。
|
||||||
|
|
||||||
|
## 目录边界
|
||||||
|
|
||||||
|
| 目录 | 约定 |
|
||||||
|
| ------------------- | ---------------------------------------------------------- |
|
||||||
|
| `src/server/` | Bun 后端代码,不能 import `src/web/`,HTML import 集成除外 |
|
||||||
|
| `src/web/` | React Dashboard,不能 import `src/server/` 运行时实现 |
|
||||||
|
| `src/shared/` | 前后端共享 TypeScript 类型 |
|
||||||
|
| `scripts/` | 独立运行脚本,可 import 项目源码 |
|
||||||
|
| `tests/` | 测试目录,结构镜像 `src/` |
|
||||||
|
| `docs/user/` | 用户使用、配置、部署、checker 和排障文档 |
|
||||||
|
| `docs/development/` | 架构、后端、前端、发布和 checker 开发文档 |
|
||||||
|
| `openspec/` | OpenSpec 变更管理与规格文档 |
|
||||||
|
|
||||||
|
## 文档影响分析
|
||||||
|
|
||||||
|
每次代码变更都必须执行文档影响分析。
|
||||||
|
|
||||||
|
| 如果变更影响 | 更新 |
|
||||||
|
| --------------------------------------------------- | ------------------------------------------ |
|
||||||
|
| 用户可见行为、配置、checker、expect、部署、状态模型 | `docs/user/` 对应文档 |
|
||||||
|
| 开发流程、架构、测试、构建发布、checker 开发机制 | `docs/development/` 对应文档 |
|
||||||
|
| 项目定位、快速开始、核心能力列表、文档导航 | `README.md` |
|
||||||
|
| 文档同步规则或文档归属矩阵 | `docs/README.md` 和 `openspec/config.yaml` |
|
||||||
|
|
||||||
|
如果无需更新文档,必须在收尾说明中说明原因。详细规则见 [文档总览](../README.md)。
|
||||||
|
|
||||||
|
## OpenSpec 协作规则
|
||||||
|
|
||||||
|
- 本项目 OpenSpec 使用 `fast-drive` schema,变更文档只包含 `design.md` 和 `tasks.md`,不创建 `proposal.md` 或 `specs/*.md`。
|
||||||
|
- `design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth。
|
||||||
|
- `tasks.md` 必须从 `design.md` 派生,一行一个 checkbox 任务。
|
||||||
|
- 实现阶段按 `tasks.md` 顺序执行,完成后立即标记任务状态。
|
||||||
|
|
||||||
|
## 事实来源
|
||||||
|
|
||||||
|
| 主题 | 事实来源 |
|
||||||
|
| -------------- | ---------------------------------------------------------- |
|
||||||
|
| 代码结构和实现 | `src/`、`scripts/`、`tests/` |
|
||||||
|
| 配置 schema | TypeBox fragments、`probe-config.schema.json`、schema 测试 |
|
||||||
|
| 项目全局规则 | `openspec/config.yaml`、本文档、本目录专题文档 |
|
||||||
|
| checker 流程 | [checker.md](checker.md) |
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改常用命令、质量门禁、全局工程规则、目录边界、OpenSpec 协作方式或开发文档索引时,必须更新本文档。
|
||||||
114
docs/development/architecture.md
Normal file
114
docs/development/architecture.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# 架构与边界
|
||||||
|
|
||||||
|
本文档说明 DiAL 的项目结构、启动链路、运行时流程、HTTP 请求流程和前后端边界。
|
||||||
|
|
||||||
|
适用场景:修改目录边界、启动流程、运行时调度、HTTP server、前后端集成方式或主要模块职责。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
server/
|
||||||
|
bootstrap.ts
|
||||||
|
config.ts
|
||||||
|
dev.ts
|
||||||
|
logger.ts
|
||||||
|
main.ts
|
||||||
|
server.ts
|
||||||
|
helpers.ts
|
||||||
|
middleware.ts
|
||||||
|
version.ts
|
||||||
|
routes/
|
||||||
|
checker/
|
||||||
|
config-loader.ts
|
||||||
|
variables.ts
|
||||||
|
schema/
|
||||||
|
store.ts
|
||||||
|
engine.ts
|
||||||
|
expect/
|
||||||
|
runner/
|
||||||
|
shared/
|
||||||
|
api.ts
|
||||||
|
web/
|
||||||
|
app.tsx
|
||||||
|
main.tsx
|
||||||
|
styles.css
|
||||||
|
components/
|
||||||
|
constants/
|
||||||
|
hooks/
|
||||||
|
utils/
|
||||||
|
scripts/
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
openspec/
|
||||||
|
probe-config.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启动流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
dev.ts / main.ts
|
||||||
|
-> readRuntimeConfig(cli args)
|
||||||
|
-> bootstrap({ configPath, mode })
|
||||||
|
-> loadConfig(yaml)
|
||||||
|
-> createRuntimeLogger(logging)
|
||||||
|
-> ProbeStore(db)
|
||||||
|
-> store.syncTargets(targets)
|
||||||
|
-> ProbeEngine(...).start()
|
||||||
|
-> startServer({ config, mode, store, logger })
|
||||||
|
-> 注册 SIGINT/SIGTERM shutdown
|
||||||
|
```
|
||||||
|
|
||||||
|
`loadConfig()` 的处理顺序:YAML 解析 -> Authoring normalize(变量替换 + expect 简写展开)-> Normalized 契约校验 -> 语义校验 -> resolve。
|
||||||
|
|
||||||
|
## 运行时流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
定时器 tick
|
||||||
|
-> ProbeEngine.probeGroup()
|
||||||
|
-> checkerRegistry.get(target.type).execute()
|
||||||
|
-> runner/*/expect.ts 校验
|
||||||
|
-> engine.writeResult()
|
||||||
|
-> store.insertCheckResult()
|
||||||
|
```
|
||||||
|
|
||||||
|
数据清理由 engine 定时调用 `store.prune(retentionMs)`,每小时执行一次。
|
||||||
|
|
||||||
|
## HTTP 请求流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
Request
|
||||||
|
-> Bun.serve routes 声明式匹配
|
||||||
|
-> routes/*.ts handler
|
||||||
|
-> middleware.ts 参数校验
|
||||||
|
-> helpers.ts 响应格式化
|
||||||
|
-> Response
|
||||||
|
```
|
||||||
|
|
||||||
|
生产模式下,非 API 路径由 fetch fallback 处理静态资源和 SPA fallback。开发模式下,Vite proxy 将 `/api` 和 `/health` 请求转发到 Bun API server。
|
||||||
|
|
||||||
|
## 前后端边界
|
||||||
|
|
||||||
|
- 前端只通过 HTTP 调用后端,API 路径为 `/api/*`。
|
||||||
|
- 共享类型放在 `src/shared/`。
|
||||||
|
- 前端不得 import `src/server/` 的运行时实现。
|
||||||
|
- 后端不得依赖 `src/web/` 运行时代码,HTML import 集成除外。
|
||||||
|
|
||||||
|
## 主要模块职责
|
||||||
|
|
||||||
|
| 模块 | 职责 |
|
||||||
|
| ------------------------------------- | ------------------------------------------- |
|
||||||
|
| `src/server/bootstrap.ts` | 统一启动引导和 shutdown 编排 |
|
||||||
|
| `src/server/server.ts` | Bun HTTP server 和 routes 注册 |
|
||||||
|
| `src/server/routes/` | API handler,按端点拆分 |
|
||||||
|
| `src/server/checker/config-loader.ts` | YAML 解析、契约校验、语义校验、resolve 调度 |
|
||||||
|
| `src/server/checker/store.ts` | SQLite 数据存储 |
|
||||||
|
| `src/server/checker/engine.ts` | 定时调度、并发控制、结果写入、数据清理 |
|
||||||
|
| `src/server/checker/runner/` | 各 checker 自包含实现 |
|
||||||
|
| `src/server/checker/expect/` | 跨 checker 复用的断言基础设施 |
|
||||||
|
| `src/web/` | React Dashboard |
|
||||||
|
| `src/shared/api.ts` | 前后端共享 API 类型 |
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界或主要模块职责时,必须更新本文档。
|
||||||
142
docs/development/backend.md
Normal file
142
docs/development/backend.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# 后端开发
|
||||||
|
|
||||||
|
本文档说明 DiAL 后端的 API、配置加载、存储、拨测引擎、日志、expect 和错误模型开发约定。
|
||||||
|
|
||||||
|
适用场景:修改 `src/server/`、`src/shared/api.ts`、后端测试、配置契约、API 响应、store、engine、logger 或 expect 基础设施。
|
||||||
|
|
||||||
|
## 库使用优先级
|
||||||
|
|
||||||
|
| 优先级 | 来源 | 典型用途 |
|
||||||
|
| ------ | ------------ | -------------------------------------------------------------- |
|
||||||
|
| 1 | Bun 内置 API | `Bun.serve`、`bun:sqlite`、`Bun.spawn`、`Bun.file`、`Bun.YAML` |
|
||||||
|
| 2 | es-toolkit | 类型判断、深度比较、错误判断、并发控制、集合操作 |
|
||||||
|
| 3 | 标准 Web API | `Object.fromEntries`、`Headers`、`fetch`、`AbortController` |
|
||||||
|
| 4 | 主流三方库 | cheerio、xpath、@xmldom/xmldom |
|
||||||
|
| 5 | 自行实现 | 仅在以上都无法满足时 |
|
||||||
|
|
||||||
|
新增依赖前必须先检查上述每一层是否已有可用方案。
|
||||||
|
|
||||||
|
## API 路由开发
|
||||||
|
|
||||||
|
路由文件位于 `src/server/routes/`,每个端点一个文件。路由通过 `server.ts` 的 `Bun.serve({ routes })` 声明式注册,使用 per-method handler 对象。
|
||||||
|
|
||||||
|
新增路由步骤:
|
||||||
|
|
||||||
|
1. 在 `src/server/routes/` 下创建 `<name>.ts`。
|
||||||
|
2. 实现 handler 函数并 export。
|
||||||
|
3. 在 `server.ts` 的 `routes` 对象中注册路径和 method handler。
|
||||||
|
4. 在 `tests/server/app.test.ts` 中添加集成测试。
|
||||||
|
|
||||||
|
请求参数校验使用 `middleware.ts` 提供的 `validateTargetId`、`validateTimeRange`、`validatePagination`、`validateDashboardWindow`、`validateRecentLimit`、`validateMetricsBucket`。
|
||||||
|
|
||||||
|
## 共享 helpers
|
||||||
|
|
||||||
|
| 函数 | 用途 |
|
||||||
|
| ------------------------------- | ------------------------------------ |
|
||||||
|
| `createApiError(error, status)` | 构造 API 错误体 |
|
||||||
|
| `createHeaders(mode, init)` | 创建响应 Headers,生产模式附加安全头 |
|
||||||
|
| `createHealthResponse()` | 构造健康检查响应 |
|
||||||
|
| `formatDuration(ms)` | 毫秒转为可读时长字符串 |
|
||||||
|
| `jsonResponse(body, options)` | JSON 响应构造 |
|
||||||
|
| `mapCheckResult(row, type)` | 数据库行转 API CheckResult |
|
||||||
|
|
||||||
|
## 类型规范
|
||||||
|
|
||||||
|
- 共享类型以 `src/shared/api.ts` 为唯一源头。
|
||||||
|
- 严格联合类型优先于宽类型。
|
||||||
|
- 存储层类型与 API 类型分离。
|
||||||
|
- checker 具体类型在各自目录定义,中间层通过 base interface 和 registry 完成类型擦除。
|
||||||
|
- 纯类型导入使用 `import type`。
|
||||||
|
|
||||||
|
## 配置契约与校验
|
||||||
|
|
||||||
|
配置加载流程固定为:`unknown -> AuthoringProbeConfig -> NormalizedProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`。
|
||||||
|
|
||||||
|
| 层级 | 职责 |
|
||||||
|
| ---------- | ------------------------------------------------ |
|
||||||
|
| Authoring | 用户 YAML 可书写形态,允许变量引用和 expect 简写 |
|
||||||
|
| Normalized | 变量替换和 expect 简写展开后的契约校验形态 |
|
||||||
|
| Validated | 通过契约校验和语义校验的形态 |
|
||||||
|
| Resolved | checker `resolve()` 后的运行期配置 |
|
||||||
|
|
||||||
|
Ajv 保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。默认对象策略是 `additionalProperties: false`,只有明确的动态键值表可以开放任意键名。
|
||||||
|
|
||||||
|
新增或修改配置字段时必须同步更新 TypeBox schema fragments、`probe-config.schema.json`、语义 validator、测试和对应用户文档,并运行 `bun run schema:check`。
|
||||||
|
|
||||||
|
## 数据存储
|
||||||
|
|
||||||
|
存储层基于 `bun:sqlite`,WAL 模式运行,数据库文件位于配置的 `dataDir` 下。
|
||||||
|
|
||||||
|
| 方法 | 用途 |
|
||||||
|
| ------------------------------------------ | ---------------------------------- |
|
||||||
|
| `syncTargets(targets)` | 启动期同步 targets |
|
||||||
|
| `insertCheckResult()` | 写入单条检查结果 |
|
||||||
|
| `getTargets()` | 查询全部 targets |
|
||||||
|
| `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果 |
|
||||||
|
| `getAllTargetWindowStats(from, to)` | 批量获取窗口基础计数 |
|
||||||
|
| `getDashboardIncidentStates(from, to)` | 获取 Dashboard 窗口状态序列 |
|
||||||
|
| `getAllRecentSamples(limit)` | 批量获取最近采样 |
|
||||||
|
| `getTargetCheckpoints(targetId, from, to)` | 获取单目标窗口检查点序列 |
|
||||||
|
| `getTargetDurations(targetId, from, to)` | 获取单目标成功耗时数组 |
|
||||||
|
| `getHistory()` | 分页查询历史记录 |
|
||||||
|
| `prune(retentionMs)` | 清理过期数据 |
|
||||||
|
|
||||||
|
数据库只负责存储、筛选、排序、分页、LIMIT 和基础聚合。指标语义在后端应用层实现。
|
||||||
|
|
||||||
|
## 拨测引擎
|
||||||
|
|
||||||
|
- 按 interval 分组,每组独立定时触发。
|
||||||
|
- 使用 `es-toolkit/Semaphore` 限制全局最大并发数。
|
||||||
|
- 通过 `checkerRegistry.get(target.type)` 选择 runner。
|
||||||
|
- 每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort。
|
||||||
|
- 状态变化通过注入的 `Logger` 输出结构化日志。
|
||||||
|
|
||||||
|
## 日志模块
|
||||||
|
|
||||||
|
后端运行时代码统一通过 `Logger` 接口输出日志,禁止直接使用 `console.*`。配置加载失败前使用 `ConsoleFallbackLogger`。
|
||||||
|
|
||||||
|
| 实现 | 用途 |
|
||||||
|
| ----------------------- | --------------------------------------------- |
|
||||||
|
| `PinoLoggerWrapper` | 生产运行时,封装 Pino、pino-pretty、pino-roll |
|
||||||
|
| `NoopLogger` | 静默丢弃日志 |
|
||||||
|
| `MemoryLogger` | 测试替身 |
|
||||||
|
| `ConsoleFallbackLogger` | 配置加载失败前的降级日志 |
|
||||||
|
|
||||||
|
敏感信息会自动 redact `authorization`、`cookie`、`set-cookie`、`authToken`、`key`、`password`、`token`、`apiKey` 及其嵌套路径。
|
||||||
|
|
||||||
|
## expect 系统
|
||||||
|
|
||||||
|
共享断言基础设施位于 `src/server/checker/expect/`。新增或修改 checker 的 expect 字段时,按以下原则选择模型:
|
||||||
|
|
||||||
|
| 模型 | 用途 | 典型字段 |
|
||||||
|
| --------------------- | ---------------------------- | ------------------------------------------------------------------- |
|
||||||
|
| enum / boolean | 状态类结果,结果集合小且稳定 | HTTP status、Cmd exitCode、TCP connected、UDP responded、ICMP alive |
|
||||||
|
| `ValueMatcher` | 数字指标和字符串元数据 | durationMs、rowCount、finishReason、usage |
|
||||||
|
| `ContentExpectations` | 返回内容或半结构化内容 | body、stdout、stderr、banner、response、output、result |
|
||||||
|
| `KeyedExpectations` | 动态键值断言 | headers、DB rows 列值 |
|
||||||
|
|
||||||
|
详细 checker 开发流程见 [Checker 开发](checker.md)。
|
||||||
|
|
||||||
|
## 错误模型
|
||||||
|
|
||||||
|
- API 错误:`{ error: "描述", status: <code> }`
|
||||||
|
- CheckFailure:`{ kind: "error" | "mismatch", phase, path, expected?, actual?, message }`
|
||||||
|
|
||||||
|
expect 校验失败记录首个失败原因;网络、超时、进程崩溃统一为 `kind: "error"`。
|
||||||
|
|
||||||
|
## 后端测试与验证
|
||||||
|
|
||||||
|
| 变更类型 | 测试重点 |
|
||||||
|
| ---------------------- | ---------------------------------------- |
|
||||||
|
| API 路由 | `tests/server/app.test.ts` 集成行为 |
|
||||||
|
| 配置 schema 或语义校验 | schema 导出、合法配置、非法配置 |
|
||||||
|
| store | SQLite 写入、查询、分页、聚合和清理 |
|
||||||
|
| engine | 调度、并发、超时、结果写入和状态变化日志 |
|
||||||
|
| expect 基础设施 | matcher 语义、快速失败、错误信息 |
|
||||||
|
| checker runner | 见 [Checker 开发](checker.md#测试要求) |
|
||||||
|
|
||||||
|
后端运行时代码统一通过注入的 Logger 输出日志,禁止直接使用 `console.*`。新增或修改后端逻辑通常需要运行 `bun run check`;影响构建产物或前后端集成时运行 `bun run verify`。
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改后端 API、共享类型、配置契约、store、engine、logger、expect 基础设施、错误模型或后端测试规范时,必须更新本文档。
|
||||||
221
docs/development/checker.md
Normal file
221
docs/development/checker.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Checker 开发
|
||||||
|
|
||||||
|
Checker 是 DiAL 的核心扩展单元。每个 checker 是 `src/server/checker/runner/<type>/` 下的自包含目录,包含类型、schema、语义校验、执行逻辑、序列化和断言。
|
||||||
|
|
||||||
|
适用场景:新增 checker、修改 checker 配置或 expect、调整 checker 注册机制、改动 checker 测试或用户文档同步规则。
|
||||||
|
|
||||||
|
新增或修改 checker 前必须阅读 [开发入口](README.md)、[配置文件](../user/configuration.md)、[校验规则](../user/expectations.md) 和 [Checker 用户文档](../user/checkers/README.md)。还应阅读现有同类 checker 的实现和测试,例如 `src/server/checker/runner/http/` 与 `tests/server/checker/runner/http/`。
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
- 每个 checker 必须自包含在 `src/server/checker/runner/<type>/`。
|
||||||
|
- checker 专属类型、schema、validate、execute、expect、normalize 和协议辅助逻辑放在同一目录。
|
||||||
|
- 注册只修改 `src/server/checker/runner/index.ts`,中间层不新增 type switch。
|
||||||
|
- schema 层只描述契约,语义规则放入 `validate.ts`。
|
||||||
|
- `resolve()` 只做默认值填充、路径解析和单位转换,不执行校验。
|
||||||
|
- `execute()` 必须支持 `CheckerContext.signal` 超时取消。
|
||||||
|
- expect 字段必须选择合适断言模型,不为了统一而滥用 ValueMatcher。
|
||||||
|
- failure phase 命名遵循去单位后缀规则,例如 `durationMs` 对应 `duration`。
|
||||||
|
|
||||||
|
## 架构目标
|
||||||
|
|
||||||
|
```text
|
||||||
|
checkerRegistry
|
||||||
|
├── runner/index.ts
|
||||||
|
├── schema/builder.ts
|
||||||
|
├── schema/validate.ts
|
||||||
|
├── config-loader.ts
|
||||||
|
├── engine.ts
|
||||||
|
└── store.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
注册后,中间层通过 registry 自动委托 schema 生成、契约校验、配置 normalize、配置 resolve、执行和序列化。新增 checker 不应在中间层新增 `switch/case` 或类型分支。
|
||||||
|
|
||||||
|
## 标准文件结构
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
| -------------- | ------------------------------------------------------- |
|
||||||
|
| `index.ts` | 模块入口,re-export Checker 类 |
|
||||||
|
| `types.ts` | Checker 专属类型 |
|
||||||
|
| `schema.ts` | TypeBox 契约 schema,包含 config 和 expect |
|
||||||
|
| `validate.ts` | 启动期语义校验 |
|
||||||
|
| `normalize.ts` | Checker 专属 authoring expect 归一化 |
|
||||||
|
| `execute.ts` | Checker 类,实现 normalize、resolve、execute、serialize |
|
||||||
|
| `expect.ts` | Checker 专用断言函数 |
|
||||||
|
| 其他文件 | 协议解析、编码、provider 适配、平台命令封装等专属逻辑 |
|
||||||
|
|
||||||
|
## 类型定义
|
||||||
|
|
||||||
|
在 `types.ts` 中定义:
|
||||||
|
|
||||||
|
- `RawXxxTargetConfig`
|
||||||
|
- `RawXxxExpectConfig`
|
||||||
|
- `ResolvedXxxExpectConfig`
|
||||||
|
- `ResolvedXxxTarget extends ResolvedTargetBase`
|
||||||
|
|
||||||
|
不需要修改顶层 `checker/types.ts`。base interface 使用 index signature 支持扩展。
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
checker 必须提供 `CheckerSchemas`,包含 Authoring 和 Normalized 两套 config/expect 片段。Authoring 描述用户 YAML 可写 DSL,Normalized 描述 normalizer 输出。
|
||||||
|
|
||||||
|
常用 fragments:
|
||||||
|
|
||||||
|
| Fragment | 用途 |
|
||||||
|
| ----------------------------------- | ------------------------- |
|
||||||
|
| `durationSchema` | 时长字符串 |
|
||||||
|
| `sizeSchema` | 大小单位 |
|
||||||
|
| `statusCodePatternSchema` | HTTP 状态码或范围 |
|
||||||
|
| `stringMapSchema` | headers、env 等字符串映射 |
|
||||||
|
| `createValueMatcherSchema()` | ValueMatcher |
|
||||||
|
| `createContentExpectationsSchema()` | ContentExpectations |
|
||||||
|
| `createKeyedExpectationsSchema()` | KeyedExpectations |
|
||||||
|
|
||||||
|
默认对象策略为 `additionalProperties: false`。只有明确的动态键值表可以开放任意键名。
|
||||||
|
|
||||||
|
## 语义校验
|
||||||
|
|
||||||
|
在 `validate.ts` 中实现 JSON Schema 无法表达的规则,统一返回 `ConfigValidationIssue[]`,不要直接拼接最终错误字符串。
|
||||||
|
|
||||||
|
共享校验工具包括:
|
||||||
|
|
||||||
|
| 函数 | 用途 |
|
||||||
|
| -------------------------------- | ---------------------------- |
|
||||||
|
| `validateRawValueExpectation` | 校验 Raw ValueExpectation |
|
||||||
|
| `validateRawContentExpectations` | 校验 ContentExpectations |
|
||||||
|
| `validateRawKeyedExpectations` | 校验 KeyedExpectations |
|
||||||
|
| `validateJsonPath` | 校验项目支持的 JSONPath 子集 |
|
||||||
|
| `isJsonValue` | 判断合法 JSON value |
|
||||||
|
|
||||||
|
## normalize 规范
|
||||||
|
|
||||||
|
`normalize()` 在 `CheckerDefinition` 中定义为必需方法,负责将 authoring expect DSL 转换为 normalized 形态。输入为变量已解析后的 target,输出为适配 normalized schema 的 target。该方法在 `resolve()` 和 normalized contract 校验之前执行。
|
||||||
|
|
||||||
|
在 `normalize.ts` 中实现 `normalizeTargetExpect` 函数,`execute.ts` 中的 `normalize` 方法委托到该函数。
|
||||||
|
|
||||||
|
共享 normalize helper 位于 `src/server/checker/expect/normalize.ts`:
|
||||||
|
|
||||||
|
| 函数 | 用途 |
|
||||||
|
| ------------------ | -------------------------------------------------------- |
|
||||||
|
| `compactExpect` | 合并两个 expect record,过滤 undefined 字段 |
|
||||||
|
| `normalizeValue` | ValueMatcher 原始值简写展开为 `{equals: value}` |
|
||||||
|
| `normalizeContent` | ContentExpectations 简写展开为 normalized 形态 |
|
||||||
|
| `normalizeKeyed` | KeyedExpectations 对象形态展开为 `[{key, matcher}]` 数组 |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { compactExpect, normalizeContent, normalizeKeyed, normalizeValue } from "../../expect/normalize";
|
||||||
|
|
||||||
|
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
|
||||||
|
const raw = target.expect as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...target,
|
||||||
|
expect: compactExpect(raw, {
|
||||||
|
/* checker 专属字段映射 */
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
expect 字段的归一化规则:ValueMatcher 字段调用 `normalizeValue()`,ContentExpectations 字段调用 `normalizeContent()`,KeyedExpectations 字段调用 `normalizeKeyed()`,boolean/enum/array 等非断言模型字段直接透传。
|
||||||
|
|
||||||
|
## resolve 规范
|
||||||
|
|
||||||
|
`resolve()` 只做内置默认值填充、路径解析、单位转换,不执行校验。输入已经通过 Normalized schema 和语义校验,expect 已是 normalized 形态。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const expect = target.expect as ResolvedXxxExpectConfig | undefined;
|
||||||
|
const resolvedExpect: ResolvedXxxExpectConfig = expect
|
||||||
|
? { ...expect, status: expect.status ?? [200] }
|
||||||
|
: { status: [200] };
|
||||||
|
```
|
||||||
|
|
||||||
|
返回值使用 `satisfies ResolvedXxxTarget` 确保类型正确。
|
||||||
|
|
||||||
|
## execute 规范
|
||||||
|
|
||||||
|
- 始终记录 `timestamp` 和 `start = performance.now()`。
|
||||||
|
- 通过 `ctx.signal` 支持超时取消。
|
||||||
|
- 首个 expect 失败即停止,返回带 `failure` 的结果。
|
||||||
|
- 成功时 `failure: null, matched: true`。
|
||||||
|
- 异常时使用 `errorFailure()`。
|
||||||
|
- 不匹配时使用 `mismatchFailure()`。
|
||||||
|
- `expected` 参数应传用户可读值,必要时使用 `displayValueExpectation()`。
|
||||||
|
|
||||||
|
## expect 字段选择
|
||||||
|
|
||||||
|
| 场景 | 模型 |
|
||||||
|
| ------------------------------------ | ------------------- |
|
||||||
|
| 状态类结果且集合小而稳定 | enum 或 boolean |
|
||||||
|
| 单值数字指标或字符串元数据 | ValueMatcher |
|
||||||
|
| 文本、JSON、HTML、XML 或半结构化内容 | ContentExpectations |
|
||||||
|
| 动态键值表 | KeyedExpectations |
|
||||||
|
|
||||||
|
不要为了统一而把状态类字段改成 ValueMatcher。一个 expect 字段只能对应一种断言模型。
|
||||||
|
|
||||||
|
## 注册
|
||||||
|
|
||||||
|
1. 创建 `src/server/checker/runner/<type>/index.ts`。
|
||||||
|
2. 在 `src/server/checker/runner/index.ts` 添加导入。
|
||||||
|
3. 在 registry 初始化数组中添加 checker 实例。
|
||||||
|
|
||||||
|
注册后,schema builder、validate、config-loader、engine、store 会自动按 registry 分发。
|
||||||
|
|
||||||
|
## 测试要求
|
||||||
|
|
||||||
|
测试文件放在 `tests/server/checker/runner/<type>/`,结构镜像源文件。
|
||||||
|
|
||||||
|
| 测试类别 | 覆盖内容 |
|
||||||
|
| -------------- | ---------------------------------------------------- |
|
||||||
|
| 契约测试 | TypeBox schema 与 JSON Schema 导出一致性 |
|
||||||
|
| 语义校验测试 | 合法和非法配置 |
|
||||||
|
| normalize 测试 | authoring expect 简写展开和 normalized contract 通过 |
|
||||||
|
| resolve 测试 | 默认值合并、路径解析、单位转换 |
|
||||||
|
| execute 测试 | 成功、失败、超时、expect 组合 |
|
||||||
|
| 注册测试 | registry 注册行为 |
|
||||||
|
| 配置加载测试 | 含新 checker 的 YAML 完整加载流程 |
|
||||||
|
|
||||||
|
## 文档和 schema 更新
|
||||||
|
|
||||||
|
新增或修改 checker 时通常需要更新:
|
||||||
|
|
||||||
|
- `probes.example.yaml`
|
||||||
|
- `probe-config.schema.json`,通过 `bun run schema` 生成
|
||||||
|
- `docs/user/checkers/<type>.md`
|
||||||
|
- `docs/user/checkers/README.md`
|
||||||
|
- `docs/user/expectations.md`,仅当断言模型、状态模型或通用规则变化
|
||||||
|
- `docs/user/configuration.md`,仅当 target 通用字段或配置加载形态变化
|
||||||
|
- `docs/development/checker.md`,仅当 checker 开发机制、测试要求或 checklist 变化
|
||||||
|
- `docs/README.md` 和 `openspec/config.yaml`,仅当文档同步规则变化
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
新增或修改 checker 后通常需要运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run schema
|
||||||
|
bun run schema:check
|
||||||
|
bun run check
|
||||||
|
```
|
||||||
|
|
||||||
|
影响构建、Docker 或发布包时追加运行 `bun run verify`。
|
||||||
|
|
||||||
|
## 完成检查清单
|
||||||
|
|
||||||
|
```text
|
||||||
|
□ checker 类型、schema、validate、normalize、resolve、execute、serialize 已实现
|
||||||
|
□ checker 已在 runner/index.ts 注册
|
||||||
|
□ 配置契约、语义校验和 JSON Schema 导出已同步
|
||||||
|
□ probes.example.yaml 已添加或更新示例
|
||||||
|
□ tests/server/checker/runner/<type>/ 已覆盖契约、校验、normalize、resolve、execute、注册和配置加载
|
||||||
|
□ docs/user/checkers/<type>.md 已添加或更新
|
||||||
|
□ docs/user/checkers/README.md 已添加或更新
|
||||||
|
□ 文档影响分析已完成,必要文档已同步
|
||||||
|
□ bun run schema 和 bun run schema:check 已通过
|
||||||
|
□ bun run check 已通过
|
||||||
|
□ bun run verify 已通过或记录未执行原因
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改 checker 开发机制、目录结构、schema/validate/normalize/resolve/execute/expect 约定、测试要求、验证命令或文档同步 checklist 时,必须更新本文档。
|
||||||
130
docs/development/frontend.md
Normal file
130
docs/development/frontend.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# 前端开发
|
||||||
|
|
||||||
|
本文档说明 DiAL Dashboard 的 React、TDesign、TanStack Query、组件、样式和前端测试约定。
|
||||||
|
|
||||||
|
适用场景:修改 `src/web/`、前端共享类型使用方式、Dashboard 数据流、组件结构、样式规则或前端测试。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层面 | 技术 | 用途 |
|
||||||
|
| ------ | ------------------------------------- | ---------------------------------------------- |
|
||||||
|
| 框架 | React 19 | UI 组件开发 |
|
||||||
|
| 构建 | Bun HTML import + Vite dev server | 开发服务与生产构建 |
|
||||||
|
| 语言 | TypeScript 6 | 类型安全 |
|
||||||
|
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
|
||||||
|
| 数据层 | TanStack Query + React Query Devtools | 服务端状态管理与自动轮询 |
|
||||||
|
| 图表 | Recharts | 拨测趋势图 |
|
||||||
|
| 动画 | @number-flow/react | 倒计时数字滚动过渡 |
|
||||||
|
| 路由 | 无 | 单页面 Dashboard,通过 Drawer/Tab 做页面内导航 |
|
||||||
|
|
||||||
|
不引入 React Router 或额外状态管理库。TanStack Query 承担服务端状态,组件内状态使用 `useState`。
|
||||||
|
|
||||||
|
## 组件树与数据流
|
||||||
|
|
||||||
|
```text
|
||||||
|
main.tsx
|
||||||
|
└── StrictMode
|
||||||
|
└── ErrorBoundary
|
||||||
|
└── QueryClientProvider
|
||||||
|
├── App
|
||||||
|
│ ├── useThemePreference()
|
||||||
|
│ ├── useDashboard(refreshInterval)
|
||||||
|
│ ├── SummaryCards
|
||||||
|
│ └── TargetBoard
|
||||||
|
│ └── TargetGroup[]
|
||||||
|
│ └── PrimaryTable
|
||||||
|
│ └── TargetDetailDrawer
|
||||||
|
│ └── useTargetDetail()
|
||||||
|
│ ├── OverviewTab
|
||||||
|
│ └── HistoryTab
|
||||||
|
└── ReactQueryDevtools
|
||||||
|
```
|
||||||
|
|
||||||
|
## TanStack Query 规范
|
||||||
|
|
||||||
|
Query key 使用 structured array,排序为 scope -> id -> 参数。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const queryKeys = {
|
||||||
|
dashboard: () => ["dashboard", "24h", 30] as const,
|
||||||
|
meta: () => ["meta"] as const,
|
||||||
|
metrics: (targetId: number, from: string, to: string, bucket: "auto" | MetricsBucket) =>
|
||||||
|
["metrics", targetId, from, to, bucket] as const,
|
||||||
|
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
全局面板级查询可持续刷新,详情级查询必须按 Drawer 状态和 Tab 状态条件启用。
|
||||||
|
|
||||||
|
## fetch 封装
|
||||||
|
|
||||||
|
统一使用 `fetch`,不引入 axios。错误抛异常,由 TanStack Query 的 `error` 状态承接。
|
||||||
|
|
||||||
|
```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>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 组件开发规范
|
||||||
|
|
||||||
|
- 每个 React 组件一个 `.tsx` 文件,文件名使用 PascalCase。
|
||||||
|
- 组件 props 定义为 `interface XxxProps`,紧邻组件函数声明。
|
||||||
|
- 类型从 `../../shared/api` 导入,使用 `import type`。
|
||||||
|
- 展示组件放在 `components/`,通过 props 接收数据,通过回调返回事件。
|
||||||
|
- 容器逻辑放在 hooks 中,组件只做数据消费。
|
||||||
|
- 列定义、排序器、筛选器、颜色阈值等常量放在 `constants/`。
|
||||||
|
- 时间处理等纯函数放在 `utils/`。
|
||||||
|
|
||||||
|
## 现有组件
|
||||||
|
|
||||||
|
| 组件 | 用途 |
|
||||||
|
| -------------------- | ----------------------------------------------------------------- |
|
||||||
|
| `App` | 根组件,Layout + HeadMenu 骨架、主题模式、刷新控制、Skeleton 加载 |
|
||||||
|
| `ErrorBoundary` | React 错误边界 |
|
||||||
|
| `SummaryCards` | 总览统计卡片 |
|
||||||
|
| `TargetBoard` | 按分组渲染目标表格列表 |
|
||||||
|
| `TargetGroup` | 单个分组 Card + PrimaryTable |
|
||||||
|
| `TargetDetailDrawer` | 目标详情抽屉 |
|
||||||
|
| `OverviewTab` | 目标详情概览 |
|
||||||
|
| `HistoryTab` | 目标历史记录表格和分页 |
|
||||||
|
| `TrendChart` | 趋势折线图 |
|
||||||
|
| `StatusDot` | 圆形状态指示点 |
|
||||||
|
| `StatusBar` | 最近采样状态条 |
|
||||||
|
| `RefreshCountdown` | Header 刷新倒计时和手动刷新按钮 |
|
||||||
|
|
||||||
|
## 样式规范
|
||||||
|
|
||||||
|
前端基于 TDesign React 构建 UI,样式开发优先级:
|
||||||
|
|
||||||
|
1. TDesign 组件
|
||||||
|
2. TDesign 组件 props
|
||||||
|
3. TDesign CSS tokens(`--td-*`)
|
||||||
|
4. `styles.css` CSS 类
|
||||||
|
5. 自行开发组件
|
||||||
|
|
||||||
|
红线:
|
||||||
|
|
||||||
|
- 严禁在组件中使用 `style` 属性内联调整样式。
|
||||||
|
- 严禁通过 CSS 覆盖 TDesign 组件内部类名。
|
||||||
|
- 严禁使用 `!important`。
|
||||||
|
- 颜色统一使用 TDesign CSS tokens,不使用硬编码色值。
|
||||||
|
|
||||||
|
## 前端测试与验证
|
||||||
|
|
||||||
|
- 测试目录为 `tests/web/`,结构对应 `src/web/`。
|
||||||
|
- 单元测试重点覆盖 `constants/`、`utils/` 和 hooks 中的纯逻辑。
|
||||||
|
- 组件测试使用 jsdom 和 `@testing-library/react`。
|
||||||
|
- 测试用户行为而非实现细节。
|
||||||
|
- 只 mock 系统边界,例如 `fetch`。
|
||||||
|
- 使用真实的 QueryClientProvider 包裹依赖 TanStack Query 的组件。
|
||||||
|
- 异步错误断言使用 helper 或显式 try/catch,避免依赖 Bun `expect(...).rejects` 与 `await-thenable` 规则的类型不匹配。
|
||||||
|
- 组件测试环境由 `tests/setup.ts` 和 `bunfig.toml` preload 提供,包含 ResizeObserver、IntersectionObserver、matchMedia、attachEvent 和 Recharts mock。
|
||||||
|
|
||||||
|
前端逻辑变更通常需要运行 `bun run check`。影响生产静态资源、前后端集成或构建流程时运行 `bun run verify`。
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改前端技术栈、组件边界、数据流、样式规则、测试环境或前端验证方式时,必须更新本文档。
|
||||||
127
docs/development/release.md
Normal file
127
docs/development/release.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# 构建与发布
|
||||||
|
|
||||||
|
本文档说明开发服务、前后端集成、生产构建、Docker 镜像、跨平台 release 和相关脚本维护方式。
|
||||||
|
|
||||||
|
适用场景:修改 `scripts/`、构建流程、Dockerfile、静态资源集成、release 打包、运行时环境变量或部署产物。
|
||||||
|
|
||||||
|
## 开发期运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev probes.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/dev.ts` 同时启动两个进程:
|
||||||
|
|
||||||
|
| 进程 | 用途 |
|
||||||
|
| --------------- | ------------------------------------------------- |
|
||||||
|
| Bun API server | 后端 API 服务,`--watch` 监听后端文件变更自动重启 |
|
||||||
|
| Vite dev server | 前端 SPA、HMR、模块热替换 |
|
||||||
|
|
||||||
|
也可以单独启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev:server probes.yaml
|
||||||
|
bun run dev:web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前后端集成
|
||||||
|
|
||||||
|
开发模式下,Vite 通过 proxy 将 `/api/*` 和 `/health` 转发到 Bun。
|
||||||
|
|
||||||
|
生产模式下,前端通过 Vite 构建为静态资源,通过 `import with { type: "file" }` 嵌入 Bun 可执行文件。非 API 路径由 fetch fallback 处理:有文件扩展名的返回静态资源或 404,无扩展名的返回 SPA index.html。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
构建流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. Vite build -> dist/web/
|
||||||
|
2. Code generation -> .build/static-assets.ts + .build/server-entry.ts
|
||||||
|
3. Bun compile -> dist/dial-server
|
||||||
|
```
|
||||||
|
|
||||||
|
构建参数:
|
||||||
|
|
||||||
|
| 环境变量 | 说明 |
|
||||||
|
| -------------- | ---------------- |
|
||||||
|
| `BUN_TARGET` | 交叉编译目标平台 |
|
||||||
|
| `BUILD_TARGET` | 交叉编译目标平台 |
|
||||||
|
|
||||||
|
## Docker 镜像
|
||||||
|
|
||||||
|
Docker 镜像使用 Alpine 多阶段构建,保持与生产单可执行文件交付模型一致。
|
||||||
|
|
||||||
|
```text
|
||||||
|
oven/bun:1-alpine -> bun install --frozen-lockfile
|
||||||
|
-> BUN_TARGET=bun-linux-*-musl bun run build
|
||||||
|
-> dist/dial-server
|
||||||
|
|
||||||
|
alpine -> 仅复制 /usr/local/bin/dial-server
|
||||||
|
-> 安装 ca-certificates、iputils-ping、libgcc、libstdc++、tzdata
|
||||||
|
-> 使用非 root dial 用户运行
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockerfile 通过 `TARGETARCH` 选择 Bun compile target。
|
||||||
|
|
||||||
|
| `TARGETARCH` | `BUN_TARGET` |
|
||||||
|
| ------------ | ---------------------- |
|
||||||
|
| `amd64` | `bun-linux-x64-musl` |
|
||||||
|
| `arm64` | `bun-linux-arm64-musl` |
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run release
|
||||||
|
bun run release --target linux-x64
|
||||||
|
bun run release --target linux-x64,windows-x64,darwin-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
release 流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. Vite build -> dist/web/
|
||||||
|
2. Code generation -> .build/
|
||||||
|
3. 多目标 Bun compile -> dist/release/binaries/
|
||||||
|
4. tar.gz 打包 -> dist/release/packages/
|
||||||
|
```
|
||||||
|
|
||||||
|
支持的平台见 [用户部署文档](../user/deployment.md#跨平台发布包)。
|
||||||
|
|
||||||
|
## 脚本说明
|
||||||
|
|
||||||
|
| 脚本 | 文件 | 说明 |
|
||||||
|
| ---------------------- | ----------------------------------- | ------------------------------ |
|
||||||
|
| `bun run dev` | `scripts/dev.ts` | 双进程开发服务 |
|
||||||
|
| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server |
|
||||||
|
| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server |
|
||||||
|
| `bun run build` | `scripts/build.ts` | Vite -> codegen -> Bun compile |
|
||||||
|
| `bun run release` | `scripts/release.ts` | 多目标交叉编译和打包 |
|
||||||
|
| `bun run schema` | `scripts/generate-config-schema.ts` | 生成配置 JSON Schema |
|
||||||
|
| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 JSON Schema 同步 |
|
||||||
|
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
|
||||||
|
|
||||||
|
## 维护约定
|
||||||
|
|
||||||
|
- `scripts/build-common.ts` 中的 import specifier 输出必须使用 `/` 分隔符。
|
||||||
|
- 跨平台路径测试不得用当前平台 `path.sep` 伪装其他平台,应使用 `node:path.win32` 或等价注入方式模拟。
|
||||||
|
- 如本地 Docker 环境不支持 buildx 或多架构模拟,需在变更记录中说明未执行原因。
|
||||||
|
|
||||||
|
## 发布验证
|
||||||
|
|
||||||
|
| 变更类型 | 验证方式 |
|
||||||
|
| ---------------- | --------------------------------------- |
|
||||||
|
| 构建脚本 | `bun run verify` |
|
||||||
|
| release 脚本 | `bun run release` 或指定受影响 target |
|
||||||
|
| Dockerfile | 本地 `docker build`,无法执行时说明原因 |
|
||||||
|
| 静态资源集成 | `bun run build`,必要时启动产物手动验证 |
|
||||||
|
| 配置 schema 同步 | `bun run schema:check` |
|
||||||
|
|
||||||
|
影响用户部署方式、Docker 运行参数、发布包内容或运行时依赖时,必须同步更新 [用户部署文档](../user/deployment.md)。
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改开发服务、前后端集成、构建产物、Docker 镜像、release target、脚本参数或发布验证方式时,必须更新本文档。
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
| 文件 | 用途 |
|
| 文件 | 用途 |
|
||||||
| ------------------------------------------------------ | ------------------------------------------------------------------------ |
|
| ------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||||
| [prompt-smart-merge.md](prompt-smart-merge.md) | 批量合并 `dev*` 分支到目标分支,含规则探测、依赖分析、冲突处理、安全回退 |
|
| [prompt-smart-merge.md](prompt-smart-merge.md) | 批量合并 `dev*` 分支到目标分支,含规则探测、依赖分析、冲突处理、安全回退 |
|
||||||
| [prompt-spec-review.md](prompt-spec-review.md) | 审查和整理 `openspec/specs/` 下的稳定规范,提升可检索性和一致性 |
|
|
||||||
| [prompt-proposal-review.md](prompt-proposal-review.md) | 审查 proposal/design/tasks/specs 与讨论、代码现状、OpenSpec 规范的一致性 |
|
| [prompt-proposal-review.md](prompt-proposal-review.md) | 审查 proposal/design/tasks/specs 与讨论、代码现状、OpenSpec 规范的一致性 |
|
||||||
| [prompt-apply-review.md](prompt-apply-review.md) | 审查 apply 后代码、测试、变更文档的一致性,并补齐遗漏或回写文档 |
|
| [prompt-apply-review.md](prompt-apply-review.md) | 审查 apply 后代码、测试、变更文档的一致性,并补齐遗漏或回写文档 |
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@
|
|||||||
- 是否以代码、文档、讨论或用户确认为准
|
- 是否以代码、文档、讨论或用户确认为准
|
||||||
- 何时必须使用提问工具确认
|
- 何时必须使用提问工具确认
|
||||||
- 删除、重写前是否必须备份
|
- 删除、重写前是否必须备份
|
||||||
- 改动后是否必须同步 README、测试、变更文档
|
- 改动后是否必须同步相关用户文档、开发文档、测试、变更文档
|
||||||
|
|
||||||
### 4. 计划与执行分离
|
### 4. 计划与执行分离
|
||||||
|
|
||||||
@@ -124,7 +123,7 @@
|
|||||||
- 作用域边界:改什么,不改什么
|
- 作用域边界:改什么,不改什么
|
||||||
- 真相来源优先级:代码 / README / spec / 讨论 / 用户确认
|
- 真相来源优先级:代码 / README / spec / 讨论 / 用户确认
|
||||||
- 风险动作边界:删除、重写、提交、推送、回退、stash、merge 等
|
- 风险动作边界:删除、重写、提交、推送、回退、stash、merge 等
|
||||||
- 同步要求:测试、README、变更文档、现有 spec 是否要同步
|
- 同步要求:测试、用户文档、开发文档、变更文档、现有 spec 是否要同步
|
||||||
- 降级规则:信息不足时如何处理
|
- 降级规则:信息不足时如何处理
|
||||||
|
|
||||||
避免:
|
避免:
|
||||||
@@ -142,7 +141,7 @@
|
|||||||
|
|
||||||
推荐做法:
|
推荐做法:
|
||||||
|
|
||||||
- 先读仓库规则来源,如 `README.md`、配置、架构文档、近期提交、任务入口
|
- 先读仓库规则来源,如 `README.md`、`DEVELOPMENT.md`、`CONTRIBUTING.md`、`docs/README.md`、配置、架构文档、近期提交、任务入口
|
||||||
- 先读直接相关 artifacts,再扩展到相关代码和测试
|
- 先读直接相关 artifacts,再扩展到相关代码和测试
|
||||||
- 需要探测时,要求 AI 先探测再决定,不把仓库结构写死在提示词里
|
- 需要探测时,要求 AI 先探测再决定,不把仓库结构写死在提示词里
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,57 @@
|
|||||||
审查 OpenSpec apply 完成后以及后续手动修补后的实际实现,判断代码、测试、变更文档是否一致,识别偏离、漏记和可优化点,并将确认后的实际变更同步回变更文档,按以下流程执行。
|
审查 OpenSpec apply 完成后以及后续手动修补后的实际变更,判断实际产物、验证结果和变更文档是否与 `design.md` source of truth 一致,识别偏离、漏记和可优化点,并将确认后的实际变更同步回变更文档,按以下流程执行。
|
||||||
|
|
||||||
## 约束
|
## 约束
|
||||||
|
|
||||||
- 先审查再修复;未经用户确认,不修改代码或变更文档
|
- 先审查再修复;未经用户确认,不修改实际产物或变更文档
|
||||||
- 默认按 `spec-driven` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
|
- 默认按 `fast-drive` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
|
||||||
- 优先使用当前会话中的实现说明、测试结论、手动修补记录和已生成的变更文档;仅在无法明确 change、`schemaName`、改动范围或修补来源时,再用提问工具或 OpenSpec 命令补充定位
|
- 在 `fast-drive` workflow 下,核心 artifacts 是 `design.md` 和 `tasks.md`;不要要求存在 `proposal.md` 或 `specs/*.md`
|
||||||
- 不要因为代码已经存在就自动以代码为准;先判断差异属于"文档要求未实现"、"测试后新增修补"还是"意外偏离/回归"
|
- 在 `fast-drive` workflow 下,`design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth,`tasks.md` 是 apply 进度和验证闭环的 tracking 文件
|
||||||
- 每批代码或文档修改执行前用提问工具获得用户确认
|
- 禁止同步或修改 `openspec/specs/` 下的主规范;若实际 schema 包含 `specs/*.md`,也只允许修改本次 change 目录下实际存在的 spec artifacts,主规范同步属于 archive 阶段,不在此提示词范围内
|
||||||
|
- 优先使用当前会话中的执行说明、验证结论、手动修补记录和已生成的变更文档;仅在无法明确 change、`schemaName`、改动范围或修补来源时,再用提问工具或 OpenSpec 命令补充定位
|
||||||
|
- 不要因为实际产物已经存在就自动以实际产物为准;先判断差异属于“design 要求未完成”、“验证后新增修补”、“合理落地细化”还是“意外偏离/回归”
|
||||||
|
- 每批实际产物或文档修改执行前用提问工具获得用户确认
|
||||||
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
|
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
|
||||||
- 若修改代码涉及新逻辑、模块结构、API、实体或用户可见行为,同步更新测试、相关变更文档和 README
|
- 若修改实际产物涉及新行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,同步更新验证材料、相关变更文档和必要的文档/沟通材料
|
||||||
|
|
||||||
## 1. 收集
|
## 1. 收集
|
||||||
|
|
||||||
并行读取:
|
读取约束:
|
||||||
|
|
||||||
- 本次 change 的实际 artifacts;在 `spec-driven` 下通常包括 `proposal.md`、`design.md`、`tasks.md`、`specs/*.md`
|
- 直接使用 Read 工具并行读取文件,禁止使用 subagent/Task 工具做文件读取和内容转发
|
||||||
- 当前会话中与本次变更相关的实现说明、apply 过程中的偏离、测试失败、手动修补原因、待确认事项
|
- 不原样输出文件内容,仅在步骤 2 输出审查结论
|
||||||
- 与本次变更相关的代码和测试文件;优先依据 `git diff --name-only`、`git diff --name-only --cached`、`tasks.md`、Impact、失败测试栈定位;若工作区已干净,再结合文档和代码模块反推
|
|
||||||
- 最近一次相关测试命令、测试结果、失败信息和修补后的验证结果
|
分步收集:
|
||||||
- `openspec/config.yaml`
|
|
||||||
- 与本次改动相关的 README、架构文档,以及现有 `openspec/specs/**/spec.md` 中与本次变更相关的规范,相关性来源包括:`proposal.md` 的 `Capabilities` / `Modified Capabilities`、手动修补涉及的受影响能力、`design.md` / Impact 中提到的模块、相关代码对应的现有能力
|
a) 先并行读取核心入口和配置,确定范围:
|
||||||
|
|
||||||
|
- 本次 change 的 `design.md`
|
||||||
|
- 本次 change 的 `tasks.md`
|
||||||
|
- workflow context/configuration,例如存在时读取 `openspec/config.yaml`
|
||||||
|
- 若可定位到 schema,读取对应 schema;`fast-drive` 下优先读取 `openspec/schemas/fast-drive/schema.yaml`
|
||||||
|
|
||||||
|
b) 从 `design.md` 提取审查基准:
|
||||||
|
|
||||||
|
- `Context`
|
||||||
|
- `Discussion Notes`
|
||||||
|
- `Requirements`
|
||||||
|
- `Goals / Non-Goals`
|
||||||
|
- `Execution Guardrails`
|
||||||
|
- `Affected Areas`
|
||||||
|
- `Decisions`
|
||||||
|
- `Execution Plan`
|
||||||
|
- `Verification Plan`
|
||||||
|
- `Risks / Trade-offs`
|
||||||
|
- `Open Questions`
|
||||||
|
|
||||||
|
c) 从 `tasks.md` 提取任务状态、已完成项、未完成项、验证任务和文档/沟通任务;重点记录所有已标记完成的 `- [x]` 或等价完成状态。
|
||||||
|
|
||||||
|
d) 获取实际改动范围:若在版本控制工作区中,优先使用 `git diff --name-only`、`git diff --name-only --cached`;若工作区已干净或不适用版本控制,再结合 `design.md`、`tasks.md`、验证记录和执行记录反推。
|
||||||
|
|
||||||
|
e) 并行读取实际改动范围、`Affected Areas`、`Execution Plan`、`Verification Plan` 涉及的实际产物、参考材料、验证材料、流程说明、配置、文档或沟通材料。
|
||||||
|
|
||||||
|
f) 收集当前会话中与本次变更相关的执行说明、apply 过程中的偏离、验证失败、手动修补原因、验证命令或检查结果、待确认事项。
|
||||||
|
|
||||||
|
g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts;若存在 `proposal.md`、`specs/*.md`,再按该 schema 的要求补充读取和审查。
|
||||||
|
|
||||||
若当前上下文无法明确 change 或文档路径:
|
若当前上下文无法明确 change 或文档路径:
|
||||||
|
|
||||||
@@ -28,63 +60,75 @@
|
|||||||
|
|
||||||
若已明确 change,但尚未确认 `schemaName`,先读取 change 元数据或执行 `openspec status --change "{name}" --json` 确认。
|
若已明确 change,但尚未确认 `schemaName`,先读取 change 元数据或执行 `openspec status --change "{name}" --json` 确认。
|
||||||
|
|
||||||
若缺少测试结果或手动修补记录,明确说明本次无法可靠判断部分差异的来源,仅能基于代码与文档现状审查。
|
若缺少验证结果或手动修补记录,明确说明本次无法可靠判断部分差异的来源,仅能基于实际产物与文档现状审查。
|
||||||
|
|
||||||
## 2. 分析
|
## 2. 分析
|
||||||
|
|
||||||
按以下优先级检查:
|
按以下优先级检查:
|
||||||
|
|
||||||
| 优先级 | 维度 | 检查点 |
|
| 优先级 | 维度 | 检查点 |
|
||||||
| ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| P0 | 实际实现与测试结论 | 当前代码的真实行为是什么;apply 后是否有手动改动或测试后修补;测试是否证明这些实现有效;若缺少测试结果,标记相关结论为"未验证";检查是否存在回归、未覆盖场景或被掩盖的问题 |
|
| P0 | 实际变更与验证结论 | 当前实际产物的真实状态是什么;apply 后是否有手动改动或验证后修补;验证是否证明这些变更有效;若缺少验证结果,标记相关结论为“未验证”;检查是否存在回归、未覆盖场景或被掩盖的问题 |
|
||||||
| P1 | 文档同步性 | 对实际存在的 artifacts 检查:已落地的实现、测试后新增修补、边界处理、异常路径、验证结论是否已同步回变更文档;若影响模块结构、API、实体或用户可见行为,再检查 README 是否同步 |
|
| P1 | `design.md` 一致性 | 实际变更是否符合 `Requirements`、`Goals / Non-Goals`、`Execution Guardrails`、`Decisions`、`Execution Plan` 和 `Verification Plan`;`Open Questions` 是否已明确区分 blocking / non-blocking 或写出 `None`;是否违反被明确否决的方案、用户偏好或约束 |
|
||||||
| P2 | 文档要求覆盖 | 对实际存在的 artifacts 检查:文档中承诺的目标、方案、Requirement、Scenario 是否都已实现;在 `spec-driven` 下重点检查 `proposal.md`、`design.md`、`specs/*.md`、`tasks.md` |
|
| P2 | `tasks.md` 真实性 | 已完成任务是否真的完成并完成必要验证;未完成任务是否仍然必要;apply 或手动修补是否引入了需要补充的新任务、验证任务或文档/沟通任务 |
|
||||||
| P3 | 实现质量 | 代码结构、复用、命名、复杂度、错误处理、测试质量、与项目现有模式的一致性是否存在明显问题或可优化点 |
|
| P3 | 文档回写完整性 | 已落地的实际变更、验证后新增修补、边界处理、异常路径、验证结论、实际触达产物是否已同步回 `design.md` 和 `tasks.md`;若影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,再检查必要的文档/沟通材料是否同步 |
|
||||||
|
| P4 | 质量与可维护性 | 实际产物的结构、清晰度、一致性、可维护性、风险处理、移交质量、验证质量、与现有模式的一致性是否存在明显问题或可优化点 |
|
||||||
|
| P5 | Schema 兼容性 | 对实际存在的 artifacts 检查是否符合其 schema;若不是 `fast-drive`,仅按实际 artifacts 检查,不凭空要求 `fast-drive` 专属结构;最终 artifacts 是否仍保留模板注释、空表格行或占位任务文本 |
|
||||||
|
|
||||||
分析时区分三类差异:
|
分析时区分四类差异:
|
||||||
|
|
||||||
- 文档要求已明确,但代码未实现或实现不完整 → 需补充代码或测试
|
- `design.md` 要求已明确,但实际变更未完成或完成不充分 → 需补充实际工作或验证
|
||||||
- 代码因测试暴露问题、手动修补或合理落地细化而新增/变更 → 需回写文档
|
- 实际变更因验证暴露问题、手动修补或合理落地细化而新增/变更 → 需回写 `design.md` 和/或 `tasks.md`
|
||||||
- 代码与文档不一致,且无法判断应以哪边为准 → 列入待确认清单
|
- 实际变更与 `design.md` 不一致,且无法判断应以哪边为准 → 列入待确认清单
|
||||||
|
- `tasks.md` 状态与实际完成情况或验证结果不一致 → 修正任务状态或补充任务
|
||||||
|
|
||||||
不要把以下情况直接视为合理修补:
|
不要把以下情况直接视为合理修补:
|
||||||
|
|
||||||
- 通过 `skip`、`only`、弱化断言、绕过错误处理来让测试通过
|
- 通过跳过、弱化或绕过验证来声称变更完成
|
||||||
- 为了贴合现有代码而降低已确认的 Requirement 或行为约束
|
- 为了贴合当前实际产物而降低已确认的 requirement、acceptance criteria 或 guardrail
|
||||||
- 未经过讨论和验证就扩大功能范围
|
- 未经过讨论和验证就扩大功能、流程、内容或责任范围
|
||||||
|
- 违反 `Execution Guardrails`、被拒绝方案或 `Open Questions` 中尚未解决的 blocker
|
||||||
|
|
||||||
重点识别:
|
重点识别:
|
||||||
|
|
||||||
- 文档要求但未落地的功能、场景、异常处理或验证步骤
|
- `design.md` 要求但未落地的结果、流程、内容、场景、异常处理、文档/沟通更新或验证步骤
|
||||||
- apply 完成后新增的代码修补、边界处理、接口调整、行为变化未同步到文档
|
- 实际变更偏离 `Goals / Non-Goals`、`Execution Guardrails`、`Decisions` 或 `Execution Plan` 的地方
|
||||||
- `tasks.md` 标记完成,但代码、测试或文档未闭环
|
- apply 完成后新增的修补、边界处理、接口调整、行为变化、流程变化或内容变化未同步到 `design.md`
|
||||||
- `Modified Capabilities` 应更新但未更新的现有 spec
|
- `Affected Areas` 与实际改动范围不一致,导致新会话无法复盘真实影响面
|
||||||
- 代码存在明显的重复、复杂度过高、命名不清、错误处理薄弱、测试质量不足等问题
|
- `Verification Plan` 中要求的验证、质量检查、审阅、批准、沟通检查或 manual checks 未执行或未记录
|
||||||
|
- `tasks.md` 标记完成,但实际产物、验证、文档或沟通未闭环
|
||||||
|
- `design.md` 或 `tasks.md` 仍保留 `<!-- ... -->` 模板注释、空表格行、`Replace with...`、`TBD`、`TODO` 等未解决占位内容
|
||||||
|
- 必要的文档/沟通材料未同步影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果的变更
|
||||||
|
- 实际产物存在明显的重复、复杂度过高、表达不清、责任不明、风险处理薄弱、验证质量不足等问题
|
||||||
|
- `fast-drive` change 中仍错误依赖 `proposal.md`、`specs/*.md`、`Capabilities` 或 `Modified Capabilities` 的内容
|
||||||
|
|
||||||
输出审查结果:
|
输出审查结果:
|
||||||
|
|
||||||
1. **问题总览表**:问题类型 × 涉及文件数
|
1. **问题总览表**:问题类型 × 涉及文件数
|
||||||
2. **实际改动与修补清单**:本次实现中已落地的主要功能、后续修补和验证结论;若缺少测试结果,对未验证部分单独标记
|
2. **实际变更与修补清单**:本次已落地的主要变更、后续修补和验证结论;若缺少验证结果,对未验证部分单独标记
|
||||||
3. **未覆盖清单**:文档要求但未在代码中实现或未充分验证的内容
|
3. **Design 偏离清单**:实际变更未完成、完成不充分或偏离 `design.md` 的内容
|
||||||
4. **需回写文档清单**:代码和测试中已确认、但文档未体现的实现、修补或约束变化
|
4. **需回写文档清单**:实际产物和验证中已确认、但 `design.md`、`tasks.md` 或相关文档/沟通材料未体现的变更、修补或约束变化
|
||||||
5. **方向待确认清单**:代码与文档不一致,且无法判断应以哪边为准的事项
|
5. **方向待确认清单**:实际变更与 `design.md` 不一致,且无法判断应以哪边为准的事项
|
||||||
6. **任务状态问题清单**:未真正完成、状态错误或需补充的新任务
|
6. **任务状态问题清单**:未真正完成、状态错误或需补充的新任务
|
||||||
7. **测试问题清单**:缺失覆盖、掩盖错误、验证不足或修补后未回归验证的测试问题
|
7. **验证问题清单**:缺失覆盖、掩盖错误、验证不足或修补后未回归验证的问题
|
||||||
8. **代码质量/优化清单**:可优化的实现问题和建议
|
8. **质量/优化清单**:可优化的实际产物问题和建议
|
||||||
9. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
|
9. **Schema 差异清单**:实际 schema 与默认 `fast-drive` 不同时,列出因此跳过或改按实际 artifacts 审查的内容
|
||||||
|
10. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
|
||||||
|
|
||||||
若所有清单均为空,输出"审查通过,未发现问题",跳至步骤 5。
|
若所有清单均为空,输出“审查通过,未发现问题”,跳至步骤 5。
|
||||||
|
|
||||||
## 3. 计划(用户确认)
|
## 3. 计划(用户确认)
|
||||||
|
|
||||||
先针对"方向待确认清单"用提问工具逐项向用户确认。
|
先针对“方向待确认清单”用提问工具逐项向用户确认。
|
||||||
|
|
||||||
再整理完整修复方案,按类别列出:
|
再整理完整修复方案,按类别列出:
|
||||||
|
|
||||||
- 代码或测试补充:补实现、补异常处理、补回归测试、修复掩盖错误的测试
|
- 实际工作或验证补充:补完成、补异常处理、补回归验证、修复被弱化或绕过的验证
|
||||||
- 文档回写:同步 `proposal.md`、`design.md`、`tasks.md`、`specs/*.md`、README 中遗漏或过时的内容
|
- Design 回写:同步 `design.md` 中遗漏或过时的 requirements、guardrails、affected areas、decisions、execution plan、verification plan、risks 或 open questions
|
||||||
- 任务状态修正:修正已完成/未完成状态,补充 apply 后新增但已完成的修补任务或验证任务
|
- 任务状态修正:修正已完成/未完成状态,补充 apply 后新增但已完成的修补任务或验证任务
|
||||||
- 代码质量优化:在不改变目标行为的前提下优化结构、复用、命名或可维护性
|
- 文档/沟通同步:同步行为、流程、接口、内容、数据、配置、责任边界或用户可见结果变化
|
||||||
|
- 质量优化:在不改变目标结果的前提下优化结构、表达、一致性、可维护性或移交质量
|
||||||
|
- Schema 兼容处理:若实际 schema 不是 `fast-drive`,按实际存在的 artifacts 说明额外文档同步项
|
||||||
|
|
||||||
对每个拟修改的文件说明:
|
对每个拟修改的文件说明:
|
||||||
|
|
||||||
@@ -98,33 +142,38 @@
|
|||||||
|
|
||||||
## 4. 执行
|
## 4. 执行
|
||||||
|
|
||||||
逐项执行已确认的代码、测试和文档修复。
|
逐项执行已确认的实际产物、验证和文档修复。
|
||||||
|
|
||||||
若涉及删除或重写:
|
若涉及删除或重写:
|
||||||
|
|
||||||
- 先创建备份文件 `{file}.bak.{timestamp}`
|
- 先创建备份文件 `{file}.bak.{timestamp}`
|
||||||
- 再执行修改
|
- 再执行修改
|
||||||
|
|
||||||
若修改了代码或测试:
|
若修改了实际产物或验证材料:
|
||||||
|
|
||||||
- 同步更新相关变更文档;若影响模块结构、API、实体或用户可见行为,再同步 README
|
- 同步更新相关变更文档;若影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,再同步必要的文档/沟通材料
|
||||||
- 运行相关测试;若修补影响范围较大,再补充执行受影响的回归测试
|
- 运行或执行相关验证;若修补影响范围较大,再补充执行受影响的回归验证
|
||||||
|
|
||||||
若修改了文档:
|
若修改了文档:
|
||||||
|
|
||||||
- 确认实际存在的变更文档之间保持一致;在 `spec-driven` 下重点检查 `proposal.md`、`design.md`、`tasks.md`、`specs/*.md`
|
- 在 `fast-drive` workflow 下,确认 `design.md` 仍是 source of truth,`tasks.md` 仍从 `design.md` 派生
|
||||||
- 若 apply 后新增修补改变了能力边界或行为约束,同步更新 `Capabilities` / `Modified Capabilities`
|
- 确认 `design.md` 的 requirements、guardrails、affected areas、decisions、execution plan、verification plan、risks 和 open questions 与实际变更一致
|
||||||
|
- 确认 `tasks.md` 每个完成任务都有对应实际产物和必要验证,新增修补已补充任务或记录在合适任务中
|
||||||
|
- 禁止将本次 change 内容同步到 `openspec/specs/`,该操作属于 archive 阶段
|
||||||
|
- 在 `fast-drive` workflow 下不创建 `proposal.md` 或 `specs/*.md`;若实际 schema 不是 `fast-drive`,则按实际 schema 的 required artifacts 创建或更新本次 change 目录下的 artifacts
|
||||||
|
|
||||||
执行后重新读取所有被修改的代码、测试和文档,并复核:
|
执行后重新读取所有被修改的实际产物、验证材料和文档,并复核:
|
||||||
|
|
||||||
- "未覆盖清单" 是否已清空或已标注保留原因
|
- “Design 偏离清单” 是否已清空或已标注保留原因
|
||||||
- "需回写文档清单" 是否已清空
|
- “需回写文档清单” 是否已清空
|
||||||
- "方向待确认清单" 是否已清空或已记录用户决策
|
- “方向待确认清单” 是否已清空或已记录用户决策
|
||||||
- "任务状态问题清单" 和 "测试问题清单" 是否已清空或已标注残留原因
|
- “任务状态问题清单” 和 “验证问题清单” 是否已清空或已标注残留原因
|
||||||
- "代码质量/优化清单" 中哪些已处理,哪些有意延期
|
- “质量/优化清单” 中哪些已处理,哪些有意延期
|
||||||
|
- 必要的文档/沟通材料是否已按影响范围同步
|
||||||
|
- 所有模板注释、空表格行和占位文本是否已清空或替换为有效内容
|
||||||
|
|
||||||
## 5. 收尾
|
## 5. 收尾
|
||||||
|
|
||||||
列出所有修改的文件、备份文件、测试命令与结果、文档同步摘要和剩余风险。
|
列出所有修改的文件、备份文件、验证命令或检查结果、文档同步摘要和剩余风险。
|
||||||
|
|
||||||
若本次因缺少测试结果、修补记录或上下文而降级执行,或有问题因信息不足暂未处理,单独说明。
|
若本次因缺少验证结果、修补记录或上下文而降级执行,或有问题因信息不足暂未处理,单独说明。
|
||||||
|
|||||||
@@ -1,22 +1,48 @@
|
|||||||
审查本次 OpenSpec 变更文档是否与前序讨论、当前代码现状和 OpenSpec 文档规范一致,识别遗漏、冲突和不合理假设,并给出可执行的补充建议,按以下流程执行。
|
审查本次 OpenSpec 变更文档是否与前序讨论、当前实际状态和实际 OpenSpec workflow 一致,重点检查 `fast-drive` workflow 下的 `design.md` 是否足以在上下文压缩或新会话中指导后续 `apply`,并识别遗漏、冲突和不合理假设,给出可执行的补充建议,按以下流程执行。
|
||||||
|
|
||||||
## 约束
|
## 约束
|
||||||
|
|
||||||
- 仅修改本次变更文档,不修改源码
|
- 仅修改本次变更文档,不修改实际产物
|
||||||
- 默认按 `spec-driven` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
|
- 默认按 `fast-drive` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
|
||||||
- 优先使用当前会话中的讨论和已生成的变更文档;仅在无法明确 change、`schemaName` 或文档范围时,再用提问工具或 OpenSpec 命令补充定位
|
- 在 `fast-drive` workflow 下,核心 artifacts 是 `design.md` 和 `tasks.md`;不要要求存在 `proposal.md` 或 `specs/*.md`
|
||||||
|
- 在 `fast-drive` workflow 下,`design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth,`tasks.md` 必须从 `design.md` 派生
|
||||||
|
- 优先使用当前会话中的讨论、explore/propose 阶段结论和已生成的变更文档;仅在无法明确 change、`schemaName` 或文档范围时,再用提问工具或 OpenSpec 命令补充定位
|
||||||
- 每批文档修改建议执行前用提问工具获得用户确认
|
- 每批文档修改建议执行前用提问工具获得用户确认
|
||||||
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
|
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
|
||||||
|
|
||||||
## 1. 收集
|
## 1. 收集
|
||||||
|
|
||||||
并行读取:
|
读取约束:
|
||||||
|
|
||||||
- 本次 change 的实际 artifacts;在 `spec-driven` 下通常包括 `proposal.md`、`design.md`、`tasks.md`、`specs/*.md`
|
- 直接使用 Read 工具并行读取文件,禁止使用 subagent/Task 工具做文件读取和内容转发
|
||||||
- 当前会话中与本次变更相关的讨论、澄清、边界约束、非目标、待确认事项
|
- 不原样输出文件内容,仅在步骤 2 输出审查结论
|
||||||
- 与本次变更直接相关的源码、测试、README、架构文档
|
|
||||||
- `openspec/config.yaml`
|
分步收集:
|
||||||
- 现有 `openspec/specs/**/spec.md` 中与本次变更相关的规范,相关性来源包括:`proposal.md` 的 `Capabilities` / `Modified Capabilities`、讨论中提到的受影响能力、`design.md` / Impact 中提到的模块、相关代码对应的现有能力
|
|
||||||
|
a) 先并行读取核心入口和配置,确定范围:
|
||||||
|
|
||||||
|
- 本次 change 的 `design.md`
|
||||||
|
- 本次 change 的 `tasks.md`
|
||||||
|
- workflow context/configuration,例如存在时读取 `openspec/config.yaml`
|
||||||
|
- 若可定位到 schema,读取对应 schema;`fast-drive` 下优先读取 `openspec/schemas/fast-drive/schema.yaml`
|
||||||
|
|
||||||
|
b) 从 `design.md` 提取审查基准:
|
||||||
|
|
||||||
|
- `Context`
|
||||||
|
- `Discussion Notes`
|
||||||
|
- `Requirements`
|
||||||
|
- `Goals / Non-Goals`
|
||||||
|
- `Execution Guardrails`
|
||||||
|
- `Affected Areas`
|
||||||
|
- `Decisions`
|
||||||
|
- `Execution Plan`
|
||||||
|
- `Verification Plan`
|
||||||
|
- `Risks / Trade-offs`
|
||||||
|
- `Open Questions`
|
||||||
|
|
||||||
|
c) 基于 `Affected Areas`、`Execution Plan`、`Verification Plan`、讨论中提到的受影响范围,并行读取相关实际产物、参考材料、验证材料、流程说明、配置、文档或沟通材料,确认文档是否贴合当前实际状态。
|
||||||
|
|
||||||
|
d) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts;若存在 `proposal.md`、`specs/*.md`,再按该 schema 的要求补充读取和审查。
|
||||||
|
|
||||||
若当前上下文无法明确 change 或文档路径:
|
若当前上下文无法明确 change 或文档路径:
|
||||||
|
|
||||||
@@ -25,48 +51,55 @@
|
|||||||
|
|
||||||
若已明确 change,但尚未确认 `schemaName`,先读取 change 元数据或执行 `openspec status --change "{name}" --json` 确认。
|
若已明确 change,但尚未确认 `schemaName`,先读取 change 元数据或执行 `openspec status --change "{name}" --json` 确认。
|
||||||
|
|
||||||
若缺少讨论记录,明确说明本次降级为"文档 + 代码现状审查",不做讨论一致性结论。
|
若缺少讨论记录,明确说明本次降级为“文档 + 当前实际状态审查”,不做讨论一致性结论。
|
||||||
|
|
||||||
## 2. 分析
|
## 2. 分析
|
||||||
|
|
||||||
按以下优先级检查:
|
按以下优先级检查:
|
||||||
|
|
||||||
| 优先级 | 维度 | 检查点 |
|
| 优先级 | 维度 | 检查点 |
|
||||||
| ------ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| P0 | 讨论一致性 | 仅在存在讨论记录时检查:文档是否完整覆盖已确认的目标、范围、非目标、约束、边界条件、风险、决策点、待办事项;若无讨论记录,标记为"跳过" |
|
| P0 | 讨论承接性 | 仅在存在讨论记录时检查:`design.md` 是否完整记录已确认的目标、非目标、用户偏好、约束、边界条件、风险、关键决策、被否决方案和待澄清事项;若无讨论记录,标记为“跳过” |
|
||||||
| P1 | 代码现实性 | 文档对当前模块、接口、数据结构、命名、依赖、目录结构、复用路径的描述是否准确;是否把"计划变更"误写成"当前现状";是否遗漏真实受影响的现有能力 |
|
| P1 | `design.md` 自包含性 | `design.md` 是否足以让看不到前序对话的执行者继续工作;是否包含完整 required sections;`Open Questions` 是否明确区分 blocking / non-blocking 或写出 `None`;是否存在依赖未记录聊天上下文的隐含要求 |
|
||||||
| P2 | 文档内部一致性 | 对实际存在的 artifacts 检查是否互相支撑;在 `spec-driven` 下重点检查 `proposal.md`、`design.md`、`tasks.md`、`specs/*.md`;`Capabilities` / `Modified Capabilities` 是否完整;每个 capability 是否有对应 spec;`tasks.md` 是否覆盖 `design.md` 和 `specs/*.md` |
|
| P2 | 当前状态真实性 | `design.md` 对当前实际产物、流程、接口、内容、数据、配置、依赖、责任边界、参考材料和验证入口的描述是否准确;是否把“计划变更”误写成“当前现状”;`Affected Areas` 是否遗漏真实受影响区域 |
|
||||||
| P3 | OpenSpec 合规性 | 对实际存在的 artifacts 检查是否遵循 OpenSpec 格式和术语;`specs/*.md` 是否只描述行为与约束、不混入实现细节;`tasks.md` 是否一行一个任务;是否混入 git 操作任务 |
|
| P3 | `tasks.md` 派生性 | `tasks.md` 是否从 `design.md` 派生;是否覆盖 requirements、guardrails、decisions、execution plan 和 verification plan;是否依赖 `proposal.md` 或 `specs/*.md` 中未写入 `design.md` 的内容 |
|
||||||
|
| P4 | OpenSpec 合规性 | 对实际存在的 artifacts 检查是否遵循对应 schema 和 OpenSpec 术语;`tasks.md` 是否一行一个 `- [ ]` checkbox 任务、按 `##` numbered headings 分组、无无关的仓库/版本控制/发布操作任务;`design.md` 是否避免 task checkbox;最终 artifacts 是否仍保留模板注释、空表格行或占位任务文本 |
|
||||||
|
|
||||||
分析时区分两类情况:
|
分析时区分两类情况:
|
||||||
|
|
||||||
- 文档对当前代码现状的描述错误
|
- 文档对当前实际状态的描述错误
|
||||||
- 文档描述的是预期变更,本来就应当与当前代码不同
|
- 文档描述的是预期变更,本来就应当与当前状态不同
|
||||||
|
|
||||||
重点识别:
|
重点识别:
|
||||||
|
|
||||||
- 讨论中已确定但文档未记录的内容
|
- 讨论中已确定但 `design.md` 未记录的内容
|
||||||
- 文档基于错误现状做出的设计或任务拆分
|
- `design.md` 中缺失或含糊的 requirements、acceptance criteria、guardrails、decisions、verification expectations
|
||||||
- 文档之间相互冲突的目标、方案、约束、任务
|
- `Open Questions` 未明确区分 blocking / non-blocking、与 `tasks.md` 冲突,或包含 apply 前必须解决的 blocker
|
||||||
- `proposal -> specs -> design -> tasks` 链路中的断点
|
- `tasks.md` 未覆盖 `design.md` 的要求、约束、执行计划、验证计划或文档/沟通更新要求
|
||||||
- `Modified Capabilities` 应更新但未更新的现有 spec
|
- `tasks.md` 标记了无法验证、跨行、过大、顺序错误或包含无关仓库/版本控制/发布操作的任务
|
||||||
|
- 文档仍保留 `<!-- ... -->` 模板注释、空表格行、`Replace with...`、`TBD`、`TODO` 等未解决占位内容
|
||||||
|
- 文档基于错误当前状态做出的设计或任务拆分
|
||||||
|
- 文档之间相互冲突的目标、方案、约束、任务和验证要求
|
||||||
|
- `fast-drive` change 中仍错误依赖 `proposal.md`、`specs/*.md`、`Capabilities` 或 `Modified Capabilities` 的内容
|
||||||
|
|
||||||
输出审查结果:
|
输出审查结果:
|
||||||
|
|
||||||
1. **问题总览表**:问题类型 × 涉及文档数
|
1. **问题总览表**:问题类型 × 涉及文档数
|
||||||
2. **讨论遗漏清单**:讨论已确定但文档未体现的内容;若缺少讨论记录,标记为"未审查"
|
2. **讨论遗漏清单**:讨论已确定但 `design.md` 未体现的内容;若缺少讨论记录,标记为“未审查”
|
||||||
3. **现实性问题清单**:与当前代码现状不符的描述、假设或影响分析
|
3. **Design 自包含性问题清单**:缺失、含糊或无法指导新会话 apply 的内容
|
||||||
4. **文档冲突清单**:proposal、design、tasks、specs 之间的不一致
|
4. **当前状态问题清单**:与当前实际状态不符的描述、假设或影响分析
|
||||||
5. **OpenSpec 规范问题清单**:格式、术语、结构问题
|
5. **Tasks 派生与覆盖问题清单**:`tasks.md` 未从 `design.md` 正确派生或覆盖不足的内容
|
||||||
6. **待澄清清单**:仅靠讨论和代码仍无法判断的事项
|
6. **文档冲突清单**:`design.md`、`tasks.md` 和实际存在的其他 artifacts 之间的不一致
|
||||||
7. **逐项分析**:每个问题说明位置、问题、影响、建议
|
7. **OpenSpec 规范问题清单**:格式、术语、结构问题
|
||||||
8. **补充建议方案**:按文件列出建议补充/修正的内容、原因和可选方案
|
8. **待澄清清单**:仅靠讨论和当前状态仍无法判断的事项
|
||||||
|
9. **逐项分析**:每个问题说明位置、问题、影响、建议
|
||||||
|
10. **补充建议方案**:按文件列出建议补充/修正的内容、原因和可选方案
|
||||||
|
|
||||||
若所有清单均为空,输出"审查通过,未发现问题",跳至步骤 5。
|
若所有清单均为空,输出“审查通过,未发现问题”,跳至步骤 5。
|
||||||
|
|
||||||
## 3. 计划(用户确认)
|
## 3. 计划(用户确认)
|
||||||
|
|
||||||
先针对"待澄清清单"用提问工具逐项向用户确认。
|
先针对“待澄清清单”用提问工具逐项向用户确认。
|
||||||
|
|
||||||
再整理完整修复方案,按文件列出:
|
再整理完整修复方案,按文件列出:
|
||||||
|
|
||||||
@@ -79,7 +112,9 @@
|
|||||||
|
|
||||||
## 4. 执行
|
## 4. 执行
|
||||||
|
|
||||||
逐项修改已确认的变更文档,不修改源码。
|
逐项修改已确认的变更文档,不修改实际产物。
|
||||||
|
|
||||||
|
在 `fast-drive` workflow 下,通常只修改本次 change 的 `design.md` 和 `tasks.md`;若实际 schema 存在其他 artifacts,仅在确有必要且用户确认后修改实际存在的 artifacts。
|
||||||
|
|
||||||
若涉及删除或重写:
|
若涉及删除或重写:
|
||||||
|
|
||||||
@@ -88,9 +123,14 @@
|
|||||||
|
|
||||||
执行后重新读取所有被修改的文档,并复核:
|
执行后重新读取所有被修改的文档,并复核:
|
||||||
|
|
||||||
- "讨论遗漏清单" 是否已清空或已标注保留原因
|
- “讨论遗漏清单” 是否已清空或已标注保留原因
|
||||||
- "现实性问题清单" 是否已清空或已标注为预期变更
|
- “Design 自包含性问题清单” 是否已清空
|
||||||
- "文档冲突清单" 和 "OpenSpec 规范问题清单" 是否已清空
|
- “当前状态问题清单” 是否已清空或已标注为预期变更
|
||||||
|
- “Tasks 派生与覆盖问题清单” 是否已清空
|
||||||
|
- “文档冲突清单” 是否已清空
|
||||||
|
- “OpenSpec 规范问题清单” 是否已清空
|
||||||
|
- “待澄清清单” 是否已清空或已记录用户决策
|
||||||
|
- 所有模板注释、空表格行和占位文本是否已清空或替换为有效内容
|
||||||
|
|
||||||
## 5. 收尾
|
## 5. 收尾
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
请审查并整理 `openspec/specs/` 下的稳定规范,使其成为可搜索、边界清晰、无冗余、与当前业务一致的能力索引,按以下流程执行。
|
|
||||||
|
|
||||||
## 约束
|
|
||||||
|
|
||||||
- `openspec/specs/` 描述长期稳定的业务能力、规则和外部行为,不记录变更过程、迁移说明、实现路径、内部类型名、组件 props、样式数值、层级分层等实现细节
|
|
||||||
- 用户可感知或对外暴露的契约可以保留:公开 API 路径、请求/响应字段、协议名、错误码、数据约束、交互结果
|
|
||||||
- `Requirement` 和 `Scenario` 应描述业务能力、外部行为或稳定约束,不以“使用某层/某组件/某库实现”作为标题或核心表述
|
|
||||||
- 不把当前代码自动视为唯一真相;若代码、README、现有 spec 冲突且无法判断应以哪边为准,列入待确认清单,不直接改写规范
|
|
||||||
- 仅删除内容已被其他规范完整覆盖且无独立检索价值的规范;非冗余内容仅迁移、合并、拆分或重命名
|
|
||||||
- 每批重构执行前用提问工具获得用户确认;删除或重写前先备份原文件为 `{file}.bak.{timestamp}`
|
|
||||||
- 命名、Purpose、Requirement 标题都必须保留用户下一次最可能搜索的业务关键词
|
|
||||||
|
|
||||||
## 1. 收集
|
|
||||||
|
|
||||||
并行读取:
|
|
||||||
|
|
||||||
- `openspec/config.yaml`
|
|
||||||
- `README.md`,以及与模块结构、API、架构相关的 README 或文档
|
|
||||||
- `openspec/specs/*/spec.md`
|
|
||||||
|
|
||||||
默认不读取 `openspec/changes/**`、历史 proposal/design/tasks 作为稳定规范整理依据;仅在用户明确要求“连同历史变更一起校对”时再纳入。
|
|
||||||
|
|
||||||
先建立索引,不直接开始改写:
|
|
||||||
|
|
||||||
| 索引 | 内容 |
|
|
||||||
| -------------- | ----------------------------------------------------------------------------- |
|
|
||||||
| `spec_index[]` | 每个 spec 的目录名、Purpose、Requirement 摘要、关键词、外部契约、疑似重叠对象 |
|
|
||||||
| `domain_map[]` | 从 README、API、模块文档中提炼的核心业务域、横切能力和术语 |
|
|
||||||
| `term_map[]` | 同义词、旧名、缩写和推荐标准术语 |
|
|
||||||
| `suspects[]` | 需要进一步对照代码或测试确认的 spec |
|
|
||||||
|
|
||||||
仅对 `suspects[]` 做定向读取:
|
|
||||||
|
|
||||||
- 读取与该 spec 对应的源码、测试、README 或架构文档
|
|
||||||
- 不对 `backend/`、`frontend/` 做无差别逐文件扫描
|
|
||||||
|
|
||||||
判定依据优先级:
|
|
||||||
|
|
||||||
- 当前稳定 spec 与 README 共同支持的事实,可直接视为高置信度
|
|
||||||
- 仅代码可见但 README 和 spec 未体现的内容,先判断它是稳定外部行为还是临时实现细节
|
|
||||||
- 代码、README、现有 spec 互相冲突且无法自动定夺时,进入 `待确认清单`
|
|
||||||
|
|
||||||
## 2. 审查
|
|
||||||
|
|
||||||
按 spec、Requirement、Scenario 三层检查:
|
|
||||||
|
|
||||||
| 维度 | 检查点 |
|
|
||||||
| --------- | --------------------------------------------------------------------------------- |
|
|
||||||
| 过时 | 描述的能力、术语、外部契约是否仍成立;是否存在 `TBD`、`TODO`、占位说明 |
|
|
||||||
| 冲突 | 不同规范是否对同一行为给出不同约束、命名或边界 |
|
|
||||||
| 重复/重叠 | 是否在文件级、Requirement 级、Scenario 级重复描述同一能力 |
|
|
||||||
| 错位 | 内容是否放错能力域;横切规则是否混入实体规范;平台实现是否混入通用能力规范 |
|
|
||||||
| 粒度 | 是否过大导致难检索,或过碎导致回答一个问题必须同时打开多个 spec |
|
|
||||||
| 术语 | 同一概念是否混用多个名字;旧名、别名、缩写是否需要归一并保留检索入口 |
|
|
||||||
| 命名/检索 | 目录名、Purpose、Requirement 标题是否准确;是否能被 README、API、业务术语直接命中 |
|
|
||||||
| 规范性 | 是否使用 SHALL/WHEN/THEN;是否混入变更记录、迁移说明、内部实现或 UI/代码细节 |
|
|
||||||
| 完整性 | Purpose 是否明确;是否存在空目录、非 spec 噪音文件、无清晰归属的孤立规范 |
|
|
||||||
|
|
||||||
重构判定规则:
|
|
||||||
|
|
||||||
- 若两个 spec 回答的是同一个核心问题,或其中一个只是另一个的子集,优先合并
|
|
||||||
- 若一个 spec 混合多个独立检索意图,或同时包含横切规则与业务流程,优先拆分
|
|
||||||
- 若内容正确但目录名、Purpose 或 Requirement 标题不利于检索,优先重命名或改写标题
|
|
||||||
- 若多个术语指向同一概念,统一到一个标准术语,并在 Purpose 或 Requirement 中保留必要别名以支持搜索
|
|
||||||
- 若某段内容只是内部实现细节,且不影响外部行为理解,删除该段而不是为其单独保留 spec
|
|
||||||
- 若某个具体值同时属于外部契约与内部实现,按“是否对调用方可见、是否影响兼容性”判断是否保留
|
|
||||||
|
|
||||||
### 命名约定
|
|
||||||
|
|
||||||
命名优先复用仓库已存在的稳定术语,如 `provider`、`model`、`stats`、`protocol`、`proxy`、`logging`、`validation`、`migration`、`frontend`、`desktop`、`mysql`。
|
|
||||||
|
|
||||||
| 类型 | 模式 | 示例 |
|
|
||||||
| ------------ | ---------------------------------------------------------- | -------------------------------------------------- |
|
|
||||||
| 实体生命周期 | `{entity}-management` | `provider-management`、`model-management` |
|
|
||||||
| 横切能力 | `{concern}` 或 `{concern}-{qualifier}` | `error-handling`、`structured-logging` |
|
|
||||||
| 协议/适配 | `{protocol}-{capability}` 或 `protocol-adapter-{protocol}` | `openai-protocol-proxy`、`protocol-adapter-openai` |
|
|
||||||
| 运行面/入口 | `{surface}` 或 `{surface}-{capability}` | `frontend`、`desktop-app` |
|
|
||||||
| 基础设施 | `{resource}-{operation}` | `database-migration`、`mysql-driver` |
|
|
||||||
|
|
||||||
命名原则:
|
|
||||||
|
|
||||||
- 1-4 个词,保持单一主题
|
|
||||||
- 优先使用业务名词,不使用 `basic`、`general`、`misc`、`info`、`data` 等泛化词
|
|
||||||
- 不使用 `crud`、`list`、`table`、`display` 等实现模式词,除非它本身就是外部契约的一部分
|
|
||||||
- 同一主题的命名模式保持一致,不同时混用多套前后缀
|
|
||||||
|
|
||||||
## 3. 报告
|
|
||||||
|
|
||||||
输出分析结果:
|
|
||||||
|
|
||||||
1. **问题总览表**:问题类型 × 涉及规范数
|
|
||||||
2. **规范关系表**:每个 spec 的主主题、重叠对象、冲突对象、建议动作
|
|
||||||
3. **术语归一表**:旧术语 / 别名 / 缩写 → 推荐标准术语
|
|
||||||
4. **逐项分析**:每个有问题的规范说明位置、问题、影响、建议和目标规范
|
|
||||||
5. **待确认清单**:代码、README、现有 spec 冲突且无法自动定夺的事项
|
|
||||||
6. **重构方案**:按优先级分批
|
|
||||||
7. **重构后目录结构**:预期的新 `openspec/specs/` 目录树
|
|
||||||
|
|
||||||
优先级建议:
|
|
||||||
|
|
||||||
- P0:删除空目录、非 spec 噪音文件、占位内容
|
|
||||||
- P1:删除完全冗余规范;将其内容映射到主规范
|
|
||||||
- P2:合并重复/子集规范;拆分错位或过大规范
|
|
||||||
- P3:重命名目录、改写 Purpose 和 Requirement 标题以提升检索性
|
|
||||||
- P4:修正过时描述,清理实现细节、迁移说明和变更记录
|
|
||||||
|
|
||||||
若所有问题清单为空,输出“审查通过,未发现问题”,跳至步骤 5。
|
|
||||||
|
|
||||||
## 4. 计划(用户确认)
|
|
||||||
|
|
||||||
先针对 `待确认清单` 用提问工具逐项向用户确认。
|
|
||||||
|
|
||||||
再按批次展示完整重构计划,每批必须包含:
|
|
||||||
|
|
||||||
- 操作类型:删除、重命名、迁移、合并、拆分、改写
|
|
||||||
- 路径变化:源路径 → 目标路径
|
|
||||||
- 内容映射:源 spec 的 Requirement / Scenario 将迁移到哪里
|
|
||||||
- 术语处理:哪些旧词保留为检索入口,哪些词统一替换
|
|
||||||
- 执行原因:为什么这样做更利于检索、去重和边界清晰
|
|
||||||
- 验证方式:如何确认没有丢失约束或引入新的冲突
|
|
||||||
|
|
||||||
用提问工具获得当前批次确认后再执行。
|
|
||||||
|
|
||||||
## 5. 执行
|
|
||||||
|
|
||||||
按 P0 → P4 逐批执行已确认的重构。
|
|
||||||
|
|
||||||
执行要求:
|
|
||||||
|
|
||||||
- 合并或拆分时先写目标 spec,再删除或重命名源 spec
|
|
||||||
- 删除前确认其 Requirement 和 Scenario 已被完整保留、迁移或判定为纯冗余
|
|
||||||
- 每批执行后重新读取受影响的 spec,并复核结构和内容
|
|
||||||
|
|
||||||
每批执行后至少验证:
|
|
||||||
|
|
||||||
- 目录结构完整,`openspec/specs/*/spec.md` 可正常读取
|
|
||||||
- 不存在未承接的 Requirement 或 Scenario
|
|
||||||
- Purpose、Requirement 标题、目录名可以直接表达主能力
|
|
||||||
- 不再包含 `TBD`、变更记录、迁移说明、内部实现细节或噪音文件
|
|
||||||
- 若本批涉及代码对照项,相关外部契约描述与当前仓库现状一致,或已列入残留待确认
|
|
||||||
|
|
||||||
收尾时输出:修改文件清单、备份文件清单、最终目录树、残留待确认事项和整理摘要。
|
|
||||||
35
docs/user/README.md
Normal file
35
docs/user/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 用户文档
|
||||||
|
|
||||||
|
本文档是 DiAL 的用户使用入口,说明如何阅读配置、部署、expect 规则、故障排查和各 checker 参考。
|
||||||
|
|
||||||
|
适用场景:编写 YAML 配置、部署 DiAL、理解拨测结果、排查运行问题、查询某个 checker 的字段和示例。
|
||||||
|
|
||||||
|
## 文档索引
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
| ---------------------------------------- | ------------------------------------------------- |
|
||||||
|
| [configuration.md](configuration.md) | YAML 顶层结构、变量、server、targets 通用字段 |
|
||||||
|
| [deployment.md](deployment.md) | 生产构建、Docker、ICMP 权限、发布包运行方式 |
|
||||||
|
| [expectations.md](expectations.md) | expect 规则、状态判定、failure、observation |
|
||||||
|
| [troubleshooting.md](troubleshooting.md) | 配置校验、变量、ICMP、CMD、Docker、证书和正则问题 |
|
||||||
|
| [checkers/README.md](checkers/README.md) | 各 checker 的配置项、expect 字段和示例 |
|
||||||
|
|
||||||
|
## 按任务阅读
|
||||||
|
|
||||||
|
| 任务 | 建议阅读 |
|
||||||
|
| --------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| 首次运行 | [项目快速开始](../../README.md#快速开始)、[配置文件](configuration.md) |
|
||||||
|
| 编写配置 | [配置文件](configuration.md)、[Checker 参考](checkers/README.md) |
|
||||||
|
| 编写 expect | [校验规则](expectations.md)、对应 checker 文档 |
|
||||||
|
| 容器或生产部署 | [部署](deployment.md)、[故障排查](troubleshooting.md) |
|
||||||
|
| 排查启动或运行问题 | [故障排查](troubleshooting.md)、相关 checker 文档 |
|
||||||
|
| 查询 checker 专属字段 | [Checker 参考](checkers/README.md) |
|
||||||
|
|
||||||
|
## 用户文档更新规则
|
||||||
|
|
||||||
|
- 配置结构、变量、server、probes、targets 通用字段变化时,更新 [configuration.md](configuration.md)。
|
||||||
|
- checker 配置项、expect 字段、示例或运行行为变化时,更新 `checkers/<type>.md` 和 [checkers/README.md](checkers/README.md)。
|
||||||
|
- expect 模型、状态判定、failure、observation 或快速失败顺序变化时,更新 [expectations.md](expectations.md)。
|
||||||
|
- 构建产物运行方式、Docker 参数、镜像内置依赖、发布包结构变化时,更新 [deployment.md](deployment.md)。
|
||||||
|
- 常见错误、运行依赖、权限、证书或配置校验排查方式变化时,更新 [troubleshooting.md](troubleshooting.md)。
|
||||||
|
- 用户文档只解释“如何使用”和“用户能观察到什么”,实现细节放入 [`../development/`](../development/README.md)。
|
||||||
49
docs/user/checkers/README.md
Normal file
49
docs/user/checkers/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Checker 参考
|
||||||
|
|
||||||
|
Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一个 checker,并配置对应的专属字段和 `expect` 规则。
|
||||||
|
|
||||||
|
适用场景:查询 checker 类型选择、专属配置、expect 字段、示例和各 checker 文档入口。
|
||||||
|
|
||||||
|
## 支持的类型
|
||||||
|
|
||||||
|
| 类型 | 用途 | 文档 |
|
||||||
|
| -------- | -------------------------------------- | ------------------- |
|
||||||
|
| `http` | HTTP/HTTPS 应用层健康检查 | [HTTP](http.md) |
|
||||||
|
| `cmd` | 执行本地命令或脚本 | [Cmd](cmd.md) |
|
||||||
|
| `db` | PostgreSQL/MySQL/SQLite 连接和查询检查 | [DB](db.md) |
|
||||||
|
| `tcp` | TCP 端口可达性和 banner 探测 | [TCP](tcp.md) |
|
||||||
|
| `udp` | UDP payload 请求-响应检查 | [UDP](udp.md) |
|
||||||
|
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
|
||||||
|
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
|
||||||
|
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
|
||||||
|
| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) |
|
||||||
|
| `cpu` | 本机 CPU 使用率健康检查 | [CPU](cpu.md) |
|
||||||
|
| `memory` | 本机系统内存使用状况检查 | [Memory](memory.md) |
|
||||||
|
|
||||||
|
## 选择建议
|
||||||
|
|
||||||
|
| 目标 | 推荐 checker |
|
||||||
|
| ---------------------------------- | ------------ |
|
||||||
|
| Web API、网页、HTTP 状态码或响应体 | `http` |
|
||||||
|
| 本机脚本、外部命令、CLI 工具 | `cmd` |
|
||||||
|
| 数据库连接或查询结果 | `db` |
|
||||||
|
| 端口是否可连接、服务 banner | `tcp` |
|
||||||
|
| UDP 服务响应或简单心跳 | `udp` |
|
||||||
|
| 主机可达性、延迟、丢包率 | `icmp` |
|
||||||
|
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
|
||||||
|
| LLM API 是否可用、输出是否符合预期 | `llm` |
|
||||||
|
| WebSocket 可达性或消息交互验证 | `ws` |
|
||||||
|
| 本机 CPU 使用率健康检查 | `cpu` |
|
||||||
|
| 本机系统内存使用状况检查 | `memory` |
|
||||||
|
|
||||||
|
## 通用字段
|
||||||
|
|
||||||
|
所有 checker 都共享 target 通用字段,见 [配置文件](../configuration.md#targets-通用字段)。
|
||||||
|
|
||||||
|
## 通用断言模型
|
||||||
|
|
||||||
|
各 checker 的 `expect` 字段复用 `ValueMatcher`、`ContentExpectations` 和 `KeyedExpectations`。详情见 [校验规则](../expectations.md)。
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
新增、移除或修改 checker 类型、用途、选择建议、通用字段或通用断言模型时,必须更新本文档。checker 专属字段变化还必须同步更新对应 `checkers/<type>.md`。
|
||||||
38
docs/user/checkers/cmd.md
Normal file
38
docs/user/checkers/cmd.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Cmd Checker
|
||||||
|
|
||||||
|
`type: cmd` 用于执行本地命令或脚本,并校验退出码、stdout、stderr 和耗时。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ---------- | ------------------------------------ | ---- | ------ |
|
||||||
|
| `cmd.exec` | 可执行文件名或路径 | 是 | 无 |
|
||||||
|
| `cmd.args` | 命令行参数列表 | 否 | `[]` |
|
||||||
|
| `cmd.env` | 环境变量覆盖,继承进程环境变量并合并 | 否 | 无 |
|
||||||
|
| `cmd.cwd` | 工作目录,相对于配置文件所在目录 | 否 | 无 |
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------ | --------------------------------------------- | ---- | ------ |
|
||||||
|
| `exitCode` | 可接受的退出码列表 | 否 | `[0]` |
|
||||||
|
| `stdout` | 标准输出校验,使用 `ContentExpectations` 数组 | 否 | 无 |
|
||||||
|
| `stderr` | 标准错误校验,使用 `ContentExpectations` 数组 | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "bun-script"
|
||||||
|
name: "Bun 脚本检查"
|
||||||
|
type: cmd
|
||||||
|
cmd:
|
||||||
|
exec: "bun"
|
||||||
|
args: ["-e", "console.log('ok')"]
|
||||||
|
expect:
|
||||||
|
exitCode: [0]
|
||||||
|
stdout:
|
||||||
|
- contains: "ok"
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker 官方镜像不内置常见外部命令。容器内使用 CMD checker 时,按需通过派生镜像安装依赖命令。
|
||||||
74
docs/user/checkers/cpu.md
Normal file
74
docs/user/checkers/cpu.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# CPU Checker
|
||||||
|
|
||||||
|
`type: cpu` 用于检查本机 CPU 使用率,基于两次系统快照计算总体和每核心的忙碌比例。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| -------------------- | -------------------------------- | ---- | ------- |
|
||||||
|
| `cpu.sampleDuration` | CPU 采样窗口,支持时长格式 | 否 | `1s` |
|
||||||
|
| `cpu.includePerCore` | 是否在结果中输出每核心使用率数组 | 否 | `false` |
|
||||||
|
|
||||||
|
`sampleDuration` 必须小于 target 的 `timeout`。
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| --------------------- | ----------------------------------------------------------------------------------------------- | ---- | ------ |
|
||||||
|
| `usagePercent` | 总体 CPU 使用率,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `idlePercent` | 总体 CPU 空闲率,与 `usagePercent` 互补,两者之和恒为 100(`idlePercent + usagePercent = 100`) | 否 | 无 |
|
||||||
|
| `maxCoreUsagePercent` | 单核心最高使用率,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `minCoreUsagePercent` | 单核心最低使用率,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
所有百分比字段范围为 `0-100`,表示所有可见逻辑 CPU 的总体比例,不是"核心数 × 100"。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "local-cpu"
|
||||||
|
name: "本机 CPU"
|
||||||
|
type: cpu
|
||||||
|
interval: "30s"
|
||||||
|
timeout: "5s"
|
||||||
|
cpu:
|
||||||
|
sampleDuration: "1s"
|
||||||
|
expect:
|
||||||
|
usagePercent:
|
||||||
|
lte: 85
|
||||||
|
maxCoreUsagePercent:
|
||||||
|
lte: 95
|
||||||
|
```
|
||||||
|
|
||||||
|
输出每核心使用率:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "local-cpu-detail"
|
||||||
|
name: "本机 CPU 详细"
|
||||||
|
type: cpu
|
||||||
|
cpu:
|
||||||
|
sampleDuration: "2s"
|
||||||
|
includePerCore: true
|
||||||
|
expect:
|
||||||
|
usagePercent:
|
||||||
|
lte: 80
|
||||||
|
```
|
||||||
|
|
||||||
|
## 语义说明
|
||||||
|
|
||||||
|
CPU checker 采集的是 DiAL 进程运行环境通过系统 API(`os.cpus()`)可见的 CPU 视图。在容器中,它可能不等于严格的 cgroup quota 使用率。
|
||||||
|
|
||||||
|
`usagePercent` 和 `idlePercent` 互补,恒等于 100。`sampleDuration` 决定了两次快照之间的等待时间,窗口越长结果越稳定,但会增加 checker 执行耗时。
|
||||||
|
|
||||||
|
## 不支持的功能
|
||||||
|
|
||||||
|
- CPU 温度、电源状态、频率
|
||||||
|
- `userPercent` / `systemPercent`(用户态/系统态占比)
|
||||||
|
- `loadAverage`(系统负载均值)
|
||||||
|
- 进程级 CPU 使用率
|
||||||
|
- Linux cgroup 精确 CPU 计算
|
||||||
|
- `logicalCoreCount` 作为 expect 字段(仅在 observation 中输出)
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改 CPU checker 配置、expect 字段、行为或语义时,必须更新本文档。
|
||||||
38
docs/user/checkers/db.md
Normal file
38
docs/user/checkers/db.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# DB Checker
|
||||||
|
|
||||||
|
`type: db` 用于数据库连接和查询结果检查,支持 PostgreSQL、MySQL 和 SQLite。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ---------- | ------------------------------------------------------------- | ---- | ------ |
|
||||||
|
| `db.url` | 数据库连接字符串,支持 `postgres://`、`mysql://`、`sqlite://` | 是 | 无 |
|
||||||
|
| `db.query` | SQL 查询语句,不配置时仅测试连接 | 否 | 无 |
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------ | ----------------------------------------------------------------------- | ---- | ------ |
|
||||||
|
| `rowCount` | 查询返回行数校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `rows` | 查询结果逐行校验,数组内每行为列名到 `KeyedExpectations` 的映射 | 否 | 无 |
|
||||||
|
| `result` | 完整查询结果 `{ rows, rowCount }` 校验,使用 `ContentExpectations` 数组 | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "sqlite-query"
|
||||||
|
name: "SQLite 数据库检查"
|
||||||
|
type: db
|
||||||
|
db:
|
||||||
|
url: "sqlite:///path/to/db.sqlite"
|
||||||
|
query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'"
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
rowCount:
|
||||||
|
gte: 1
|
||||||
|
rows:
|
||||||
|
- cnt:
|
||||||
|
gte: 0
|
||||||
|
```
|
||||||
104
docs/user/checkers/dns.md
Normal file
104
docs/user/checkers/dns.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# DNS Checker
|
||||||
|
|
||||||
|
`type: dns` 支持两种解析模式:本机解析器检查和指定 DNS server 协议级检查。
|
||||||
|
|
||||||
|
## resolver 模式
|
||||||
|
|
||||||
|
| 模式 | 说明 |
|
||||||
|
| -------- | ----------------------------------------------------------------------------- |
|
||||||
|
| `system` | 使用本机 DNS 解析器检查域名是否能解析到预期地址 |
|
||||||
|
| `server` | 直接向指定 DNS server 发起 UDP/TCP 深度拨测,检查 RCODE、TTL、flags、记录值等 |
|
||||||
|
|
||||||
|
## `dns.resolver: system` 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| -------------- | ----------------------------- | ---- | -------- |
|
||||||
|
| `dns.resolver` | 解析模式 | 是 | `system` |
|
||||||
|
| `dns.name` | 待解析域名 | 是 | 无 |
|
||||||
|
| `dns.family` | 地址族:`any`、`ipv4`、`ipv6` | 否 | `any` |
|
||||||
|
|
||||||
|
### system 模式 expect
|
||||||
|
|
||||||
|
| 字段 | 说明 | 断言模型 |
|
||||||
|
| ------------ | -------------------- | --------------------------------- |
|
||||||
|
| `values` | 解析结果地址集合断言 | DNS 集合(include/exclude/exact) |
|
||||||
|
| `valueCount` | 解析结果数量 | ValueMatcher |
|
||||||
|
| `durationMs` | 解析耗时 | ValueMatcher |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "dns-system-api"
|
||||||
|
name: "本机 DNS 解析"
|
||||||
|
type: dns
|
||||||
|
dns:
|
||||||
|
resolver: system
|
||||||
|
name: "api.example.com"
|
||||||
|
family: any
|
||||||
|
expect:
|
||||||
|
values:
|
||||||
|
exact:
|
||||||
|
- "203.0.113.10"
|
||||||
|
durationMs:
|
||||||
|
lte: 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## `dns.resolver: server` 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ---------------------- | --------------------------------- | ---- | -------- |
|
||||||
|
| `dns.resolver` | 解析模式 | 是 | `server` |
|
||||||
|
| `dns.server` | DNS server 地址 | 是 | 无 |
|
||||||
|
| `dns.name` | 查询域名 | 是 | 无 |
|
||||||
|
| `dns.port` | DNS server 端口 | 否 | `53` |
|
||||||
|
| `dns.protocol` | 传输协议:`udp`、`tcp` | 否 | `udp` |
|
||||||
|
| `dns.recordType` | DNS 记录类型 | 否 | `A` |
|
||||||
|
| `dns.recursionDesired` | 是否设置 RD flag | 否 | `true` |
|
||||||
|
| `dns.tcpFallback` | UDP 响应 TC=1 时是否 TCP fallback | 否 | `true` |
|
||||||
|
| `dns.maxResponseBytes` | 响应最大字节数 | 否 | `4KB` |
|
||||||
|
|
||||||
|
`recordType` 可选值:`A`、`AAAA`、`CNAME`、`NS`、`MX`、`TXT`、`SOA`、`SRV`、`CAA`、`PTR`。
|
||||||
|
|
||||||
|
### server 模式 expect
|
||||||
|
|
||||||
|
| 字段 | 说明 | 断言模型 |
|
||||||
|
| -------------------- | ---------------------------------- | --------------------------------- |
|
||||||
|
| `responded` | 是否收到 DNS response | boolean |
|
||||||
|
| `rcode` | 期望 RCODE 列表,如 `NOERROR` | string[] |
|
||||||
|
| `values` | 目标类型记录值集合断言 | DNS 集合(include/exclude/exact) |
|
||||||
|
| `valueCount` | 目标类型记录数量 | ValueMatcher |
|
||||||
|
| `answerCount` | answer section 总记录数 | ValueMatcher |
|
||||||
|
| `ttlMin` | answer 中最小 TTL | ValueMatcher |
|
||||||
|
| `ttlMax` | answer 中最大 TTL | ValueMatcher |
|
||||||
|
| `authoritative` | AA flag | boolean |
|
||||||
|
| `recursionAvailable` | RA flag | boolean |
|
||||||
|
| `truncated` | TC flag | boolean |
|
||||||
|
| `authenticatedData` | AD flag | boolean |
|
||||||
|
| `result` | 完整结构化响应的 JSONPath 兜底断言 | ContentExpectations |
|
||||||
|
| `durationMs` | 完整查询耗时 | ValueMatcher |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "dns-server-api"
|
||||||
|
name: "Cloudflare DNS A 记录"
|
||||||
|
type: dns
|
||||||
|
dns:
|
||||||
|
resolver: server
|
||||||
|
server: "1.1.1.1"
|
||||||
|
name: "api.example.com"
|
||||||
|
recordType: A
|
||||||
|
expect:
|
||||||
|
rcode: ["NOERROR"]
|
||||||
|
values:
|
||||||
|
include:
|
||||||
|
- "203.0.113.10"
|
||||||
|
ttlMin:
|
||||||
|
gte: 60
|
||||||
|
durationMs:
|
||||||
|
lte: 200
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 未配置 expect 时,`system` 模式默认要求解析成功且 `valueCount > 0`,`server` 模式默认要求 `NOERROR + valueCount > 0`。
|
||||||
|
- 显式配置非 `NOERROR` rcode(如 `NXDOMAIN`)时,不自动要求 `valueCount > 0`。
|
||||||
|
- `values.exact` 忽略返回顺序。
|
||||||
|
- 对 A/AAAA 查询,CNAME 链不计入 `values`,单独放入 `cnameChain`。
|
||||||
|
- `values` 按记录类型规范化为字符串,例如 MX 为 `"10 mail.example.com"`,SRV 为 `"10 60 443 server.example.com"`。
|
||||||
48
docs/user/checkers/http.md
Normal file
48
docs/user/checkers/http.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# HTTP Checker
|
||||||
|
|
||||||
|
`type: http` 用于 HTTP/HTTPS 应用层健康检查。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------------- | ------------------- | ---- | ------- |
|
||||||
|
| `http.url` | 目标 URL | 是 | 无 |
|
||||||
|
| `http.method` | HTTP 方法 | 否 | `GET` |
|
||||||
|
| `http.headers` | 请求头 | 否 | 无 |
|
||||||
|
| `http.body` | 请求体 | 否 | 无 |
|
||||||
|
| `http.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
|
||||||
|
| `http.maxRedirects` | 最大重定向跟随次数 | 否 | `0` |
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------ | -------------------------------------------------- | ---- | ------- |
|
||||||
|
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"`) | 否 | `[200]` |
|
||||||
|
| `headers` | 响应头校验,使用 `KeyedExpectations` | 否 | 无 |
|
||||||
|
| `body` | 响应体校验,使用 `ContentExpectations` 数组 | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "json-api"
|
||||||
|
name: "JSON API 示例"
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "https://httpbin.org/json"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer token"
|
||||||
|
expect:
|
||||||
|
status: [200]
|
||||||
|
headers:
|
||||||
|
Content-Type:
|
||||||
|
contains: "application/json"
|
||||||
|
body:
|
||||||
|
- json:
|
||||||
|
path: "$.slideshow.title"
|
||||||
|
equals: "Sample Slide Show"
|
||||||
|
durationMs:
|
||||||
|
lte: 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP checker 的 `durationMs` 覆盖完整执行,包括重定向、按需响应体读取、解码和 expect 校验。未配置 body expectation、status 失败或 headers 失败时不会读取 body。
|
||||||
45
docs/user/checkers/icmp.md
Normal file
45
docs/user/checkers/icmp.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# ICMP Checker
|
||||||
|
|
||||||
|
`type: icmp` 使用系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ----------------- | ------------------------- | ---- | ------ |
|
||||||
|
| `icmp.host` | 目标主机地址 | 是 | 无 |
|
||||||
|
| `icmp.count` | ICMP 包数量,范围 `1-100` | 否 | `3` |
|
||||||
|
| `icmp.packetSize` | ICMP 包大小,bytes | 否 | `56` |
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------------- | --------------------------------------------------- | ---- | ------ |
|
||||||
|
| `alive` | 期望主机可达性 | 否 | `true` |
|
||||||
|
| `packetLossPercent` | 丢包率百分比校验,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `avgLatencyMs` | 平均延迟校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `maxLatencyMs` | 最大单次延迟校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "gateway-icmp"
|
||||||
|
name: "网关 ICMP 可达"
|
||||||
|
type: icmp
|
||||||
|
icmp:
|
||||||
|
host: "10.0.0.1"
|
||||||
|
count: 3
|
||||||
|
packetSize: 56
|
||||||
|
expect:
|
||||||
|
alive: true
|
||||||
|
packetLossPercent:
|
||||||
|
lte: 10
|
||||||
|
avgLatencyMs:
|
||||||
|
lte: 100
|
||||||
|
maxLatencyMs:
|
||||||
|
lte: 300
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
容器中运行 ICMP checker 通常需要 `--cap-add=NET_RAW`,详情见 [部署文档](../deployment.md#icmp-权限)。
|
||||||
53
docs/user/checkers/llm.md
Normal file
53
docs/user/checkers/llm.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# LLM Checker
|
||||||
|
|
||||||
|
`type: llm` 用于大模型服务应用层健康检查。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| --------------------- | ----------------------------------------------------- | ---- | ------- |
|
||||||
|
| `llm.provider` | 模型提供方:`openai`、`openai-responses`、`anthropic` | 是 | 无 |
|
||||||
|
| `llm.url` | API base URL | 是 | 无 |
|
||||||
|
| `llm.model` | 模型名称 | 是 | 无 |
|
||||||
|
| `llm.prompt` | 单轮 prompt | 是 | 无 |
|
||||||
|
| `llm.mode` | 调用模式:`http` 或 `stream` | 否 | `http` |
|
||||||
|
| `llm.key` | API key,支持 `${VAR}` 变量替换 | 否 | `""` |
|
||||||
|
| `llm.authToken` | Bearer token,仅 `anthropic` provider,与 `key` 互斥 | 否 | 无 |
|
||||||
|
| `llm.headers` | 附加请求头 | 否 | 无 |
|
||||||
|
| `llm.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
|
||||||
|
| `llm.options` | 生成选项 | 否 | 无 |
|
||||||
|
| `llm.providerOptions` | Provider 专属选项 | 否 | 无 |
|
||||||
|
|
||||||
|
`llm.options` 支持 `maxOutputTokens`(默认 `16`)、`temperature`(默认 `0`)、`topP`、`topK`、`presencePenalty`、`frequencyPenalty`、`stopSequences`、`seed`。
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ----------------- | --------------------------------------------------------------------------- | ---- | ------- |
|
||||||
|
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"`) | 否 | `[200]` |
|
||||||
|
| `headers` | 响应头校验,使用 `KeyedExpectations` | 否 | 无 |
|
||||||
|
| `output` | 模型输出校验,使用 `ContentExpectations` 数组 | 否 | 无 |
|
||||||
|
| `finishReason` | finish reason 校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `rawFinishReason` | 原始 finish reason 校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `usage` | Token usage 校验,支持 `inputTokens`、`outputTokens`、`totalTokens` matcher | 否 | 无 |
|
||||||
|
| `stream` | 流式断言,支持 `completed`、`firstTokenMs` matcher,仅 `mode: stream` | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "llm-openai-probe"
|
||||||
|
name: "OpenAI 健康检查"
|
||||||
|
type: llm
|
||||||
|
llm:
|
||||||
|
provider: openai
|
||||||
|
url: "https://api.openai.com/v1"
|
||||||
|
model: "gpt-4o-mini"
|
||||||
|
prompt: "Say OK"
|
||||||
|
key: "${OPENAI_API_KEY}"
|
||||||
|
expect:
|
||||||
|
status: [200]
|
||||||
|
finishReason: "stop"
|
||||||
|
output:
|
||||||
|
- contains: "OK"
|
||||||
|
```
|
||||||
119
docs/user/checkers/mem.md
Normal file
119
docs/user/checkers/mem.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Mem Checker
|
||||||
|
|
||||||
|
`type: mem` 用于检查本机系统级内存使用状况,包括物理内存和交换空间的使用率及字节数。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
Mem checker 配置为空对象,无需额外参数:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mem: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
### 百分比字段
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------------ | -------------------------------------------------------------------------- | ---- | ------ |
|
||||||
|
| `usagePercent` | 真实内存使用率 = `activeBytes / totalBytes × 100`,不含 buffers/cache 假象 | 否 | 无 |
|
||||||
|
| `usedPercent` | 原始已用百分比 = `usedBytes / totalBytes × 100`,包含 buffers/cache | 否 | 无 |
|
||||||
|
| `freePercent` | 空闲百分比 = `freeBytes / totalBytes × 100` | 否 | 无 |
|
||||||
|
| `activePercent` | 活跃内存百分比 = `activeBytes / totalBytes × 100` | 否 | 无 |
|
||||||
|
| `availablePercent` | 可用内存百分比 = `availableBytes / totalBytes × 100` | 否 | 无 |
|
||||||
|
| `swapUsagePercent` | 交换空间使用率,当系统无交换分区时为 `null` | 否 | 无 |
|
||||||
|
|
||||||
|
所有百分比字段范围为 `0-100`,使用 `ValueMatcher`。
|
||||||
|
|
||||||
|
### 字节字段
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ---------------- | ----------------------------------------- | ---- | ------ |
|
||||||
|
| `activeBytes` | 活跃内存字节数 | 否 | 无 |
|
||||||
|
| `usedBytes` | 已用内存字节数(含 buffers/cache) | 否 | 无 |
|
||||||
|
| `freeBytes` | 空闲内存字节数 | 否 | 无 |
|
||||||
|
| `availableBytes` | 可用内存字节数 | 否 | 无 |
|
||||||
|
| `totalBytes` | 物理内存总字节数 | 否 | 无 |
|
||||||
|
| `swapUsedBytes` | 交换空间已用字节数,无交换分区时为 `null` | 否 | 无 |
|
||||||
|
| `swapFreeBytes` | 交换空间空闲字节数,无交换分区时为 `null` | 否 | 无 |
|
||||||
|
| `swapTotalBytes` | 交换空间总字节数,无交换分区时为 `0` | 否 | 无 |
|
||||||
|
| `buffcacheBytes` | 缓冲缓存字节数,部分平台可能为 `null` | 否 | 无 |
|
||||||
|
|
||||||
|
字节字段支持数字(字节数)或大小字符串(如 `"512MB"`、`"1GB"`),使用 `ValueMatcher`。
|
||||||
|
|
||||||
|
### 通用字段
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------ | ------------------------------------- | ---- | ------ |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
检查内存使用率不超过 85%:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "local-memory"
|
||||||
|
name: "本机内存"
|
||||||
|
type: mem
|
||||||
|
interval: "30s"
|
||||||
|
timeout: "5s"
|
||||||
|
mem: {}
|
||||||
|
expect:
|
||||||
|
usagePercent:
|
||||||
|
lte: 85
|
||||||
|
```
|
||||||
|
|
||||||
|
检查可用内存不低于 4GB:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "local-memory-available"
|
||||||
|
name: "可用内存检查"
|
||||||
|
type: mem
|
||||||
|
mem: {}
|
||||||
|
expect:
|
||||||
|
availableBytes:
|
||||||
|
gte: "4GB"
|
||||||
|
```
|
||||||
|
|
||||||
|
同时检查内存和交换空间:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "local-memory-swap"
|
||||||
|
name: "内存和交换空间"
|
||||||
|
type: mem
|
||||||
|
mem: {}
|
||||||
|
expect:
|
||||||
|
usagePercent:
|
||||||
|
lte: 80
|
||||||
|
swapUsagePercent:
|
||||||
|
lte: 50
|
||||||
|
```
|
||||||
|
|
||||||
|
## 语义说明
|
||||||
|
|
||||||
|
Mem checker 通过 `systeminformation` 库读取系统内存数据,在 Linux、macOS 和 Windows 上均可运行。
|
||||||
|
|
||||||
|
- **`usagePercent`** 使用 `activeBytes / totalBytes` 计算,反映真实的内存压力,不受 Linux buffers/cache 缓存影响。推荐使用此字段进行内存健康检查。
|
||||||
|
- **`usedPercent`** 使用 `usedBytes / totalBytes` 计算,包含 buffers/cache。在 Linux 上此值通常高于 `usagePercent`。
|
||||||
|
- **Swap 字段**:当系统未配置交换分区时,`swapTotalBytes` 为 `0`,`swapUsagePercent` 为 `null`(非 `0`)。
|
||||||
|
- **`buffcacheBytes`**:反映 Linux 的 buffers + cache 用量,在其他平台上可能为 `null`。
|
||||||
|
|
||||||
|
Mem checker 是即时读取(非采样),无需 `sampleDuration`,执行速度远快于 CPU checker。虽然读取本身很快,但仍受 target `timeout` 约束——若底层系统调用悬挂或阻塞超过 `timeout`,checker 会返回 `mem/timeout` failure。
|
||||||
|
|
||||||
|
## 跨平台注意事项
|
||||||
|
|
||||||
|
- Windows 环境依赖 PowerShell 5+ 获取部分内存指标
|
||||||
|
- `buffcacheBytes` 在非 Linux 平台上可能返回 `null`
|
||||||
|
- 容器环境中内存数据可能不反映 cgroup 内存限制
|
||||||
|
|
||||||
|
## 不支持的功能
|
||||||
|
|
||||||
|
- 进程级内存使用(如 RSS、VSZ)
|
||||||
|
- cgroup/container 内存限制精度
|
||||||
|
- 内存趋势采样和历史记录
|
||||||
|
- 内存条物理布局信息
|
||||||
|
- 详细内存分类(slab、reclaimable、dirty 等)作为 expect 字段
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改 Mem checker 配置、expect 字段、行为或语义时,必须更新本文档。
|
||||||
35
docs/user/checkers/tcp.md
Normal file
35
docs/user/checkers/tcp.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# TCP Checker
|
||||||
|
|
||||||
|
`type: tcp` 用于 TCP 端口可达性和可选 banner 探测。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ----------------------- | --------------------------------------------- | ---- | ------- |
|
||||||
|
| `tcp.host` | 目标主机地址 | 是 | 无 |
|
||||||
|
| `tcp.port` | 目标端口,范围 `1-65535` | 是 | 无 |
|
||||||
|
| `tcp.readBanner` | 是否读取服务端 banner | 否 | `false` |
|
||||||
|
| `tcp.bannerReadTimeout` | banner 读取超时,毫秒 | 否 | `2000` |
|
||||||
|
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB`、`MB`、`GB` 单位 | 否 | `4KB` |
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------ | ------------------------------------------------------------------------- | ---- | ------ |
|
||||||
|
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
|
||||||
|
| `banner` | Banner 内容校验,使用 `ContentExpectations` 数组,需开启 `tcp.readBanner` | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "redis-port"
|
||||||
|
name: "Redis 端口可达"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 6379
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 3000
|
||||||
|
```
|
||||||
43
docs/user/checkers/udp.md
Normal file
43
docs/user/checkers/udp.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# UDP Checker
|
||||||
|
|
||||||
|
`type: udp` 用于 UDP payload 请求-响应检查。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ---------------------- | ------------------------------------------ | ---- | ------ |
|
||||||
|
| `udp.host` | 目标主机地址 | 是 | 无 |
|
||||||
|
| `udp.port` | 目标端口,范围 `1-65535` | 是 | 无 |
|
||||||
|
| `udp.payload` | 发送数据 | 否 | `""` |
|
||||||
|
| `udp.encoding` | payload 编码:`text`、`hex`、`base64` | 否 | `text` |
|
||||||
|
| `udp.responseEncoding` | 响应解码:`text`、`hex`、`base64` | 否 | `text` |
|
||||||
|
| `udp.maxResponseBytes` | 响应最大字节数,支持 `KB`、`MB`、`GB` 单位 | 否 | `4KB` |
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| -------------- | --------------------------------------------- | ---- | ------ |
|
||||||
|
| `responded` | 期望是否收到响应 | 否 | `true` |
|
||||||
|
| `response` | 响应内容校验,使用 `ContentExpectations` 数组 | 否 | 无 |
|
||||||
|
| `responseSize` | 响应字节数校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `sourceHost` | 响应来源地址校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `sourcePort` | 响应来源端口校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "udp-heartbeat"
|
||||||
|
name: "UDP 心跳检测"
|
||||||
|
type: udp
|
||||||
|
udp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 9000
|
||||||
|
payload: "PING"
|
||||||
|
expect:
|
||||||
|
responded: true
|
||||||
|
response:
|
||||||
|
- contains: "PONG"
|
||||||
|
durationMs:
|
||||||
|
lte: 100
|
||||||
|
```
|
||||||
81
docs/user/checkers/ws.md
Normal file
81
docs/user/checkers/ws.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# WS Checker
|
||||||
|
|
||||||
|
`type: ws` 用于 WebSocket 服务可达性检查和消息交互验证。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| -------------------- | ---------------------------------------------- | ---- | ------- |
|
||||||
|
| `ws.url` | 目标 URL,必须以 `ws://` 或 `wss://` 开头 | 是 | 无 |
|
||||||
|
| `ws.headers` | 握手 HTTP 头 | 否 | `{}` |
|
||||||
|
| `ws.subprotocols` | 子协议协商 | 否 | `[]` |
|
||||||
|
| `ws.ignoreSSL` | 忽略 TLS 证书校验 | 否 | `false` |
|
||||||
|
| `ws.send` | 发送的 text 消息,配置后进入请求-响应模式 | 否 | 无 |
|
||||||
|
| `ws.receiveTimeout` | 等待响应超时,毫秒 | 否 | `5000` |
|
||||||
|
| `ws.maxMessageBytes` | 单条消息最大字节数,支持 `KB`、`MB`、`GB` 单位 | 否 | `4KB` |
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------------ | --------------------------------------------------------------------- | ---- | ------ |
|
||||||
|
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
|
||||||
|
| `handshakeHeaders` | 握手响应头校验,使用 `KeyedExpectations` | 否 | 无 |
|
||||||
|
| `message` | 收到的消息内容校验,使用 `ContentExpectations` 数组,需配置 `ws.send` | 否 | 无 |
|
||||||
|
| `connectTimeMs` | 连接建立耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 两种模式
|
||||||
|
|
||||||
|
不配置 `ws.send` 时只做可达性检查(连接后立即关闭),配置 `ws.send` 后进入请求-响应模式(发送消息并等待首条响应)。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
可达性检查:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "ws-reachability"
|
||||||
|
name: "WebSocket 服务可达"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "wss://api.example.com/ws"
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
带鉴权的请求-响应:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "ws-echo"
|
||||||
|
name: "WebSocket Echo 检查"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "wss://echo.example.com/ws"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer ${TOKEN}"
|
||||||
|
subprotocols: ["json"]
|
||||||
|
send: '{"action":"ping"}'
|
||||||
|
receiveTimeout: 3000
|
||||||
|
expect:
|
||||||
|
handshakeHeaders:
|
||||||
|
Sec-WebSocket-Protocol:
|
||||||
|
equals: "json"
|
||||||
|
message:
|
||||||
|
- json:
|
||||||
|
path: "$.action"
|
||||||
|
equals: "pong"
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
期望不可达:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "ws-internal-down"
|
||||||
|
name: "内部服务已下线"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "ws://internal.monitor:9443/ws"
|
||||||
|
expect:
|
||||||
|
connected: false
|
||||||
|
```
|
||||||
135
docs/user/configuration.md
Normal file
135
docs/user/configuration.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# 配置文件
|
||||||
|
|
||||||
|
DiAL 通过 YAML 配置文件定义运行参数和拨测目标。完整可运行示例参见 [`../../probes.example.yaml`](../../probes.example.yaml)。配置 JSON Schema 位于 [`../../probe-config.schema.json`](../../probe-config.schema.json)。
|
||||||
|
|
||||||
|
## 配置结构
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# yaml-language-server: $schema=./probe-config.schema.json
|
||||||
|
|
||||||
|
server:
|
||||||
|
listen:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: "${server_port}"
|
||||||
|
storage:
|
||||||
|
dataDir: "/tmp/probes_data"
|
||||||
|
retention: "${retention}"
|
||||||
|
logging:
|
||||||
|
level: "${log_level|info}"
|
||||||
|
file:
|
||||||
|
path: "<dataDir>/logs/dial.log"
|
||||||
|
|
||||||
|
probes:
|
||||||
|
execution:
|
||||||
|
maxConcurrentChecks: "${max_checks}"
|
||||||
|
|
||||||
|
variables:
|
||||||
|
server_port: 3000
|
||||||
|
retention: "7d"
|
||||||
|
max_checks: 20
|
||||||
|
default_interval: "30s"
|
||||||
|
default_timeout: "10s"
|
||||||
|
|
||||||
|
targets:
|
||||||
|
- id: "baidu-home"
|
||||||
|
name: "Baidu"
|
||||||
|
type: http
|
||||||
|
interval: "${default_interval}"
|
||||||
|
timeout: "${default_timeout}"
|
||||||
|
http:
|
||||||
|
url: "https://www.baidu.com"
|
||||||
|
expect:
|
||||||
|
status: [200]
|
||||||
|
```
|
||||||
|
|
||||||
|
## server.listen
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------ | -------- | ---- | ----------- |
|
||||||
|
| `host` | 监听地址 | 否 | `127.0.0.1` |
|
||||||
|
| `port` | 监听端口 | 否 | `3000` |
|
||||||
|
|
||||||
|
## server.storage
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ----------- | ---------------------------------------------------- | ---- | -------- |
|
||||||
|
| `dataDir` | 数据目录,相对路径基于配置文件所在目录解析 | 否 | `./data` |
|
||||||
|
| `retention` | 历史数据保留时长,支持 `ms`、`s`、`m`、`h`、`d` 单位 | 否 | `7d` |
|
||||||
|
|
||||||
|
## probes.execution
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| --------------------- | -------------- | ---- | ------ |
|
||||||
|
| `maxConcurrentChecks` | 最大并发拨测数 | 否 | `20` |
|
||||||
|
|
||||||
|
## server.logging
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ---------------------------------------- | ---------------------------------------------- | ---- | ------------------------- |
|
||||||
|
| `server.logging.level` | 全局日志等级,console 和 file 未指定时继承此值 | 否 | `info` |
|
||||||
|
| `server.logging.console.level` | 控制台日志等级 | 否 | 继承 `level` |
|
||||||
|
| `server.logging.file.level` | 文件日志等级 | 否 | 继承 `level` |
|
||||||
|
| `server.logging.file.path` | 日志文件路径,相对路径基于配置文件目录解析 | 否 | `<dataDir>/logs/dial.log` |
|
||||||
|
| `server.logging.file.rotation.size` | 按大小滚动,支持 `KB`、`MB`、`GB` 单位 | 否 | `50MB` |
|
||||||
|
| `server.logging.file.rotation.frequency` | 按时间滚动:`hourly`、`daily`、`weekly` | 否 | `daily` |
|
||||||
|
| `server.logging.file.rotation.maxFiles` | 保留的归档文件数量,不含活跃日志 | 否 | `14` |
|
||||||
|
|
||||||
|
日志等级支持:`trace`、`debug`、`info`、`warn`、`error`、`fatal`。
|
||||||
|
|
||||||
|
控制台始终输出 pretty 格式,文件始终输出 JSONL 格式并支持滚动。`rotation.size` 和 `rotation.frequency` 任一条件触发即滚动。
|
||||||
|
|
||||||
|
## 内置默认值
|
||||||
|
|
||||||
|
| 字段 | 默认值 | 约束 |
|
||||||
|
| ---------- | ------ | ----------------------- |
|
||||||
|
| `interval` | `30s` | 最小 `10s` |
|
||||||
|
| `timeout` | `10s` | 必须小于等于 `interval` |
|
||||||
|
|
||||||
|
各 checker 专属默认值见 [Checker 参考](checkers/README.md)。
|
||||||
|
|
||||||
|
## variables
|
||||||
|
|
||||||
|
`variables` 是顶层动态键值表,key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`,value 仅支持 string、number、boolean。`server`、`probes` 和 `targets` 中的字符串值可引用变量。
|
||||||
|
|
||||||
|
| 语法 | 说明 |
|
||||||
|
| ----------------- | ------------------------------------------ |
|
||||||
|
| `${key}` | 引用 variables 或环境变量 |
|
||||||
|
| `${key\|default}` | variables 和环境变量都不存在时使用默认值 |
|
||||||
|
| `${key\|}` | variables 和环境变量都不存在时使用空字符串 |
|
||||||
|
| `$${key}` | 转义输出字面量 `${key}` |
|
||||||
|
|
||||||
|
解析优先级为 `variables -> process.env -> 默认值`。三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number、boolean、string 类型;部分拼接时统一转为字符串。
|
||||||
|
|
||||||
|
变量替换作用于 `server`、`probes` 和 `targets`,不作用于 `variables` 段自身,且不会替换 `targets[].id` 和 `targets[].type` 字段;对象 key 不参与替换。
|
||||||
|
|
||||||
|
## 配置加载形态
|
||||||
|
|
||||||
|
配置加载内部区分三层形态:
|
||||||
|
|
||||||
|
| 形态 | 说明 |
|
||||||
|
| ----------------- | ------------------------------------------------------------------------------------- |
|
||||||
|
| Authoring Config | 用户 YAML 可书写形态,允许变量引用和 expect 简写 |
|
||||||
|
| Normalized Config | `normalizeAuthoringConfig()` 完成变量替换、expect 简写展开并移除 `variables` 后的形态 |
|
||||||
|
| ResolvedConfig | checker `resolve()` 补默认值并解析 duration、size、路径和运行期环境后的形态 |
|
||||||
|
|
||||||
|
根目录 `probe-config.schema.json` 面向 Authoring Config,因此 VSCode 校验会接受 `server.listen.port: "${server_port|3000}"`、`http.maxRedirects: "${MAX|5}"` 和 `expect.durationMs: 5000` 这类写法。
|
||||||
|
|
||||||
|
## targets 通用字段
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------- | ------------------------------------------------------------------------------------ | ---- | --------- |
|
||||||
|
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
|
||||||
|
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 | 无 |
|
||||||
|
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 | 无 |
|
||||||
|
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`dns`、`icmp`、`llm`、`ws`、`cpu` | 是 | 无 |
|
||||||
|
| `group` | 分组名称 | 否 | `default` |
|
||||||
|
| `interval` | 拨测间隔,最小 `10s` | 否 | `30s` |
|
||||||
|
| `timeout` | 超时时间,必须小于等于 `interval` | 否 | `10s` |
|
||||||
|
|
||||||
|
## Checker 专属配置
|
||||||
|
|
||||||
|
每个 target 必须根据 `type` 配置对应的 checker 专属字段。详情见 [Checker 参考](checkers/README.md)。
|
||||||
|
|
||||||
|
## 校验规则
|
||||||
|
|
||||||
|
`expect` 字段按 checker 类型不同而变化。通用断言模型见 [校验规则](expectations.md)。
|
||||||
109
docs/user/deployment.md
Normal file
109
docs/user/deployment.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# 部署
|
||||||
|
|
||||||
|
本文档说明如何构建、运行、容器化和发布 DiAL。开发环境运行见 [README 快速开始](../../README.md#快速开始)。
|
||||||
|
|
||||||
|
## 生产构建和运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
./dist/dial-server ./probes.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物为独立可执行文件,只需要一个 YAML 配置文件即可运行。
|
||||||
|
|
||||||
|
启动后:
|
||||||
|
|
||||||
|
| 地址 | 行为 |
|
||||||
|
| ------------------------------ | ------------------ |
|
||||||
|
| `http://127.0.0.1:3000/` | 返回前端 Dashboard |
|
||||||
|
| `http://127.0.0.1:3000/api/*` | 返回后端 API |
|
||||||
|
| `http://127.0.0.1:3000/health` | 返回健康检查 |
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
DiAL 提供基于 Alpine 的多阶段镜像。构建阶段使用 Bun 生成 musl 目标单可执行文件,运行阶段只包含 `dial-server`、基础证书、`ping`、Bun musl executable 必需运行库、时区数据和容器运行目录。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t dial:alpine .
|
||||||
|
docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
容器默认读取 `/etc/dial/probes.yaml`,推荐将数据卷挂载到 `/data/dial`。
|
||||||
|
|
||||||
|
使用自定义配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 3000:3000 \
|
||||||
|
-v "$PWD/docker/probes.yaml:/etc/dial/probes.yaml:ro" \
|
||||||
|
-v dial-data:/data/dial \
|
||||||
|
dial:alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
容器专用示例配置位于 [`../../docker/probes.yaml`](../../docker/probes.yaml),默认监听 `0.0.0.0:3000`,并将 SQLite 数据和日志写入 `/data/dial`。
|
||||||
|
|
||||||
|
## ICMP 权限
|
||||||
|
|
||||||
|
如需在容器中运行 ICMP checker,除镜像内置 `iputils-ping` 外,还需要授予 `NET_RAW` capability:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
## CMD checker 额外命令
|
||||||
|
|
||||||
|
官方镜像不内置 `bun`、`node`、`curl`、`dig`、`psql`、`mysql`、`redis-cli` 等 CMD checker 可能需要的额外命令。需要这些命令时请使用派生镜像自行安装:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM dial:alpine
|
||||||
|
|
||||||
|
USER root
|
||||||
|
RUN apk add --no-cache curl bind-tools postgresql-client
|
||||||
|
USER dial
|
||||||
|
```
|
||||||
|
|
||||||
|
## 多架构镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/amd64,linux/arm64 -t dial:alpine .
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockerfile 通过 Docker 提供的 `TARGETARCH` 选择 Bun compile target。
|
||||||
|
|
||||||
|
| `TARGETARCH` | `BUN_TARGET` |
|
||||||
|
| ------------ | ---------------------- |
|
||||||
|
| `amd64` | `bun-linux-x64-musl` |
|
||||||
|
| `arm64` | `bun-linux-arm64-musl` |
|
||||||
|
|
||||||
|
## 跨平台发布包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run release
|
||||||
|
bun run release --target linux-x64
|
||||||
|
bun run release --target linux-x64,windows-x64,darwin-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
支持的目标平台:
|
||||||
|
|
||||||
|
| CLI 参数 | Bun CompileTarget |
|
||||||
|
| ------------------ | ---------------------- |
|
||||||
|
| `linux-x64` | `bun-linux-x64` |
|
||||||
|
| `linux-arm64` | `bun-linux-arm64` |
|
||||||
|
| `linux-x64-musl` | `bun-linux-x64-musl` |
|
||||||
|
| `linux-arm64-musl` | `bun-linux-arm64-musl` |
|
||||||
|
| `windows-x64` | `bun-windows-x64` |
|
||||||
|
| `darwin-x64` | `bun-darwin-x64` |
|
||||||
|
| `darwin-arm64` | `bun-darwin-arm64` |
|
||||||
|
|
||||||
|
产出物结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/release/
|
||||||
|
├── binaries/
|
||||||
|
│ ├── dial-server-0.1.0-linux-x64
|
||||||
|
│ └── dial-server-0.1.0-windows-x64.exe
|
||||||
|
└── packages/
|
||||||
|
├── dial-server_0.1.0_linux_x64.tar.gz
|
||||||
|
└── dial-server_0.1.0_linux_x64.tar.gz.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
压缩包内含可执行文件、`probes.example.yaml` 和 `LICENSE`,解压后可直接使用。
|
||||||
161
docs/user/expectations.md
Normal file
161
docs/user/expectations.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# 校验规则
|
||||||
|
|
||||||
|
本文档说明 `expect` 规则、状态判定、failure、observation 和各 checker 的快速失败顺序。
|
||||||
|
|
||||||
|
适用场景:编写 `expect`、理解 UP/DOWN、排查 mismatch/error、查看返回结果中的 `failure` 和 `observation`。
|
||||||
|
|
||||||
|
`expect` 描述拨测结果必须满足的条件。不同 checker 暴露不同字段,但共享三类基础断言模型:`ValueMatcher`、`ContentExpectations` 和 `KeyedExpectations`。
|
||||||
|
|
||||||
|
## 状态判定
|
||||||
|
|
||||||
|
DiAL 使用单层状态模型。
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
| ------ | ---------------------------------------- |
|
||||||
|
| `UP` | 拨测结果符合 `expect` 规则 |
|
||||||
|
| `DOWN` | 拨测结果不符合 `expect` 规则,或执行失败 |
|
||||||
|
|
||||||
|
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `DOWN`,通过 `failure.kind` 区分原因。
|
||||||
|
|
||||||
|
| `failure.kind` | 含义 |
|
||||||
|
| -------------- | ---------------------------------------- |
|
||||||
|
| `error` | 网络、超时、进程、协议解析或内部执行错误 |
|
||||||
|
| `mismatch` | 拨测完成,但结果不满足 expect |
|
||||||
|
|
||||||
|
## API 结果字段
|
||||||
|
|
||||||
|
API 返回的检查结果包含 `detail` 和 `observation`。
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| ------------- | ------------------------------------------------------------ |
|
||||||
|
| `detail` | 后端按 checker 类型从结构化 observation 动态生成的人可读摘要 |
|
||||||
|
| `observation` | 保存该次检查的结构化观测数据 |
|
||||||
|
| `failure` | 保存首个错误或不匹配原因 |
|
||||||
|
| `matched` | 是否符合 expect |
|
||||||
|
| `durationMs` | 本次检查耗时 |
|
||||||
|
| `timestamp` | 本次检查时间 |
|
||||||
|
|
||||||
|
`detail` 不写入 SQLite。存储层仅持久化 `observation` JSON、`failure` JSON、匹配状态、耗时和时间戳。
|
||||||
|
|
||||||
|
## observation 示例
|
||||||
|
|
||||||
|
不同 checker 的 observation 字段不同,常见信息包括:
|
||||||
|
|
||||||
|
| Checker | observation 内容示例 |
|
||||||
|
| ------- | ------------------------------------------------------------------ |
|
||||||
|
| HTTP | 状态码、响应头、按需读取的 body 预览 |
|
||||||
|
| Cmd | exit code、stdout/stderr 预览 |
|
||||||
|
| TCP | 连接结果、banner 摘要 |
|
||||||
|
| UDP | 响应内容、来源地址、响应大小 |
|
||||||
|
| ICMP | 存活结果、丢包率、平均延迟、最大延迟 |
|
||||||
|
| DNS | RCODE、记录值、TTL、flags、CNAME 链 |
|
||||||
|
| LLM | HTTP 状态、模型输出、finish reason、token usage、流式首 token 时间 |
|
||||||
|
| WS | 连接结果、连接耗时、握手头、消息内容、消息大小 |
|
||||||
|
|
||||||
|
Dashboard 基于存储的检查结果计算实时状态、可用率、耗时趋势、P95、状态条和故障段等指标。指标语义由后端应用层实现,SQLite 主要负责存储、筛选、排序、分页和基础聚合。
|
||||||
|
|
||||||
|
## ContentExpectations
|
||||||
|
|
||||||
|
`body`、`stdout`、`stderr`、`banner`、`response`、`output`、`result`、`message` 等返回内容字段均使用数组。
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
| ---------- | ------------------------------------------------------ |
|
||||||
|
| `contains` | 内容包含指定文本 |
|
||||||
|
| `regex` | 正则匹配,启动期会拒绝存在 ReDoS 风险的模式 |
|
||||||
|
| `json` | JSONPath 提取值比较,`path` 必填 |
|
||||||
|
| `css` | CSS 选择器提取 HTML 元素,`selector` 必填,`attr` 可选 |
|
||||||
|
| `xpath` | XPath 提取 XML/HTML 节点,`path` 必填 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
expect:
|
||||||
|
body:
|
||||||
|
- contains: "ok"
|
||||||
|
- json:
|
||||||
|
path: "$.status"
|
||||||
|
equals: "ready"
|
||||||
|
```
|
||||||
|
|
||||||
|
ContentExpectations 数组按顺序快速失败。数组项可以是直接 matcher,也可以是 `json`、`css`、`xpath` 提取器规则。一条规则不能混用直接 matcher 和 extractor,多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`。
|
||||||
|
|
||||||
|
## ValueMatcher
|
||||||
|
|
||||||
|
`ValueMatcher` 用于单个标量值、数字指标和字符串元数据。
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| ---------- | ------------------------------- |
|
||||||
|
| `equals` | 精确匹配,支持 JSON 深度相等 |
|
||||||
|
| `contains` | 字符串包含 |
|
||||||
|
| `regex` | 正则匹配,固定使用无 flags 正则 |
|
||||||
|
| `empty` | 判断是否为空 |
|
||||||
|
| `exists` | 判断是否存在 |
|
||||||
|
| `gte` | 大于等于 |
|
||||||
|
| `lte` | 小于等于 |
|
||||||
|
| `gt` | 大于 |
|
||||||
|
| `lt` | 小于 |
|
||||||
|
|
||||||
|
一个 matcher 对象内多个字段为 AND 语义。`exists: false` 不能和其他 matcher 组合。
|
||||||
|
|
||||||
|
ValueMatcher expect 字段可直接写 string、number、boolean 或 null,等价于 `{ equals: value }`。数组和对象必须显式写成 `{ equals: ... }`。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
finishReason: "stop"
|
||||||
|
```
|
||||||
|
|
||||||
|
## KeyedExpectations
|
||||||
|
|
||||||
|
`headers`、DB `rows[]` 中的列值等动态键值对象使用 `KeyedExpectations`。每个键的值支持 `ValueMatcher` 的全部字段,字面量值自动等价于 `{ equals: value }`。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
expect:
|
||||||
|
headers:
|
||||||
|
Content-Type:
|
||||||
|
contains: "application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 大小和时长格式
|
||||||
|
|
||||||
|
| 类型 | 示例 |
|
||||||
|
| ---- | -------------------------------- |
|
||||||
|
| 大小 | `4KB`、`10MB`、`1GB`、直接数字 |
|
||||||
|
| 时长 | `500ms`、`30s`、`5m`、`2h`、`7d` |
|
||||||
|
|
||||||
|
`maxBodyBytes`、`maxOutputBytes`、`maxResponseBytes`、`maxBannerBytes` 等大小字段支持 `KB`、`MB`、`GB` 单位。
|
||||||
|
|
||||||
|
## 快速失败顺序
|
||||||
|
|
||||||
|
| Checker | 顺序 |
|
||||||
|
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| HTTP | `status -> headers -> body -> durationMs` |
|
||||||
|
| Cmd | `exitCode -> durationMs -> stdout -> stderr` |
|
||||||
|
| DB | `durationMs -> rowCount -> rows -> result` |
|
||||||
|
| TCP | `connected -> banner -> durationMs` |
|
||||||
|
| UDP | `responded -> responseSize -> response -> sourceHost -> sourcePort -> durationMs` |
|
||||||
|
| ICMP | `alive -> packetLossPercent -> avgLatencyMs -> maxLatencyMs -> durationMs` |
|
||||||
|
| DNS system | `values -> valueCount -> durationMs` |
|
||||||
|
| DNS server | `responded -> rcode -> values -> valueCount -> answerCount -> ttlMin -> ttlMax -> authoritative -> recursionAvailable -> truncated -> authenticatedData -> result -> durationMs` |
|
||||||
|
| LLM http | `status -> headers -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
|
||||||
|
| LLM stream | `status -> headers -> stream.completed -> stream.firstTokenMs -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
|
||||||
|
| WS | `connected -> handshakeHeaders -> message -> connectTimeMs -> durationMs` |
|
||||||
|
|
||||||
|
## JSON Schema
|
||||||
|
|
||||||
|
仓库根目录导出 `probe-config.schema.json`。在 YAML 文件顶部添加以下注释可在编辑器中获得提示和校验:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# yaml-language-server: $schema=./probe-config.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已移除字段
|
||||||
|
|
||||||
|
旧字段 `maxDurationMs`、`maxPacketLoss`、`maxAvgLatencyMs`、`maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、ICMP matcher 字段和 `regex`。
|
||||||
|
|
||||||
|
非法配置会阻止启动并输出错误信息。除动态键值表(`headers`、`env`、`variables`)外,未知字段会导致启动失败,请使用 YAML 注释表达说明。
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改 expect 断言模型、状态判定、failure 字段、observation 字段、快速失败顺序或已移除字段说明时,必须更新本文档。
|
||||||
73
docs/user/troubleshooting.md
Normal file
73
docs/user/troubleshooting.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 故障排查
|
||||||
|
|
||||||
|
本文档记录常见运行问题和排查入口。
|
||||||
|
|
||||||
|
## 配置校验失败
|
||||||
|
|
||||||
|
DiAL 启动时会校验 YAML 配置。除动态键值表(`headers`、`env`、`variables`)外,未知字段会导致启动失败。
|
||||||
|
|
||||||
|
排查顺序:
|
||||||
|
|
||||||
|
1. 在 YAML 顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json`。
|
||||||
|
2. 对照 [配置文件](configuration.md) 检查顶层结构和通用字段。
|
||||||
|
3. 对照 [Checker 参考](checkers/README.md) 检查 checker 专属字段。
|
||||||
|
4. 对照 [校验规则](expectations.md) 检查 expect 写法。
|
||||||
|
|
||||||
|
## 变量无法解析
|
||||||
|
|
||||||
|
变量解析优先级为 `variables -> process.env -> 默认值`。如果三者均不存在,配置校验会失败。
|
||||||
|
|
||||||
|
常见修复:
|
||||||
|
|
||||||
|
| 问题 | 修复 |
|
||||||
|
| -------------- | ----------------------------------- |
|
||||||
|
| 环境变量未设置 | 设置环境变量或在 `variables` 中声明 |
|
||||||
|
| 希望允许空值 | 使用 `${key\|}` |
|
||||||
|
| 希望提供默认值 | 使用 `${key\|default}` |
|
||||||
|
| 希望输出字面量 | 使用 `$${key}` |
|
||||||
|
|
||||||
|
## ICMP checker 无法运行
|
||||||
|
|
||||||
|
ICMP checker 依赖系统 `ping` 命令。
|
||||||
|
|
||||||
|
| 环境 | 处理 |
|
||||||
|
| ------------------- | -------------------------------------- |
|
||||||
|
| Alpine 或精简镜像 | 安装 `iputils-ping` |
|
||||||
|
| Docker 容器 | 运行容器时增加 `--cap-add=NET_RAW` |
|
||||||
|
| Windows/macOS/Linux | 确认系统 `ping` 可执行且输出格式受支持 |
|
||||||
|
|
||||||
|
Docker 示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
## CMD checker 找不到命令
|
||||||
|
|
||||||
|
官方 Docker 镜像不内置 `bun`、`node`、`curl`、`dig`、`psql`、`mysql`、`redis-cli` 等额外命令。需要这些命令时请使用派生镜像安装。
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM dial:alpine
|
||||||
|
|
||||||
|
USER root
|
||||||
|
RUN apk add --no-cache curl bind-tools postgresql-client
|
||||||
|
USER dial
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker 数据或日志丢失
|
||||||
|
|
||||||
|
推荐将数据卷挂载到 `/data/dial`,并在配置中使用该目录作为 storage dataDir。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
容器示例配置位于 [`../../docker/probes.yaml`](../../docker/probes.yaml)。
|
||||||
|
|
||||||
|
## HTTP 或 LLM 证书问题
|
||||||
|
|
||||||
|
HTTP 和 LLM checker 支持 `ignoreSSL`。该选项适合内网、自签名证书或测试环境;生产环境应优先修复证书链。
|
||||||
|
|
||||||
|
## 正则规则被拒绝
|
||||||
|
|
||||||
|
`regex` 启动期会执行 ReDoS 风险检测。被拒绝时应改写为更明确、回溯风险更低的表达式。
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
|
import importPlugin from "eslint-plugin-import";
|
||||||
|
import perfectionist from "eslint-plugin-perfectionist";
|
||||||
|
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from "eslint-plugin-react-refresh";
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
const noDirectConsoleMessage =
|
||||||
|
"后端运行时代码禁止直接使用 console.*;请通过注入的 Logger 实例输出日志,配置加载失败前使用 createConsoleFallback()。";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
@@ -14,16 +20,62 @@ export default tseslint.config(
|
|||||||
".opencode/**",
|
".opencode/**",
|
||||||
".claude/**",
|
".claude/**",
|
||||||
".codex/**",
|
".codex/**",
|
||||||
|
".agents/**",
|
||||||
|
"bun.lock",
|
||||||
|
"data/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
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: {
|
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/no-empty-function": ["error", { allow: ["private-constructors", "protected-constructors"] }],
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
|
"@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",
|
"no-undef": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ["src/server/**/*.ts"],
|
||||||
|
ignores: ["src/server/logger.ts"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
message: noDirectConsoleMessage,
|
||||||
|
selector: "MemberExpression[object.name='console']",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["eslint.config.js"],
|
||||||
|
rules: {
|
||||||
|
"import/no-named-as-default": "off",
|
||||||
|
"import/no-named-as-default-member": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ["src/web/**/*.{ts,tsx}"],
|
files: ["src/web/**/*.{ts,tsx}"],
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -32,7 +84,6 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
|
||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
@@ -53,6 +104,8 @@ export default tseslint.config(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
);
|
);
|
||||||
|
|||||||
21
opencode.json
Normal file
21
opencode.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"tdesign-mcp-server": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "local",
|
||||||
|
"command": ["bunx", "tdesign-mcp-server@latest"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"bash": {
|
||||||
|
"npm *": "deny",
|
||||||
|
"npx *": "deny",
|
||||||
|
"pnpm *": "deny",
|
||||||
|
"pnpx *": "deny"
|
||||||
|
},
|
||||||
|
"external_directory": {
|
||||||
|
"/tmp/**": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,39 @@
|
|||||||
schema: spec-driven
|
schema: fast-drive
|
||||||
|
|
||||||
context: |
|
context: |
|
||||||
- 使用中文(注释、文档、交流),面向中文开发者
|
- 使用中文(注释、文档、交流),面向中文开发者
|
||||||
- openspec文档的关键字按openspec规范使用,不要翻译为中文
|
- openspec文档的关键字按openspec规范使用,不要翻译为中文
|
||||||
- **优先阅读README.md**获取项目结构与开发规范,所有代码风格、命名、注解、依赖、API等规范以README为准
|
- 本项目openspec使用fast-drive自定义schema,变更文档只包含design.md和tasks.md,无proposal.md和specs
|
||||||
- 涉及模块结构、API、实体等变更时同步更新README.md
|
- **优先阅读docs/README.md**判断文档归属和本次任务需要读取的专题文档
|
||||||
|
- README.md用于项目概览、快速开始和顶层文档引导;docs/user/README.md用于用户使用入口;docs/development/README.md用于开发入口、全局规则和质量门禁
|
||||||
|
- 所有代码风格、命名、注解、依赖、API等开发规范以docs/development/README.md和docs/development/下对应专题文档为准
|
||||||
|
- 新增或修改checker时必须阅读docs/development/checker.md、docs/user/checkers/README.md和相近checker用户文档
|
||||||
|
- 每次代码变更都必须执行文档影响分析:判断是否影响用户可见行为、配置格式、checker行为、expect规则、API、部署方式、开发流程、架构边界、测试规范或构建发布流程
|
||||||
|
- 若影响用户使用方式、配置格式、checker行为、expect规则、部署方式或运行行为,必须同步更新docs/user/下对应文档;README.md仅在项目定位、快速开始、核心能力列表或文档导航变化时更新
|
||||||
|
- 若影响开发流程、架构边界、质量门禁、测试规范、构建发布流程或checker开发机制,必须同步更新docs/development/README.md或docs/development/下对应专题文档
|
||||||
|
- 若影响文档同步规则或文档归属矩阵,必须同步更新docs/README.md和openspec/config.yaml
|
||||||
|
- 若无需更新文档,必须在收尾说明中说明原因
|
||||||
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||||
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
||||||
|
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
||||||
|
- src/server目录下是基于bun实现的后端代码
|
||||||
|
- 后端库使用优先级:Bun 内置 API > es-toolkit > 标准 Web API > 主流三方库 > 项目公共工具 > 自行实现
|
||||||
|
- src/web目录下是基于Bun HTML import、React、TDesign实现的前端代码
|
||||||
|
- 前端样式开发优先级:TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
|
||||||
|
- 前端严禁:组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
|
||||||
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||||
- 禁止创建git操作task
|
- 禁止创建git操作task
|
||||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
- 使用subagents处理计算密集或多步骤的并行任务(如代码实现、测试执行);文件读取直接使用Read工具并行调用,禁止用subagent转发文件内容
|
||||||
- 优先使用提问工具对用户进行提问
|
- 优先使用提问工具对用户进行提问
|
||||||
|
- (当前项目未上线,不需要考虑向前兼容)
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
proposal:
|
|
||||||
- 仔细审查每一个过往spec判断是否存在Modified Capabilities
|
|
||||||
design:
|
design:
|
||||||
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
||||||
tasks:
|
tasks:
|
||||||
- 一行一个任务,严禁任务内容跨行
|
- 一行一个任务,严禁任务内容跨行
|
||||||
|
- 如果是代码存在更新必须
|
||||||
|
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
||||||
|
- 执行文档影响分析,并按影响范围更新对应文档;若无需更新文档,必须在任务或收尾说明中明确写出原因
|
||||||
|
- 新增或修改checker时必须更新docs/user/checkers/下对应用户文档,并在checker开发机制变化时更新docs/development/checker.md
|
||||||
|
- 新增或修改配置字段时必须更新probe-config.schema.json、probes.example.yaml、docs/user/configuration.md或对应checker文档
|
||||||
|
|||||||
181
openspec/schemas/fast-drive/schema.yaml
Normal file
181
openspec/schemas/fast-drive/schema.yaml
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
name: fast-drive
|
||||||
|
version: 1
|
||||||
|
description: Fast OpenSpec workflow - design -> tasks -> apply
|
||||||
|
artifacts:
|
||||||
|
- id: design
|
||||||
|
generates: design.md
|
||||||
|
description: Self-contained solution brief and execution plan
|
||||||
|
template: design.md
|
||||||
|
instruction: |
|
||||||
|
Create design.md as the self-contained source of truth for what will
|
||||||
|
change, why it is changing, and how the work will be executed.
|
||||||
|
|
||||||
|
This workflow does not use proposal or specs artifacts. design.md MUST
|
||||||
|
preserve the important outcomes from prior exploration and user
|
||||||
|
discussion so a later apply phase can proceed correctly even after
|
||||||
|
context compression or in a new session.
|
||||||
|
|
||||||
|
Write for someone who cannot see the earlier conversation. Keep simple
|
||||||
|
changes concise, but include enough detail to make execution
|
||||||
|
unambiguous. Add more detail when any apply:
|
||||||
|
|
||||||
|
- Cross-cutting change across multiple systems, teams, workstreams, or
|
||||||
|
artifacts
|
||||||
|
|
||||||
|
- New dependency, integration, vendor, tool, policy, or external input
|
||||||
|
|
||||||
|
- Significant information model, process model, data model, or ownership
|
||||||
|
changes
|
||||||
|
|
||||||
|
- Security, privacy, compliance, performance, operational, or migration
|
||||||
|
complexity
|
||||||
|
|
||||||
|
- Ambiguity that benefits from decisions before execution
|
||||||
|
|
||||||
|
- Prior discussion settled non-obvious requirements, constraints, or
|
||||||
|
rejected alternatives
|
||||||
|
|
||||||
|
Required sections:
|
||||||
|
|
||||||
|
- **Context**: Problem, current state, relevant references, and the user
|
||||||
|
request that triggered this change
|
||||||
|
|
||||||
|
- **Discussion Notes**: Key points from exploration or prior discussion
|
||||||
|
that must not be lost. Include agreed conclusions, user preferences,
|
||||||
|
constraints, and important rejected ideas.
|
||||||
|
|
||||||
|
- **Requirements**: Expected outcomes, behavior/process/interface/content
|
||||||
|
changes, continuity expectations, and acceptance criteria.
|
||||||
|
|
||||||
|
- **Goals / Non-Goals**: What this change will achieve and what is
|
||||||
|
explicitly out of scope.
|
||||||
|
|
||||||
|
- **Execution Guardrails**: Must-follow constraints, forbidden approaches,
|
||||||
|
preserved behavior/processes, dependency limits, and project- or
|
||||||
|
workflow-specific boundaries.
|
||||||
|
|
||||||
|
- **Affected Areas**: Concrete artifacts, references, stakeholders,
|
||||||
|
systems, workstreams, documents, configurations, assets, or handoffs that
|
||||||
|
are relevant to the change.
|
||||||
|
|
||||||
|
- **Decisions**: Key choices with rationale (why X over Y?). For each
|
||||||
|
important decision, include alternatives considered and why they were not
|
||||||
|
chosen.
|
||||||
|
|
||||||
|
- **Execution Plan**: Main workstreams or artifacts to change, integration
|
||||||
|
or handoff points, sequencing, and any rollout notes.
|
||||||
|
|
||||||
|
- **Verification Plan**: Validation checks, reviews, approvals,
|
||||||
|
acceptance checks, documentation checks, communication checks, and manual
|
||||||
|
checks needed to prove the change is complete.
|
||||||
|
|
||||||
|
- **Risks / Trade-offs**: Known limitations and things that could go
|
||||||
|
wrong.
|
||||||
|
Format: [Risk] -> Mitigation
|
||||||
|
|
||||||
|
- **Open Questions**: Outstanding decisions, assumptions, or unknowns to
|
||||||
|
resolve before execution. Separate blocking questions that must pause
|
||||||
|
apply from non-blocking follow-ups. Use "None" if there are no open
|
||||||
|
questions.
|
||||||
|
|
||||||
|
Optional sections when relevant:
|
||||||
|
|
||||||
|
- **Migration / Rollout Plan**: Rollout steps, communication, ownership,
|
||||||
|
rollback, or continuity strategy.
|
||||||
|
|
||||||
|
Focus on preserving requirements, rationale, constraints, and approach.
|
||||||
|
Avoid line-by-line or step-by-step details unless a detail is a deliberate
|
||||||
|
decision from the discussion.
|
||||||
|
|
||||||
|
Prefer durable summaries over chat transcripts. Use concrete artifact
|
||||||
|
names, data/information shapes, examples, stakeholders, ownership, and
|
||||||
|
edge cases when they affect execution.
|
||||||
|
|
||||||
|
Do not use task checkboxes in design.md; checkboxes belong only in
|
||||||
|
tasks.md.
|
||||||
|
|
||||||
|
Final design.md must not contain unresolved template comments, empty
|
||||||
|
table rows, or placeholder text.
|
||||||
|
|
||||||
|
If information is missing, state assumptions and open questions instead
|
||||||
|
of inventing hidden requirements. Do not rely on unstated chat context.
|
||||||
|
requires: []
|
||||||
|
- id: tasks
|
||||||
|
generates: tasks.md
|
||||||
|
description: Trackable execution checklist derived from design.md
|
||||||
|
template: tasks.md
|
||||||
|
instruction: |
|
||||||
|
Create tasks.md by breaking design.md into executable work.
|
||||||
|
|
||||||
|
**IMPORTANT: Follow the template below exactly.** The apply phase parses
|
||||||
|
checkbox format to track progress. Tasks not using `- [ ]` will not be
|
||||||
|
tracked.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
|
||||||
|
- Derive tasks from design.md. Do not depend on proposal.md or specs
|
||||||
|
artifacts; any relevant prior discussion must already be captured in
|
||||||
|
design.md.
|
||||||
|
|
||||||
|
- Group related tasks under `##` numbered headings
|
||||||
|
|
||||||
|
- Each task MUST be a single-line checkbox: `- [ ] X.Y Task description`
|
||||||
|
|
||||||
|
- Tasks should be small enough to complete in one session
|
||||||
|
|
||||||
|
- Order tasks by dependency (what must be done first?)
|
||||||
|
|
||||||
|
- Start with context review tasks when execution depends on guardrails,
|
||||||
|
affected areas, or open questions
|
||||||
|
|
||||||
|
- Include validation tasks for checks, reviews, approvals, acceptance,
|
||||||
|
documentation, communication, and manual checks when required
|
||||||
|
|
||||||
|
- Do not include repository, version-control, or release operation tasks
|
||||||
|
unless they are explicitly part of the change scope
|
||||||
|
|
||||||
|
- Final tasks.md must not contain unresolved template comments, empty
|
||||||
|
table rows, or placeholder task text
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
## 1. Context Review
|
||||||
|
|
||||||
|
- [ ] 1.1 Read design.md and identify scope, requirements, decisions, guardrails, and open questions
|
||||||
|
- [ ] 1.2 Review relevant artifacts and references listed in Affected Areas
|
||||||
|
|
||||||
|
## 2. Execution
|
||||||
|
|
||||||
|
- [ ] 2.1 Execute first concrete work item from design.md
|
||||||
|
- [ ] 2.2 Execute next concrete work item from design.md
|
||||||
|
|
||||||
|
## 3. Validation
|
||||||
|
|
||||||
|
- [ ] 3.1 Run required validation from Verification Plan
|
||||||
|
- [ ] 3.2 Perform quality checks required by the project or workflow
|
||||||
|
- [ ] 3.3 Perform required manual review or acceptance checks from Verification Plan
|
||||||
|
|
||||||
|
## 4. Documentation / Communication
|
||||||
|
|
||||||
|
- [ ] 4.1 Update relevant documentation, runbooks, communication materials, or project references if behavior, process, interface, configuration, or usage changed
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference design.md for scope, requirements, decisions, execution
|
||||||
|
direction, and verification expectations.
|
||||||
|
|
||||||
|
Each task should be verifiable: it must be clear when the task is done.
|
||||||
|
requires:
|
||||||
|
- design
|
||||||
|
apply:
|
||||||
|
requires:
|
||||||
|
- design
|
||||||
|
- tasks
|
||||||
|
tracks: tasks.md
|
||||||
|
instruction: |
|
||||||
|
Read design.md first, then tasks.md.
|
||||||
|
Also follow workflow context/configuration, such as openspec/config.yaml when available, and any relevant project or workflow documentation referenced by design.md.
|
||||||
|
Treat design.md as the source of truth for scope, requirements, decisions, guardrails, execution direction, and verification expectations.
|
||||||
|
Work through pending tasks in dependency order and mark complete as you go.
|
||||||
|
Mark a task complete only after its execution and required verification are done.
|
||||||
|
Pause if tasks conflict with design.md, if design.md has blocking open questions, or if clarification is needed.
|
||||||
77
openspec/schemas/fast-drive/templates/design.md
Normal file
77
openspec/schemas/fast-drive/templates/design.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
<!-- Problem, current state, relevant references, and triggering user request -->
|
||||||
|
|
||||||
|
## Discussion Notes
|
||||||
|
|
||||||
|
<!-- Key conclusions from exploration or prior discussion that apply must preserve -->
|
||||||
|
|
||||||
|
- Agreed conclusions:
|
||||||
|
- User preferences:
|
||||||
|
- Constraints:
|
||||||
|
- Rejected ideas:
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
<!-- Expected outcomes, behavior/process/interface/content changes, continuity expectations, and acceptance criteria -->
|
||||||
|
|
||||||
|
| Requirement | Acceptance Criteria |
|
||||||
|
| ----------- | ------------------- |
|
||||||
|
| | |
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
<!-- What this design aims to achieve -->
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
<!-- What is explicitly out of scope -->
|
||||||
|
|
||||||
|
## Execution Guardrails
|
||||||
|
|
||||||
|
<!-- Must-follow constraints, forbidden approaches, preserved behavior/processes, dependency limits, and project- or workflow-specific boundaries -->
|
||||||
|
|
||||||
|
- Dependencies:
|
||||||
|
- Constraints:
|
||||||
|
- Quality Bar:
|
||||||
|
- Stakeholders:
|
||||||
|
- Documentation / Communication:
|
||||||
|
- Compatibility / Continuity:
|
||||||
|
|
||||||
|
## Affected Areas
|
||||||
|
|
||||||
|
<!-- Concrete artifacts, references, stakeholders, systems, workstreams, documents, configurations, assets, or handoffs relevant to this change -->
|
||||||
|
|
||||||
|
| Area | Artifacts / References | Expected Change | Notes |
|
||||||
|
| ---- | ---------------------- | --------------- | ----- |
|
||||||
|
| <!-- Area --> | <!-- Artifacts / References --> | <!-- Expected Change --> | <!-- Notes --> |
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
<!-- Key decisions, rationale, and alternatives considered -->
|
||||||
|
|
||||||
|
| Decision | Rationale | Alternatives Rejected |
|
||||||
|
| -------- | --------- | --------------------- |
|
||||||
|
| | | |
|
||||||
|
|
||||||
|
## Execution Plan
|
||||||
|
|
||||||
|
<!-- Main workstreams or artifacts to change, integration or handoff points, sequencing, and rollout notes -->
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
<!-- Validation checks, reviews, approvals, acceptance checks, documentation checks, communication checks, and manual checks needed -->
|
||||||
|
|
||||||
|
| Requirement / Risk | Verification |
|
||||||
|
| ------------------ | ------------ |
|
||||||
|
| | |
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
<!-- Format: [Risk] -> Mitigation -->
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
| Status | Question | Decision Needed |
|
||||||
|
| ------ | -------- | --------------- |
|
||||||
|
| None | No open questions. | None |
|
||||||
19
openspec/schemas/fast-drive/templates/tasks.md
Normal file
19
openspec/schemas/fast-drive/templates/tasks.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## 1. Context Review
|
||||||
|
|
||||||
|
- [ ] 1.1 Read design.md and identify scope, requirements, decisions, guardrails, and open questions
|
||||||
|
- [ ] 1.2 Review relevant artifacts and references listed in Affected Areas
|
||||||
|
|
||||||
|
## 2. Execution
|
||||||
|
|
||||||
|
- [ ] 2.1 Execute first concrete work item from design.md
|
||||||
|
- [ ] 2.2 Execute next concrete work item from design.md
|
||||||
|
|
||||||
|
## 3. Validation
|
||||||
|
|
||||||
|
- [ ] 3.1 Run required validation from Verification Plan
|
||||||
|
- [ ] 3.2 Perform quality checks required by the project or workflow
|
||||||
|
- [ ] 3.3 Perform required manual review or acceptance checks from Verification Plan
|
||||||
|
|
||||||
|
## 4. Documentation / Communication
|
||||||
|
|
||||||
|
- [ ] 4.1 Update relevant documentation, runbooks, communication materials, or project references if behavior, process, interface, configuration, or usage changed
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
## Purpose
|
|
||||||
|
|
||||||
定义项目代码质量门禁、格式化检查、快速检查和完整验证命令的行为要求,确保开发者可以通过文档化命令稳定验证源码质量、基础测试和生产 executable 行为。
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Requirement: ESLint 代码质量门禁
|
|
||||||
项目 SHALL 提供 ESLint 代码质量门禁,用于审查 TypeScript、React 前端、脚本和测试代码中的质量问题。
|
|
||||||
|
|
||||||
#### Scenario: 运行 lint 检查
|
|
||||||
- **WHEN** 开发者运行文档化的 lint 命令
|
|
||||||
- **THEN** 系统 SHALL 使用 ESLint 检查项目源码、脚本和测试代码,并在发现违规时以非零状态退出
|
|
||||||
|
|
||||||
#### Scenario: 检查 React Hooks 规则
|
|
||||||
- **WHEN** 前端 React 代码违反 Hooks 调用规则
|
|
||||||
- **THEN** lint 命令 MUST 失败并报告对应违规
|
|
||||||
|
|
||||||
#### Scenario: 保护前后端边界
|
|
||||||
- **WHEN** `src/web` 前端代码导入 `src/server` 后端运行时实现
|
|
||||||
- **THEN** lint 命令 MUST 失败并报告前后端边界违规
|
|
||||||
|
|
||||||
### Requirement: Prettier 代码格式门禁
|
|
||||||
项目 SHALL 提供 Prettier 格式化和格式检查命令,用于统一代码风格。
|
|
||||||
|
|
||||||
#### Scenario: 检查代码格式
|
|
||||||
- **WHEN** 开发者运行文档化的格式检查命令
|
|
||||||
- **THEN** 系统 SHALL 使用 Prettier 检查受管理文件,并在发现未格式化文件时以非零状态退出
|
|
||||||
|
|
||||||
#### Scenario: 自动格式化代码
|
|
||||||
- **WHEN** 开发者运行文档化的格式化命令
|
|
||||||
- **THEN** 系统 SHALL 使用 Prettier 重写受管理文件的格式
|
|
||||||
|
|
||||||
#### Scenario: 排除 OpenSpec 文档和生成产物
|
|
||||||
- **WHEN** Prettier 格式化或格式检查运行
|
|
||||||
- **THEN** 系统 MUST 排除 `openspec/`、`dist/`、`.build/`、`node_modules/`、`bun.lock` 和临时构建产物
|
|
||||||
|
|
||||||
### Requirement: 快速检查命令
|
|
||||||
项目 SHALL 提供快速 `check` 命令,用于日常开发期间验证代码质量和基础行为。
|
|
||||||
|
|
||||||
#### Scenario: 运行快速检查
|
|
||||||
- **WHEN** 开发者运行 `bun run check`
|
|
||||||
- **THEN** 系统 SHALL 依次执行类型检查、lint、格式检查和单元测试
|
|
||||||
|
|
||||||
#### Scenario: 快速检查失败
|
|
||||||
- **WHEN** `check` 中任一子检查失败
|
|
||||||
- **THEN** `check` MUST 以非零状态退出且不静默忽略失败
|
|
||||||
|
|
||||||
### Requirement: 完整验证命令
|
|
||||||
项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产 executable 行为。
|
|
||||||
|
|
||||||
#### Scenario: 运行完整验证
|
|
||||||
- **WHEN** 开发者运行 `bun run verify`
|
|
||||||
- **THEN** 系统 SHALL 先运行 `check`,再运行生产构建和 executable smoke test
|
|
||||||
|
|
||||||
#### Scenario: 完整验证失败
|
|
||||||
- **WHEN** `verify` 中任一阶段失败
|
|
||||||
- **THEN** `verify` MUST 以非零状态退出且不能继续声明验证成功
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
## Purpose
|
|
||||||
|
|
||||||
定义 Vite + React + TypeScript 前端开发工作流、开发期 API 代理、共享契约和端到端 demo 的行为要求。
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Requirement: Vite React 开发服务器
|
|
||||||
系统 SHALL 提供基于 Vite + React + TypeScript 的前端开发工作流,并支持热模块替换。
|
|
||||||
|
|
||||||
#### Scenario: 启动前端开发服务器
|
|
||||||
- **WHEN** 开发者启动前端开发命令
|
|
||||||
- **THEN** 前端 SHALL 由 Vite 提供服务,并启用 React 热模块替换
|
|
||||||
|
|
||||||
#### Scenario: 构建前端静态资源
|
|
||||||
- **WHEN** 开发者运行前端生产构建命令
|
|
||||||
- **THEN** 系统 SHALL 产出可由 Bun 后端服务的前端静态资源
|
|
||||||
|
|
||||||
### Requirement: 前端开发期 API 代理
|
|
||||||
前端开发服务器 SHALL 在本地开发期间将 `/api/*` 请求代理到 Bun 后端服务。
|
|
||||||
|
|
||||||
#### Scenario: 前端开发期调用 API
|
|
||||||
- **WHEN** 浏览器从 Vite 开发源请求 `/api/demo`
|
|
||||||
- **THEN** Vite SHALL 将请求转发到 Bun 后端服务,且不需要浏览器 CORS 配置
|
|
||||||
|
|
||||||
#### Scenario: 开发期访问非 API 前端路由
|
|
||||||
- **WHEN** 浏览器从 Vite 开发源请求非 API 前端路由
|
|
||||||
- **THEN** Vite SHALL 将该请求作为前端应用流量处理,而不是转发到后端
|
|
||||||
|
|
||||||
### Requirement: 开发期后端端口一致性
|
|
||||||
项目 SHALL 保证文档化的全栈开发命令中,Vite proxy 目标端口与 Bun 后端监听端口来自同一配置来源。
|
|
||||||
|
|
||||||
#### Scenario: 使用默认开发端口
|
|
||||||
- **WHEN** 开发者未提供端口覆盖并运行文档化的全栈开发命令
|
|
||||||
- **THEN** Bun 后端 SHALL 监听默认端口,且 Vite SHALL 将 `/api/*` 代理到同一端口
|
|
||||||
|
|
||||||
#### Scenario: 使用 PORT 覆盖开发端口
|
|
||||||
- **WHEN** 开发者通过 `PORT` 覆盖后端端口并运行文档化的全栈开发命令
|
|
||||||
- **THEN** Bun 后端 SHALL 监听该端口,且 Vite SHALL 将 `/api/*` 代理到同一端口
|
|
||||||
|
|
||||||
#### Scenario: 避免代理端口与后端端口分叉
|
|
||||||
- **WHEN** 开发期脚本需要向 Vite 传递后端端口
|
|
||||||
- **THEN** 该代理端口 MUST 从文档化的后端端口配置派生,而不是作为独立对外配置导致分叉
|
|
||||||
|
|
||||||
### Requirement: 前端使用相对 API 路径
|
|
||||||
除非有文档化的部署配置覆盖该行为,前端代码 MUST 通过相对 `/api/*` URL 调用后端 API。
|
|
||||||
|
|
||||||
#### Scenario: 前端获取后端数据
|
|
||||||
- **WHEN** 前端代码调用后端 API
|
|
||||||
- **THEN** 请求 URL 默认 MUST 使用相对 `/api/*` 路径
|
|
||||||
|
|
||||||
#### Scenario: 运行环境变化
|
|
||||||
- **WHEN** host 或 port 在开发环境和生产环境之间变化
|
|
||||||
- **THEN** 前端 API 调用 SHALL 无需修改源码即可继续工作
|
|
||||||
|
|
||||||
### Requirement: 端到端开发 demo
|
|
||||||
项目 SHALL 提供一个可见的开发 demo,用于证明 React 前端可以通过 Vite 代理调用 Bun 后端。
|
|
||||||
|
|
||||||
#### Scenario: Demo 页面展示后端响应
|
|
||||||
- **WHEN** 开发者启动文档化的开发命令并打开前端 URL
|
|
||||||
- **THEN** 页面 SHALL 调用 `/api/demo` 并展示 Bun 后端返回的数据
|
|
||||||
|
|
||||||
#### Scenario: 开发期后端不可用
|
|
||||||
- **WHEN** 前端 demo 无法访问 `/api/demo`
|
|
||||||
- **THEN** 页面 SHALL 展示清晰的错误状态,而不是静默显示为成功
|
|
||||||
|
|
||||||
### Requirement: 集成开发命令
|
|
||||||
项目 SHALL 提供一个文档化命令,用于在 demo 开发期间同时运行前端和后端。
|
|
||||||
|
|
||||||
#### Scenario: 启动全栈开发
|
|
||||||
- **WHEN** 开发者运行文档化的全栈开发命令
|
|
||||||
- **THEN** 系统 SHALL 启动 Vite 前端开发服务器和 `/api/demo` 所需的 Bun 后端服务器
|
|
||||||
|
|
||||||
### Requirement: 开发质量命令文档化
|
|
||||||
项目 SHALL 在前端开发工作流文档中说明日常检查和完整验证命令。
|
|
||||||
|
|
||||||
#### Scenario: 查阅开发命令
|
|
||||||
- **WHEN** 开发者阅读 README 的开发或测试章节
|
|
||||||
- **THEN** 文档 SHALL 说明 `check` 用于日常开发检查,`verify` 用于提交前或发布前完整验证
|
|
||||||
|
|
||||||
### Requirement: 共享 TypeScript 契约
|
|
||||||
项目 SHALL 为前端和后端共同使用的请求与响应类型提供共享 TypeScript 边界。
|
|
||||||
|
|
||||||
#### Scenario: 定义 API 响应结构
|
|
||||||
- **WHEN** 前端和后端都需要某个 API 响应类型
|
|
||||||
- **THEN** 该类型 SHALL 定义在 shared 模块中,而不是在两端重复定义
|
|
||||||
|
|
||||||
#### Scenario: 前端导入共享类型
|
|
||||||
- **WHEN** 前端代码导入共享 API 类型
|
|
||||||
- **THEN** 该导入 SHALL 不要求将后端运行时实现打包进前端
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
## Purpose
|
|
||||||
|
|
||||||
定义 Bun 全栈应用运行时的 HTTP server、API 命名空间、健康检查、生产静态资源服务和 SPA fallback 行为。
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Requirement: Bun HTTP 运行时
|
|
||||||
系统 SHALL 运行一个 Bun HTTP server,由单个进程提供后端 API、健康检查、生产静态资源和 SPA fallback 行为。
|
|
||||||
|
|
||||||
#### Scenario: 启动运行时服务器
|
|
||||||
- **WHEN** server 进程成功启动
|
|
||||||
- **THEN** 它 SHALL 监听配置的 host 和 port,并记录实际 server URL
|
|
||||||
|
|
||||||
#### Scenario: 提供运行时配置
|
|
||||||
- **WHEN** 通过支持的运行时配置提供 host 或 port
|
|
||||||
- **THEN** server SHALL 使用该值,且不需要重新构建
|
|
||||||
|
|
||||||
### Requirement: HTTP method 语义
|
|
||||||
系统 SHALL 为运行时端点提供明确的 HTTP method 语义,避免不支持的 method 被错误地当作成功请求处理。
|
|
||||||
|
|
||||||
#### Scenario: GET 请求访问运行时端点
|
|
||||||
- **WHEN** 客户端使用 `GET` 请求 `/health` 或 `/api/demo`
|
|
||||||
- **THEN** Bun server SHALL 返回对应端点的成功响应
|
|
||||||
|
|
||||||
#### Scenario: HEAD 请求访问运行时端点
|
|
||||||
- **WHEN** 客户端使用 `HEAD` 请求 `/health` 或 `/api/demo`
|
|
||||||
- **THEN** Bun server SHALL 返回与 `GET` 相同的成功状态和 headers,但 MUST NOT 返回响应体
|
|
||||||
|
|
||||||
#### Scenario: 不支持的 method 访问运行时端点
|
|
||||||
- **WHEN** 客户端使用不支持的 method 请求 `/health` 或 `/api/demo`
|
|
||||||
- **THEN** Bun server MUST 返回 JSON 405 响应,并带有描述允许 method 的 `Allow` header
|
|
||||||
|
|
||||||
### Requirement: 运行配置校验
|
|
||||||
系统 SHALL 对运行时 host 和 port 配置提供稳定、可测试的解析与校验行为。
|
|
||||||
|
|
||||||
#### Scenario: 使用默认运行配置
|
|
||||||
- **WHEN** 未提供 host 或 port 覆盖
|
|
||||||
- **THEN** server SHALL 使用 README 文档化的默认 host 和 port
|
|
||||||
|
|
||||||
#### Scenario: CLI 参数优先于环境变量
|
|
||||||
- **WHEN** CLI 参数和环境变量同时提供同一项运行配置
|
|
||||||
- **THEN** server SHALL 使用 CLI 参数中的值
|
|
||||||
|
|
||||||
#### Scenario: 拒绝无效端口
|
|
||||||
- **WHEN** port 配置不是整数、小于 0 或大于 65535
|
|
||||||
- **THEN** server MUST 拒绝启动并报告无效端口
|
|
||||||
|
|
||||||
#### Scenario: 接受端口边界值
|
|
||||||
- **WHEN** port 配置为 0 或 65535
|
|
||||||
- **THEN** server SHALL 将其作为有效端口配置处理
|
|
||||||
|
|
||||||
### Requirement: API 路由命名空间
|
|
||||||
系统 MUST 将 `/api/*` 保留给后端 API 路由。
|
|
||||||
|
|
||||||
#### Scenario: API 路由匹配
|
|
||||||
- **WHEN** 请求匹配已注册的 `/api/*` 路由
|
|
||||||
- **THEN** Bun server SHALL 返回 API handler 的响应
|
|
||||||
|
|
||||||
#### Scenario: API 路由未命中
|
|
||||||
- **WHEN** 请求访问未注册的 `/api/*` 路由
|
|
||||||
- **THEN** Bun server MUST 返回 JSON 404 响应,而不是前端 HTML 文档
|
|
||||||
|
|
||||||
### Requirement: API 错误响应一致性
|
|
||||||
系统 SHALL 为 API 命名空间内的错误返回机器可读 JSON 响应。
|
|
||||||
|
|
||||||
#### Scenario: 未知 API 路由
|
|
||||||
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
|
||||||
- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 404 响应,而不是前端 HTML 文档
|
|
||||||
|
|
||||||
#### Scenario: API method 不允许
|
|
||||||
- **WHEN** 客户端使用不支持的 method 请求已存在的 API 路由
|
|
||||||
- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 405 响应
|
|
||||||
|
|
||||||
### Requirement: Demo API 端点
|
|
||||||
系统 SHALL 暴露 `/api/demo` 作为稳定 demo 端点,用于证明前后端集成可用。
|
|
||||||
|
|
||||||
#### Scenario: Demo API 成功响应
|
|
||||||
- **WHEN** 客户端请求 `/api/demo`
|
|
||||||
- **THEN** Bun server SHALL 返回包含可读 message 和 runtime metadata 的 JSON 响应
|
|
||||||
|
|
||||||
#### Scenario: Demo API 内容类型
|
|
||||||
- **WHEN** 客户端请求 `/api/demo`
|
|
||||||
- **THEN** Bun server SHALL 返回 JSON content type 的响应
|
|
||||||
|
|
||||||
### Requirement: 健康检查端点
|
|
||||||
系统 SHALL 在前端 SPA fallback 之外暴露健康检查端点。
|
|
||||||
|
|
||||||
#### Scenario: 健康检查成功
|
|
||||||
- **WHEN** 客户端请求 `/health`
|
|
||||||
- **THEN** Bun server SHALL 返回成功的、机器可读的健康检查响应
|
|
||||||
|
|
||||||
### Requirement: 生产静态资源服务
|
|
||||||
系统 SHALL 在生产模式下由 Bun runtime 服务 Vite 生产资源。
|
|
||||||
|
|
||||||
#### Scenario: 请求构建后的资源
|
|
||||||
- **WHEN** 客户端请求构建后的前端资源,例如 `/assets/app.js`
|
|
||||||
- **THEN** Bun server SHALL 返回该资源并带有适当的 content type
|
|
||||||
|
|
||||||
#### Scenario: 请求前端根路径
|
|
||||||
- **WHEN** 客户端请求 `/`
|
|
||||||
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
|
|
||||||
|
|
||||||
#### Scenario: 生产 demo 页面调用 API
|
|
||||||
- **WHEN** 客户端从生产 Bun runtime 打开前端页面
|
|
||||||
- **THEN** demo 页面 SHALL 能够从同源调用 `/api/demo` 并展示后端响应
|
|
||||||
|
|
||||||
### Requirement: 生产缓存策略
|
|
||||||
系统 SHALL 为生产静态资源和前端入口 HTML 使用明确的缓存策略。
|
|
||||||
|
|
||||||
#### Scenario: 请求前端入口 HTML
|
|
||||||
- **WHEN** 生产 Bun server 返回前端入口 HTML 文档
|
|
||||||
- **THEN** 响应 SHALL 使用 `Cache-Control: no-cache`
|
|
||||||
|
|
||||||
#### Scenario: 请求构建后的静态资源
|
|
||||||
- **WHEN** 生产 Bun server 返回 Vite 构建后的静态资源
|
|
||||||
- **THEN** 响应 SHALL 使用长缓存策略 `public, max-age=31536000, immutable`
|
|
||||||
|
|
||||||
#### Scenario: 请求未知静态资源
|
|
||||||
- **WHEN** 客户端请求不存在的 `/assets/*` 资源或带文件扩展名的未知路径
|
|
||||||
- **THEN** Bun server MUST 返回 404,且 MUST NOT 返回前端入口 HTML 文档
|
|
||||||
|
|
||||||
### Requirement: 低风险安全响应头
|
|
||||||
系统 SHALL 在生产运行时响应中附加低风险安全响应头,提升基础安全性且不提前约束未来前端资源策略。
|
|
||||||
|
|
||||||
#### Scenario: 生产 HTML 响应包含安全头
|
|
||||||
- **WHEN** 生产 Bun server 返回前端 HTML 文档
|
|
||||||
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers
|
|
||||||
|
|
||||||
#### Scenario: 生产 JSON 响应包含安全头
|
|
||||||
- **WHEN** 生产 Bun server 返回 `/health` 或 `/api/*` JSON 响应
|
|
||||||
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers
|
|
||||||
|
|
||||||
#### Scenario: 生产静态资源响应包含安全头
|
|
||||||
- **WHEN** 生产 Bun server 返回 Vite 构建后的静态资源
|
|
||||||
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers
|
|
||||||
|
|
||||||
### Requirement: SPA fallback 行为
|
|
||||||
系统 SHALL 在生产环境中为非 API、非静态资源的前端路由返回前端入口 HTML 文档。
|
|
||||||
|
|
||||||
#### Scenario: 刷新前端路由
|
|
||||||
- **WHEN** 客户端请求前端路由,例如 `/dashboard`
|
|
||||||
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
|
|
||||||
|
|
||||||
#### Scenario: 保留 API 错误语义
|
|
||||||
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
|
||||||
- **THEN** Bun server MUST NOT 返回前端入口 HTML 文档
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
## Purpose
|
|
||||||
|
|
||||||
定义将 Vite 前端资源与 Bun 后端打包为单个 standalone executable 的生产构建、运行配置和验证要求。
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Requirement: 生产构建顺序
|
|
||||||
生产构建 MUST 在编译 Bun 后端 executable 之前先构建 Vite 前端。
|
|
||||||
|
|
||||||
#### Scenario: 运行生产构建
|
|
||||||
- **WHEN** 开发者运行生产构建命令
|
|
||||||
- **THEN** 系统 MUST 在调用 Bun standalone executable 编译之前生成前端静态资源
|
|
||||||
|
|
||||||
#### Scenario: 前端构建失败
|
|
||||||
- **WHEN** 前端生产构建失败
|
|
||||||
- **THEN** 系统 MUST 停止生产构建,且不能输出 stale executable
|
|
||||||
|
|
||||||
### Requirement: 构建生成确定性
|
|
||||||
生产构建 SHALL 以稳定顺序生成嵌入静态资源清单,减少重复构建产生无意义差异。
|
|
||||||
|
|
||||||
#### Scenario: 生成静态资源清单
|
|
||||||
- **WHEN** 生产构建扫描 Vite 输出目录并生成嵌入资源模块
|
|
||||||
- **THEN** 资源条目 SHALL 按稳定顺序输出
|
|
||||||
|
|
||||||
#### Scenario: 重复构建相同前端产物
|
|
||||||
- **WHEN** Vite 输出内容未变化且生产构建重复运行
|
|
||||||
- **THEN** 生成的嵌入资源模块 SHALL 保持语义一致且不依赖文件系统遍历顺序
|
|
||||||
|
|
||||||
### Requirement: 单 executable 输出
|
|
||||||
生产构建 SHALL 输出一个 standalone executable,其中包含 Bun 后端、必要 server 依赖和构建后的前端资源。构建成功后 SHALL 自动清理中间产物目录(`.build/`),构建失败时 SHALL 保留中间产物以便排查。
|
|
||||||
|
|
||||||
#### Scenario: 在目标机器运行 executable
|
|
||||||
- **WHEN** 生成的 executable 在兼容目标平台上运行
|
|
||||||
- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun、Vite 或 `node_modules`
|
|
||||||
|
|
||||||
#### Scenario: 服务嵌入的前端
|
|
||||||
- **WHEN** executable 收到前端根路径请求
|
|
||||||
- **THEN** 它 SHALL 从 executable 内包含的资源服务前端,且不需要外部 `dist/` 目录
|
|
||||||
|
|
||||||
#### Scenario: 服务嵌入 demo API 和页面
|
|
||||||
- **WHEN** 生成的 executable 启动,且浏览器打开前端根路径
|
|
||||||
- **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/demo` 返回的数据
|
|
||||||
|
|
||||||
#### Scenario: 构建成功后清理中间产物
|
|
||||||
- **WHEN** 生产构建成功完成并输出 executable
|
|
||||||
- **THEN** 系统 SHALL 自动删除 `.build/` 目录及其所有内容
|
|
||||||
|
|
||||||
#### Scenario: 构建失败时保留中间产物
|
|
||||||
- **WHEN** 生产构建在任意步骤失败(前端构建、中间产物生成、Bun 编译)
|
|
||||||
- **THEN** `.build/` 目录 SHALL 保留在磁盘上以供排查
|
|
||||||
|
|
||||||
### Requirement: 外部运行时配置
|
|
||||||
executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。
|
|
||||||
|
|
||||||
#### Scenario: 修改监听端口
|
|
||||||
- **WHEN** 操作者修改受支持的 port 配置
|
|
||||||
- **THEN** 同一个 executable SHALL 在不重新构建的情况下监听新端口
|
|
||||||
|
|
||||||
#### Scenario: 缺少可选配置
|
|
||||||
- **WHEN** 可选运行时配置被省略
|
|
||||||
- **THEN** executable SHALL 使用文档化的默认值
|
|
||||||
|
|
||||||
### Requirement: 构建验证
|
|
||||||
项目 SHALL 提供验证,证明生产 executable 可以服务 API、健康检查、静态资源和 SPA fallback 路由,并且完整验证 MUST 针对当前源码重新构建后的 executable 运行。
|
|
||||||
|
|
||||||
#### Scenario: 验证 executable 路由
|
|
||||||
- **WHEN** 构建验证针对生成的 executable 运行
|
|
||||||
- **THEN** 它 SHALL 检查 `/api/demo`、`/health`、前端根路径、静态资源、未知 API、未知静态资源和前端 fallback 请求
|
|
||||||
|
|
||||||
#### Scenario: 验证生产模式和响应头
|
|
||||||
- **WHEN** 构建验证针对生成的 executable 运行
|
|
||||||
- **THEN** 它 SHALL 检查 demo 响应处于 production runtime mode,并验证代表性 HTML、JSON 和静态资源响应的缓存或低风险安全 headers
|
|
||||||
|
|
||||||
#### Scenario: 完整验证重新构建 executable
|
|
||||||
- **WHEN** 开发者运行完整验证命令
|
|
||||||
- **THEN** 系统 MUST 先基于当前源码执行生产构建,再对新生成的 executable 运行 smoke test
|
|
||||||
|
|
||||||
#### Scenario: 验证失败
|
|
||||||
- **WHEN** 任一代表性生产路由、响应头、生产模式或构建阶段检查失败
|
|
||||||
- **THEN** 验证 SHALL 使构建或测试命令失败
|
|
||||||
67
package.json
67
package.json
@@ -1,40 +1,79 @@
|
|||||||
{
|
{
|
||||||
"name": "gateway-checker",
|
"name": "dial-server",
|
||||||
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run scripts/dev.ts",
|
"dev": "bun run scripts/dev.ts",
|
||||||
"dev:server": "bun --watch src/server/dev.ts",
|
"dev:server": "bun --watch src/server/dev.ts",
|
||||||
"dev:web": "bunx --bun vite --host 127.0.0.1",
|
"dev:web": "bunx --bun vite --host",
|
||||||
"build:web": "bunx --bun vite build",
|
|
||||||
"build": "bun run scripts/build.ts",
|
"build": "bun run scripts/build.ts",
|
||||||
"start": "bun src/server/dev.ts",
|
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier . --write",
|
"format": "prettier . --write",
|
||||||
"format:check": "prettier . --check",
|
"schema": "bun run scripts/generate-config-schema.ts",
|
||||||
"check": "bun run typecheck && bun run lint && bun run format:check && bun test",
|
"schema:check": "bun run scripts/generate-config-schema.ts --check",
|
||||||
"verify": "bun run check && bun run build && bun run test:smoke",
|
"check": "bun run schema:check && bun run typecheck && bun run lint && bun test",
|
||||||
|
"verify": "bun run check && bun run build",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:smoke": "bun run scripts/smoke.ts",
|
|
||||||
"clean": "bun run scripts/clean.ts",
|
"clean": "bun run scripts/clean.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"release": "bun run scripts/release.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prepare": "husky",
|
||||||
|
"version:patch": "bun run scripts/bump-version.ts patch",
|
||||||
|
"version:minor": "bun run scripts/bump-version.ts minor",
|
||||||
|
"version:major": "bun run scripts/bump-version.ts major",
|
||||||
|
"version:set": "bun run scripts/bump-version.ts set"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^21.0.1",
|
||||||
|
"@commitlint/config-conventional": "^21.0.1",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bun": "^1.3.13",
|
"@tanstack/react-query-devtools": "^5.100.10",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
|
"@types/jsdom": "^28.0.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@types/tar-stream": "^3.1.4",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"eslint-plugin-perfectionist": "^5.9.0",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"lint-staged": "^17.0.4",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
|
"tar-stream": "^3.2.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.3",
|
||||||
"vite": "^8.0.11"
|
"vite": "^8.0.13"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "^3",
|
||||||
|
"@ai-sdk/openai": "^3",
|
||||||
|
"@number-flow/react": "^0.6.0",
|
||||||
|
"@sinclair/typebox": "^0.34.49",
|
||||||
|
"@tanstack/react-query": "^5.100.10",
|
||||||
|
"@xmldom/xmldom": "^0.9.10",
|
||||||
|
"ai": "^6",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
|
"croner": "^10.0.1",
|
||||||
|
"es-toolkit": "^1.46.1",
|
||||||
|
"pino": "^10.3.1",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
|
"pino-roll": "^4.0.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"systeminformation": "^5.31.6",
|
||||||
|
"tdesign-icons-react": "^0.6.4",
|
||||||
|
"tdesign-react": "^1.16.9",
|
||||||
|
"xpath": "^0.0.34"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8106
probe-config.schema.json
Normal file
8106
probe-config.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
380
probes.example.yaml
Normal file
380
probes.example.yaml
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
# yaml-language-server: $schema=./probe-config.schema.json
|
||||||
|
|
||||||
|
server:
|
||||||
|
listen:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: "${server_port|3000}"
|
||||||
|
storage:
|
||||||
|
dataDir: "/tmp/probes_data"
|
||||||
|
# logging:
|
||||||
|
# level: "info"
|
||||||
|
# console:
|
||||||
|
# level: "info"
|
||||||
|
# file:
|
||||||
|
# level: "info"
|
||||||
|
# path: "/var/log/dial/dial.log"
|
||||||
|
# rotation:
|
||||||
|
# size: "50MB"
|
||||||
|
# frequency: "daily"
|
||||||
|
# maxFiles: 14
|
||||||
|
|
||||||
|
probes:
|
||||||
|
execution:
|
||||||
|
maxConcurrentChecks: "${max_checks|20}"
|
||||||
|
|
||||||
|
variables:
|
||||||
|
env_name: "演示"
|
||||||
|
httpbin_base: "https://httpbin.org"
|
||||||
|
api_token: "Bearer demo-token"
|
||||||
|
max_checks: 20
|
||||||
|
server_port: 3000
|
||||||
|
sqlite_url: "sqlite://:memory:"
|
||||||
|
|
||||||
|
targets:
|
||||||
|
# ========== HTTP targets ==========
|
||||||
|
|
||||||
|
- id: "baidu-home"
|
||||||
|
name: "Baidu 首页可用"
|
||||||
|
description: "监控百度首页的可用性和响应时间"
|
||||||
|
type: http
|
||||||
|
group: "搜索引擎"
|
||||||
|
http:
|
||||||
|
url: "https://www.baidu.com"
|
||||||
|
expect:
|
||||||
|
status: [200]
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
|
||||||
|
- id: "httpbin-json"
|
||||||
|
name: "${env_name} JSON API — 完整流水线"
|
||||||
|
type: http
|
||||||
|
group: "后端服务"
|
||||||
|
interval: "1m"
|
||||||
|
timeout: "15s"
|
||||||
|
http:
|
||||||
|
url: "${httpbin_base}/json"
|
||||||
|
headers:
|
||||||
|
Accept: "application/json"
|
||||||
|
Authorization: "${api_token|Bearer fallback-token}"
|
||||||
|
expect:
|
||||||
|
headers:
|
||||||
|
Content-Type:
|
||||||
|
contains: "application/json"
|
||||||
|
durationMs:
|
||||||
|
lte: 8000
|
||||||
|
body:
|
||||||
|
- json:
|
||||||
|
path: "$.slideshow.title"
|
||||||
|
equals: "Sample Slide Show"
|
||||||
|
- json:
|
||||||
|
path: "$.slideshow.slides[0].title"
|
||||||
|
contains: "Wake"
|
||||||
|
- regex: '"title"'
|
||||||
|
|
||||||
|
- id: "httpbin-post"
|
||||||
|
name: "POST 接口测试"
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "${httpbin_base}/post"
|
||||||
|
method: POST
|
||||||
|
headers:
|
||||||
|
Content-Type: "application/json"
|
||||||
|
body: '{"action":"check","version":1}'
|
||||||
|
expect:
|
||||||
|
status: [200]
|
||||||
|
body:
|
||||||
|
- json:
|
||||||
|
path: "$.json.action"
|
||||||
|
equals: "check"
|
||||||
|
- json:
|
||||||
|
path: "$.json.version"
|
||||||
|
gte: 1
|
||||||
|
|
||||||
|
# ========== Cmd targets ==========
|
||||||
|
|
||||||
|
- id: "bun-version"
|
||||||
|
name: "Bun 版本输出匹配"
|
||||||
|
type: cmd
|
||||||
|
group: "系统检查"
|
||||||
|
cmd:
|
||||||
|
exec: "bun"
|
||||||
|
args: ["--version"]
|
||||||
|
expect:
|
||||||
|
exitCode: [0]
|
||||||
|
stdout:
|
||||||
|
- regex: "^\\d+\\.\\d+\\.\\d+"
|
||||||
|
|
||||||
|
- id: "bun-stdout-rules"
|
||||||
|
name: "多规则 stdout 顺序校验"
|
||||||
|
type: cmd
|
||||||
|
interval: "5m"
|
||||||
|
cmd:
|
||||||
|
exec: "bun"
|
||||||
|
args: ["-e", "console.log('version: 2.0.1, status: healthy')"]
|
||||||
|
expect:
|
||||||
|
stdout:
|
||||||
|
- contains: "version:"
|
||||||
|
- regex: "\\d+\\.\\d+\\.\\d+"
|
||||||
|
- contains: "healthy"
|
||||||
|
|
||||||
|
- id: "bun-stderr"
|
||||||
|
name: "stderr 内容检查"
|
||||||
|
type: cmd
|
||||||
|
cmd:
|
||||||
|
exec: "bun"
|
||||||
|
args: ["-e", "process.stderr.write('simulated error\\n'); process.exit(1)"]
|
||||||
|
expect:
|
||||||
|
exitCode: [1]
|
||||||
|
stderr:
|
||||||
|
- contains: "simulated error"
|
||||||
|
|
||||||
|
# ========== DB targets ==========
|
||||||
|
|
||||||
|
- id: "sqlite-connect"
|
||||||
|
name: "SQLite 内存数据库连接测试"
|
||||||
|
type: db
|
||||||
|
group: "数据库"
|
||||||
|
db:
|
||||||
|
url: "${sqlite_url}"
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 1000
|
||||||
|
|
||||||
|
- id: "sqlite-query"
|
||||||
|
name: "SQLite 内存数据库多列结果校验"
|
||||||
|
type: db
|
||||||
|
db:
|
||||||
|
url: "${sqlite_url}"
|
||||||
|
query: "SELECT 1 as id, 'Alice' as name, 'engineer' as role"
|
||||||
|
expect:
|
||||||
|
rowCount: 1
|
||||||
|
rows:
|
||||||
|
- id:
|
||||||
|
gte: 1
|
||||||
|
name:
|
||||||
|
exists: true
|
||||||
|
role:
|
||||||
|
contains: "engineer"
|
||||||
|
result:
|
||||||
|
- json:
|
||||||
|
path: "$.rows[0].role"
|
||||||
|
equals: "engineer"
|
||||||
|
|
||||||
|
# ========== TCP targets ==========
|
||||||
|
|
||||||
|
- id: "redis-port"
|
||||||
|
name: "Redis 端口可达"
|
||||||
|
type: tcp
|
||||||
|
group: "基础设施"
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 6379
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 3000
|
||||||
|
|
||||||
|
- id: "smtp-banner"
|
||||||
|
name: "SMTP Banner 探测"
|
||||||
|
type: tcp
|
||||||
|
group: "基础设施"
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 25
|
||||||
|
readBanner: true
|
||||||
|
bannerReadTimeout: 3000
|
||||||
|
expect:
|
||||||
|
banner:
|
||||||
|
- contains: "ESMTP"
|
||||||
|
|
||||||
|
# ========== ICMP targets ==========
|
||||||
|
|
||||||
|
- id: "gateway-icmp"
|
||||||
|
name: "网关 ICMP 可达"
|
||||||
|
type: icmp
|
||||||
|
group: "基础设施"
|
||||||
|
icmp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
count: 3
|
||||||
|
packetSize: 56
|
||||||
|
expect:
|
||||||
|
alive: true
|
||||||
|
packetLossPercent:
|
||||||
|
lte: 10
|
||||||
|
avgLatencyMs:
|
||||||
|
lte: 100
|
||||||
|
maxLatencyMs:
|
||||||
|
lte: 300
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
|
||||||
|
# ========== DNS targets ==========
|
||||||
|
|
||||||
|
# 本机 DNS 解析检查(system 模式)
|
||||||
|
- id: "dns-system-localhost"
|
||||||
|
name: "本机 DNS 解析"
|
||||||
|
type: dns
|
||||||
|
group: "DNS"
|
||||||
|
dns:
|
||||||
|
resolver: system
|
||||||
|
name: "localhost"
|
||||||
|
family: ipv4
|
||||||
|
expect:
|
||||||
|
values:
|
||||||
|
exact:
|
||||||
|
- "127.0.0.1"
|
||||||
|
durationMs:
|
||||||
|
lte: 200
|
||||||
|
|
||||||
|
# DNS server 拨测(server 模式,A 记录)
|
||||||
|
- id: "dns-server-cf"
|
||||||
|
name: "Cloudflare DNS A 记录"
|
||||||
|
type: dns
|
||||||
|
group: "DNS"
|
||||||
|
dns:
|
||||||
|
resolver: server
|
||||||
|
server: "1.1.1.1"
|
||||||
|
name: "example.com"
|
||||||
|
recordType: A
|
||||||
|
expect:
|
||||||
|
rcode: ["NOERROR"]
|
||||||
|
ttlMin:
|
||||||
|
gte: 60
|
||||||
|
durationMs:
|
||||||
|
lte: 500
|
||||||
|
|
||||||
|
# 负向 DNS 检查(NXDOMAIN)
|
||||||
|
- id: "dns-nxdomain-check"
|
||||||
|
name: "负向 DNS 检查"
|
||||||
|
type: dns
|
||||||
|
group: "DNS"
|
||||||
|
dns:
|
||||||
|
resolver: server
|
||||||
|
server: "1.1.1.1"
|
||||||
|
name: "this-domain-should-not-exist.example.com"
|
||||||
|
recordType: A
|
||||||
|
expect:
|
||||||
|
rcode: ["NXDOMAIN"]
|
||||||
|
|
||||||
|
# MX 记录检查
|
||||||
|
- id: "dns-mx-check"
|
||||||
|
name: "MX 记录检查"
|
||||||
|
type: dns
|
||||||
|
group: "DNS"
|
||||||
|
dns:
|
||||||
|
resolver: server
|
||||||
|
server: "1.1.1.1"
|
||||||
|
name: "gmail.com"
|
||||||
|
recordType: MX
|
||||||
|
expect:
|
||||||
|
rcode: ["NOERROR"]
|
||||||
|
|
||||||
|
# ========== UDP targets ==========
|
||||||
|
|
||||||
|
- id: "udp-heartbeat"
|
||||||
|
name: "UDP 心跳检测"
|
||||||
|
type: udp
|
||||||
|
group: "基础设施"
|
||||||
|
udp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 9000
|
||||||
|
payload: "PING"
|
||||||
|
expect:
|
||||||
|
response:
|
||||||
|
- contains: "PONG"
|
||||||
|
durationMs:
|
||||||
|
lte: 100
|
||||||
|
|
||||||
|
- id: "udp-binary-probe"
|
||||||
|
name: "UDP 二进制协议探测"
|
||||||
|
type: udp
|
||||||
|
group: "基础设施"
|
||||||
|
udp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 5683
|
||||||
|
payload: "400100"
|
||||||
|
encoding: hex
|
||||||
|
responseEncoding: hex
|
||||||
|
expect:
|
||||||
|
responseSize:
|
||||||
|
gte: 4
|
||||||
|
durationMs:
|
||||||
|
lte: 200
|
||||||
|
|
||||||
|
- id: "udp-fire-and-forget"
|
||||||
|
name: "UDP 发送验证(不等待响应)"
|
||||||
|
type: udp
|
||||||
|
group: "基础设施"
|
||||||
|
udp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 514
|
||||||
|
payload: "<14>health check"
|
||||||
|
expect:
|
||||||
|
responded: false
|
||||||
|
|
||||||
|
- id: "llm-openai-probe"
|
||||||
|
name: "OpenAI Chat Completions 健康检查"
|
||||||
|
type: llm
|
||||||
|
group: "AI 服务"
|
||||||
|
llm:
|
||||||
|
provider: openai
|
||||||
|
url: "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
model: "glm-4.7-flash"
|
||||||
|
prompt: "Say OK"
|
||||||
|
key: "d1e97306540d12bb2f834be961fcacb1.SNBShlCxWYJCx0qZ"
|
||||||
|
expect:
|
||||||
|
status:
|
||||||
|
- 200
|
||||||
|
finishReason: "stop"
|
||||||
|
output:
|
||||||
|
- contains: "OK"
|
||||||
|
|
||||||
|
# ========== WS targets ==========
|
||||||
|
|
||||||
|
- id: "ws-reachability"
|
||||||
|
name: "WebSocket 服务可达"
|
||||||
|
type: ws
|
||||||
|
group: "基础设施"
|
||||||
|
ws:
|
||||||
|
url: "wss://echo.websocket.org"
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
|
||||||
|
- id: "ws-echo-check"
|
||||||
|
name: "WebSocket Echo 交互检查"
|
||||||
|
type: ws
|
||||||
|
group: "基础设施"
|
||||||
|
ws:
|
||||||
|
url: "wss://echo.websocket.org"
|
||||||
|
send: "hello"
|
||||||
|
receiveTimeout: 3000
|
||||||
|
expect:
|
||||||
|
message:
|
||||||
|
- contains: "hello"
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
|
||||||
|
- id: "local-cpu"
|
||||||
|
name: "本机 CPU"
|
||||||
|
type: cpu
|
||||||
|
group: "基础设施"
|
||||||
|
interval: "30s"
|
||||||
|
timeout: "5s"
|
||||||
|
cpu:
|
||||||
|
sampleDuration: "1s"
|
||||||
|
expect:
|
||||||
|
usagePercent:
|
||||||
|
lte: 85
|
||||||
|
maxCoreUsagePercent:
|
||||||
|
lte: 95
|
||||||
|
|
||||||
|
- id: "local-memory"
|
||||||
|
name: "本机内存"
|
||||||
|
type: mem
|
||||||
|
group: "基础设施"
|
||||||
|
interval: "30s"
|
||||||
|
timeout: "5s"
|
||||||
|
mem: {}
|
||||||
|
expect:
|
||||||
|
usagePercent:
|
||||||
|
lte: 85
|
||||||
127
scripts/build-common.ts
Normal file
127
scripts/build-common.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { join, relative } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { validateVersion } from "./bump-version-logic";
|
||||||
|
|
||||||
|
export const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
export const distWebDir = join(projectRoot, "dist/web");
|
||||||
|
export const buildDir = join(projectRoot, ".build");
|
||||||
|
export const packageJsonPath = join(projectRoot, "package.json");
|
||||||
|
|
||||||
|
export async function cleanup() {
|
||||||
|
await rm(buildDir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function codeGeneration() {
|
||||||
|
console.log("Step 2/3: Code generation...");
|
||||||
|
await rm(buildDir, { force: true, recursive: true });
|
||||||
|
await Bun.write(join(buildDir, ".gitkeep"), "");
|
||||||
|
|
||||||
|
const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string };
|
||||||
|
const version = packageJson.version;
|
||||||
|
if (typeof version !== "string") {
|
||||||
|
console.error("package.json does not have a valid version field");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
validateVersion(version);
|
||||||
|
|
||||||
|
const allFiles = await scanDir(distWebDir, "/");
|
||||||
|
const importLines: string[] = [];
|
||||||
|
const fileEntries: string[] = [];
|
||||||
|
let indexHtmlVar = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < allFiles.length; i++) {
|
||||||
|
const urlPath = allFiles[i]!;
|
||||||
|
const varName = `f${i}`;
|
||||||
|
const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1)));
|
||||||
|
importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`);
|
||||||
|
|
||||||
|
if (urlPath === "/index.html") {
|
||||||
|
indexHtmlVar = varName;
|
||||||
|
} else {
|
||||||
|
fileEntries.push(` "${urlPath}": Bun.file(${varName}),`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!indexHtmlVar) {
|
||||||
|
console.error("index.html not found in dist/web/");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticAssetsTs = [
|
||||||
|
`import type { StaticAssets } from "../src/server/static";`,
|
||||||
|
"",
|
||||||
|
...importLines,
|
||||||
|
"",
|
||||||
|
`export const staticAssets: StaticAssets = {`,
|
||||||
|
` files: {`,
|
||||||
|
...fileEntries,
|
||||||
|
` },`,
|
||||||
|
` indexHtml: Bun.file(${indexHtmlVar}),`,
|
||||||
|
`};`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs);
|
||||||
|
|
||||||
|
const serverEntryTs = [
|
||||||
|
`import { bootstrap } from "../src/server/bootstrap";`,
|
||||||
|
`import { readRuntimeConfig } from "../src/server/config";`,
|
||||||
|
`import { staticAssets } from "./static-assets";`,
|
||||||
|
"",
|
||||||
|
`const APP_VERSION = "${version}" as const;`,
|
||||||
|
"",
|
||||||
|
`async function main() {`,
|
||||||
|
` const { configPath } = readRuntimeConfig();`,
|
||||||
|
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||||
|
`}`,
|
||||||
|
"",
|
||||||
|
`void main().catch((error) => {`,
|
||||||
|
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
|
||||||
|
` process.exit(1);`,
|
||||||
|
`});`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanDir(dir: string, prefix: string): Promise<string[]> {
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
const paths: string[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dir, entry.name);
|
||||||
|
const urlPath = `${prefix}${entry.name}`;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
paths.push(...(await scanDir(fullPath, `${urlPath}/`)));
|
||||||
|
} else {
|
||||||
|
paths.push(urlPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toImportSpecifier(
|
||||||
|
fromDir: string,
|
||||||
|
targetPath: string,
|
||||||
|
relativePath: (from: string, to: string) => string = relative,
|
||||||
|
) {
|
||||||
|
return relativePath(fromDir, targetPath).replaceAll("\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function viteBuild() {
|
||||||
|
console.log("Step 1/3: Vite build...");
|
||||||
|
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
stderr: "inherit",
|
||||||
|
stdout: "inherit",
|
||||||
|
});
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
console.error("Vite build failed");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
scripts/build.ts
151
scripts/build.ts
@@ -1,121 +1,52 @@
|
|||||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { dirname, relative, sep } from "node:path";
|
import { join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { $ } from "bun";
|
|
||||||
|
|
||||||
const buildDir = fileURLToPath(new URL("../.build/", import.meta.url));
|
import { buildDir, cleanup, codeGeneration, projectRoot, viteBuild } from "./build-common";
|
||||||
const webDistDir = fileURLToPath(new URL("../dist/web/", import.meta.url));
|
|
||||||
const executablePath = fileURLToPath(new URL("../dist/gateway-checker", import.meta.url));
|
|
||||||
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 });
|
const executablePath = join(projectRoot, "dist/dial-server");
|
||||||
await rm(executablePath, { force: true });
|
|
||||||
await mkdir(buildDir, { recursive: true });
|
|
||||||
|
|
||||||
await $`bunx --bun vite build`;
|
async function build() {
|
||||||
|
try {
|
||||||
const files = await listFiles(webDistDir);
|
await viteBuild();
|
||||||
const indexPath = files.find((file) => normalize(relative(webDistDir, file)) === "index.html");
|
await codeGeneration();
|
||||||
|
await bunCompile();
|
||||||
if (!indexPath) {
|
await cleanup();
|
||||||
throw new Error("Vite build 未生成 dist/web/index.html");
|
console.log(`Built executable: ${executablePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
await cleanup();
|
||||||
|
console.error("Build failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetFiles = files.filter((file) => file !== indexPath);
|
async function bunCompile() {
|
||||||
await writeGeneratedAssets(indexPath, assetFiles);
|
console.log("Step 3/3: Bun compile...");
|
||||||
await writeGeneratedEntry();
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
outfile: executablePath,
|
|
||||||
autoloadDotenv: true,
|
|
||||||
autoloadBunfig: true,
|
|
||||||
},
|
|
||||||
minify: true,
|
|
||||||
sourcemap: "linked",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
await rm(executablePath, { force: true });
|
await rm(executablePath, { force: true });
|
||||||
throw new Error("Bun executable 构建失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Built executable: ${executablePath}`);
|
const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"];
|
||||||
|
const result = await Bun.build({
|
||||||
await rm(buildDir, { recursive: true, force: true });
|
compile: target
|
||||||
|
? {
|
||||||
async function listFiles(directory: string): Promise<string[]> {
|
autoloadBunfig: true,
|
||||||
const entries = await readdir(directory, { withFileTypes: true });
|
autoloadDotenv: true,
|
||||||
const files = await Promise.all(
|
outfile: executablePath,
|
||||||
entries.map(async (entry) => {
|
target: target as Bun.Build.CompileTarget,
|
||||||
const path = `${directory.replace(/\/$/, "")}/${entry.name}`;
|
}
|
||||||
|
: {
|
||||||
if (entry.isDirectory()) {
|
autoloadBunfig: true,
|
||||||
return listFiles(path);
|
autoloadDotenv: true,
|
||||||
}
|
outfile: executablePath,
|
||||||
|
},
|
||||||
return [path];
|
entrypoints: [join(buildDir, "server-entry.ts")],
|
||||||
}),
|
minify: true,
|
||||||
);
|
sourcemap: "linked",
|
||||||
|
|
||||||
return files.flat().sort((left, right) => normalize(left).localeCompare(normalize(right)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeGeneratedAssets(indexPath: string, assetFiles: string[]) {
|
|
||||||
const imports = [
|
|
||||||
`import type { StaticAssets } from "../src/server/app";`,
|
|
||||||
`import indexPath from "${toImportPath(indexPath)}" with { type: "file" };`,
|
|
||||||
...assetFiles.map((file, index) => `import asset${index}Path from "${toImportPath(file)}" with { type: "file" };`),
|
|
||||||
];
|
|
||||||
const assetEntries = assetFiles.map((file, index) => {
|
|
||||||
const urlPath = `/${normalize(relative(webDistDir, file))}`;
|
|
||||||
return ` ${JSON.stringify(urlPath)}: Bun.file(asset${index}Path),`;
|
|
||||||
});
|
});
|
||||||
const source = `${imports.join("\n")}
|
|
||||||
|
|
||||||
export const staticAssets: StaticAssets = {
|
if (!result.success) {
|
||||||
indexHtml: Bun.file(indexPath),
|
console.error("Bun compile failed:", result.logs);
|
||||||
files: {
|
await cleanup();
|
||||||
${assetEntries.join("\n")}
|
process.exit(1);
|
||||||
},
|
}
|
||||||
};
|
|
||||||
`;
|
|
||||||
|
|
||||||
await mkdir(dirname(generatedAssetsPath), { recursive: true });
|
|
||||||
await writeFile(generatedAssetsPath, source);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeGeneratedEntry() {
|
await build();
|
||||||
await writeFile(
|
|
||||||
generatedEntryPath,
|
|
||||||
`import { readRuntimeConfig } from "../src/server/config";
|
|
||||||
import { startServer } from "../src/server/server";
|
|
||||||
import { staticAssets } from "./static-assets";
|
|
||||||
|
|
||||||
startServer({
|
|
||||||
config: readRuntimeConfig(),
|
|
||||||
mode: "production",
|
|
||||||
staticAssets,
|
|
||||||
});
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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("/");
|
|
||||||
}
|
|
||||||
|
|||||||
40
scripts/bump-version-logic.ts
Normal file
40
scripts/bump-version-logic.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const VERSION_REGEX = /^\d+\.\d+\.\d+$/;
|
||||||
|
|
||||||
|
export function bumpVersion(current: string, command: "major" | "minor" | "patch" | "set", target?: string): string {
|
||||||
|
validateVersion(current);
|
||||||
|
const [major, minor, patch] = parseVersion(current);
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case "major":
|
||||||
|
return formatVersion(major + 1, 0, 0);
|
||||||
|
case "minor":
|
||||||
|
return formatVersion(major, minor + 1, 0);
|
||||||
|
case "patch":
|
||||||
|
return formatVersion(major, minor, patch + 1);
|
||||||
|
case "set": {
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("set command requires a target version");
|
||||||
|
}
|
||||||
|
validateVersion(target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVersion(major: number, minor: number, patch: number): string {
|
||||||
|
return `${major}.${minor}.${patch}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseVersion(version: string): [number, number, number] {
|
||||||
|
const parts = version.split(".").map((p) => parseInt(p, 10));
|
||||||
|
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||||
|
throw new Error(`Invalid version format: ${version}`);
|
||||||
|
}
|
||||||
|
return [parts[0]!, parts[1]!, parts[2]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateVersion(version: string): void {
|
||||||
|
if (!VERSION_REGEX.test(version)) {
|
||||||
|
throw new Error(`Invalid version format: ${version}. Expected MAJOR.MINOR.PATCH (e.g., 0.1.0)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
scripts/bump-version.ts
Normal file
45
scripts/bump-version.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { bumpVersion, validateVersion } from "./bump-version-logic";
|
||||||
|
|
||||||
|
const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "package.json");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.error("Usage: bun run bump-version.ts <patch|minor|major|set> [version]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = args[0];
|
||||||
|
if (command !== "patch" && command !== "minor" && command !== "major" && command !== "set") {
|
||||||
|
console.error(`Unknown command: ${command}. Expected patch, minor, major, or set`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "set" && args.length < 2) {
|
||||||
|
console.error("Usage: bun run bump-version.ts set <version>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string };
|
||||||
|
const currentVersion = packageJson.version;
|
||||||
|
|
||||||
|
if (typeof currentVersion !== "string") {
|
||||||
|
console.error("package.json does not have a valid version field");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateVersion(currentVersion);
|
||||||
|
|
||||||
|
const targetVersion = command === "set" ? args[1] : undefined;
|
||||||
|
const nextVersion = bumpVersion(currentVersion, command, targetVersion);
|
||||||
|
|
||||||
|
packageJson.version = nextVersion;
|
||||||
|
writeFileSync(PACKAGE_JSON_PATH, JSON.stringify(packageJson, null, 2) + "\n");
|
||||||
|
|
||||||
|
console.log(`${currentVersion} -> ${nextVersion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
import { readdir, rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
const root = resolve(import.meta.dir, "..");
|
const root = resolve(import.meta.dir, "..");
|
||||||
|
|
||||||
const patterns: Array<{ glob: string; desc: string }> = [
|
const dirs: Array<{ desc: string; path: string }> = [
|
||||||
{ glob: ".build/", desc: "Bun 构建缓存" },
|
{ desc: "构建产物", path: "dist" },
|
||||||
{ glob: ".*.bun-build", desc: "Bun 构建临时文件" },
|
{ desc: "Bun 构建缓存", path: ".build" },
|
||||||
|
{ desc: "Playwright 测试报告", path: "playwright-report" },
|
||||||
|
{ desc: "测试结果", path: "test-results" },
|
||||||
|
{ desc: "发布产物", path: "dist/release" },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { glob, desc } of patterns) {
|
const filePatterns: Array<{ desc: string; glob: string }> = [{ desc: "Bun 构建临时文件", glob: ".*.bun-build" }];
|
||||||
const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ root, dot: true }));
|
|
||||||
|
for (const { desc, path } of dirs) {
|
||||||
|
const full = resolve(root, path);
|
||||||
|
await rm(full, { force: true, recursive: true });
|
||||||
|
console.log(`已清理 ${desc}: ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { desc, glob } of filePatterns) {
|
||||||
|
const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: root, dot: true }));
|
||||||
if (entries.length === 0) continue;
|
if (entries.length === 0) continue;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = resolve(root, entry);
|
const full = resolve(root, entry);
|
||||||
await rm(full, { recursive: true, force: true });
|
await rm(full, { force: true, recursive: true });
|
||||||
console.log(`已清理 ${desc}: ${entry}`);
|
console.log(`已清理 ${desc}: ${entry}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,26 @@
|
|||||||
interface ChildProcessInfo {
|
import { fileURLToPath } from "node:url";
|
||||||
name: string;
|
|
||||||
process: Bun.Subprocess;
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = {
|
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||||
...process.env,
|
|
||||||
BACKEND_PORT: process.env.PORT ?? "3000",
|
|
||||||
};
|
|
||||||
|
|
||||||
const children: ChildProcessInfo[] = [
|
const apiServer = Bun.spawn(["bun", "--watch", "src/server/dev.ts", ...process.argv.slice(2)], {
|
||||||
{
|
cwd: projectRoot,
|
||||||
name: "server",
|
stderr: "inherit",
|
||||||
process: Bun.spawn(["bun", "run", "dev:server"], {
|
stdout: "inherit",
|
||||||
env,
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "web",
|
|
||||||
process: Bun.spawn(["bun", "run", "dev:web"], {
|
|
||||||
env,
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const stopChildren = () => {
|
|
||||||
for (const child of children) {
|
|
||||||
child.process.kill();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
stopChildren();
|
|
||||||
process.exit(130);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
const viteServer = Bun.spawn(["bunx", "--bun", "vite", "--host"], {
|
||||||
stopChildren();
|
cwd: projectRoot,
|
||||||
process.exit(143);
|
stderr: "inherit",
|
||||||
|
stdout: "inherit",
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstExit = await Promise.race(
|
function shutdown() {
|
||||||
children.map(async (child) => ({ name: child.name, code: await child.process.exited })),
|
apiServer.kill();
|
||||||
);
|
viteServer.kill();
|
||||||
|
|
||||||
stopChildren();
|
|
||||||
|
|
||||||
if (firstExit.code !== 0) {
|
|
||||||
console.error(`${firstExit.name} exited with code ${firstExit.code}`);
|
|
||||||
process.exit(firstExit.code ?? 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
|
await Promise.race([apiServer.exited, viteServer.exited]);
|
||||||
|
shutdown();
|
||||||
|
|||||||
16
scripts/generate-config-schema.ts
Normal file
16
scripts/generate-config-schema.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { createDefaultCheckerRegistry } from "../src/server/checker/runner";
|
||||||
|
import { createProbeConfigJsonSchema } from "../src/server/checker/schema/export";
|
||||||
|
|
||||||
|
const schemaPath = "probe-config.schema.json";
|
||||||
|
const schema = `${JSON.stringify(createProbeConfigJsonSchema(createDefaultCheckerRegistry()), null, 2)}\n`;
|
||||||
|
|
||||||
|
if (process.argv.includes("--check")) {
|
||||||
|
const existing = await Bun.file(schemaPath)
|
||||||
|
.text()
|
||||||
|
.catch(() => null);
|
||||||
|
if (existing !== schema) {
|
||||||
|
throw new Error(`${schemaPath} 未同步,请运行 bun run schema`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await Bun.write(schemaPath, schema);
|
||||||
|
}
|
||||||
196
scripts/release.ts
Normal file
196
scripts/release.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, rm, stat } from "node:fs/promises";
|
||||||
|
import { join, relative } from "node:path";
|
||||||
|
import { createGzip } from "node:zlib";
|
||||||
|
import tar from "tar-stream";
|
||||||
|
|
||||||
|
import { buildDir, cleanup, codeGeneration, projectRoot, viteBuild } from "./build-common";
|
||||||
|
|
||||||
|
const releaseDir = join(projectRoot, "dist/release");
|
||||||
|
const binariesDir = join(releaseDir, "binaries");
|
||||||
|
const packagesDir = join(releaseDir, "packages");
|
||||||
|
|
||||||
|
export interface ReleaseTarget {
|
||||||
|
arch: string;
|
||||||
|
bunTarget: string;
|
||||||
|
displayName: string;
|
||||||
|
os: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_TARGETS: ReleaseTarget[] = [
|
||||||
|
{ arch: "x64", bunTarget: "bun-linux-x64", displayName: "Linux x64 (glibc)", os: "linux" },
|
||||||
|
{ arch: "arm64", bunTarget: "bun-linux-arm64", displayName: "Linux ARM64 (glibc)", os: "linux" },
|
||||||
|
{ arch: "x64-musl", bunTarget: "bun-linux-x64-musl", displayName: "Linux x64 (musl)", os: "linux" },
|
||||||
|
{ arch: "arm64-musl", bunTarget: "bun-linux-arm64-musl", displayName: "Linux ARM64 (musl)", os: "linux" },
|
||||||
|
{ arch: "x64", bunTarget: "bun-windows-x64", displayName: "Windows x64", os: "windows" },
|
||||||
|
{ arch: "x64", bunTarget: "bun-darwin-x64", displayName: "macOS x64 (Intel)", os: "darwin" },
|
||||||
|
{ arch: "arm64", bunTarget: "bun-darwin-arm64", displayName: "macOS ARM64 (Apple Silicon)", os: "darwin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function archiveName(target: ReleaseTarget, version: string): string {
|
||||||
|
return `dial-server_${version}_${target.os}_${target.arch}.tar.gz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checksumName(target: ReleaseTarget, version: string): string {
|
||||||
|
return `${archiveName(target, version)}.sha256`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compileTarget(target: ReleaseTarget, version: string): Promise<string> {
|
||||||
|
const outfile = join(binariesDir, execName(target, version));
|
||||||
|
console.log(` 编译 ${target.displayName}...`);
|
||||||
|
|
||||||
|
const result = await Bun.build({
|
||||||
|
compile: {
|
||||||
|
autoloadBunfig: true,
|
||||||
|
autoloadDotenv: true,
|
||||||
|
outfile,
|
||||||
|
target: target.bunTarget as Bun.Build.CompileTarget,
|
||||||
|
},
|
||||||
|
entrypoints: [join(buildDir, "server-entry.ts")],
|
||||||
|
minify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(` 编译失败 (${target.displayName}):`, result.logs);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeChecksum(archivePath: string): Promise<string> {
|
||||||
|
const content = await Bun.file(archivePath).arrayBuffer();
|
||||||
|
const hash = createHash("sha256").update(Buffer.from(content)).digest("hex");
|
||||||
|
const filename = relative(packagesDir, archivePath);
|
||||||
|
const checksumPath = `${archivePath}.sha256`;
|
||||||
|
const checksumContent = `${hash} ${filename}\n`;
|
||||||
|
await Bun.write(checksumPath, checksumContent);
|
||||||
|
return checksumPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function execName(target: ReleaseTarget, version: string): string {
|
||||||
|
const suffix = target.os === "windows" ? ".exe" : "";
|
||||||
|
return `dial-server-${version}-${target.os}-${target.arch}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function packageTarget(target: ReleaseTarget, version: string, binaryPath: string): Promise<string> {
|
||||||
|
const archivePath = join(packagesDir, archiveName(target, version));
|
||||||
|
const prefix = `dial-server_${version}_${target.os}_${target.arch}`;
|
||||||
|
const binaryName = target.os === "windows" ? "dial-server.exe" : "dial-server";
|
||||||
|
|
||||||
|
const binaryContent = await Bun.file(binaryPath).arrayBuffer();
|
||||||
|
const probesContent = await Bun.file(join(projectRoot, "probes.example.yaml")).arrayBuffer();
|
||||||
|
const licenseContent = await Bun.file(join(projectRoot, "LICENSE")).arrayBuffer();
|
||||||
|
|
||||||
|
const pack = tar.pack();
|
||||||
|
pack.entry(
|
||||||
|
{ mode: 0o755, name: `${prefix}/${binaryName}`, size: binaryContent.byteLength },
|
||||||
|
Buffer.from(binaryContent),
|
||||||
|
);
|
||||||
|
pack.entry(
|
||||||
|
{ mode: 0o644, name: `${prefix}/probes.example.yaml`, size: probesContent.byteLength },
|
||||||
|
Buffer.from(probesContent),
|
||||||
|
);
|
||||||
|
pack.entry({ mode: 0o644, name: `${prefix}/LICENSE`, size: licenseContent.byteLength }, Buffer.from(licenseContent));
|
||||||
|
pack.finalize();
|
||||||
|
|
||||||
|
const gzip = createGzip();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
pack.pipe(gzip);
|
||||||
|
gzip.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
gzip.on("end", resolve);
|
||||||
|
gzip.on("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Bun.write(archivePath, Buffer.concat(chunks));
|
||||||
|
return archivePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTargets(args: string[]): ReleaseTarget[] {
|
||||||
|
const targetIndex = args.indexOf("--target");
|
||||||
|
if (targetIndex === -1 || targetIndex === args.length - 1) {
|
||||||
|
return ALL_TARGETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetValues = args[targetIndex + 1]!.split(",");
|
||||||
|
const targets: ReleaseTarget[] = [];
|
||||||
|
|
||||||
|
for (const value of targetValues) {
|
||||||
|
const bunTarget = `bun-${value.trim()}`;
|
||||||
|
const found = ALL_TARGETS.find((t) => t.bunTarget === bunTarget);
|
||||||
|
if (!found) {
|
||||||
|
const available = ALL_TARGETS.map((t) => t.bunTarget.replace(/^bun-/, "")).join(", ");
|
||||||
|
console.error(`无效的 target: ${value.trim()}`);
|
||||||
|
console.error(`可用的 target 值: ${available}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
targets.push(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function printReport(binaries: string[], archives: string[]): Promise<void> {
|
||||||
|
console.log("\n=== Release 报告 ===\n");
|
||||||
|
|
||||||
|
console.log("裸二进制:");
|
||||||
|
for (const binary of binaries) {
|
||||||
|
const size = (await stat(binary)).size;
|
||||||
|
const mb = (size / 1024 / 1024).toFixed(1);
|
||||||
|
console.log(` ${relative(projectRoot, binary)} (${mb} MB)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n压缩包:");
|
||||||
|
for (const archive of archives) {
|
||||||
|
const size = (await stat(archive)).size;
|
||||||
|
const mb = (size / 1024 / 1024).toFixed(1);
|
||||||
|
console.log(` ${relative(projectRoot, archive)} (${mb} MB)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function release() {
|
||||||
|
const targets = parseTargets(process.argv);
|
||||||
|
console.log(`Release 目标: ${targets.map((t) => t.displayName).join(", ")}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await viteBuild();
|
||||||
|
const version = await codeGeneration();
|
||||||
|
|
||||||
|
console.log(`\n版本: ${version}`);
|
||||||
|
console.log(`编译 ${targets.length} 个目标...\n`);
|
||||||
|
|
||||||
|
await rm(releaseDir, { force: true, recursive: true });
|
||||||
|
await mkdir(binariesDir, { recursive: true });
|
||||||
|
await mkdir(packagesDir, { recursive: true });
|
||||||
|
|
||||||
|
const binaries: string[] = [];
|
||||||
|
for (const target of targets) {
|
||||||
|
const binaryPath = await compileTarget(target, version);
|
||||||
|
binaries.push(binaryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const archives: string[] = [];
|
||||||
|
for (let i = 0; i < targets.length; i++) {
|
||||||
|
const target = targets[i]!;
|
||||||
|
const binaryPath = binaries[i]!;
|
||||||
|
console.log(` 打包 ${target.displayName}...`);
|
||||||
|
const archivePath = await packageTarget(target, version, binaryPath);
|
||||||
|
await computeChecksum(archivePath);
|
||||||
|
archives.push(archivePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
await printReport(binaries, archives);
|
||||||
|
console.log("\nRelease 完成!");
|
||||||
|
} catch (error) {
|
||||||
|
await cleanup();
|
||||||
|
console.error("Release 失败:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
await release();
|
||||||
|
}
|
||||||
149
scripts/smoke.ts
149
scripts/smoke.ts
@@ -1,149 +0,0 @@
|
|||||||
import { access } from "node:fs/promises";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import type { DemoResponse, HealthResponse } from "../src/shared/api";
|
|
||||||
|
|
||||||
const executablePath = process.argv[2] ?? fileURLToPath(new URL("../dist/gateway-checker", import.meta.url));
|
|
||||||
|
|
||||||
await assertExecutableExists(executablePath);
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
const app = Bun.spawn([executablePath, "--host", "127.0.0.1", "--port", String(port)], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
HOST: "127.0.0.1",
|
|
||||||
PORT: String(port),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const stdout = readStream(app.stdout);
|
|
||||||
const stderr = readStream(app.stderr);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForServer(`${baseUrl}/health`);
|
|
||||||
|
|
||||||
const { body: health, response: healthResponse } = await expectJson<HealthResponse>(`${baseUrl}/health`, 200);
|
|
||||||
assert(health.ok === true, "健康检查响应缺少 ok=true");
|
|
||||||
assertSecurityHeaders(healthResponse, "/health");
|
|
||||||
|
|
||||||
const { body: demo, response: demoResponse } = await expectJson<DemoResponse>(`${baseUrl}/api/demo`, 200);
|
|
||||||
assert(demo.message.includes("/api/demo"), "demo 响应未包含预期 message");
|
|
||||||
assert(demo.runtime.mode === "production", "demo 响应 runtime mode 应为 production");
|
|
||||||
assertSecurityHeaders(demoResponse, "/api/demo");
|
|
||||||
|
|
||||||
const missingApi = await fetch(`${baseUrl}/api/not-found`);
|
|
||||||
assert(missingApi.status === 404, "未知 API 应返回 404");
|
|
||||||
assert(missingApi.headers.get("content-type")?.includes("application/json") === true, "未知 API 应返回 JSON");
|
|
||||||
assertSecurityHeaders(missingApi, "/api/not-found");
|
|
||||||
|
|
||||||
const { body: rootHtml, response: rootResponse } = await expectText(`${baseUrl}/`, 200);
|
|
||||||
assert(rootHtml.includes("Gateway Checker Demo"), "前端根页面缺少 demo 标题");
|
|
||||||
assert(rootResponse.headers.get("cache-control") === "no-cache", "前端根页面应使用 no-cache");
|
|
||||||
assertSecurityHeaders(rootResponse, "/");
|
|
||||||
|
|
||||||
const { body: fallbackHtml, response: fallbackResponse } = await expectText(`${baseUrl}/dashboard`, 200);
|
|
||||||
assert(fallbackHtml.includes("Gateway Checker Demo"), "SPA fallback 未返回前端入口页面");
|
|
||||||
assert(fallbackResponse.headers.get("cache-control") === "no-cache", "SPA fallback 应使用 no-cache");
|
|
||||||
assertSecurityHeaders(fallbackResponse, "/dashboard");
|
|
||||||
|
|
||||||
const assetPath = rootHtml.match(/(?:src|href)="(\/assets\/[^"]+)"/)?.[1];
|
|
||||||
assert(assetPath !== undefined, "前端入口页面未引用 /assets/* 资源");
|
|
||||||
|
|
||||||
const asset = await fetch(`${baseUrl}${assetPath}`);
|
|
||||||
assert(asset.status === 200, `静态资源 ${assetPath} 未返回 200`);
|
|
||||||
assert(asset.headers.get("cache-control") === "public, max-age=31536000, immutable", "静态资源应使用长缓存");
|
|
||||||
assertSecurityHeaders(asset, assetPath);
|
|
||||||
|
|
||||||
const missingAsset = await expectText(`${baseUrl}/assets/not-found.js`, 404);
|
|
||||||
assert(!missingAsset.body.includes("Gateway Checker Demo"), "未知静态资源不应返回前端入口页面");
|
|
||||||
assertSecurityHeaders(missingAsset.response, "/assets/not-found.js");
|
|
||||||
|
|
||||||
console.log(`Smoke test passed: ${baseUrl}`);
|
|
||||||
} catch (error) {
|
|
||||||
app.kill();
|
|
||||||
const [out, err] = await Promise.all([stdout, stderr]);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
|
|
||||||
throw new Error(`executable smoke test 失败: ${message}\nstdout:\n${out}\nstderr:\n${err}`, { cause: error });
|
|
||||||
} finally {
|
|
||||||
app.kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertExecutableExists(path: string) {
|
|
||||||
try {
|
|
||||||
await access(path);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`找不到 executable: ${path},请先运行 bun run build`, { cause: error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectJson<T>(url: string, status: number): Promise<{ body: T; response: Response }> {
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
|
|
||||||
assert(response.headers.get("content-type")?.includes("application/json") === true, `${url} 应返回 JSON`);
|
|
||||||
|
|
||||||
return { body: (await response.json()) as T, response };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectText(url: string, status: number): Promise<{ body: string; response: Response }> {
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
|
|
||||||
|
|
||||||
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 assert(condition: boolean, message: string): asserts condition {
|
|
||||||
if (!condition) {
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
|
|
||||||
if (!stream) return "";
|
|
||||||
|
|
||||||
return new Response(stream).text();
|
|
||||||
}
|
|
||||||
11
src/pino-roll.d.ts
vendored
Normal file
11
src/pino-roll.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
declare module "pino-roll" {
|
||||||
|
interface RollingStreamOptions {
|
||||||
|
file: string;
|
||||||
|
frequency?: string;
|
||||||
|
limit?: { count?: number };
|
||||||
|
mkdir?: boolean;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function build(options: RollingStreamOptions): Promise<NodeJS.WritableStream>;
|
||||||
|
}
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import type { ApiErrorResponse, DemoResponse, HealthResponse, RuntimeMode } from "../shared/api";
|
|
||||||
|
|
||||||
export interface StaticAssets {
|
|
||||||
indexHtml: Blob;
|
|
||||||
files: Record<string, Blob>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppOptions {
|
|
||||||
mode: RuntimeMode;
|
|
||||||
staticAssets?: StaticAssets;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === "/api/demo") {
|
|
||||||
if (!allowsGetHead(request.method)) {
|
|
||||||
return methodNotAllowedResponse(["GET", "HEAD"], options.mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(createDemoResponse(options.mode), { method: request.method, mode: options.mode });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname.startsWith("/api/")) {
|
|
||||||
return jsonResponse(createApiError("API route not found", 404), {
|
|
||||||
method: request.method,
|
|
||||||
mode: options.mode,
|
|
||||||
status: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.staticAssets) {
|
|
||||||
return serveStaticAsset(url.pathname, options.staticAssets, options.mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("开发期请通过 Vite 前端地址访问页面。", {
|
|
||||||
status: 404,
|
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDemoResponse(mode: RuntimeMode): DemoResponse {
|
|
||||||
return {
|
|
||||||
message: "Bun 后端已通过 /api/demo 连接到 React 前端。",
|
|
||||||
runtime: {
|
|
||||||
mode,
|
|
||||||
bunVersion: Bun.version,
|
|
||||||
platform: process.platform,
|
|
||||||
arch: process.arch,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHealthResponse(): HealthResponse {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
service: "gateway-checker",
|
|
||||||
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: ApiErrorResponse | DemoResponse | HealthResponse,
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
132
src/server/bootstrap.ts
Normal file
132
src/server/bootstrap.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { RuntimeMode } from "../shared/api";
|
||||||
|
import type { ResolvedLoggingConfig } from "./checker/types";
|
||||||
|
import type { Logger } from "./logger";
|
||||||
|
import type { StartServerOptions } from "./server";
|
||||||
|
import type { StaticAssets } from "./static";
|
||||||
|
|
||||||
|
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
|
||||||
|
import { ProbeEngine } from "./checker/engine";
|
||||||
|
import { ProbeStore } from "./checker/store";
|
||||||
|
import { createConsoleFallback, createRuntimeLogger } from "./logger";
|
||||||
|
import { startServer } from "./server";
|
||||||
|
|
||||||
|
export interface BootstrapDependencies {
|
||||||
|
createEngine?: (
|
||||||
|
store: ProbeStore,
|
||||||
|
targets: ResolvedConfig["targets"],
|
||||||
|
maxConcurrentChecks: number,
|
||||||
|
retentionMs: number,
|
||||||
|
logger: Logger,
|
||||||
|
) => BootstrapEngine;
|
||||||
|
createLogger?: (config: ResolvedLoggingConfig, mode: string, version: string) => Promise<Logger>;
|
||||||
|
createStore?: (dbPath: string) => ProbeStore;
|
||||||
|
exit?: (code: number) => never;
|
||||||
|
loadConfig?: (configPath: string) => Promise<ResolvedConfig>;
|
||||||
|
logError?: (...data: unknown[]) => void;
|
||||||
|
onSignal?: (signal: ShutdownSignal, handler: () => void) => void;
|
||||||
|
startServer?: (options: StartServerOptions) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapOptions {
|
||||||
|
configPath: string;
|
||||||
|
mode: RuntimeMode;
|
||||||
|
staticAssets?: StaticAssets;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
|
||||||
|
type ShutdownSignal = "SIGINT" | "SIGTERM";
|
||||||
|
|
||||||
|
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
|
||||||
|
const load = dependencies.loadConfig ?? loadConfig;
|
||||||
|
const createStore = dependencies.createStore ?? ((dbPath: string) => new ProbeStore(dbPath));
|
||||||
|
const createEngine =
|
||||||
|
dependencies.createEngine ??
|
||||||
|
((
|
||||||
|
store: ProbeStore,
|
||||||
|
targets: ResolvedConfig["targets"],
|
||||||
|
maxConcurrentChecks: number,
|
||||||
|
retentionMs: number,
|
||||||
|
logger: Logger,
|
||||||
|
) => new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs, logger));
|
||||||
|
const buildLogger = dependencies.createLogger ?? createRuntimeLogger;
|
||||||
|
const serve = dependencies.startServer ?? startServer;
|
||||||
|
const onSignal =
|
||||||
|
dependencies.onSignal ??
|
||||||
|
((signal: ShutdownSignal, handler: () => void) => {
|
||||||
|
process.on(signal, handler);
|
||||||
|
});
|
||||||
|
const exit = dependencies.exit ?? ((code: number) => process.exit(code));
|
||||||
|
const logError =
|
||||||
|
dependencies.logError ??
|
||||||
|
((...data: unknown[]) => {
|
||||||
|
createConsoleFallback().fatal(
|
||||||
|
data.map((item) => (item instanceof Error ? item.message : String(item))).join(" "),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let store: ProbeStore | undefined;
|
||||||
|
let engine: BootstrapEngine | undefined;
|
||||||
|
let logger: Logger | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await load(options.configPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger = await buildLogger(config.logging, options.mode, options.version);
|
||||||
|
} catch (logInitError) {
|
||||||
|
logError("日志初始化失败:", logInitError instanceof Error ? logInitError.message : logInitError);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger!.info({ configPath: options.configPath, mode: options.mode, version: options.version }, "配置加载成功");
|
||||||
|
|
||||||
|
store = createStore(join(config.dataDir, "probe.db"));
|
||||||
|
store.syncTargets(config.targets);
|
||||||
|
logger!.info({ dataDir: config.dataDir }, "数据库初始化成功");
|
||||||
|
|
||||||
|
engine = createEngine(
|
||||||
|
store,
|
||||||
|
config.targets,
|
||||||
|
config.maxConcurrentChecks,
|
||||||
|
config.retentionMs,
|
||||||
|
logger!.child({ component: "engine" }),
|
||||||
|
);
|
||||||
|
engine.start();
|
||||||
|
logger!.info(
|
||||||
|
{ maxConcurrentChecks: config.maxConcurrentChecks, targetCount: config.targets.length },
|
||||||
|
"调度引擎启动",
|
||||||
|
);
|
||||||
|
|
||||||
|
const shutdown = () => {
|
||||||
|
logger?.info("收到退出信号,开始优雅关机");
|
||||||
|
engine?.stop();
|
||||||
|
store?.close();
|
||||||
|
logger?.flush();
|
||||||
|
exit(0);
|
||||||
|
};
|
||||||
|
onSignal("SIGINT", shutdown);
|
||||||
|
onSignal("SIGTERM", shutdown);
|
||||||
|
|
||||||
|
serve({
|
||||||
|
config: { host: config.host, port: config.port },
|
||||||
|
logger: logger!.child({ component: "server" }),
|
||||||
|
mode: options.mode,
|
||||||
|
staticAssets: options.staticAssets,
|
||||||
|
store,
|
||||||
|
version: options.version,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
engine?.stop();
|
||||||
|
store?.close();
|
||||||
|
if (logger) {
|
||||||
|
logger.fatal({ error: error instanceof Error ? error.message : String(error) }, "启动失败");
|
||||||
|
logger.flush();
|
||||||
|
} else {
|
||||||
|
logError("启动失败:", error instanceof Error ? error.message : error);
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
410
src/server/checker/config-loader.ts
Normal file
410
src/server/checker/config-loader.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
import type { ConfigValidationIssue } from "./schema/issues";
|
||||||
|
import type {
|
||||||
|
ExecutionConfig,
|
||||||
|
LoggingConfig,
|
||||||
|
LogLevel,
|
||||||
|
RawTargetConfig,
|
||||||
|
ResolvedLoggingConfig,
|
||||||
|
ResolvedTargetBase,
|
||||||
|
RotationFrequency,
|
||||||
|
ServerStorageConfig,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
import { normalizeAuthoringConfig } from "./normalizer";
|
||||||
|
import { checkerRegistry } from "./runner";
|
||||||
|
import { issue, throwConfigIssues } from "./schema/issues";
|
||||||
|
import { asValidatedConfig, type NormalizedProbeConfig } from "./schema/types";
|
||||||
|
import { validateProbeConfigContract } from "./schema/validate";
|
||||||
|
import { parseDuration, parseSize } from "./utils";
|
||||||
|
|
||||||
|
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_MAX_CONCURRENT_CHECKS = 20;
|
||||||
|
const DEFAULT_RETENTION = "7d";
|
||||||
|
const DEFAULT_LOG_LEVEL: LogLevel = "info";
|
||||||
|
const DEFAULT_ROTATION_SIZE = "50MB";
|
||||||
|
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
|
||||||
|
const DEFAULT_ROTATION_MAX_FILES = 14;
|
||||||
|
|
||||||
|
const MINIMUM_INTERVAL_MS = parseDuration("10s");
|
||||||
|
|
||||||
|
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||||
|
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
|
||||||
|
|
||||||
|
export interface ResolvedConfig {
|
||||||
|
configDir: string;
|
||||||
|
dataDir: string;
|
||||||
|
host: string;
|
||||||
|
logging: ResolvedLoggingConfig;
|
||||||
|
maxConcurrentChecks: number;
|
||||||
|
port: number;
|
||||||
|
retentionMs: number;
|
||||||
|
targets: ResolvedTargetBase[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||||
|
const file = Bun.file(configPath);
|
||||||
|
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
throw new Error(`配置文件不存在: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await file.text();
|
||||||
|
const parsed = Bun.YAML.parse(content);
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error("配置文件内容为空或格式无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeResult = normalizeAuthoringConfig(parsed, checkerRegistry);
|
||||||
|
if (normalizeResult.issues.length > 0) {
|
||||||
|
throwConfigIssues(dedupeIssues(normalizeResult.issues));
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedConfig = normalizeResult.config;
|
||||||
|
const contractResult = validateProbeConfigContract(normalizedConfig, checkerRegistry);
|
||||||
|
if (contractResult.config === null && !canRunSemanticValidation(normalizedConfig)) {
|
||||||
|
throwConfigIssues(contractResult.issues);
|
||||||
|
}
|
||||||
|
const semanticInput = (contractResult.config ?? normalizedConfig) as NormalizedProbeConfig;
|
||||||
|
const validationIssues = validateConfig(semanticInput);
|
||||||
|
|
||||||
|
const allIssues = [...contractResult.issues, ...validationIssues];
|
||||||
|
if (contractResult.config === null) {
|
||||||
|
if (allIssues.length > 0) {
|
||||||
|
throwConfigIssues(dedupeIssues(allIssues));
|
||||||
|
}
|
||||||
|
throw new Error("配置文件内容为空或格式无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = contractResult.config;
|
||||||
|
|
||||||
|
const validated = asValidatedConfig(raw);
|
||||||
|
|
||||||
|
const configDir = dirname(resolve(configPath));
|
||||||
|
const server = validated.server ?? {};
|
||||||
|
const listen = server.listen ?? {};
|
||||||
|
const storage = server.storage ?? {};
|
||||||
|
|
||||||
|
const host = listen.host ?? DEFAULT_HOST;
|
||||||
|
const port = listen.port ?? DEFAULT_PORT;
|
||||||
|
const dataDir = resolve(configDir, storage.dataDir ?? DEFAULT_DATA_DIR);
|
||||||
|
|
||||||
|
const probes = validated.probes ?? {};
|
||||||
|
const execution = probes.execution ?? {};
|
||||||
|
const maxConcurrentChecks = resolveMaxConcurrentChecks(execution);
|
||||||
|
const retentionMs = resolveRetention(storage);
|
||||||
|
|
||||||
|
const logging = resolveLogging(server.logging ?? {}, dataDir, configDir);
|
||||||
|
|
||||||
|
const allRuntimeIssues = [...allIssues];
|
||||||
|
validateLoggingConfig(server.logging, allRuntimeIssues);
|
||||||
|
if (allRuntimeIssues.length > 0) {
|
||||||
|
throwConfigIssues(dedupeIssues(allRuntimeIssues));
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultIntervalMs = parseDuration(DEFAULT_INTERVAL);
|
||||||
|
const defaultTimeoutMs = parseDuration(DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
|
const targets: ResolvedTargetBase[] = validated.targets.map((target) =>
|
||||||
|
resolveTarget(target, defaultIntervalMs, defaultTimeoutMs, configDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { configDir, dataDir, host, logging, maxConcurrentChecks, port, retentionMs, targets };
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRunSemanticValidation(value: unknown): boolean {
|
||||||
|
return isPlainObject(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: ConfigValidationIssue[] = [];
|
||||||
|
for (const item of issues) {
|
||||||
|
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}:${item.targetId ?? ""}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseDuration } from "./utils";
|
||||||
|
|
||||||
|
function isAbsolute(p: string): boolean {
|
||||||
|
return p.startsWith("/") || /^[A-Za-z]:/.test(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLogging(logging: LoggingConfig, dataDir: string, configDir: string): ResolvedLoggingConfig {
|
||||||
|
const globalLevel = resolveLogLevel(logging.level, DEFAULT_LOG_LEVEL);
|
||||||
|
const consoleLevel = resolveLogLevel(logging.console?.level, globalLevel);
|
||||||
|
const fileLevel = resolveLogLevel(logging.file?.level, globalLevel);
|
||||||
|
|
||||||
|
const rawPath = logging.file?.path;
|
||||||
|
const filePath = rawPath
|
||||||
|
? isAbsolute(rawPath)
|
||||||
|
? rawPath
|
||||||
|
: resolve(configDir, rawPath)
|
||||||
|
: resolve(dataDir, "logs/dial.log");
|
||||||
|
|
||||||
|
const rotationRaw = logging.file?.rotation;
|
||||||
|
const rotationSizeRaw = rotationRaw?.size ?? DEFAULT_ROTATION_SIZE;
|
||||||
|
const rotationSizeBytes = parseSize(rotationSizeRaw);
|
||||||
|
const rotationFrequency = rotationRaw?.frequency ?? DEFAULT_ROTATION_FREQUENCY;
|
||||||
|
const rotationMaxFiles = rotationRaw?.maxFiles ?? DEFAULT_ROTATION_MAX_FILES;
|
||||||
|
|
||||||
|
return {
|
||||||
|
consoleLevel,
|
||||||
|
fileLevel,
|
||||||
|
filePath,
|
||||||
|
rotationFrequency,
|
||||||
|
rotationMaxFiles,
|
||||||
|
rotationSizeBytes,
|
||||||
|
rotationSizeRaw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLogLevel(level: unknown, fallback: LogLevel): LogLevel {
|
||||||
|
if (!isString(level)) return fallback;
|
||||||
|
if (VALID_LOG_LEVELS.includes(level as LogLevel)) return level as LogLevel;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMaxConcurrentChecks(execution: ExecutionConfig): number {
|
||||||
|
if (execution.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
|
||||||
|
if (
|
||||||
|
!isNumber(execution.maxConcurrentChecks) ||
|
||||||
|
!Number.isInteger(execution.maxConcurrentChecks) ||
|
||||||
|
execution.maxConcurrentChecks <= 0
|
||||||
|
)
|
||||||
|
return DEFAULT_MAX_CONCURRENT_CHECKS;
|
||||||
|
return execution.maxConcurrentChecks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRetention(storage: ServerStorageConfig): number {
|
||||||
|
return parseDuration(storage.retention ?? DEFAULT_RETENTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTarget(
|
||||||
|
target: RawTargetConfig,
|
||||||
|
defaultIntervalMs: number,
|
||||||
|
defaultTimeoutMs: number,
|
||||||
|
configDir: string,
|
||||||
|
): ResolvedTargetBase {
|
||||||
|
const intervalMs = parseDuration(target.interval ?? DEFAULT_INTERVAL);
|
||||||
|
const timeoutMs = parseDuration(target.timeout ?? DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
|
const checker = checkerRegistry.get(target.type);
|
||||||
|
const result = checker.resolve(target, { configDir, defaultIntervalMs, defaultTimeoutMs });
|
||||||
|
|
||||||
|
result.intervalMs = intervalMs;
|
||||||
|
result.timeoutMs = timeoutMs;
|
||||||
|
result.description = target.description ?? null;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseDuration(value: string): null | number {
|
||||||
|
try {
|
||||||
|
return parseDuration(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
if (!Array.isArray(config.targets) || config.targets.length === 0) {
|
||||||
|
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const supportedTypes = checkerRegistry.supportedTypes;
|
||||||
|
|
||||||
|
for (let i = 0; i < config.targets.length; i++) {
|
||||||
|
const rawTarget = config.targets[i] as unknown;
|
||||||
|
if (!isPlainObject(rawTarget)) {
|
||||||
|
issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const raw = rawTarget as Record<string, unknown>;
|
||||||
|
|
||||||
|
const id: unknown = raw["id"];
|
||||||
|
if (!isString(id) || id.trim() === "") {
|
||||||
|
issues.push(issue("required", `targets[${i}].id`, "缺少 id 字段"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id)) {
|
||||||
|
issues.push(issue("invalid-format", `targets[${i}].id`, "id 不符合命名规则", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameValue: unknown = raw["name"];
|
||||||
|
const name = isString(nameValue) ? nameValue : id;
|
||||||
|
|
||||||
|
if (isString(nameValue) && nameValue.trim() === "") {
|
||||||
|
issues.push(issue("invalid-value", `targets[${i}].name`, "name 不能为空白", name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const type: unknown = raw["type"];
|
||||||
|
if (!isString(type)) {
|
||||||
|
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportedTypes.includes(type)) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"unsupported-type",
|
||||||
|
`targets[${i}].type`,
|
||||||
|
`使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`,
|
||||||
|
name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group: unknown = raw["group"];
|
||||||
|
if (group !== undefined && !isString(group)) {
|
||||||
|
issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.has(id)) {
|
||||||
|
issues.push(issue("duplicate-id", `targets[${i}].id`, `target id 重复: "${id}"`, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const checker of checkerRegistry.definitions) {
|
||||||
|
issues.push(...checker.validate({ targets: config.targets }));
|
||||||
|
}
|
||||||
|
|
||||||
|
validateDurationValue(
|
||||||
|
isString(config.server?.storage?.retention) ? config.server.storage.retention : undefined,
|
||||||
|
"server.storage.retention",
|
||||||
|
issues,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < config.targets.length; i++) {
|
||||||
|
const target = config.targets[i] as unknown;
|
||||||
|
if (!isPlainObject(target)) continue;
|
||||||
|
const targetRecord = target as Record<string, unknown>;
|
||||||
|
const targetNameValue: unknown = targetRecord["name"];
|
||||||
|
const targetIdValue: unknown = targetRecord["id"];
|
||||||
|
const targetName = isString(targetNameValue)
|
||||||
|
? targetNameValue
|
||||||
|
: isString(targetIdValue)
|
||||||
|
? targetIdValue
|
||||||
|
: undefined;
|
||||||
|
const intervalRaw = isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined;
|
||||||
|
const timeoutRaw = isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined;
|
||||||
|
validateDurationValue(intervalRaw, `targets[${i}].interval`, issues, targetName);
|
||||||
|
validateDurationValue(timeoutRaw, `targets[${i}].timeout`, issues, targetName);
|
||||||
|
|
||||||
|
const intervalMs = tryParseDuration(intervalRaw ?? DEFAULT_INTERVAL);
|
||||||
|
const timeoutMs = tryParseDuration(timeoutRaw ?? DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
|
if (intervalMs !== null && intervalMs < MINIMUM_INTERVAL_MS) {
|
||||||
|
issues.push(issue("invalid-value", `targets[${i}].interval`, "interval 不能小于 10s", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intervalMs !== null && timeoutMs !== null && timeoutMs > intervalMs) {
|
||||||
|
issues.push(issue("invalid-value", `targets[${i}].timeout`, "timeout 不能大于 interval", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDurationValue(
|
||||||
|
value: string | undefined,
|
||||||
|
path: string,
|
||||||
|
issues: ConfigValidationIssue[],
|
||||||
|
targetName?: string,
|
||||||
|
): void {
|
||||||
|
if (value === undefined) return;
|
||||||
|
try {
|
||||||
|
parseDuration(value);
|
||||||
|
} catch (error) {
|
||||||
|
issues.push(issue("invalid-duration", path, error instanceof Error ? error.message : "时长格式不合法", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLoggingConfig(logging: LoggingConfig | undefined, issues: ConfigValidationIssue[]): void {
|
||||||
|
if (logging === undefined) return;
|
||||||
|
|
||||||
|
if (logging.level !== undefined && !VALID_LOG_LEVELS.includes(logging.level)) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
"server.logging.level",
|
||||||
|
`日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logging.console?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.console.level)) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
"server.logging.console.level",
|
||||||
|
`日志等级非法: "${logging.console.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logging.file?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.file.level)) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
"server.logging.file.level",
|
||||||
|
`日志等级非法: "${logging.file.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logging.file?.path !== undefined) {
|
||||||
|
if (!isString(logging.file.path) || logging.file.path.trim() === "") {
|
||||||
|
issues.push(issue("invalid-value", "server.logging.file.path", "日志路径不能为空字符串或空白字符串"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotation = logging.file?.rotation;
|
||||||
|
if (rotation?.size !== undefined) {
|
||||||
|
try {
|
||||||
|
const bytes = parseSize(rotation.size);
|
||||||
|
if (bytes <= 0) {
|
||||||
|
issues.push(issue("invalid-value", "server.logging.file.rotation.size", "滚动大小必须为正整数字节数"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
"server.logging.file.rotation.size",
|
||||||
|
error instanceof Error ? error.message : "size 格式非法",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rotation?.frequency !== undefined && !VALID_ROTATION_FREQUENCIES.includes(rotation.frequency)) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
"server.logging.file.rotation.frequency",
|
||||||
|
`滚动频率非法: "${rotation.frequency}",支持: ${VALID_ROTATION_FREQUENCIES.join(", ")}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rotation?.maxFiles !== undefined) {
|
||||||
|
if (!isNumber(rotation.maxFiles) || !Number.isInteger(rotation.maxFiles) || rotation.maxFiles <= 0) {
|
||||||
|
issues.push(issue("invalid-value", "server.logging.file.rotation.maxFiles", "maxFiles 必须为正整数"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/server/checker/engine.ts
Normal file
220
src/server/checker/engine.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { isError, Semaphore } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { Logger } from "../logger";
|
||||||
|
import type { ProbeStore } from "./store";
|
||||||
|
import type { CheckResult, ResolvedTargetBase } from "./types";
|
||||||
|
|
||||||
|
import { createNoopLogger } from "../logger";
|
||||||
|
import { errorFailure } from "./expect/failure";
|
||||||
|
import { checkerRegistry } from "./runner";
|
||||||
|
|
||||||
|
const PRUNE_INTERVAL_MS = 3600000;
|
||||||
|
|
||||||
|
export class ProbeEngine {
|
||||||
|
private abort: AbortController | null = null;
|
||||||
|
private lastMatched = new Map<string, boolean>();
|
||||||
|
private logger: Logger;
|
||||||
|
private pruneTimer: null | ReturnType<typeof setInterval> = null;
|
||||||
|
private retentionMs: number;
|
||||||
|
private semaphore: Semaphore;
|
||||||
|
private store: ProbeStore;
|
||||||
|
private targetIds = new Set<string>();
|
||||||
|
private targets: ResolvedTargetBase[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
store: ProbeStore,
|
||||||
|
targets: ResolvedTargetBase[],
|
||||||
|
maxConcurrentChecks?: number,
|
||||||
|
retentionMs?: number,
|
||||||
|
logger?: Logger,
|
||||||
|
) {
|
||||||
|
this.store = store;
|
||||||
|
this.targets = targets;
|
||||||
|
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
|
||||||
|
this.retentionMs = retentionMs ?? 0;
|
||||||
|
this.logger = logger ?? createNoopLogger();
|
||||||
|
this.refreshCache();
|
||||||
|
this.initStateCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.abort = new AbortController();
|
||||||
|
const signal = this.abort.signal;
|
||||||
|
|
||||||
|
for (const target of this.targets) {
|
||||||
|
void this.runLoop(target, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.retentionMs > 0) {
|
||||||
|
this.store.prune(this.retentionMs);
|
||||||
|
this.pruneTimer = setInterval(() => {
|
||||||
|
this.store.prune(this.retentionMs);
|
||||||
|
}, PRUNE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.abort?.abort();
|
||||||
|
this.abort = null;
|
||||||
|
if (this.pruneTimer) {
|
||||||
|
clearInterval(this.pruneTimer);
|
||||||
|
this.pruneTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initStateCache(): void {
|
||||||
|
const latestMap = this.store.getLatestChecksMap();
|
||||||
|
for (const [id, row] of latestMap) {
|
||||||
|
this.lastMatched.set(id, row.matched === 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private logCheckDebug(result: CheckResult): void {
|
||||||
|
this.logger.debug({
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
failureMessage: result.failure?.message ?? null,
|
||||||
|
failurePhase: result.failure?.phase ?? null,
|
||||||
|
matched: result.matched,
|
||||||
|
targetId: result.targetId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private logStateChange(result: CheckResult): void {
|
||||||
|
const previous = this.lastMatched.get(result.targetId);
|
||||||
|
const current = result.matched;
|
||||||
|
|
||||||
|
if (previous === undefined) {
|
||||||
|
if (!current) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ durationMs: result.durationMs, failurePhase: result.failure?.phase, targetId: result.targetId },
|
||||||
|
`目标首次 DOWN: ${result.targetId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (previous && !current) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ durationMs: result.durationMs, failurePhase: result.failure?.phase, targetId: result.targetId },
|
||||||
|
`目标状态变化 UP → DOWN: ${result.targetId}`,
|
||||||
|
);
|
||||||
|
} else if (!previous && current) {
|
||||||
|
this.logger.info(
|
||||||
|
{ durationMs: result.durationMs, targetId: result.targetId },
|
||||||
|
`目标恢复 DOWN → UP: ${result.targetId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastMatched.set(result.targetId, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshCache(): void {
|
||||||
|
this.targetIds.clear();
|
||||||
|
for (const target of this.store.getTargets()) {
|
||||||
|
this.targetIds.add(target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runCheck(target: ResolvedTargetBase): Promise<CheckResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runLoop(target: ResolvedTargetBase, signal: AbortSignal): Promise<void> {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
await this.runOnce(target, signal);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
if (elapsed > target.intervalMs) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ elapsed, intervalMs: target.intervalMs, targetId: target.id },
|
||||||
|
`拨测超时: ${target.id} 耗时 ${Math.round(elapsed)}ms > 间隔 ${target.intervalMs}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const delay = Math.max(0, target.intervalMs - elapsed);
|
||||||
|
try {
|
||||||
|
await sleep(delay, signal);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runOnce(target: ResolvedTargetBase, signal?: AbortSignal): Promise<CheckResult> {
|
||||||
|
await this.semaphore.acquire();
|
||||||
|
if (signal?.aborted) {
|
||||||
|
this.semaphore.release();
|
||||||
|
throw new DOMException("Aborted", "AbortError");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await this.runCheck(target);
|
||||||
|
this.writeResult(result);
|
||||||
|
this.logStateChange(result);
|
||||||
|
this.logCheckDebug(result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const reason = formatReason(error);
|
||||||
|
this.logger.error({ reason, targetId: target.id, targetType: target.type }, `探针执行失败: ${reason}`);
|
||||||
|
const errorResult: CheckResult = {
|
||||||
|
detail: null,
|
||||||
|
durationMs: null,
|
||||||
|
failure: errorFailure("internal", "engine", reason),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: target.id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.writeResult(errorResult);
|
||||||
|
return errorResult;
|
||||||
|
} finally {
|
||||||
|
this.semaphore.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeResult(result: CheckResult): void {
|
||||||
|
if (!this.targetIds.has(result.targetId)) return;
|
||||||
|
|
||||||
|
this.store.insertCheckResult({
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
failure: result.failure,
|
||||||
|
matched: result.matched,
|
||||||
|
observation: result.observation ?? null,
|
||||||
|
targetId: result.targetId,
|
||||||
|
timestamp: result.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReason(reason: unknown): string {
|
||||||
|
return isError(reason) ? reason.message : String(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
|
||||||
|
function onAbort() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
281
src/server/checker/expect/content.ts
Normal file
281
src/server/checker/expect/content.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { DOMParser } from "@xmldom/xmldom";
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
import * as xpath from "xpath";
|
||||||
|
|
||||||
|
import type { CheckFailure } from "../types";
|
||||||
|
import type {
|
||||||
|
ContentCssExpectation,
|
||||||
|
ContentExpectation,
|
||||||
|
ContentExpectations,
|
||||||
|
ContentJsonExpectation,
|
||||||
|
ContentValueExpectation,
|
||||||
|
ContentXpathExpectation,
|
||||||
|
ExpectationResult,
|
||||||
|
RawContentCssExpectation,
|
||||||
|
RawContentExpectation,
|
||||||
|
RawContentExpectations,
|
||||||
|
RawContentJsonExpectation,
|
||||||
|
RawContentXpathExpectation,
|
||||||
|
ValueExpectation,
|
||||||
|
ValueMatcher,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
import { errorFailure, mismatchFailure } from "./failure";
|
||||||
|
import { MATCHER_KEY_SET } from "./keys";
|
||||||
|
import { applyValueMatcher, displayValueExpectation, evaluateJsonPath } from "./value";
|
||||||
|
|
||||||
|
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
|
||||||
|
|
||||||
|
export function checkContentExpectations(
|
||||||
|
source: unknown,
|
||||||
|
expectations: ContentExpectations | undefined,
|
||||||
|
options: { path?: string; phase: CheckFailure["phase"] },
|
||||||
|
): ExpectationResult {
|
||||||
|
if (!expectations || expectations.length === 0) return { failure: null, matched: true };
|
||||||
|
|
||||||
|
const basePath = options.path ?? options.phase;
|
||||||
|
let parsedJson: ParsedJsonResult | undefined;
|
||||||
|
|
||||||
|
for (let i = 0; i < expectations.length; i++) {
|
||||||
|
const expectation = expectations[i]!;
|
||||||
|
if (expectation.kind === "json" && parsedJson === undefined) {
|
||||||
|
parsedJson = parseJsonSource(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = checkSingleContentExpectation(source, expectation, `${basePath}[${i}]`, options.phase, parsedJson);
|
||||||
|
if (!result.matched) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveContentExpectations(raw: RawContentExpectations | undefined): ContentExpectations | undefined {
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
return raw.map((entry) => resolveContentExpectation(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCssExpectation(
|
||||||
|
source: unknown,
|
||||||
|
expectation: ContentCssExpectation,
|
||||||
|
expectationPath: string,
|
||||||
|
phase: CheckFailure["phase"],
|
||||||
|
): ExpectationResult {
|
||||||
|
const fullPath = `${expectationPath}.css(${expectation.selector}${expectation.attr ? `@${expectation.attr}` : ""})`;
|
||||||
|
|
||||||
|
let $: cheerio.CheerioAPI;
|
||||||
|
try {
|
||||||
|
$ = cheerio.load(contentText(source));
|
||||||
|
} catch {
|
||||||
|
return { failure: errorFailure(phase, fullPath, "failed to parse HTML"), matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = $(expectation.selector).first();
|
||||||
|
const actual = el.length === 0 ? undefined : expectation.attr ? el.attr(expectation.attr) : el.text();
|
||||||
|
|
||||||
|
if (!applyValueMatcher(actual, expectation.matcher)) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure(
|
||||||
|
phase,
|
||||||
|
fullPath,
|
||||||
|
displayValueExpectation(expectation.matcher),
|
||||||
|
actual,
|
||||||
|
`css selector ${expectation.selector} mismatch`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkJsonExpectation(
|
||||||
|
expectation: ContentJsonExpectation,
|
||||||
|
expectationPath: string,
|
||||||
|
phase: CheckFailure["phase"],
|
||||||
|
parsedJson?: ParsedJsonResult,
|
||||||
|
): ExpectationResult {
|
||||||
|
const fullPath = `${expectationPath}.json(${expectation.path})`;
|
||||||
|
|
||||||
|
if (!parsedJson?.ok) {
|
||||||
|
return { failure: errorFailure(phase, fullPath, parsedJson?.error ?? "content is not valid JSON"), matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const actual = evaluateJsonPath(parsedJson.value, expectation.path);
|
||||||
|
if (!applyValueMatcher(actual, expectation.matcher)) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure(
|
||||||
|
phase,
|
||||||
|
fullPath,
|
||||||
|
displayValueExpectation(expectation.matcher),
|
||||||
|
actual,
|
||||||
|
`json path ${expectation.path} mismatch`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSingleContentExpectation(
|
||||||
|
source: unknown,
|
||||||
|
expectation: ContentExpectation,
|
||||||
|
expectationPath: string,
|
||||||
|
phase: CheckFailure["phase"],
|
||||||
|
parsedJson?: ParsedJsonResult,
|
||||||
|
): ExpectationResult {
|
||||||
|
switch (expectation.kind) {
|
||||||
|
case "css":
|
||||||
|
return checkCssExpectation(source, expectation, expectationPath, phase);
|
||||||
|
case "json":
|
||||||
|
return checkJsonExpectation(expectation, expectationPath, phase, parsedJson);
|
||||||
|
case "value":
|
||||||
|
return checkValueContentExpectation(source, expectation, expectationPath, phase);
|
||||||
|
case "xpath":
|
||||||
|
return checkXpathExpectation(source, expectation, expectationPath, phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValueContentExpectation(
|
||||||
|
source: unknown,
|
||||||
|
expectation: ContentValueExpectation,
|
||||||
|
expectationPath: string,
|
||||||
|
phase: CheckFailure["phase"],
|
||||||
|
): ExpectationResult {
|
||||||
|
if (!applyValueMatcher(source, expectation.matcher, { stringifyNonString: true })) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure(
|
||||||
|
phase,
|
||||||
|
expectationPath,
|
||||||
|
displayValueExpectation(expectation.matcher),
|
||||||
|
source,
|
||||||
|
`${phase} expectation mismatch`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkXpathExpectation(
|
||||||
|
source: unknown,
|
||||||
|
expectation: ContentXpathExpectation,
|
||||||
|
expectationPath: string,
|
||||||
|
phase: CheckFailure["phase"],
|
||||||
|
): ExpectationResult {
|
||||||
|
const fullPath = `${expectationPath}.xpath(${expectation.path})`;
|
||||||
|
|
||||||
|
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||||
|
try {
|
||||||
|
doc = new DOMParser().parseFromString(contentText(source), "text/xml");
|
||||||
|
} catch {
|
||||||
|
return { failure: errorFailure(phase, fullPath, "failed to parse XML/HTML"), matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = xpath.select(expectation.path, doc as unknown as Node);
|
||||||
|
const actual = xpathValue(result);
|
||||||
|
|
||||||
|
if (!applyValueMatcher(actual, expectation.matcher)) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure(
|
||||||
|
phase,
|
||||||
|
fullPath,
|
||||||
|
displayValueExpectation(expectation.matcher),
|
||||||
|
actual,
|
||||||
|
`xpath ${expectation.path} mismatch`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentText(source: unknown): string {
|
||||||
|
if (source === null || source === undefined) return "";
|
||||||
|
if (typeof source === "string") return source;
|
||||||
|
if (typeof source === "number" || typeof source === "boolean" || typeof source === "bigint") return String(source);
|
||||||
|
if (typeof source === "symbol") return source.description ?? "";
|
||||||
|
if (typeof source === "function") return source.name;
|
||||||
|
return JSON.stringify(source) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDirectMatcher(raw: Record<string, unknown>): ValueMatcher {
|
||||||
|
const matcher: ValueMatcher = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (MATCHER_KEY_SET.has(key) && value !== undefined) {
|
||||||
|
(matcher as Record<string, unknown>)[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExtractorMatcher(raw: Record<string, unknown>, ownFields: ReadonlySet<string>): ValueExpectation {
|
||||||
|
const matcher: ValueMatcher = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (ownFields.has(key)) continue;
|
||||||
|
if (MATCHER_KEY_SET.has(key) && value !== undefined) {
|
||||||
|
(matcher as Record<string, unknown>)[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(matcher).length === 0) return { exists: true };
|
||||||
|
return matcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonSource(source: unknown): ParsedJsonResult {
|
||||||
|
if (typeof source !== "string") return { ok: true, value: source };
|
||||||
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(source) as unknown };
|
||||||
|
} catch {
|
||||||
|
return { error: "content is not valid JSON", ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveContentExpectation(raw: RawContentExpectation): ContentExpectation {
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
return { kind: "value", matcher: { equals: raw } };
|
||||||
|
}
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (isPlainObject(record["json"])) {
|
||||||
|
const json = record["json"] as RawContentJsonExpectation;
|
||||||
|
return {
|
||||||
|
kind: "json",
|
||||||
|
matcher: extractExtractorMatcher(json as unknown as Record<string, unknown>, new Set(["path"])),
|
||||||
|
path: json.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(record["css"])) {
|
||||||
|
const css = record["css"] as RawContentCssExpectation;
|
||||||
|
const expectation: ContentCssExpectation = {
|
||||||
|
kind: "css",
|
||||||
|
matcher: extractExtractorMatcher(css as unknown as Record<string, unknown>, new Set(["attr", "selector"])),
|
||||||
|
selector: css.selector,
|
||||||
|
};
|
||||||
|
if (css.attr !== undefined) expectation.attr = css.attr;
|
||||||
|
return expectation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(record["xpath"])) {
|
||||||
|
const xpathExpectation = record["xpath"] as RawContentXpathExpectation;
|
||||||
|
return {
|
||||||
|
kind: "xpath",
|
||||||
|
matcher: extractExtractorMatcher(xpathExpectation as unknown as Record<string, unknown>, new Set(["path"])),
|
||||||
|
path: xpathExpectation.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "value", matcher: extractDirectMatcher(record) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function xpathValue(result: unknown): unknown {
|
||||||
|
if (!Array.isArray(result)) return result;
|
||||||
|
if (result.length === 0) return undefined;
|
||||||
|
|
||||||
|
const node = (result as unknown[])[0]!;
|
||||||
|
if (typeof node !== "object" || node === null) return node;
|
||||||
|
const asNode = node as Node;
|
||||||
|
return asNode.nodeValue ?? (asNode as unknown as Element).textContent ?? "";
|
||||||
|
}
|
||||||
38
src/server/checker/expect/failure.ts
Normal file
38
src/server/checker/expect/failure.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { isString } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { CheckFailure } from "../types";
|
||||||
|
|
||||||
|
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
message,
|
||||||
|
path,
|
||||||
|
phase,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mismatchFailure(
|
||||||
|
phase: CheckFailure["phase"],
|
||||||
|
path: string,
|
||||||
|
expected: unknown,
|
||||||
|
actual: unknown,
|
||||||
|
message: string,
|
||||||
|
): CheckFailure {
|
||||||
|
return {
|
||||||
|
actual: truncateActual(actual),
|
||||||
|
expected,
|
||||||
|
kind: "mismatch",
|
||||||
|
message,
|
||||||
|
path,
|
||||||
|
phase,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||||
|
if (value === undefined || value === null) return value;
|
||||||
|
|
||||||
|
const str = isString(value) ? value : typeof value === "object" && value !== null ? JSON.stringify(value) : undefined;
|
||||||
|
if (str === undefined) return value;
|
||||||
|
if (str.length <= maxLen) return value;
|
||||||
|
return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`;
|
||||||
|
}
|
||||||
14
src/server/checker/expect/headers.ts
Normal file
14
src/server/checker/expect/headers.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ExpectationResult, KeyedExpectations } from "./types";
|
||||||
|
|
||||||
|
import { checkKeyedExpectations } from "./keyed";
|
||||||
|
|
||||||
|
export function checkHeaderExpectations(
|
||||||
|
headers: Record<string, unknown>,
|
||||||
|
expectations?: KeyedExpectations,
|
||||||
|
): ExpectationResult {
|
||||||
|
return checkKeyedExpectations(headers, expectations, {
|
||||||
|
normalizeKey: (key) => key.toLowerCase(),
|
||||||
|
path: "headers",
|
||||||
|
phase: "headers",
|
||||||
|
});
|
||||||
|
}
|
||||||
46
src/server/checker/expect/keyed.ts
Normal file
46
src/server/checker/expect/keyed.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { CheckFailure } from "../types";
|
||||||
|
import type { ExpectationResult, KeyedExpectations, RawKeyedExpectations } from "./types";
|
||||||
|
|
||||||
|
import { mismatchFailure } from "./failure";
|
||||||
|
import { applyValueMatcher, displayValueExpectation, resolveValueExpectation } from "./value";
|
||||||
|
|
||||||
|
export function checkKeyedExpectations(
|
||||||
|
actual: Record<string, unknown>,
|
||||||
|
expectations: KeyedExpectations | undefined,
|
||||||
|
options: { normalizeKey?: (key: string) => string; path?: string; phase: CheckFailure["phase"] },
|
||||||
|
): ExpectationResult {
|
||||||
|
if (!expectations || expectations.length === 0) return { failure: null, matched: true };
|
||||||
|
|
||||||
|
const normalizeKey = options.normalizeKey ?? ((key: string) => key);
|
||||||
|
const basePath = options.path ?? options.phase;
|
||||||
|
const actualMap = new Map<string, unknown>();
|
||||||
|
for (const [key, value] of Object.entries(actual)) {
|
||||||
|
actualMap.set(normalizeKey(key), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const expectation of expectations) {
|
||||||
|
const actualValue = actualMap.get(normalizeKey(expectation.key));
|
||||||
|
if (!applyValueMatcher(actualValue, expectation.matcher)) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure(
|
||||||
|
options.phase,
|
||||||
|
`${basePath}.${expectation.key}`,
|
||||||
|
displayValueExpectation(expectation.matcher),
|
||||||
|
actualValue,
|
||||||
|
`${expectation.key} mismatch`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveKeyedExpectations(raw: RawKeyedExpectations | undefined): KeyedExpectations | undefined {
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
return Object.entries(raw).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
matcher: resolveValueExpectation(value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
7
src/server/checker/expect/keys.ts
Normal file
7
src/server/checker/expect/keys.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
|
||||||
|
|
||||||
|
export const MATCHER_KEY_SET: ReadonlySet<string> = new Set<string>(MatcherKeys);
|
||||||
|
|
||||||
|
export const ContentExtractorKeys = ["css", "json", "xpath"] as const;
|
||||||
|
|
||||||
|
export const CONTENT_EXTRACTOR_KEY_SET: ReadonlySet<string> = new Set<string>(ContentExtractorKeys);
|
||||||
50
src/server/checker/expect/normalize.ts
Normal file
50
src/server/checker/expect/normalize.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import { resolveContentExpectations } from "./content";
|
||||||
|
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
|
||||||
|
import { isValueMatcherObject, isValueMatcherPrimitive, resolveValueExpectation } from "./value";
|
||||||
|
|
||||||
|
type ExpectRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function compactExpect(original: ExpectRecord, overrides: ExpectRecord): ExpectRecord {
|
||||||
|
const result: ExpectRecord = {};
|
||||||
|
for (const [key, value] of Object.entries(original)) {
|
||||||
|
if (value !== undefined) result[key] = value;
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
if (value !== undefined) result[key] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeContent(value: unknown): unknown {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!Array.isArray(value)) return value;
|
||||||
|
return (value as unknown[]).map((entry): unknown => {
|
||||||
|
if (!canNormalizeContentEntry(entry)) return entry;
|
||||||
|
const resolved = resolveContentExpectations([entry] as never);
|
||||||
|
return resolved?.[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKeyed(value: unknown): unknown {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!isPlainObject(value)) return value;
|
||||||
|
return Object.entries(value as ExpectRecord).map(([key, item]) => ({ key, matcher: normalizeValue(item) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeValue(value: unknown): unknown {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (isValueMatcherPrimitive(value) || isValueMatcherObject(value)) return resolveValueExpectation(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canNormalizeContentEntry(value: unknown): boolean {
|
||||||
|
if (!isPlainObject(value)) return false;
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
const extractorKeys = keys.filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
|
||||||
|
const matcherKeys = keys.filter((key) => MATCHER_KEY_SET.has(key));
|
||||||
|
if (extractorKeys.length === 0) return matcherKeys.length > 0 && matcherKeys.length === keys.length;
|
||||||
|
if (extractorKeys.length !== 1 || matcherKeys.length > 0 || keys.length !== 1) return false;
|
||||||
|
return isPlainObject((value as ExpectRecord)[extractorKeys[0]!]);
|
||||||
|
}
|
||||||
151
src/server/checker/expect/redos.ts
Normal file
151
src/server/checker/expect/redos.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
export function isUnsafeRegex(pattern: string): boolean {
|
||||||
|
const groups = findQuantifiedGroups(pattern);
|
||||||
|
return groups.some((group) => containsQuantifier(group) || containsOverlappingAlternation(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsOverlappingAlternation(pattern: string): boolean {
|
||||||
|
const branches = splitTopLevelAlternation(stripGroupPrefix(pattern));
|
||||||
|
if (branches.length < 2) return false;
|
||||||
|
|
||||||
|
for (let i = 0; i < branches.length; i++) {
|
||||||
|
const current = branches[i]!;
|
||||||
|
if (current === "") continue;
|
||||||
|
for (let j = i + 1; j < branches.length; j++) {
|
||||||
|
const next = branches[j]!;
|
||||||
|
if (next === "") continue;
|
||||||
|
if (current === next || current.startsWith(next) || next.startsWith(current)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsQuantifier(pattern: string): boolean {
|
||||||
|
const input = stripGroupPrefix(pattern);
|
||||||
|
let inCharClass = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const char = input[i]!;
|
||||||
|
if (isEscaped(input, i)) continue;
|
||||||
|
if (char === "[") {
|
||||||
|
inCharClass = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "]") {
|
||||||
|
inCharClass = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCharClass) continue;
|
||||||
|
if (char === "*" || char === "+" || char === "?") return true;
|
||||||
|
if (char === "{" && readQuantifierBody(input, i) !== null) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findQuantifiedGroups(pattern: string): string[] {
|
||||||
|
const groups: string[] = [];
|
||||||
|
const stack: number[] = [];
|
||||||
|
let inCharClass = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const char = pattern[i]!;
|
||||||
|
if (isEscaped(pattern, i)) continue;
|
||||||
|
if (char === "[") {
|
||||||
|
inCharClass = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "]") {
|
||||||
|
inCharClass = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCharClass) continue;
|
||||||
|
|
||||||
|
if (char === "(") {
|
||||||
|
stack.push(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ")") {
|
||||||
|
const start = stack.pop();
|
||||||
|
if (start === undefined) continue;
|
||||||
|
if (hasRepeatingQuantifierAt(pattern, i + 1)) {
|
||||||
|
groups.push(pattern.slice(start + 1, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRepeatingQuantifierAt(pattern: string, index: number): boolean {
|
||||||
|
const char = pattern[index];
|
||||||
|
if (char === "*" || char === "+") return true;
|
||||||
|
if (char !== "{") return false;
|
||||||
|
|
||||||
|
const body = readQuantifierBody(pattern, index);
|
||||||
|
if (body === null) return false;
|
||||||
|
const parts = body.split(",");
|
||||||
|
if (parts.length === 1) return Number(parts[0]) > 1;
|
||||||
|
if (parts[1] === "") return true;
|
||||||
|
return Number(parts[1]) > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEscaped(pattern: string, index: number): boolean {
|
||||||
|
let slashCount = 0;
|
||||||
|
for (let i = index - 1; i >= 0 && pattern[i] === "\\"; i--) {
|
||||||
|
slashCount++;
|
||||||
|
}
|
||||||
|
return slashCount % 2 === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readQuantifierBody(pattern: string, index: number): null | string {
|
||||||
|
const end = pattern.indexOf("}", index + 1);
|
||||||
|
if (end === -1) return null;
|
||||||
|
|
||||||
|
const body = pattern.slice(index + 1, end);
|
||||||
|
return /^\d+(?:,\d*)?$/.test(body) ? body : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTopLevelAlternation(pattern: string): string[] {
|
||||||
|
const branches: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
let depth = 0;
|
||||||
|
let inCharClass = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const char = pattern[i]!;
|
||||||
|
if (isEscaped(pattern, i)) continue;
|
||||||
|
if (char === "[") {
|
||||||
|
inCharClass = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "]") {
|
||||||
|
inCharClass = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCharClass) continue;
|
||||||
|
if (char === "(") {
|
||||||
|
depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ")") {
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "|" && depth === 0) {
|
||||||
|
branches.push(pattern.slice(start, i));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
branches.push(pattern.slice(start));
|
||||||
|
return branches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripGroupPrefix(pattern: string): string {
|
||||||
|
if (pattern.startsWith("?:") || pattern.startsWith("?=") || pattern.startsWith("?!")) return pattern.slice(2);
|
||||||
|
if (pattern.startsWith("?<=") || pattern.startsWith("?<!")) return pattern.slice(3);
|
||||||
|
|
||||||
|
const namedCapture = /^\?<[^>]+>/.exec(pattern);
|
||||||
|
return namedCapture ? pattern.slice(namedCapture[0].length) : pattern;
|
||||||
|
}
|
||||||
27
src/server/checker/expect/status.ts
Normal file
27
src/server/checker/expect/status.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { isNumber } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { ExpectationResult } from "./types";
|
||||||
|
|
||||||
|
import { mismatchFailure } from "./failure";
|
||||||
|
|
||||||
|
export function checkStatusCode(statusCode: number, allowed: Array<number | string>): ExpectationResult {
|
||||||
|
const matched = allowed.some((pattern) => {
|
||||||
|
if (isNumber(pattern)) return statusCode === pattern;
|
||||||
|
const base = parseInt(pattern[0]!, 10) * 100;
|
||||||
|
return statusCode >= base && statusCode < base + 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure(
|
||||||
|
"status",
|
||||||
|
"status",
|
||||||
|
allowed,
|
||||||
|
statusCode,
|
||||||
|
`status ${statusCode} not in [${allowed.join(", ")}]`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
86
src/server/checker/expect/types.ts
Normal file
86
src/server/checker/expect/types.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { CheckFailure, JsonValue } from "../types";
|
||||||
|
|
||||||
|
export interface ContentCssExpectation {
|
||||||
|
attr?: string;
|
||||||
|
kind: "css";
|
||||||
|
matcher: ValueExpectation;
|
||||||
|
selector: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentExpectation =
|
||||||
|
| ContentCssExpectation
|
||||||
|
| ContentJsonExpectation
|
||||||
|
| ContentValueExpectation
|
||||||
|
| ContentXpathExpectation;
|
||||||
|
|
||||||
|
export type ContentExpectations = ContentExpectation[];
|
||||||
|
|
||||||
|
export interface ContentJsonExpectation {
|
||||||
|
kind: "json";
|
||||||
|
matcher: ValueExpectation;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentValueExpectation {
|
||||||
|
kind: "value";
|
||||||
|
matcher: ValueExpectation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentXpathExpectation {
|
||||||
|
kind: "xpath";
|
||||||
|
matcher: ValueExpectation;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpectationResult {
|
||||||
|
failure: CheckFailure | null;
|
||||||
|
matched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyedExpectation {
|
||||||
|
key: string;
|
||||||
|
matcher: ValueExpectation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeyedExpectations = KeyedExpectation[];
|
||||||
|
|
||||||
|
export interface RawContentCssExpectation extends ValueMatcher {
|
||||||
|
attr?: string;
|
||||||
|
selector: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RawContentExpectation =
|
||||||
|
| ValueMatcher
|
||||||
|
| { css: RawContentCssExpectation }
|
||||||
|
| { json: RawContentJsonExpectation }
|
||||||
|
| { xpath: RawContentXpathExpectation };
|
||||||
|
|
||||||
|
export type RawContentExpectations = RawContentExpectation[];
|
||||||
|
|
||||||
|
export interface RawContentJsonExpectation extends ValueMatcher {
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawContentXpathExpectation extends ValueMatcher {
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RawKeyedExpectations = Record<string, RawValueExpectation>;
|
||||||
|
|
||||||
|
export type RawValueExpectation = ValueMatcher | ValueMatcherPrimitive;
|
||||||
|
|
||||||
|
export type ValueExpectation = ValueMatcher;
|
||||||
|
|
||||||
|
export interface ValueMatcher {
|
||||||
|
contains?: string;
|
||||||
|
empty?: boolean;
|
||||||
|
equals?: JsonValue;
|
||||||
|
exists?: boolean;
|
||||||
|
gt?: number;
|
||||||
|
gte?: number;
|
||||||
|
lt?: number;
|
||||||
|
lte?: number;
|
||||||
|
regex?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValueMatcherPrimitive = boolean | null | number | string;
|
||||||
322
src/server/checker/expect/validate.ts
Normal file
322
src/server/checker/expect/validate.ts
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import { DOMParser } from "@xmldom/xmldom";
|
||||||
|
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
|
||||||
|
import * as xpath from "xpath";
|
||||||
|
|
||||||
|
import type { ConfigValidationIssue } from "../schema/issues";
|
||||||
|
import type { JsonValue } from "../types";
|
||||||
|
|
||||||
|
import { issue, joinPath } from "../schema/issues";
|
||||||
|
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
|
||||||
|
import { isUnsafeRegex } from "./redos";
|
||||||
|
import { isValueMatcherPrimitive } from "./value";
|
||||||
|
|
||||||
|
export function isJsonValue(value: unknown): value is JsonValue {
|
||||||
|
if (value === null) return true;
|
||||||
|
if (isString(value) || isBoolean(value)) return true;
|
||||||
|
if (isNumber(value)) return Number.isFinite(value);
|
||||||
|
if (Array.isArray(value)) return value.every(isJsonValue);
|
||||||
|
if (isPlainObject(value)) return Object.values(value).every(isJsonValue);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return isPlainObject(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateJsonPath(path: string, expectationPath: string, targetName?: string): ConfigValidationIssue[] {
|
||||||
|
if (!path.startsWith("$.") || path.length <= 2) {
|
||||||
|
return [
|
||||||
|
issue("invalid-jsonpath", joinPath(expectationPath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const segments = path.slice(2).split(".");
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (seg === "") {
|
||||||
|
issues.push(issue("invalid-jsonpath", joinPath(expectationPath, "path"), "包含空段", targetName));
|
||||||
|
}
|
||||||
|
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||||
|
if (bracketMatch?.[1]!.trim() === "") {
|
||||||
|
issues.push(issue("invalid-jsonpath", joinPath(expectationPath, "path"), "数组访问缺少属性名", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRawContentExpectations(
|
||||||
|
expectations: unknown,
|
||||||
|
path: string,
|
||||||
|
targetName?: string,
|
||||||
|
): ConfigValidationIssue[] {
|
||||||
|
if (!Array.isArray(expectations)) return [issue("invalid-type", path, "必须为数组", targetName)];
|
||||||
|
return expectations.flatMap((entry, index) => validateRawContentExpectation(entry, `${path}[${index}]`, targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRawKeyedExpectations(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
targetName?: string,
|
||||||
|
options?: { caseInsensitive?: boolean },
|
||||||
|
): ConfigValidationIssue[] {
|
||||||
|
if (Array.isArray(value)) return validateNormalizedKeyedExpectations(value, path, targetName, options);
|
||||||
|
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||||
|
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
if (options?.caseInsensitive) {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
const prev = seen.get(lower);
|
||||||
|
if (prev !== undefined) {
|
||||||
|
issues.push(issue("duplicate-key", joinPath(path, key), `与 "${prev}" 大小写归一化后重复`, targetName));
|
||||||
|
} else {
|
||||||
|
seen.set(lower, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
const itemPath = joinPath(path, key);
|
||||||
|
issues.push(...validateRawValueExpectation(item, itemPath, targetName));
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRawValueExpectation(
|
||||||
|
matcher: unknown,
|
||||||
|
path: string,
|
||||||
|
targetName?: string,
|
||||||
|
options: { requireAtLeastOne?: boolean } = {},
|
||||||
|
): ConfigValidationIssue[] {
|
||||||
|
const requireAtLeastOne = options.requireAtLeastOne ?? true;
|
||||||
|
if (isValueMatcherPrimitive(matcher)) return [];
|
||||||
|
if (Array.isArray(matcher)) {
|
||||||
|
return [
|
||||||
|
issue(
|
||||||
|
"invalid-type",
|
||||||
|
path,
|
||||||
|
"必须为 primitive 原始值或 matcher 对象;如需数组 equals 匹配应写成 {equals: [...]}",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!isPlainRecord(matcher))
|
||||||
|
return [issue("invalid-type", path, "必须为 primitive 原始值或 matcher 对象", targetName)];
|
||||||
|
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
let found = 0;
|
||||||
|
for (const [key, value] of Object.entries(matcher)) {
|
||||||
|
if (!MATCHER_KEY_SET.has(key)) {
|
||||||
|
issues.push(issue("unknown-matcher", joinPath(path, key), "是未知 matcher", targetName));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value === undefined) continue;
|
||||||
|
found++;
|
||||||
|
issues.push(...validateMatcherValue(key, value, joinPath(path, key), targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireAtLeastOne && found === 0) {
|
||||||
|
issues.push(issue("empty-matcher", path, "必须包含至少一个合法 matcher", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matcher["exists"] === false && found > 1) {
|
||||||
|
issues.push(issue("invalid-value", joinPath(path, "exists"), "exists:false 不能与其他 matcher 组合", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCssExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||||
|
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
|
||||||
|
if (!isString(expectation["selector"]) || expectation["selector"].trim() === "") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
|
||||||
|
}
|
||||||
|
if ("attr" in expectation && !isString(expectation["attr"])) {
|
||||||
|
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
|
||||||
|
}
|
||||||
|
issues.push(...validateExtractorMatcher(expectation, new Set(["attr", "selector"]), path, targetName));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExtractorMatcher(
|
||||||
|
expectation: Record<string, unknown>,
|
||||||
|
allowedFields: Set<string>,
|
||||||
|
path: string,
|
||||||
|
targetName?: string,
|
||||||
|
): ConfigValidationIssue[] {
|
||||||
|
const matcher: Record<string, unknown> = {};
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
for (const [key, value] of Object.entries(expectation)) {
|
||||||
|
if (allowedFields.has(key)) continue;
|
||||||
|
matcher[key] = value;
|
||||||
|
}
|
||||||
|
issues.push(...validateRawValueExpectation(matcher, path, targetName, { requireAtLeastOne: false }));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateJsonExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||||
|
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
|
||||||
|
if (!isString(expectation["path"])) {
|
||||||
|
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
|
||||||
|
} else {
|
||||||
|
issues.push(...validateJsonPath(expectation["path"], path, targetName));
|
||||||
|
}
|
||||||
|
issues.push(...validateExtractorMatcher(expectation, new Set(["path"]), path, targetName));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMatcherValue(key: string, value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||||
|
switch (key) {
|
||||||
|
case "contains":
|
||||||
|
return isString(value) ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||||
|
case "empty":
|
||||||
|
case "exists":
|
||||||
|
return isBoolean(value) ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
|
||||||
|
case "equals":
|
||||||
|
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
|
||||||
|
case "gt":
|
||||||
|
case "gte":
|
||||||
|
case "lt":
|
||||||
|
case "lte":
|
||||||
|
return isNumber(value) && Number.isFinite(value)
|
||||||
|
? []
|
||||||
|
: [issue("invalid-type", path, "必须为有限数字", targetName)];
|
||||||
|
case "regex":
|
||||||
|
if (!isString(value)) return [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||||
|
try {
|
||||||
|
new RegExp(value);
|
||||||
|
} catch {
|
||||||
|
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
||||||
|
}
|
||||||
|
return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
|
||||||
|
default:
|
||||||
|
return [issue("unknown-matcher", path, "是未知 matcher", targetName)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNormalizedContentExpectation(
|
||||||
|
expectation: Record<string, unknown>,
|
||||||
|
path: string,
|
||||||
|
targetName?: string,
|
||||||
|
): ConfigValidationIssue[] {
|
||||||
|
const kind = expectation["kind"];
|
||||||
|
const matcherPath = joinPath(path, "matcher");
|
||||||
|
const issues = validateRawValueExpectation(expectation["matcher"], matcherPath, targetName);
|
||||||
|
switch (kind) {
|
||||||
|
case "css":
|
||||||
|
if (!isString(expectation["selector"]) || expectation["selector"].trim() === "") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
|
||||||
|
}
|
||||||
|
if ("attr" in expectation && !isString(expectation["attr"])) {
|
||||||
|
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
case "json":
|
||||||
|
return isString(expectation["path"])
|
||||||
|
? [...issues, ...validateJsonPath(expectation["path"], path, targetName)]
|
||||||
|
: [...issues, issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName)];
|
||||||
|
case "value":
|
||||||
|
return issues;
|
||||||
|
case "xpath":
|
||||||
|
return isString(expectation["path"])
|
||||||
|
? [...issues, ...validateXpathExpectation({ path: expectation["path"] }, path, targetName)]
|
||||||
|
: [...issues, issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName)];
|
||||||
|
default:
|
||||||
|
return [...issues, issue("invalid-type", joinPath(path, "kind"), "必须为 value、json、css 或 xpath", targetName)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNormalizedKeyedExpectations(
|
||||||
|
value: unknown[],
|
||||||
|
path: string,
|
||||||
|
targetName?: string,
|
||||||
|
options?: { caseInsensitive?: boolean },
|
||||||
|
): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const itemPath = `${path}[${i}]`;
|
||||||
|
const item = value[i];
|
||||||
|
if (!isPlainRecord(item)) {
|
||||||
|
issues.push(issue("invalid-type", itemPath, "必须为对象", targetName));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isString(item["key"])) {
|
||||||
|
issues.push(issue("invalid-type", joinPath(itemPath, "key"), "必须为字符串", targetName));
|
||||||
|
} else if (options?.caseInsensitive) {
|
||||||
|
const normalized = item["key"].toLowerCase();
|
||||||
|
const prev = seen.get(normalized);
|
||||||
|
if (prev !== undefined) {
|
||||||
|
issues.push(issue("duplicate-key", joinPath(itemPath, "key"), `与 "${prev}" 大小写归一化后重复`, targetName));
|
||||||
|
} else {
|
||||||
|
seen.set(normalized, item["key"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues.push(...validateRawValueExpectation(item["matcher"], joinPath(itemPath, "matcher"), targetName));
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRawContentExpectation(
|
||||||
|
expectation: unknown,
|
||||||
|
path: string,
|
||||||
|
targetName?: string,
|
||||||
|
): ConfigValidationIssue[] {
|
||||||
|
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||||
|
if (isString(expectation["kind"])) return validateNormalizedContentExpectation(expectation, path, targetName);
|
||||||
|
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const extractors = Object.keys(expectation).filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
|
||||||
|
const directMatchers = Object.keys(expectation).filter((key) => MATCHER_KEY_SET.has(key));
|
||||||
|
|
||||||
|
for (const key of Object.keys(expectation)) {
|
||||||
|
if (!MATCHER_KEY_SET.has(key) && !CONTENT_EXTRACTOR_KEY_SET.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractors.length > 1) {
|
||||||
|
issues.push(
|
||||||
|
issue("multiple-content-expectations", path, "一条 expectation 不能同时包含多个 extractor", targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (extractors.length === 1 && directMatchers.length > 0) {
|
||||||
|
issues.push(issue("invalid-content-expectation", path, "直接 matcher 不能与 extractor 混用", targetName));
|
||||||
|
}
|
||||||
|
if (issues.length > 0) return issues;
|
||||||
|
|
||||||
|
if (extractors.length === 0) return validateRawValueExpectation(expectation, path, targetName);
|
||||||
|
|
||||||
|
const extractor = extractors[0]!;
|
||||||
|
switch (extractor) {
|
||||||
|
case "css":
|
||||||
|
return validateCssExpectation(expectation["css"], joinPath(path, "css"), targetName);
|
||||||
|
case "json":
|
||||||
|
return validateJsonExpectation(expectation["json"], joinPath(path, "json"), targetName);
|
||||||
|
case "xpath":
|
||||||
|
return validateXpathExpectation(expectation["xpath"], joinPath(path, "xpath"), targetName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateXpathExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||||
|
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
|
||||||
|
if (!isString(expectation["path"]) || expectation["path"].trim() === "") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
|
||||||
|
xpath.select(expectation["path"], doc as unknown as Node);
|
||||||
|
} catch {
|
||||||
|
issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues.push(...validateExtractorMatcher(expectation, new Set(["path"]), path, targetName));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
140
src/server/checker/expect/value.ts
Normal file
140
src/server/checker/expect/value.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { CheckFailure } from "../types";
|
||||||
|
import type { ExpectationResult, RawValueExpectation, ValueExpectation, ValueMatcher } from "./types";
|
||||||
|
|
||||||
|
import { mismatchFailure } from "./failure";
|
||||||
|
import { MATCHER_KEY_SET } from "./keys";
|
||||||
|
|
||||||
|
export function applyValueMatcher(
|
||||||
|
actual: unknown,
|
||||||
|
matcher: ValueMatcher,
|
||||||
|
options: { stringifyNonString?: boolean } = {},
|
||||||
|
): boolean {
|
||||||
|
for (const [key, expected] of Object.entries(matcher)) {
|
||||||
|
if (expected === undefined) continue;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "contains":
|
||||||
|
if (!stringValue(actual, options).includes(expected as string)) return false;
|
||||||
|
break;
|
||||||
|
case "empty": {
|
||||||
|
const empty = isEmptyValue(actual);
|
||||||
|
if (expected !== empty) 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 (!compareNumber(actual, expected as number, (left, right) => left > right)) return false;
|
||||||
|
break;
|
||||||
|
case "gte":
|
||||||
|
if (!compareNumber(actual, expected as number, (left, right) => left >= right)) return false;
|
||||||
|
break;
|
||||||
|
case "lt":
|
||||||
|
if (!compareNumber(actual, expected as number, (left, right) => left < right)) return false;
|
||||||
|
break;
|
||||||
|
case "lte":
|
||||||
|
if (!compareNumber(actual, expected as number, (left, right) => left <= right)) return false;
|
||||||
|
break;
|
||||||
|
case "regex":
|
||||||
|
if (!new RegExp(expected as string).test(stringValue(actual, options))) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkValueExpectation(
|
||||||
|
actual: unknown,
|
||||||
|
expectation: undefined | ValueExpectation,
|
||||||
|
options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean },
|
||||||
|
): ExpectationResult {
|
||||||
|
if (expectation === undefined) return { failure: null, matched: true };
|
||||||
|
if (applyValueMatcher(actual, expectation, { stringifyNonString: options.stringifyNonString })) {
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure(
|
||||||
|
options.phase,
|
||||||
|
options.path,
|
||||||
|
displayValueExpectation(expectation),
|
||||||
|
actual,
|
||||||
|
options.message ?? `${options.path} mismatch`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayValueExpectation(expectation: ValueExpectation): unknown {
|
||||||
|
const entries = Object.entries(expectation).filter(([, value]) => value !== undefined);
|
||||||
|
if (entries.length === 1 && entries[0]?.[0] === "equals") return entries[0][1];
|
||||||
|
return expectation;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (current === null || current === undefined) return undefined;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValueMatcherObject(value: unknown): value is ValueMatcher {
|
||||||
|
return isPlainObject(value) && Object.keys(value).some((key) => MATCHER_KEY_SET.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValueMatcherPrimitive(value: unknown): value is boolean | null | number | string {
|
||||||
|
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveValueExpectation(raw: RawValueExpectation): ValueExpectation;
|
||||||
|
export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation;
|
||||||
|
export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation {
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
if (isValueMatcherObject(raw)) return raw;
|
||||||
|
return { equals: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNumber(
|
||||||
|
actual: unknown,
|
||||||
|
expected: number,
|
||||||
|
compare: (actual: number, expected: number) => boolean,
|
||||||
|
): boolean {
|
||||||
|
const value = Number(actual);
|
||||||
|
return Number.isFinite(value) && compare(value, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyValue(value: unknown): boolean {
|
||||||
|
return isNil(value) || value === "" || (Array.isArray(value) && value.length === 0) || isEmptyObject(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(actual: unknown, options: { stringifyNonString?: boolean }): string {
|
||||||
|
if (!options.stringifyNonString || typeof actual === "string") return String(actual);
|
||||||
|
if (actual !== null && typeof actual === "object") return JSON.stringify(actual);
|
||||||
|
return String(actual);
|
||||||
|
}
|
||||||
42
src/server/checker/normalizer.ts
Normal file
42
src/server/checker/normalizer.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { CheckerRegistry } from "./runner/registry";
|
||||||
|
import type { ConfigValidationIssue } from "./schema/issues";
|
||||||
|
import type { AuthoringProbeConfig, NormalizedProbeConfig } from "./schema/types";
|
||||||
|
import type { RawTargetConfig } from "./types";
|
||||||
|
|
||||||
|
import { checkerRegistry } from "./runner";
|
||||||
|
import { resolveVariables } from "./variables";
|
||||||
|
|
||||||
|
export function normalizeAuthoringConfig(
|
||||||
|
config: unknown,
|
||||||
|
registry: CheckerRegistry = checkerRegistry,
|
||||||
|
): {
|
||||||
|
config: unknown;
|
||||||
|
issues: ConfigValidationIssue[];
|
||||||
|
} {
|
||||||
|
const variableResult = resolveVariables(config);
|
||||||
|
if (!isPlainObject(variableResult.config)) {
|
||||||
|
return variableResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = { ...(variableResult.config as Record<string, unknown>) };
|
||||||
|
delete normalized["variables"];
|
||||||
|
if (Array.isArray(normalized["targets"])) {
|
||||||
|
normalized["targets"] = normalized["targets"].map((target) => normalizeTarget(target, registry));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: normalized, issues: variableResult.issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTarget(target: unknown, registry: CheckerRegistry): unknown {
|
||||||
|
if (!isPlainObject(target)) return target;
|
||||||
|
const result = { ...(target as RawTargetConfig) };
|
||||||
|
const type = result.type;
|
||||||
|
if (typeof type !== "string") return result;
|
||||||
|
const checker = registry?.tryGet(type);
|
||||||
|
if (!checker) return result;
|
||||||
|
return checker.normalize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { AuthoringProbeConfig, NormalizedProbeConfig };
|
||||||
318
src/server/checker/runner/cmd/execute.ts
Normal file
318
src/server/checker/runner/cmd/execute.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { isError } from "es-toolkit";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||||
|
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||||
|
import type { CommandTargetConfig, ResolvedCommandExpectConfig, ResolvedCommandTarget } from "./types";
|
||||||
|
|
||||||
|
import { checkContentExpectations } from "../../expect/content";
|
||||||
|
import { errorFailure } from "../../expect/failure";
|
||||||
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
|
import { parseSize } from "../../utils";
|
||||||
|
import { checkExitCode } from "./expect";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
|
import { commandCheckerSchemas } from "./schema";
|
||||||
|
import { validateCommandConfig } from "./validate";
|
||||||
|
|
||||||
|
const STDOUT_PREVIEW_MAX = 1024;
|
||||||
|
const STDERR_PREVIEW_MAX = 1024;
|
||||||
|
|
||||||
|
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
|
||||||
|
readonly configKey = "cmd";
|
||||||
|
|
||||||
|
readonly schemas = commandCheckerSchemas;
|
||||||
|
|
||||||
|
readonly type = "cmd";
|
||||||
|
|
||||||
|
buildDetail(observation: Record<string, unknown>): null | string {
|
||||||
|
const exitCode = observation["exitCode"];
|
||||||
|
return typeof exitCode === "number" ? `exitCode=${exitCode}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
let proc: ReturnType<typeof Bun.spawn>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
proc = Bun.spawn([t.cmd.exec, ...t.cmd.args], {
|
||||||
|
cwd: t.cmd.cwd,
|
||||||
|
env: t.cmd.env,
|
||||||
|
stderr: "pipe",
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
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.cmd.maxOutputBytes,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const exitCode = proc.exitCode ?? 1;
|
||||||
|
const stdoutPreview = truncatePreview(outputResult.stdout, STDOUT_PREVIEW_MAX);
|
||||||
|
const stderrPreview = truncatePreview(outputResult.stderr, STDERR_PREVIEW_MAX);
|
||||||
|
const observation: Record<string, unknown> = { error: null, exitCode, stderrPreview, stdoutPreview };
|
||||||
|
|
||||||
|
if (outputResult.exceeded) {
|
||||||
|
const message = `输出超过限制 ${t.cmd.maxOutputBytes} 字节`;
|
||||||
|
observation["error"] = message;
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("exitCode", "output", message),
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.signal.aborted) {
|
||||||
|
const message = `命令执行超时 (${t.timeoutMs}ms)`;
|
||||||
|
observation["error"] = message;
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("exitCode", "timeout", message),
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
||||||
|
if (!exitCodeResult.matched) {
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: exitCodeResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, {
|
||||||
|
message: "durationMs mismatch",
|
||||||
|
path: "durationMs",
|
||||||
|
phase: "duration",
|
||||||
|
});
|
||||||
|
if (!durationResult.matched) {
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: durationResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.expect?.stdout && t.expect.stdout.length > 0) {
|
||||||
|
const stdoutResult = checkContentExpectations(outputResult.stdout, t.expect.stdout, {
|
||||||
|
path: "stdout",
|
||||||
|
phase: "stdout",
|
||||||
|
});
|
||||||
|
if (!stdoutResult.matched) {
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: stdoutResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.expect?.stderr && t.expect.stderr.length > 0) {
|
||||||
|
const stderrResult = checkContentExpectations(outputResult.stderr, t.expect.stderr, {
|
||||||
|
path: "stderr",
|
||||||
|
phase: "stderr",
|
||||||
|
});
|
||||||
|
if (!stderrResult.matched) {
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: stderrResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget {
|
||||||
|
const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" };
|
||||||
|
|
||||||
|
const cwd = t.cmd.cwd ?? ".";
|
||||||
|
const resolvedCwd = resolve(context.configDir, cwd);
|
||||||
|
|
||||||
|
const maxOutputBytes = parseSize(t.cmd.maxOutputBytes ?? "100MB");
|
||||||
|
|
||||||
|
const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record<string, string>;
|
||||||
|
|
||||||
|
const expect = target.expect as ResolvedCommandExpectConfig | undefined;
|
||||||
|
const resolvedExpect: ResolvedCommandExpectConfig = expect
|
||||||
|
? {
|
||||||
|
...expect,
|
||||||
|
exitCode: expect.exitCode ?? [0],
|
||||||
|
}
|
||||||
|
: { exitCode: [0] };
|
||||||
|
|
||||||
|
return {
|
||||||
|
cmd: {
|
||||||
|
args: t.cmd.args ?? [],
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
env,
|
||||||
|
exec: t.cmd.exec,
|
||||||
|
maxOutputBytes,
|
||||||
|
},
|
||||||
|
description: null,
|
||||||
|
expect: resolvedExpect,
|
||||||
|
group: target.group ?? "default",
|
||||||
|
id: t.id,
|
||||||
|
intervalMs: context.defaultIntervalMs,
|
||||||
|
name: t.name ?? null,
|
||||||
|
timeoutMs: context.defaultTimeoutMs,
|
||||||
|
type: "cmd",
|
||||||
|
} satisfies ResolvedCommandTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(t: ResolvedCommandTarget): { config: string; target: string } {
|
||||||
|
const parts = [t.cmd.exec, ...t.cmd.args];
|
||||||
|
return {
|
||||||
|
config: JSON.stringify({
|
||||||
|
args: t.cmd.args,
|
||||||
|
cwd: t.cmd.cwd,
|
||||||
|
env: t.cmd.env,
|
||||||
|
exec: t.cmd.exec,
|
||||||
|
maxOutputBytes: t.cmd.maxOutputBytes,
|
||||||
|
}),
|
||||||
|
target: `exec ${parts.join(" ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(input: CheckerValidationInput) {
|
||||||
|
return validateCommandConfig(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncatePreview(text: string, maxLen: number): string {
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
return text.slice(0, maxLen);
|
||||||
|
}
|
||||||
19
src/server/checker/runner/cmd/expect.ts
Normal file
19
src/server/checker/runner/cmd/expect.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ExpectationResult } from "../../expect/types";
|
||||||
|
|
||||||
|
import { mismatchFailure } from "../../expect/failure";
|
||||||
|
|
||||||
|
export function checkExitCode(exitCode: number, allowed: number[]): ExpectationResult {
|
||||||
|
if (!allowed.includes(exitCode)) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure(
|
||||||
|
"exitCode",
|
||||||
|
"exitCode",
|
||||||
|
allowed,
|
||||||
|
exitCode,
|
||||||
|
`exitCode ${exitCode} not in [${allowed.join(", ")}]`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
1
src/server/checker/runner/cmd/index.ts
Normal file
1
src/server/checker/runner/cmd/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { CommandChecker } from "./execute";
|
||||||
19
src/server/checker/runner/cmd/normalize.ts
Normal file
19
src/server/checker/runner/cmd/normalize.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeContent, normalizeValue } from "../../expect/normalize";
|
||||||
|
|
||||||
|
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
|
||||||
|
const raw = target.expect as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...target,
|
||||||
|
expect: compactExpect(raw, {
|
||||||
|
durationMs: normalizeValue(raw["durationMs"]),
|
||||||
|
exitCode: raw["exitCode"],
|
||||||
|
stderr: normalizeContent(raw["stderr"]),
|
||||||
|
stdout: normalizeContent(raw["stdout"]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/server/checker/runner/cmd/schema.ts
Normal file
54
src/server/checker/runner/cmd/schema.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
import type { CheckerSchemas } from "../types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createAuthoringContentExpectationsSchema,
|
||||||
|
createAuthoringValueExpectationSchema,
|
||||||
|
createNormalizedContentExpectationsSchema,
|
||||||
|
createNormalizedValueExpectationSchema,
|
||||||
|
sizeSchema,
|
||||||
|
stringMapSchema,
|
||||||
|
} from "../../schema/fragments";
|
||||||
|
|
||||||
|
export const commandCheckerSchemas: CheckerSchemas = {
|
||||||
|
authoring: {
|
||||||
|
config: createCommandConfigSchema(),
|
||||||
|
expect: createCommandExpectSchema("authoring"),
|
||||||
|
},
|
||||||
|
normalized: {
|
||||||
|
config: createCommandConfigSchema(),
|
||||||
|
expect: createCommandExpectSchema("normalized"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createCommandConfigSchema() {
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
args: Type.Optional(Type.Array(Type.String())),
|
||||||
|
cwd: Type.Optional(Type.String()),
|
||||||
|
env: Type.Optional(stringMapSchema),
|
||||||
|
exec: Type.String({ minLength: 1 }),
|
||||||
|
maxOutputBytes: Type.Optional(sizeSchema),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCommandExpectSchema(kind: "authoring" | "normalized") {
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
durationMs: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||||
|
),
|
||||||
|
exitCode: Type.Optional(Type.Array(Type.Integer())),
|
||||||
|
stderr: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||||
|
),
|
||||||
|
stdout: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/server/checker/runner/cmd/types.ts
Normal file
47
src/server/checker/runner/cmd/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type {
|
||||||
|
ContentExpectations,
|
||||||
|
RawContentExpectations,
|
||||||
|
RawValueExpectation,
|
||||||
|
ValueExpectation,
|
||||||
|
} from "../../expect/types";
|
||||||
|
import type { ResolvedTargetBase } from "../../types";
|
||||||
|
|
||||||
|
export interface CommandTargetConfig {
|
||||||
|
args?: string[];
|
||||||
|
cwd?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
exec: string;
|
||||||
|
maxOutputBytes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawCommandExpectConfig {
|
||||||
|
durationMs?: RawValueExpectation;
|
||||||
|
exitCode?: number[];
|
||||||
|
stderr?: RawContentExpectations;
|
||||||
|
stdout?: RawContentExpectations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCommandConfig {
|
||||||
|
args: string[];
|
||||||
|
cwd: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
exec: string;
|
||||||
|
maxOutputBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCommandExpectConfig {
|
||||||
|
durationMs?: ValueExpectation;
|
||||||
|
exitCode: number[];
|
||||||
|
stderr?: ContentExpectations;
|
||||||
|
stdout?: ContentExpectations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCommandTarget extends ResolvedTargetBase {
|
||||||
|
cmd: ResolvedCommandConfig;
|
||||||
|
expect?: ResolvedCommandExpectConfig;
|
||||||
|
group: string;
|
||||||
|
intervalMs: number;
|
||||||
|
name: null | string;
|
||||||
|
timeoutMs: number;
|
||||||
|
type: "cmd";
|
||||||
|
}
|
||||||
79
src/server/checker/runner/cmd/validate.ts
Normal file
79
src/server/checker/runner/cmd/validate.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { isNumber, isString } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||||
|
import type { CheckerValidationInput } from "../types";
|
||||||
|
|
||||||
|
import { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate";
|
||||||
|
import { issue, joinPath } from "../../schema/issues";
|
||||||
|
import { parseSize } from "../../utils";
|
||||||
|
|
||||||
|
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < input.targets.length; i++) {
|
||||||
|
const target = input.targets[i] as unknown;
|
||||||
|
if (!isPlainRecord(target)) continue;
|
||||||
|
if (target["type"] !== "cmd") continue;
|
||||||
|
issues.push(...validateCommandTarget(target, `targets[${i}]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||||
|
if (isString(target["name"])) return target["name"];
|
||||||
|
return isString(target["id"]) ? target["id"] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSizeInput(value: unknown): value is number | string {
|
||||||
|
return isNumber(value) || isString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
const expect = target["expect"];
|
||||||
|
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const expectPath = joinPath(path, "expect");
|
||||||
|
|
||||||
|
if (expect["stdout"] !== undefined) {
|
||||||
|
issues.push(...validateRawContentExpectations(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
|
||||||
|
}
|
||||||
|
if (expect["stderr"] !== undefined) {
|
||||||
|
issues.push(...validateRawContentExpectations(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
|
||||||
|
}
|
||||||
|
if (expect["durationMs"] !== undefined) {
|
||||||
|
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCommandTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
const cmd = target["cmd"];
|
||||||
|
if (!isPlainRecord(cmd)) {
|
||||||
|
issues.push(issue("required", joinPath(path, "cmd"), "缺少 cmd.exec 字段", targetName));
|
||||||
|
issues.push(...validateCommandExpect(target, path));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
if (!isString(cmd["exec"]) || cmd["exec"].trim() === "") {
|
||||||
|
issues.push(issue("required", joinPath(joinPath(path, "cmd"), "exec"), "缺少 cmd.exec 字段", targetName));
|
||||||
|
}
|
||||||
|
if (isSizeInput(cmd["maxOutputBytes"])) {
|
||||||
|
issues.push(
|
||||||
|
...validateSizeValue(cmd["maxOutputBytes"], joinPath(joinPath(path, "cmd"), "maxOutputBytes"), targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
issues.push(...validateCommandExpect(target, path));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||||
|
try {
|
||||||
|
parseSize(value);
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/server/checker/runner/cpu/calculate.ts
Normal file
111
src/server/checker/runner/cpu/calculate.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { cpus } from "node:os";
|
||||||
|
|
||||||
|
import type { CpuCoreSnapshot, CpuStats } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据两次 CPU times 快照计算使用率统计。
|
||||||
|
*
|
||||||
|
* - usagePercent = 100 - idlePercent(互补关系,恒等于 100)
|
||||||
|
* - idlePercent = 所有核心 idle delta 之和 ÷ 所有核心 total delta 之和 × 100
|
||||||
|
* - maxCoreUsagePercent / minCoreUsagePercent 为单核心粒度的最高/最低使用率
|
||||||
|
* - 所有百分比范围 0-100,保留 1 位小数
|
||||||
|
*/
|
||||||
|
export function calculateCpuStats(before: CpuCoreSnapshot[], after: CpuCoreSnapshot[]): CpuStats {
|
||||||
|
let totalIdleDelta = 0;
|
||||||
|
let totalDelta = 0;
|
||||||
|
const perCoreUsage: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < before.length; i++) {
|
||||||
|
const b = before[i]!.times;
|
||||||
|
const a = after[i]!.times;
|
||||||
|
|
||||||
|
const idleDelta = a.idle - b.idle;
|
||||||
|
const userDelta = a.user - b.user;
|
||||||
|
const niceDelta = a.nice - b.nice;
|
||||||
|
const sysDelta = a.sys - b.sys;
|
||||||
|
const irqDelta = a.irq - b.irq;
|
||||||
|
|
||||||
|
const coreTotalDelta = userDelta + niceDelta + sysDelta + idleDelta + irqDelta;
|
||||||
|
const coreIdleDelta = idleDelta;
|
||||||
|
|
||||||
|
totalIdleDelta += coreIdleDelta;
|
||||||
|
totalDelta += coreTotalDelta;
|
||||||
|
|
||||||
|
const coreUsagePercent = coreTotalDelta === 0 ? 0 : round1((1 - coreIdleDelta / coreTotalDelta) * 100);
|
||||||
|
perCoreUsage.push(coreUsagePercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idlePercent = totalDelta === 0 ? 0 : round1((totalIdleDelta / totalDelta) * 100);
|
||||||
|
const usagePercent = totalDelta === 0 ? 0 : round1(100 - idlePercent);
|
||||||
|
|
||||||
|
const maxCoreUsagePercent = Math.max(...perCoreUsage);
|
||||||
|
const minCoreUsagePercent = Math.min(...perCoreUsage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
idlePercent,
|
||||||
|
logicalCoreCount: before.length,
|
||||||
|
maxCoreUsagePercent,
|
||||||
|
minCoreUsagePercent,
|
||||||
|
perCoreUsagePercent: perCoreUsage,
|
||||||
|
usagePercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取当前 CPU 各核心 times 快照。
|
||||||
|
* 委托给 node:os 的 os.cpus(),便于测试时注入 mock。
|
||||||
|
*/
|
||||||
|
export function readCpuSnapshot(): CpuCoreSnapshot[] {
|
||||||
|
return cpus().map((cpu) => ({
|
||||||
|
times: {
|
||||||
|
idle: cpu.times.idle,
|
||||||
|
irq: cpu.times.irq,
|
||||||
|
nice: cpu.times.nice,
|
||||||
|
sys: cpu.times.sys,
|
||||||
|
user: cpu.times.user,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCpuSnapshots(before: CpuCoreSnapshot[], after: CpuCoreSnapshot[]): null | string {
|
||||||
|
if (before.length === 0 || after.length === 0) {
|
||||||
|
return "CPU 快照为空";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (before.length !== after.length) {
|
||||||
|
return `CPU 快照核心数不一致: before=${before.length}, after=${after.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < before.length; i++) {
|
||||||
|
const bTimes = before[i]!.times;
|
||||||
|
const aTimes = after[i]!.times;
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(bTimes)) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return `CPU 快照包含非有限值: before[${i}].times.${name}=${value}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries(aTimes)) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return `CPU 快照包含非有限值: after[${i}].times.${name}=${value}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleDelta = aTimes.idle - bTimes.idle;
|
||||||
|
const userDelta = aTimes.user - bTimes.user;
|
||||||
|
const niceDelta = aTimes.nice - bTimes.nice;
|
||||||
|
const sysDelta = aTimes.sys - bTimes.sys;
|
||||||
|
const irqDelta = aTimes.irq - bTimes.irq;
|
||||||
|
const coreTotalDelta = userDelta + niceDelta + sysDelta + idleDelta + irqDelta;
|
||||||
|
|
||||||
|
if (coreTotalDelta < 0) {
|
||||||
|
return `CPU 快照包含负数 delta: core[${i}] totalDelta=${coreTotalDelta}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
219
src/server/checker/runner/cpu/execute.ts
Normal file
219
src/server/checker/runner/cpu/execute.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||||
|
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||||
|
import type { CpuCoreSnapshot, CpuStats, CpuTargetConfig, ResolvedCpuExpectConfig, ResolvedCpuTarget } from "./types";
|
||||||
|
|
||||||
|
import { errorFailure } from "../../expect/failure";
|
||||||
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
|
import { parseDuration } from "../../utils";
|
||||||
|
import { calculateCpuStats, readCpuSnapshot, validateCpuSnapshots } from "./calculate";
|
||||||
|
import { checkIdlePercent, checkMaxCoreUsage, checkMinCoreUsage, checkUsagePercent } from "./expect";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
|
import { cpuCheckerSchemas } from "./schema";
|
||||||
|
import { validateCpuConfig } from "./validate";
|
||||||
|
|
||||||
|
const DEFAULT_SAMPLE_DURATION_MS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可注入的 CPU 快照读取函数,便于测试。
|
||||||
|
* 生产环境使用 node:os 的 os.cpus()。
|
||||||
|
*/
|
||||||
|
export type SnapshotReader = () => CpuCoreSnapshot[];
|
||||||
|
|
||||||
|
export class CpuChecker implements CheckerDefinition<ResolvedCpuTarget> {
|
||||||
|
readonly configKey = "cpu";
|
||||||
|
|
||||||
|
readonly schemas = cpuCheckerSchemas;
|
||||||
|
|
||||||
|
readonly type = "cpu";
|
||||||
|
|
||||||
|
constructor(private readonly readSnapshot: SnapshotReader = readCpuSnapshot) {}
|
||||||
|
|
||||||
|
buildDetail(observation: Record<string, unknown>): null | string {
|
||||||
|
const usage = observation["usagePercent"];
|
||||||
|
const usageStr = typeof usage === "number" ? formatNumber(usage) : "n/a";
|
||||||
|
const maxCore = observation["maxCoreUsagePercent"];
|
||||||
|
const maxStr = typeof maxCore === "number" ? formatNumber(maxCore) : "n/a";
|
||||||
|
const cores = observation["logicalCoreCount"];
|
||||||
|
const coresStr = typeof cores === "number" ? String(cores) : "?";
|
||||||
|
return `usage ${usageStr}%, max core ${maxStr}%, ${coresStr} cores`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(t: ResolvedCpuTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
let before: CpuCoreSnapshot[];
|
||||||
|
try {
|
||||||
|
before = this.readSnapshot();
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure(
|
||||||
|
"cpu",
|
||||||
|
"snapshot",
|
||||||
|
`CPU 快照读取失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 采样等待,支持 AbortSignal 取消
|
||||||
|
const aborted = await waitForDuration(t.cpu.sampleDurationMs, ctx.signal);
|
||||||
|
|
||||||
|
let after: CpuCoreSnapshot[];
|
||||||
|
if (aborted) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("cpu", "timeout", `CPU 采样超时 (${t.timeoutMs}ms)`),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
after = this.readSnapshot();
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure(
|
||||||
|
"cpu",
|
||||||
|
"snapshot",
|
||||||
|
`CPU 快照读取失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateCpuSnapshots(before, after);
|
||||||
|
if (validationError !== null) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("cpu", "snapshot", validationError),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = calculateCpuStats(before, after);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const result = checkStats(stats, t.expect, durationMs);
|
||||||
|
|
||||||
|
const observation: Record<string, unknown> = {
|
||||||
|
error: null,
|
||||||
|
idlePercent: stats.idlePercent,
|
||||||
|
logicalCoreCount: stats.logicalCoreCount,
|
||||||
|
maxCoreUsagePercent: stats.maxCoreUsagePercent,
|
||||||
|
minCoreUsagePercent: stats.minCoreUsagePercent,
|
||||||
|
usagePercent: stats.usagePercent,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (t.cpu.includePerCore) {
|
||||||
|
observation["perCoreUsagePercent"] = stats.perCoreUsagePercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: result.failure,
|
||||||
|
matched: result.matched,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCpuTarget {
|
||||||
|
const t = target as RawTargetConfig & { cpu: CpuTargetConfig; type: "cpu" };
|
||||||
|
|
||||||
|
const rawSampleDuration = t.cpu.sampleDuration;
|
||||||
|
const sampleDurationMs = rawSampleDuration ? parseDuration(rawSampleDuration) : DEFAULT_SAMPLE_DURATION_MS;
|
||||||
|
const includePerCore = t.cpu.includePerCore ?? false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu: { includePerCore, sampleDurationMs },
|
||||||
|
description: null,
|
||||||
|
expect: target.expect as ResolvedCpuExpectConfig | undefined,
|
||||||
|
group: target.group ?? "default",
|
||||||
|
id: t.id,
|
||||||
|
intervalMs: context.defaultIntervalMs,
|
||||||
|
name: t.name ?? null,
|
||||||
|
timeoutMs: context.defaultTimeoutMs,
|
||||||
|
type: "cpu",
|
||||||
|
} satisfies ResolvedCpuTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(t: ResolvedCpuTarget): { config: string; target: string } {
|
||||||
|
return {
|
||||||
|
config: JSON.stringify(t.cpu),
|
||||||
|
target: `cpu sample ${t.cpu.sampleDurationMs}ms`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(input: CheckerValidationInput) {
|
||||||
|
return validateCpuConfig(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStats(stats: CpuStats, expect: ResolvedCpuExpectConfig | undefined, durationMs: number) {
|
||||||
|
const usageResult = checkUsagePercent(stats.usagePercent, expect?.usagePercent);
|
||||||
|
if (!usageResult.matched) return usageResult;
|
||||||
|
const idleResult = checkIdlePercent(stats.idlePercent, expect?.idlePercent);
|
||||||
|
if (!idleResult.matched) return idleResult;
|
||||||
|
const maxCoreResult = checkMaxCoreUsage(stats.maxCoreUsagePercent, expect?.maxCoreUsagePercent);
|
||||||
|
if (!maxCoreResult.matched) return maxCoreResult;
|
||||||
|
const minCoreResult = checkMinCoreUsage(stats.minCoreUsagePercent, expect?.minCoreUsagePercent);
|
||||||
|
if (!minCoreResult.matched) return minCoreResult;
|
||||||
|
return checkValueExpectation(durationMs, expect?.durationMs, {
|
||||||
|
message: "durationMs mismatch",
|
||||||
|
path: "durationMs",
|
||||||
|
phase: "duration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待指定毫秒,支持 AbortSignal 取消。
|
||||||
|
* 返回 true 表示被中断(aborted),false 表示正常完成。
|
||||||
|
*/
|
||||||
|
async function waitForDuration(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||||
|
if (signal.aborted) return true;
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(false);
|
||||||
|
}, ms);
|
||||||
|
|
||||||
|
function onAbort() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
35
src/server/checker/runner/cpu/expect.ts
Normal file
35
src/server/checker/runner/cpu/expect.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ExpectationResult, ValueExpectation } from "../../expect/types";
|
||||||
|
|
||||||
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
|
|
||||||
|
export function checkIdlePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||||
|
return checkValueExpectation(actual, matcher, {
|
||||||
|
message: "CPU 空闲率不满足条件",
|
||||||
|
path: "idlePercent",
|
||||||
|
phase: "idle",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkMaxCoreUsage(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||||
|
return checkValueExpectation(actual, matcher, {
|
||||||
|
message: "单核心最大使用率不满足条件",
|
||||||
|
path: "maxCoreUsagePercent",
|
||||||
|
phase: "maxCoreUsage",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkMinCoreUsage(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||||
|
return checkValueExpectation(actual, matcher, {
|
||||||
|
message: "单核心最小使用率不满足条件",
|
||||||
|
path: "minCoreUsagePercent",
|
||||||
|
phase: "minCoreUsage",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkUsagePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||||
|
return checkValueExpectation(actual, matcher, {
|
||||||
|
message: "CPU 使用率不满足条件",
|
||||||
|
path: "usagePercent",
|
||||||
|
phase: "usage",
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user