feat: initial commit for HookPilot
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# HookPilot
|
||||||
|
|
||||||
|
面向**单管理员、单台 Linux 服务器**的 Webhook 脚本管理平台:在网页上创建多个 Webhook 接口,为每个接口配置参数映射和 Bash 脚本;外部应用调用接口后,平台在宿主机上执行脚本并记录结果。
|
||||||
|
|
||||||
|
**一个接口 = 调用地址 + 鉴权凭证 + 参数映射 + Bash 脚本 + 执行策略。**
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 单管理员登录、修改用户名与密码,不开放注册
|
||||||
|
- 接口管理:创建、编辑、复制、启用、停用、删除,支持分组
|
||||||
|
- 自定义接口标识(`/hooks/<标识>`),GET / POST,独立 Token(只保存哈希,创建时显示一次)
|
||||||
|
- 参数从 JSON、表单、Query 或指定 Header 提取,必填 / 默认值 / 字符串、数字、布尔、IP 校验,映射为 `HOOK_` 环境变量
|
||||||
|
- Bash 编辑器(行号、缩进、语法高亮、示例模板),保存即生成脚本版本
|
||||||
|
- 执行策略:工作目录、超时、同步 / 异步响应、接口并发数
|
||||||
|
- 在线测试:预览参数映射,或真实执行并实时查看输出
|
||||||
|
- 执行记录:来源、参数摘要(敏感参数掩码)、脚本版本、状态、耗时、退出码;stdout / stderr 增量查看
|
||||||
|
- 任务控制:取消排队、终止运行(清理整个进程组)、使用原版本和参数再次执行
|
||||||
|
- 系统设置:全局并发上限、日志保留天数、输出大小上限
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|---|---|
|
||||||
|
| 前端 | Vite 8 + Vue 3 + TypeScript + CodeMirror 6 |
|
||||||
|
| 后端 | Node.js 22 + TypeScript + Fastify 5 |
|
||||||
|
| 数据库 | SQLite(Node 内置 `node:sqlite`,无原生编译依赖) |
|
||||||
|
| 执行层 | 宿主机 Bash 子进程,独立进程组 |
|
||||||
|
| 部署 | systemd + HTTPS 反向代理 |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
server/ 后端(模块化单体)
|
||||||
|
src/
|
||||||
|
index.ts 入口:初始化数据目录、恢复任务、启动服务
|
||||||
|
app.ts Fastify 应用装配、静态文件托管
|
||||||
|
config.ts 环境变量配置
|
||||||
|
db.ts SQLite 访问与迁移
|
||||||
|
settings.ts 系统设置
|
||||||
|
cli.ts 命令行工具(重置密码)
|
||||||
|
auth/ 密码哈希、管理员与会话、登录限流
|
||||||
|
hooks/ 接口配置存储、参数提取与校验
|
||||||
|
executor/ 任务存储(持久化队列)、调度器、Bash 执行器
|
||||||
|
routes/ 管理 API 与 Webhook 入口
|
||||||
|
web/ 前端
|
||||||
|
src/
|
||||||
|
api/ 接口类型与请求封装
|
||||||
|
components/ 布局、脚本编辑器、输出查看器、弹窗等
|
||||||
|
views/ 页面(接口详情的五个标签页在 views/hook/)
|
||||||
|
deploy/ systemd 单元、环境变量示例、Nginx / Caddy 示例
|
||||||
|
docs/ 部署说明、Lucky 对接示例
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
需要 Node.js 22.13+ 和 Bash(Windows 下可使用 Git Bash,仅用于开发调试;进程组清理等行为以 Linux 为准)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 终端 1:后端(默认 http://127.0.0.1:8787,数据写入 ./data)
|
||||||
|
HOOKPILOT_ADMIN_PASSWORD=dev-password npm run dev:server
|
||||||
|
|
||||||
|
# 终端 2:前端开发服务器(http://localhost:5173,/api 与 /hooks 代理到后端)
|
||||||
|
npm run dev:web
|
||||||
|
```
|
||||||
|
|
||||||
|
首次启动若未设置 `HOOKPILOT_ADMIN_PASSWORD`,初始密码会随机生成并打印在后端日志中。
|
||||||
|
|
||||||
|
## 构建与运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # 构建 web/dist 与 server/dist
|
||||||
|
npm start # 后端同时托管前端静态文件
|
||||||
|
npm run typecheck # 前后端静态类型检查
|
||||||
|
```
|
||||||
|
|
||||||
|
生产部署见 [docs/deployment.md](docs/deployment.md),Lucky 对接见 [docs/lucky.md](docs/lucky.md)。
|
||||||
|
|
||||||
|
## 调用接口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://hooks.example.com/hooks/home-ip' \
|
||||||
|
-H 'Authorization: Bearer <token>' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"ip":"203.0.113.10"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Token 也可通过 `X-Hook-Token` 请求头或 `?token=` 传递。
|
||||||
|
|
||||||
|
| 结果 | 同步模式 | 异步模式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 已接收 | — | `202`,返回任务 ID |
|
||||||
|
| 脚本成功 | `200` | — |
|
||||||
|
| 脚本失败 / 超时 / 被终止 | `500` / `504` / `409` | — |
|
||||||
|
| Token 错误 / 参数无效 / 已停用 / 方法不符 | `401` / `400` / `403` / `405`(不执行脚本) | 同左 |
|
||||||
|
|
||||||
|
响应只包含任务 ID、状态与退出码,脚本输出只在管理界面查看。
|
||||||
|
|
||||||
|
## 脚本可用的环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `HOOK_*` | 参数映射得到的变量,名称由管理员配置 |
|
||||||
|
| `HOOK_EXECUTION_ID` | 任务 ID |
|
||||||
|
| `HOOK_SLUG` | 接口标识 |
|
||||||
|
| `HOOK_TRIGGER` | 触发方式:`webhook` / `test` / `rerun` |
|
||||||
|
| `HOOK_REQUEST_FILE` | 本次任务专属的请求 JSON 文件(method、contentType、query、headers、bodyKind、body),执行结束后删除 |
|
||||||
|
| `PATH`、`HOME`、`USER`、`LOGNAME`、`SHELL`、`LANG`、`TZ` | 基础运行环境;不继承服务自身的其他环境变量 |
|
||||||
|
|
||||||
|
请求数据不会替换进脚本文本,也不会拼接成 Shell 命令;含引号、换行或 Shell 特殊字符的参数都作为数据原样传递。脚本中请始终用双引号引用:`"$HOOK_IP"`。
|
||||||
|
|
||||||
|
## 执行规则
|
||||||
|
|
||||||
|
- 任务先持久化到 SQLite,再由调度器按接收顺序启动;同时受全局并发(默认 4)与接口并发(默认 1,即串行)限制。
|
||||||
|
- 入队时固定参数与脚本版本,之后修改脚本不影响已入队任务。
|
||||||
|
- 默认超时 30 秒;超时或手动终止时向整个进程组发送 SIGTERM,3 秒后 SIGKILL。
|
||||||
|
- 不自动重试。服务重启后排队任务继续执行,运行中的任务标记为“中断”,不自动重跑。
|
||||||
|
- 停用接口会拒绝新请求并取消该接口的排队任务;运行中的任务可单独终止。
|
||||||
|
- 单次执行输出上限默认 5 MiB(stdout + stderr),超出部分继续读取并丢弃,页面标记截断。
|
||||||
|
- 鉴权头、Cookie 与 query 中的 token 不写入执行记录;敏感参数在界面中以掩码显示,但脚本自己打印的内容仍会出现在输出里。
|
||||||
|
|
||||||
|
## 后续扩展(未实现)
|
||||||
|
|
||||||
|
第三方签名验证(如 GitHub)、定时执行与通知、可复用脚本库、配置导入导出、多节点与多用户、可视化工作流。
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Caddy 反向代理示例:/etc/caddy/Caddyfile
|
||||||
|
# Caddy 会自动申请并续期 HTTPS 证书
|
||||||
|
|
||||||
|
hooks.example.com {
|
||||||
|
request_body {
|
||||||
|
max_size 2MB
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy 127.0.0.1:8787 {
|
||||||
|
# 同步模式会等待脚本执行结束
|
||||||
|
transport http {
|
||||||
|
read_timeout 600s
|
||||||
|
write_timeout 600s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# HookPilot 环境配置,复制到 /etc/hookpilot/hookpilot.env 后按需修改
|
||||||
|
# 修改后执行:sudo systemctl restart hookpilot
|
||||||
|
|
||||||
|
# 监听地址。由反向代理对外提供 HTTPS,服务本身只监听本机
|
||||||
|
HOOKPILOT_HOST=127.0.0.1
|
||||||
|
HOOKPILOT_PORT=8787
|
||||||
|
|
||||||
|
# 数据目录:SQLite 数据库、脚本文件、任务临时目录、默认工作目录
|
||||||
|
HOOKPILOT_DATA_DIR=/var/lib/hookpilot
|
||||||
|
|
||||||
|
# 前端构建产物目录(默认为程序目录下的 web/dist)
|
||||||
|
# HOOKPILOT_WEB_DIR=/opt/hookpilot/web/dist
|
||||||
|
|
||||||
|
# 首次启动时创建的管理员。不设置密码时会随机生成并打印到日志(journalctl -u hookpilot)
|
||||||
|
HOOKPILOT_ADMIN_USER=admin
|
||||||
|
# HOOKPILOT_ADMIN_PASSWORD=请替换为强密码
|
||||||
|
|
||||||
|
# 信任的反向代理:loopback(默认,代理在本机)、true、false,或逗号分隔的 IP/网段
|
||||||
|
HOOKPILOT_TRUST_PROXY=loopback
|
||||||
|
|
||||||
|
# 登录 Cookie 是否带 Secure:auto(按请求协议判断)、true、false
|
||||||
|
HOOKPILOT_COOKIE_SECURE=auto
|
||||||
|
|
||||||
|
# 登录会话有效期(小时)
|
||||||
|
HOOKPILOT_SESSION_TTL_HOURS=168
|
||||||
|
|
||||||
|
# 请求体上限(字节),默认 1 MiB
|
||||||
|
HOOKPILOT_BODY_LIMIT=1048576
|
||||||
|
|
||||||
|
# 脚本执行使用的 Bash 与 PATH
|
||||||
|
HOOKPILOT_BASH=/bin/bash
|
||||||
|
HOOKPILOT_EXEC_PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|
||||||
|
# 日志级别:fatal / error / warn / info / debug
|
||||||
|
HOOKPILOT_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# 脚本执行时区(可选)
|
||||||
|
# TZ=Asia/Shanghai
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=HookPilot Webhook 脚本管理平台
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=hookpilot
|
||||||
|
Group=hookpilot
|
||||||
|
WorkingDirectory=/opt/hookpilot
|
||||||
|
EnvironmentFile=-/etc/hookpilot/hookpilot.env
|
||||||
|
ExecStart=/usr/bin/node /opt/hookpilot/server/dist/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# 停止时只向主进程发送 SIGTERM,由服务终止运行中的任务并标记为“中断”;
|
||||||
|
# 超过 TimeoutStopSec 仍未退出时,systemd 再清理整个 cgroup。
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=15
|
||||||
|
|
||||||
|
# 脚本需要 sudo 提权时不能启用 NoNewPrivileges;如确定不需要提权,可取消下面的注释加固
|
||||||
|
# NoNewPrivileges=true
|
||||||
|
# ProtectSystem=full
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Nginx 反向代理示例:/etc/nginx/conf.d/hookpilot.conf
|
||||||
|
# 证书可使用 certbot 等工具申请,替换 hooks.example.com 与证书路径
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name hooks.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name hooks.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/hooks.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/hooks.example.com/privkey.pem;
|
||||||
|
|
||||||
|
# 与 HOOKPILOT_BODY_LIMIT 保持一致或略大
|
||||||
|
client_max_body_size 2m;
|
||||||
|
|
||||||
|
# 不在访问日志中记录 query(?token= 方式调用时避免 Token 落盘)
|
||||||
|
log_format hookpilot '$remote_addr - [$time_local] "$request_method $uri" $status $body_bytes_sent';
|
||||||
|
access_log /var/log/nginx/hookpilot.access.log hookpilot;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8787;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
# 同步模式会等待脚本执行结束,超时时间应大于最长的脚本超时 + 排队时间
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# 宿主机部署说明
|
||||||
|
|
||||||
|
HookPilot 直接部署在 Linux 宿主机上,由 systemd 管理,通过 HTTPS 反向代理对外提供服务。脚本以服务账户身份运行,可以操作该账户有权访问的文件和命令。
|
||||||
|
|
||||||
|
## 1. 环境要求
|
||||||
|
|
||||||
|
- Linux(systemd),x86_64 或 arm64
|
||||||
|
- Node.js **22.13 及以上**(使用内置的 `node:sqlite`,无需编译原生模块)
|
||||||
|
- Bash
|
||||||
|
- Nginx 或 Caddy(提供 HTTPS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -v # 应输出 v22.13.0 或更高
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 构建
|
||||||
|
|
||||||
|
可以在服务器上直接构建,也可以在其他机器构建后复制整个目录。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <仓库地址> /opt/hookpilot # 或将项目目录上传到 /opt/hookpilot
|
||||||
|
cd /opt/hookpilot
|
||||||
|
npm ci
|
||||||
|
npm run build # 构建前端(web/dist)与后端(server/dist)
|
||||||
|
npm prune --omit=dev # 可选:移除开发依赖
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 创建服务账户与目录
|
||||||
|
|
||||||
|
默认使用专用账户 `hookpilot`,数据目录 `/var/lib/hookpilot`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system --home-dir /var/lib/hookpilot --create-home --shell /usr/sbin/nologin hookpilot
|
||||||
|
sudo chmod 700 /var/lib/hookpilot
|
||||||
|
sudo chown -R root:root /opt/hookpilot # 程序目录只读,服务账户无需写权限
|
||||||
|
```
|
||||||
|
|
||||||
|
数据目录结构(服务启动时自动创建):
|
||||||
|
|
||||||
|
| 路径 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `hookpilot.db` | SQLite 数据库:管理员、会话、接口、脚本版本、任务与输出、系统设置 |
|
||||||
|
| `scripts/<接口ID>/v<版本>.sh` | 各脚本版本的文件,执行时由数据库内容校验并重写 |
|
||||||
|
| `runs/<任务ID>/request.json` | 任务执行期间的请求文件(`HOOK_REQUEST_FILE`),结束后删除 |
|
||||||
|
| `workspace/` | 未配置工作目录时的默认工作目录 |
|
||||||
|
|
||||||
|
脚本需要访问的其他目录,请用常规的文件权限授予 `hookpilot` 账户,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R hookpilot:hookpilot /srv/my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/hookpilot
|
||||||
|
sudo cp deploy/hookpilot.env.example /etc/hookpilot/hookpilot.env
|
||||||
|
sudo chmod 600 /etc/hookpilot/hookpilot.env
|
||||||
|
sudo nano /etc/hookpilot/hookpilot.env
|
||||||
|
```
|
||||||
|
|
||||||
|
建议设置 `HOOKPILOT_ADMIN_PASSWORD` 作为初始密码(只在首次启动、数据库中还没有管理员时生效)。未设置时会随机生成,并打印在服务日志中。
|
||||||
|
|
||||||
|
## 5. 安装 systemd 服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/hookpilot.service /etc/systemd/system/hookpilot.service
|
||||||
|
# 如果 node 不在 /usr/bin/node,请修改 ExecStart(可用 `which node` 查看)
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now hookpilot
|
||||||
|
sudo systemctl status hookpilot
|
||||||
|
journalctl -u hookpilot -f # 查看日志,未设置初始密码时在这里找到随机密码
|
||||||
|
```
|
||||||
|
|
||||||
|
服务停止或重启时:
|
||||||
|
|
||||||
|
- 运行中的任务会被终止(整个进程组),状态记为“中断”,**不会自动重跑**;
|
||||||
|
- 排队中的任务保留在数据库中,服务启动后按原顺序继续执行;
|
||||||
|
- 服务异常退出(如被 `kill -9`、断电)后再启动,上次运行中的任务同样标记为“中断”。
|
||||||
|
|
||||||
|
## 6. 配置 HTTPS 反向代理
|
||||||
|
|
||||||
|
服务默认只监听 `127.0.0.1:8787`,由反向代理提供统一域名和 HTTPS。
|
||||||
|
|
||||||
|
- Nginx:参考 [`deploy/nginx.conf.example`](../deploy/nginx.conf.example)
|
||||||
|
- Caddy:参考 [`deploy/Caddyfile.example`](../deploy/Caddyfile.example)(自动申请证书)
|
||||||
|
|
||||||
|
注意事项:
|
||||||
|
|
||||||
|
1. **同步模式**的接口会等脚本执行完才响应,代理的读取超时要大于最长脚本超时加排队时间。
|
||||||
|
2. 代理的请求体上限应不小于 `HOOKPILOT_BODY_LIMIT`(默认 1 MiB)。
|
||||||
|
3. 代理与服务不在同一台机器时,把 `HOOKPILOT_TRUST_PROXY` 设置为代理的 IP,否则执行记录中的请求来源会显示为代理地址。
|
||||||
|
4. 使用 HTTPS 访问时登录 Cookie 自动带 `Secure` 标记(`HOOKPILOT_COOKIE_SECURE=auto`)。
|
||||||
|
|
||||||
|
## 7. 按命令授予提权
|
||||||
|
|
||||||
|
脚本默认只有 `hookpilot` 账户的权限。确实需要重启系统服务或修改受保护文件时,由部署者在 sudoers 中**按具体命令**授权,不要授予全部权限:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo visudo -f /etc/sudoers.d/hookpilot
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 只允许无密码执行这两条命令
|
||||||
|
hookpilot ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx
|
||||||
|
hookpilot ALL=(root) NOPASSWD: /usr/bin/systemctl reload caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本中使用 `sudo -n`(不交互询问密码,未授权时立即失败):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -n /usr/bin/systemctl restart nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
平台页面不提供切换系统用户的功能。启用提权后请保持 systemd 单元中 `NoNewPrivileges` 处于关闭状态。
|
||||||
|
|
||||||
|
## 8. 安全要点
|
||||||
|
|
||||||
|
- 管理端使用登录会话,Webhook 使用每个接口独立的 Token,两种凭证互不通用;Token 只保存哈希。
|
||||||
|
- 执行子进程使用显式构造的环境(`PATH`、`HOME`、`USER`、`LANG` 与 `HOOK_` 变量),不继承服务的环境变量与凭证。
|
||||||
|
- 请求数据只通过环境变量和请求文件传给脚本,不会替换进脚本文本或拼接成 Shell 命令。脚本中请始终用双引号引用变量(`"$HOOK_IP"`)。
|
||||||
|
- 平台面向可信管理员:任何能登录管理界面的人都能以服务账户身份执行任意命令,请使用强密码并限制管理界面的访问来源(如在代理层对 `/` 做 IP 白名单、仅放行 `/hooks/`)。
|
||||||
|
- 连续登录失败 10 次后,该来源 IP 锁定 15 分钟。
|
||||||
|
|
||||||
|
## 9. 升级、备份与维护
|
||||||
|
|
||||||
|
**升级:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/hookpilot
|
||||||
|
git pull # 或替换为新版本文件
|
||||||
|
npm ci && npm run build
|
||||||
|
sudo systemctl restart hookpilot
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库结构变更会在启动时自动迁移。
|
||||||
|
|
||||||
|
**备份:** 停止服务后复制整个数据目录;或在运行中使用 SQLite 在线备份:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u hookpilot sqlite3 /var/lib/hookpilot/hookpilot.db ".backup '/var/backups/hookpilot.db'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**忘记管理员密码:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/hookpilot
|
||||||
|
sudo -u hookpilot env HOOKPILOT_DATA_DIR=/var/lib/hookpilot node server/dist/cli.js reset-password
|
||||||
|
# 或指定新密码:
|
||||||
|
sudo -u hookpilot env HOOKPILOT_DATA_DIR=/var/lib/hookpilot HOOKPILOT_NEW_PASSWORD='新密码' node server/dist/cli.js reset-password
|
||||||
|
```
|
||||||
|
|
||||||
|
重置后所有登录会话失效。
|
||||||
|
|
||||||
|
**日志清理:** 超过“日志保留天数”(默认 30 天)的已结束执行记录及其输出每小时自动清理一次,可在“系统设置”中调整。
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# Lucky 对接示例:DDNS 更新后同步家庭 IP
|
||||||
|
|
||||||
|
本示例演示设计文档中的验收场景:Lucky 检测到公网 IP 变化后调用 HookPilot,脚本把新 IP 写入指定文件。你可以在此基础上改为更新防火墙白名单、重写 Nginx 配置等。
|
||||||
|
|
||||||
|
## 1. 在 HookPilot 中创建接口
|
||||||
|
|
||||||
|
进入“接口管理 → 新建接口”:
|
||||||
|
|
||||||
|
**基本配置**
|
||||||
|
|
||||||
|
| 配置项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 接口名称 | 更新家庭 IP |
|
||||||
|
| 接口标识 | `home-ip`(调用地址为 `/hooks/home-ip`) |
|
||||||
|
| 请求方式 | POST |
|
||||||
|
|
||||||
|
**参数**
|
||||||
|
|
||||||
|
| 环境变量 | 来源 | 字段 | 类型 | 必填 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `HOOK_IP` | JSON 请求体 | `ip` | IP 地址 | ✓ |
|
||||||
|
|
||||||
|
**脚本**:在“插入示例模板”中选择“更新家庭 IP”,按需修改 `TARGET_FILE`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TARGET_FILE="/var/lib/hookpilot/workspace/home-ip.txt"
|
||||||
|
|
||||||
|
old_ip=""
|
||||||
|
if [[ -f "$TARGET_FILE" ]]; then
|
||||||
|
old_ip="$(cat "$TARGET_FILE")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$old_ip" == "$HOOK_IP" ]]; then
|
||||||
|
echo "IP 未变化:$HOOK_IP"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$HOOK_IP" > "$TARGET_FILE.tmp"
|
||||||
|
mv "$TARGET_FILE.tmp" "$TARGET_FILE"
|
||||||
|
echo "IP 已更新:${old_ip:-无} -> $HOOK_IP"
|
||||||
|
```
|
||||||
|
|
||||||
|
**执行策略**
|
||||||
|
|
||||||
|
| 配置项 | 值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 响应模式 | 同步 | Lucky 可以根据响应判断脚本是否成功 |
|
||||||
|
| 超时时间 | 30 秒 | |
|
||||||
|
| 接口并发数 | 1 | 连续多次调用按接收顺序执行 |
|
||||||
|
|
||||||
|
点击“创建接口”,**立即复制弹窗中的 Token**(只显示一次)。
|
||||||
|
|
||||||
|
创建后切到“测试”标签,点击“按参数生成示例”→“预览参数映射”确认映射正确,再点击“执行(真实运行)”验证脚本。
|
||||||
|
|
||||||
|
## 2. 在 Lucky 中配置 Webhook
|
||||||
|
|
||||||
|
在 Lucky 的“动态域名”任务中打开 Webhook 设置(不同版本界面位置和名称可能略有差异,以实际界面为准):
|
||||||
|
|
||||||
|
| Lucky 配置项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 接口地址 | `https://hooks.example.com/hooks/home-ip` |
|
||||||
|
| 请求方法 | POST |
|
||||||
|
| 请求头 | `Authorization: Bearer hp_xxxxxxxx`<br>`Content-Type: application/json` |
|
||||||
|
| 请求主体 | `{"ip":"#{ipAddr}"}` |
|
||||||
|
| 接口调用成功包含的字符串 | `"ok":true` |
|
||||||
|
|
||||||
|
- `#{ipAddr}` 是 Lucky 提供的“当前 IP”变量,具体变量名请以你所用 Lucky 版本 Webhook 设置页的提示为准。
|
||||||
|
- 如果 Lucky 版本不支持自定义请求头,可以把 Token 放在地址中:`https://hooks.example.com/hooks/home-ip?token=hp_xxxxxxxx`。这种方式的 Token 可能出现在代理访问日志中,示例 Nginx 配置已去掉日志中的 query。
|
||||||
|
|
||||||
|
## 3. 响应说明
|
||||||
|
|
||||||
|
同步模式下 HookPilot 的响应:
|
||||||
|
|
||||||
|
| 情况 | HTTP 状态 | 响应体示例 |
|
||||||
|
|---|---|---|
|
||||||
|
| 脚本成功(退出码 0) | 200 | `{"ok":true,"executionId":12,"status":"success","exitCode":0,"durationMs":25}` |
|
||||||
|
| 脚本失败(非零退出码) | 500 | `{"ok":false,"executionId":13,"status":"failed","exitCode":1,...}` |
|
||||||
|
| 脚本超时 | 504 | `{"ok":false,...,"status":"timeout"}` |
|
||||||
|
| 被手动终止 / 取消 | 409 | `{"ok":false,...,"status":"killed"}` |
|
||||||
|
| Token 错误 | 401 | `{"ok":false,"error":"unauthorized","message":"Token 无效"}` |
|
||||||
|
| 参数缺失或 IP 非法 | 400 | `{"ok":false,"error":"invalid_params","details":["HOOK_IP:不是合法的 IP 地址"]}` |
|
||||||
|
| 接口已停用 | 403 | `{"ok":false,"error":"disabled",...}` |
|
||||||
|
| 请求方式不符 | 405 | `{"ok":false,"error":"method_not_allowed",...}` |
|
||||||
|
|
||||||
|
Token 错误、参数校验失败等情况**不会执行脚本**,也不会生成执行记录。脚本输出只在管理界面查看,不返回给 Lucky。
|
||||||
|
|
||||||
|
异步模式下只要任务入队就返回 `202 {"ok":true,"executionId":12,"status":"queued"}`,表示已接收,不代表脚本执行成功。
|
||||||
|
|
||||||
|
## 4. 用 curl 手动验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X POST 'https://hooks.example.com/hooks/home-ip' \
|
||||||
|
-H 'Authorization: Bearer hp_xxxxxxxx' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"ip":"203.0.113.10"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在“执行记录”中查看本次执行的参数、脚本版本、stdout / stderr 与退出码。
|
||||||
Generated
+3281
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "hookpilot",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "单管理员 Webhook 脚本管理平台",
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev:server": "npm run dev -w server",
|
||||||
|
"dev:web": "npm run dev -w web",
|
||||||
|
"build": "npm run build -w web && npm run build -w server",
|
||||||
|
"start": "npm run start -w server",
|
||||||
|
"typecheck": "npm run typecheck -w server && npm run typecheck -w web",
|
||||||
|
"reset-password": "npm run reset-password -w server"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@hookpilot/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"reset-password": "node dist/cli.js reset-password"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.1.2",
|
||||||
|
"@fastify/formbody": "^9.0.0",
|
||||||
|
"@fastify/static": "^10.1.5",
|
||||||
|
"fastify": "^5.12.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.20.4",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
|
"typescript": "~5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';
|
||||||
|
import type { AppConfig } from './config.js';
|
||||||
|
import type { Db } from './db.js';
|
||||||
|
import type { Scheduler } from './executor/scheduler.js';
|
||||||
|
import type { ExecutionStore } from './executor/store.js';
|
||||||
|
import type { HookRepo } from './hooks/repo.js';
|
||||||
|
import { authRoutes, registerSessionGuard } from './routes/auth.js';
|
||||||
|
import { executionRoutes } from './routes/executions.js';
|
||||||
|
import { hookRoutes } from './routes/hooks.js';
|
||||||
|
import { systemRoutes } from './routes/system.js';
|
||||||
|
import { webhookRoutes } from './routes/webhook.js';
|
||||||
|
import type { SettingsStore } from './settings.js';
|
||||||
|
import { HttpError } from './util/http-error.js';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
interface FastifyContextConfig {
|
||||||
|
/** 无需登录即可访问的管理 API */
|
||||||
|
public?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppContext {
|
||||||
|
config: AppConfig;
|
||||||
|
db: Db;
|
||||||
|
settings: SettingsStore;
|
||||||
|
hooks: HookRepo;
|
||||||
|
store: ExecutionStore;
|
||||||
|
scheduler: Scheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日志中去掉 query 里的 token,避免凭证写入日志 */
|
||||||
|
function redactUrl(url: string): string {
|
||||||
|
return url.replace(/([?&]token=)[^&]*/gi, '$1***');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildApp(ctx: AppContext): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({
|
||||||
|
trustProxy: ctx.config.trustProxy,
|
||||||
|
bodyLimit: ctx.config.bodyLimit,
|
||||||
|
logger: {
|
||||||
|
level: ctx.config.logLevel,
|
||||||
|
serializers: {
|
||||||
|
req: (req) => ({ method: req.method, url: redactUrl(req.url), remoteAddress: req.ip }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorateRequest('sessionId', '');
|
||||||
|
await app.register(cookie);
|
||||||
|
|
||||||
|
await app.register(
|
||||||
|
async (api) => {
|
||||||
|
registerSessionGuard(api, ctx);
|
||||||
|
api.setErrorHandler((err: FastifyError, req, reply) => {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return reply.code(err.statusCode).send({ error: err.message, details: err.details });
|
||||||
|
}
|
||||||
|
if (err.validation) return reply.code(400).send({ error: `请求参数无效:${err.message}` });
|
||||||
|
if (err.statusCode && err.statusCode < 500) return reply.code(err.statusCode).send({ error: err.message });
|
||||||
|
req.log.error({ err }, '管理 API 处理失败');
|
||||||
|
return reply.code(500).send({ error: '服务器内部错误' });
|
||||||
|
});
|
||||||
|
await authRoutes(api, ctx);
|
||||||
|
await hookRoutes(api, ctx);
|
||||||
|
await executionRoutes(api, ctx);
|
||||||
|
await systemRoutes(api, ctx);
|
||||||
|
},
|
||||||
|
{ prefix: '/api' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.register(async (scope) => webhookRoutes(scope, ctx));
|
||||||
|
|
||||||
|
const indexFile = path.join(ctx.config.webDir, 'index.html');
|
||||||
|
const hasWeb = fs.existsSync(indexFile);
|
||||||
|
if (hasWeb) {
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: ctx.config.webDir,
|
||||||
|
wildcard: false,
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
const immutable = filePath.includes(`${path.sep}assets${path.sep}`);
|
||||||
|
res.header('Cache-Control', immutable ? 'public, max-age=31536000, immutable' : 'no-cache');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
app.log.warn({ webDir: ctx.config.webDir }, '未找到前端构建产物,仅提供 API(开发时请使用 Vite 开发服务器)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前端路由回退到 index.html;API 与 Webhook 路径保持 JSON 404
|
||||||
|
app.setNotFoundHandler((req, reply) => {
|
||||||
|
const url = req.url.split('?')[0]!;
|
||||||
|
const isBackend = url.startsWith('/api/') || url === '/api' || url.startsWith('/hooks/');
|
||||||
|
if (hasWeb && req.method === 'GET' && !isBackend) {
|
||||||
|
return reply.header('Cache-Control', 'no-cache').sendFile('index.html');
|
||||||
|
}
|
||||||
|
return reply.code(404).send({ error: '资源不存在' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Db } from '../db.js';
|
||||||
|
import { randomToken, sha256 } from '../util/crypto.js';
|
||||||
|
import { hashPassword } from './password.js';
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = 'hp_session';
|
||||||
|
export const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
export interface AdminRow {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdmin(db: Db): AdminRow | undefined {
|
||||||
|
return db.get<AdminRow>('SELECT * FROM admin WHERE id = 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAdminCredentials(db: Db, username: string, password: string): Promise<void> {
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO admin (id, username, password_hash, updated_at) VALUES (1, ?, ?, ?)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET username = excluded.username, password_hash = excluded.password_hash, updated_at = excluded.updated_at`,
|
||||||
|
username,
|
||||||
|
hash,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首次启动时创建管理员。未通过环境变量指定密码时生成随机密码并输出到日志。
|
||||||
|
* 返回生成的随机密码(若有)。
|
||||||
|
*/
|
||||||
|
export async function ensureAdmin(db: Db, username: string, password: string | undefined): Promise<string | null> {
|
||||||
|
if (getAdmin(db)) return null;
|
||||||
|
const actual = password ?? randomToken(12);
|
||||||
|
await setAdminCredentials(db, username, actual);
|
||||||
|
return password ? null : actual;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession(db: Db, ttlMs: number, ip: string, userAgent: string): string {
|
||||||
|
const token = randomToken(32);
|
||||||
|
const now = Date.now();
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO sessions (id, created_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
sha256(token),
|
||||||
|
now,
|
||||||
|
now + ttlMs,
|
||||||
|
ip,
|
||||||
|
userAgent.slice(0, 300),
|
||||||
|
);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findSession(db: Db, token: string): { id: string } | undefined {
|
||||||
|
return db.get<{ id: string }>('SELECT id FROM sessions WHERE id = ? AND expires_at > ?', sha256(token), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSession(db: Db, id: string): void {
|
||||||
|
db.run('DELETE FROM sessions WHERE id = ?', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOtherSessions(db: Db, keepId: string | null): void {
|
||||||
|
if (keepId) db.run('DELETE FROM sessions WHERE id <> ?', keepId);
|
||||||
|
else db.run('DELETE FROM sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function purgeExpiredSessions(db: Db): void {
|
||||||
|
db.run('DELETE FROM sessions WHERE expires_at <= ?', Date.now());
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto';
|
||||||
|
|
||||||
|
const PARAMS = { N: 16384, r: 8, p: 1 };
|
||||||
|
const KEY_LENGTH = 64;
|
||||||
|
|
||||||
|
function scryptAsync(password: string, salt: Buffer, keyLength: number, options: ScryptOptions): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
scrypt(password, salt, keyLength, options, (err, key) => (err ? reject(err) : resolve(key)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const key = await scryptAsync(password, salt, KEY_LENGTH, PARAMS);
|
||||||
|
return ['scrypt', PARAMS.N, PARAMS.r, PARAMS.p, salt.toString('base64'), key.toString('base64')].join('$');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||||
|
const [scheme, n, r, p, saltB64, keyB64] = stored.split('$');
|
||||||
|
if (scheme !== 'scrypt' || !saltB64 || !keyB64) return false;
|
||||||
|
const expected = Buffer.from(keyB64, 'base64');
|
||||||
|
const actual = await scryptAsync(password, Buffer.from(saltB64, 'base64'), expected.length, {
|
||||||
|
N: Number(n),
|
||||||
|
r: Number(r),
|
||||||
|
p: Number(p),
|
||||||
|
});
|
||||||
|
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const MAX_FAILURES = 10;
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
failures: number;
|
||||||
|
firstAt: number;
|
||||||
|
lockedUntil: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录失败限流:同一来源 15 分钟内失败 10 次后锁定 15 分钟 */
|
||||||
|
export class LoginLimiter {
|
||||||
|
private readonly entries = new Map<string, Entry>();
|
||||||
|
|
||||||
|
/** 返回剩余锁定秒数,未锁定返回 0 */
|
||||||
|
lockedFor(ip: string): number {
|
||||||
|
const entry = this.entries.get(ip);
|
||||||
|
if (!entry) return 0;
|
||||||
|
const now = Date.now();
|
||||||
|
if (entry.lockedUntil > now) return Math.ceil((entry.lockedUntil - now) / 1000);
|
||||||
|
if (now - entry.firstAt > WINDOW_MS) this.entries.delete(ip);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(ip: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = this.entries.get(ip);
|
||||||
|
if (!entry || now - entry.firstAt > WINDOW_MS) {
|
||||||
|
entry = { failures: 0, firstAt: now, lockedUntil: 0 };
|
||||||
|
this.entries.set(ip, entry);
|
||||||
|
}
|
||||||
|
entry.failures++;
|
||||||
|
if (entry.failures >= MAX_FAILURES) entry.lockedUntil = now + WINDOW_MS;
|
||||||
|
if (this.entries.size > 10_000) this.prune(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
succeed(ip: string): void {
|
||||||
|
this.entries.delete(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private prune(now: number): void {
|
||||||
|
for (const [ip, entry] of this.entries) {
|
||||||
|
if (entry.lockedUntil <= now && now - entry.firstAt > WINDOW_MS) this.entries.delete(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import './warnings.js';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { deleteOtherSessions, getAdmin, MIN_PASSWORD_LENGTH, setAdminCredentials } from './auth/admin.js';
|
||||||
|
import { config, dataPath } from './config.js';
|
||||||
|
import { Db } from './db.js';
|
||||||
|
import { randomToken } from './util/crypto.js';
|
||||||
|
|
||||||
|
const USAGE = `用法:
|
||||||
|
node dist/cli.js reset-password [用户名]
|
||||||
|
|
||||||
|
重置管理员密码并使所有登录会话失效。
|
||||||
|
新密码取自环境变量 HOOKPILOT_NEW_PASSWORD,未设置时随机生成并输出。`;
|
||||||
|
|
||||||
|
async function resetPassword(username: string | undefined): Promise<void> {
|
||||||
|
fs.mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
||||||
|
const db = new Db(dataPath('hookpilot.db'));
|
||||||
|
try {
|
||||||
|
const provided = process.env.HOOKPILOT_NEW_PASSWORD;
|
||||||
|
if (provided !== undefined && provided.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
throw new Error(`HOOKPILOT_NEW_PASSWORD 至少需要 ${MIN_PASSWORD_LENGTH} 个字符`);
|
||||||
|
}
|
||||||
|
const password = provided ?? randomToken(12);
|
||||||
|
const name = username ?? getAdmin(db)?.username ?? config.initialAdminUser;
|
||||||
|
await setAdminCredentials(db, name, password);
|
||||||
|
deleteOtherSessions(db, null);
|
||||||
|
console.log(`管理员 ${name} 的密码已重置。`);
|
||||||
|
if (!provided) console.log(`新密码:${password}`);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [command, arg] = process.argv.slice(2);
|
||||||
|
if (command === 'reset-password') {
|
||||||
|
resetPassword(arg).catch((err) => {
|
||||||
|
console.error(err instanceof Error ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(USAGE);
|
||||||
|
process.exit(command ? 1 : 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = path.resolve(here, '..', '..');
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
|
function env(name: string): string | undefined {
|
||||||
|
const value = process.env[name];
|
||||||
|
return value === undefined || value.trim() === '' ? undefined : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function intEnv(name: string, fallback: number): number {
|
||||||
|
const raw = env(name);
|
||||||
|
if (raw === undefined) return fallback;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`环境变量 ${name} 必须是正整数,当前值:${raw}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustProxyEnv(raw: string): boolean | string {
|
||||||
|
if (raw === 'true') return true;
|
||||||
|
if (raw === 'false') return false;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieSecureEnv(raw: string): 'auto' | boolean {
|
||||||
|
if (raw === 'true') return true;
|
||||||
|
if (raw === 'false') return false;
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
host: env('HOOKPILOT_HOST') ?? '127.0.0.1',
|
||||||
|
port: intEnv('HOOKPILOT_PORT', 8787),
|
||||||
|
dataDir: path.resolve(env('HOOKPILOT_DATA_DIR') ?? path.join(projectRoot, 'data')),
|
||||||
|
webDir: path.resolve(env('HOOKPILOT_WEB_DIR') ?? path.join(projectRoot, 'web', 'dist')),
|
||||||
|
logLevel: env('HOOKPILOT_LOG_LEVEL') ?? 'info',
|
||||||
|
/** 反向代理信任设置,决定如何解析客户端 IP 与协议 */
|
||||||
|
trustProxy: trustProxyEnv(env('HOOKPILOT_TRUST_PROXY') ?? 'loopback'),
|
||||||
|
/** Webhook 与管理 API 的请求体上限,默认 1 MiB */
|
||||||
|
bodyLimit: intEnv('HOOKPILOT_BODY_LIMIT', 1024 * 1024),
|
||||||
|
cookieSecure: cookieSecureEnv(env('HOOKPILOT_COOKIE_SECURE') ?? 'auto'),
|
||||||
|
sessionTtlMs: intEnv('HOOKPILOT_SESSION_TTL_HOURS', 168) * 3600 * 1000,
|
||||||
|
bash: env('HOOKPILOT_BASH') ?? (isWindows ? 'bash' : '/bin/bash'),
|
||||||
|
/** 脚本执行时的 PATH,子进程不继承服务自身的完整环境 */
|
||||||
|
execPath:
|
||||||
|
env('HOOKPILOT_EXEC_PATH') ??
|
||||||
|
(isWindows ? (process.env.PATH ?? '') : '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'),
|
||||||
|
initialAdminUser: env('HOOKPILOT_ADMIN_USER') ?? 'admin',
|
||||||
|
initialAdminPassword: env('HOOKPILOT_ADMIN_PASSWORD'),
|
||||||
|
/** 终止任务时 SIGTERM 与 SIGKILL 之间的等待时间 */
|
||||||
|
killGraceMs: 3000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppConfig = typeof config;
|
||||||
|
|
||||||
|
export function dataPath(...parts: string[]): string {
|
||||||
|
return path.join(config.dataDir, ...parts);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import type { DatabaseSync as DatabaseSyncType, StatementSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
// ESM 会在任何代码执行前链接内置模块,静态 import 会让 warnings.ts 的过滤来不及生效,因此在运行时加载
|
||||||
|
const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as typeof import('node:sqlite');
|
||||||
|
|
||||||
|
export type SqlParam = null | number | bigint | string | Uint8Array;
|
||||||
|
|
||||||
|
const MIGRATIONS: string[] = [
|
||||||
|
`
|
||||||
|
CREATE TABLE admin (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE hooks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
group_name TEXT NOT NULL DEFAULT '',
|
||||||
|
method TEXT NOT NULL DEFAULT 'POST' CHECK (method IN ('GET', 'POST')),
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
token_hint TEXT NOT NULL DEFAULT '',
|
||||||
|
params TEXT NOT NULL DEFAULT '[]',
|
||||||
|
current_version_id INTEGER,
|
||||||
|
working_dir TEXT NOT NULL DEFAULT '',
|
||||||
|
timeout_sec INTEGER NOT NULL DEFAULT 30,
|
||||||
|
response_mode TEXT NOT NULL DEFAULT 'async' CHECK (response_mode IN ('sync', 'async')),
|
||||||
|
concurrency INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE script_versions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hook_id INTEGER NOT NULL REFERENCES hooks(id) ON DELETE CASCADE,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE (hook_id, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 任务表兼作持久化队列,保存参数、请求与执行配置快照
|
||||||
|
CREATE TABLE executions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hook_id INTEGER NOT NULL REFERENCES hooks(id) ON DELETE CASCADE,
|
||||||
|
script_version_id INTEGER NOT NULL,
|
||||||
|
trigger_type TEXT NOT NULL,
|
||||||
|
source_execution_id INTEGER,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
env TEXT NOT NULL,
|
||||||
|
param_defs TEXT NOT NULL,
|
||||||
|
request TEXT NOT NULL,
|
||||||
|
config TEXT NOT NULL,
|
||||||
|
request_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
exit_code INTEGER,
|
||||||
|
signal TEXT,
|
||||||
|
error TEXT,
|
||||||
|
stdout_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
stderr_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
output_truncated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
started_at INTEGER,
|
||||||
|
finished_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_executions_status ON executions (status, id);
|
||||||
|
CREATE INDEX idx_executions_hook ON executions (hook_id, id);
|
||||||
|
CREATE INDEX idx_executions_finished ON executions (finished_at);
|
||||||
|
|
||||||
|
CREATE TABLE execution_output (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
execution_id INTEGER NOT NULL REFERENCES executions(id) ON DELETE CASCADE,
|
||||||
|
stream TEXT NOT NULL CHECK (stream IN ('stdout', 'stderr')),
|
||||||
|
byte_offset INTEGER NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_execution_output ON execution_output (execution_id, stream, byte_offset);
|
||||||
|
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
export class Db {
|
||||||
|
readonly raw: DatabaseSyncType;
|
||||||
|
private readonly statements = new Map<string, StatementSync>();
|
||||||
|
private inTransaction = false;
|
||||||
|
|
||||||
|
constructor(file: string) {
|
||||||
|
this.raw = new DatabaseSync(file);
|
||||||
|
this.raw.exec('PRAGMA journal_mode = WAL');
|
||||||
|
this.raw.exec('PRAGMA synchronous = NORMAL');
|
||||||
|
this.raw.exec('PRAGMA foreign_keys = ON');
|
||||||
|
this.raw.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepare(sql: string): StatementSync {
|
||||||
|
let statement = this.statements.get(sql);
|
||||||
|
if (!statement) {
|
||||||
|
statement = this.raw.prepare(sql);
|
||||||
|
this.statements.set(sql, statement);
|
||||||
|
}
|
||||||
|
return statement;
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(sql: string, ...params: SqlParam[]): T | undefined {
|
||||||
|
return this.prepare(sql).get(...params) as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
all<T>(sql: string, ...params: SqlParam[]): T[] {
|
||||||
|
return this.prepare(sql).all(...params) as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
run(sql: string, ...params: SqlParam[]): { changes: number; lastInsertRowid: number } {
|
||||||
|
const result = this.prepare(sql).run(...params);
|
||||||
|
return { changes: Number(result.changes), lastInsertRowid: Number(result.lastInsertRowid) };
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(sql: string): void {
|
||||||
|
this.raw.exec(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步事务;嵌套调用时并入外层事务 */
|
||||||
|
transaction<T>(fn: () => T): T {
|
||||||
|
if (this.inTransaction) return fn();
|
||||||
|
this.raw.exec('BEGIN IMMEDIATE');
|
||||||
|
this.inTransaction = true;
|
||||||
|
try {
|
||||||
|
const result = fn();
|
||||||
|
this.raw.exec('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.raw.exec('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.inTransaction = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.raw.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrate(): void {
|
||||||
|
const current = this.get<{ user_version: number }>('PRAGMA user_version')?.user_version ?? 0;
|
||||||
|
for (let i = current; i < MIGRATIONS.length; i++) {
|
||||||
|
this.transaction(() => {
|
||||||
|
this.raw.exec(MIGRATIONS[i]!);
|
||||||
|
this.raw.exec(`PRAGMA user_version = ${i + 1}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { StringDecoder } from 'node:string_decoder';
|
||||||
|
import type { RequestSnapshot } from '../hooks/params.js';
|
||||||
|
import type { ExecutionOutcome, FinalStatus, OutputStream } from './store.js';
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
/** 主进程退出后等待输出管道关闭的时间;后台子进程仍占用管道时不再无限等待 */
|
||||||
|
const PIPE_CLOSE_WAIT_MS = 2000;
|
||||||
|
const FLUSH_INTERVAL_MS = 250;
|
||||||
|
const FLUSH_THRESHOLD_CHARS = 64 * 1024;
|
||||||
|
const WINDOWS_ENV_PASSTHROUGH = ['SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'USERPROFILE', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA'];
|
||||||
|
|
||||||
|
export type KillReason = 'timeout' | 'killed' | 'shutdown';
|
||||||
|
|
||||||
|
export interface RunSpec {
|
||||||
|
executionId: number;
|
||||||
|
hookSlug: string;
|
||||||
|
trigger: string;
|
||||||
|
scriptPath: string;
|
||||||
|
/** 参数映射得到的 HOOK_ 变量 */
|
||||||
|
env: Record<string, string>;
|
||||||
|
request: RequestSnapshot;
|
||||||
|
workingDir: string;
|
||||||
|
timeoutSec: number;
|
||||||
|
outputLimitBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerOptions {
|
||||||
|
bash: string;
|
||||||
|
execPath: string;
|
||||||
|
dataDir: string;
|
||||||
|
killGraceMs: number;
|
||||||
|
writeOutput: (stream: OutputStream, offset: number, data: string) => void;
|
||||||
|
onError: (err: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按块缓存输出并写入数据库。超出上限后继续读取并丢弃,避免管道写满阻塞脚本。
|
||||||
|
* 偏移量按已保存文本的 UTF-8 字节数计算,块边界不会切断多字节字符。
|
||||||
|
*/
|
||||||
|
class OutputCollector {
|
||||||
|
private readonly decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') };
|
||||||
|
private readonly pending = { stdout: '', stderr: '' };
|
||||||
|
private readonly offsets = { stdout: 0, stderr: 0 };
|
||||||
|
private stored = 0;
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private closed = false;
|
||||||
|
truncated = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly limit: number,
|
||||||
|
private readonly write: RunnerOptions['writeOutput'],
|
||||||
|
private readonly onError: RunnerOptions['onError'],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
push(stream: OutputStream, chunk: Buffer): void {
|
||||||
|
if (this.closed || this.truncated) return;
|
||||||
|
let data = chunk;
|
||||||
|
const remaining = this.limit - this.stored;
|
||||||
|
if (data.length > remaining) {
|
||||||
|
data = data.subarray(0, remaining);
|
||||||
|
this.truncated = true;
|
||||||
|
}
|
||||||
|
this.stored += data.length;
|
||||||
|
this.pending[stream] += this.decoders[stream].write(data);
|
||||||
|
if (this.pending[stream].length >= FLUSH_THRESHOLD_CHARS) this.flush(stream);
|
||||||
|
else if (!this.timer) {
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
this.flushAll();
|
||||||
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushAll(): void {
|
||||||
|
this.flush('stdout');
|
||||||
|
this.flush('stderr');
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
this.pending.stdout += this.decoders.stdout.end();
|
||||||
|
this.pending.stderr += this.decoders.stderr.end();
|
||||||
|
this.flushAll();
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes(stream: OutputStream): number {
|
||||||
|
return this.offsets[stream];
|
||||||
|
}
|
||||||
|
|
||||||
|
private flush(stream: OutputStream): void {
|
||||||
|
const text = this.pending[stream];
|
||||||
|
if (!text) return;
|
||||||
|
this.pending[stream] = '';
|
||||||
|
const offset = this.offsets[stream];
|
||||||
|
this.offsets[stream] += Buffer.byteLength(text);
|
||||||
|
try {
|
||||||
|
this.write(stream, offset, text);
|
||||||
|
} catch (err) {
|
||||||
|
this.onError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Runner {
|
||||||
|
private child: ChildProcess | null = null;
|
||||||
|
private killReason: KillReason | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly spec: RunSpec,
|
||||||
|
private readonly options: RunnerOptions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 终止任务进程组:先 SIGTERM,宽限期后 SIGKILL(即使主进程已退出,也清理残留子进程) */
|
||||||
|
kill(reason: KillReason): void {
|
||||||
|
if (this.killReason) return;
|
||||||
|
this.killReason = reason;
|
||||||
|
if (!this.child || this.child.pid === undefined) return;
|
||||||
|
this.signalGroup('SIGTERM');
|
||||||
|
setTimeout(() => this.signalGroup('SIGKILL'), this.options.killGraceMs).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(): Promise<ExecutionOutcome> {
|
||||||
|
const output = new OutputCollector(this.spec.outputLimitBytes, this.options.writeOutput, this.options.onError);
|
||||||
|
const runDir = path.join(this.options.dataDir, 'runs', String(this.spec.executionId));
|
||||||
|
try {
|
||||||
|
if (this.killReason) return this.outcome(output, null, null, null);
|
||||||
|
|
||||||
|
await fs.mkdir(runDir, { recursive: true, mode: 0o700 });
|
||||||
|
const requestFile = path.join(runDir, 'request.json');
|
||||||
|
await fs.writeFile(requestFile, JSON.stringify(this.spec.request, null, 2), { mode: 0o600 });
|
||||||
|
|
||||||
|
const cwd = this.spec.workingDir || path.join(this.options.dataDir, 'workspace');
|
||||||
|
const stat = await fs.stat(cwd).catch(() => null);
|
||||||
|
if (!stat?.isDirectory()) return this.outcome(output, null, null, `工作目录不存在或不可访问:${cwd}`);
|
||||||
|
|
||||||
|
return await this.spawnAndWait(cwd, this.buildEnv(requestFile), output);
|
||||||
|
} catch (err) {
|
||||||
|
return this.outcome(output, null, null, err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
output.close();
|
||||||
|
await fs.rm(runDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显式构造子进程环境,不继承服务自身的配置与凭证 */
|
||||||
|
private buildEnv(requestFile: string): Record<string, string> {
|
||||||
|
let username = process.env.USER ?? '';
|
||||||
|
try {
|
||||||
|
username = os.userInfo().username;
|
||||||
|
} catch {
|
||||||
|
// 部分容器环境没有 passwd 记录
|
||||||
|
}
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
PATH: this.options.execPath,
|
||||||
|
HOME: process.env.HOME ?? os.homedir(),
|
||||||
|
USER: username,
|
||||||
|
LOGNAME: username,
|
||||||
|
SHELL: this.options.bash,
|
||||||
|
LANG: process.env.LANG || 'C.UTF-8',
|
||||||
|
};
|
||||||
|
if (process.env.TZ) env.TZ = process.env.TZ;
|
||||||
|
if (isWindows) {
|
||||||
|
for (const key of WINDOWS_ENV_PASSTHROUGH) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value) env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(this.spec.env)) {
|
||||||
|
if (key.startsWith('HOOK_')) env[key] = value;
|
||||||
|
}
|
||||||
|
env.HOOK_EXECUTION_ID = String(this.spec.executionId);
|
||||||
|
env.HOOK_SLUG = this.spec.hookSlug;
|
||||||
|
env.HOOK_TRIGGER = this.spec.trigger;
|
||||||
|
env.HOOK_REQUEST_FILE = requestFile;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnAndWait(cwd: string, env: Record<string, string>, output: OutputCollector): Promise<ExecutionOutcome> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let child: ChildProcess;
|
||||||
|
try {
|
||||||
|
// 请求数据只通过环境变量传入,脚本文件原样执行,不做任何文本替换
|
||||||
|
child = spawn(this.options.bash, [this.spec.scriptPath], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
detached: !isWindows,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
resolve(this.outcome(output, null, null, `无法启动 Bash:${err instanceof Error ? err.message : String(err)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.child = child;
|
||||||
|
|
||||||
|
// 准备阶段收到的终止请求在进程启动后立即执行
|
||||||
|
if (this.killReason) {
|
||||||
|
const reason = this.killReason;
|
||||||
|
this.killReason = null;
|
||||||
|
this.kill(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exitCode: number | null = null;
|
||||||
|
let signal: string | null = null;
|
||||||
|
let spawnError: string | null = null;
|
||||||
|
let settled = false;
|
||||||
|
let closeTimer: NodeJS.Timeout | undefined;
|
||||||
|
const timeout = setTimeout(() => this.kill('timeout'), this.spec.timeoutSec * 1000);
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (closeTimer) clearTimeout(closeTimer);
|
||||||
|
output.flushAll();
|
||||||
|
resolve(this.outcome(output, exitCode, signal, spawnError ? `无法启动 Bash:${spawnError}` : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout?.on('data', (chunk: Buffer) => output.push('stdout', chunk));
|
||||||
|
child.stderr?.on('data', (chunk: Buffer) => output.push('stderr', chunk));
|
||||||
|
child.on('error', (err) => {
|
||||||
|
spawnError = err.message;
|
||||||
|
if (child.pid === undefined) finish();
|
||||||
|
});
|
||||||
|
child.on('exit', (code, sig) => {
|
||||||
|
exitCode = code;
|
||||||
|
signal = sig;
|
||||||
|
closeTimer = setTimeout(() => {
|
||||||
|
child.stdout?.destroy();
|
||||||
|
child.stderr?.destroy();
|
||||||
|
finish();
|
||||||
|
}, PIPE_CLOSE_WAIT_MS);
|
||||||
|
});
|
||||||
|
child.on('close', (code, sig) => {
|
||||||
|
if (exitCode === null && signal === null) {
|
||||||
|
exitCode = code;
|
||||||
|
signal = sig;
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private signalGroup(signal: NodeJS.Signals): void {
|
||||||
|
const pid = this.child?.pid;
|
||||||
|
if (pid === undefined) return;
|
||||||
|
if (isWindows) {
|
||||||
|
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }).on('error', () => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
process.kill(-pid, signal);
|
||||||
|
} catch {
|
||||||
|
// 进程组已全部退出
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private outcome(
|
||||||
|
output: OutputCollector,
|
||||||
|
exitCode: number | null,
|
||||||
|
signal: string | null,
|
||||||
|
error: string | null,
|
||||||
|
): ExecutionOutcome {
|
||||||
|
let status: FinalStatus;
|
||||||
|
let message = error;
|
||||||
|
switch (this.killReason) {
|
||||||
|
case 'timeout':
|
||||||
|
status = 'timeout';
|
||||||
|
message = `执行超过 ${this.spec.timeoutSec} 秒,已终止进程组`;
|
||||||
|
break;
|
||||||
|
case 'killed':
|
||||||
|
status = 'killed';
|
||||||
|
message = '已被管理员手动终止';
|
||||||
|
break;
|
||||||
|
case 'shutdown':
|
||||||
|
status = 'interrupted';
|
||||||
|
message = '服务停止,执行被中断';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
status = !error && exitCode === 0 ? 'success' : 'failed';
|
||||||
|
if (!message && signal) message = `进程被信号 ${signal} 结束`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
error: message,
|
||||||
|
stdoutBytes: output.bytes('stdout'),
|
||||||
|
stderrBytes: output.bytes('stderr'),
|
||||||
|
truncated: output.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import type { AppConfig } from '../config.js';
|
||||||
|
import type { HookRepo } from '../hooks/repo.js';
|
||||||
|
import type { RequestSnapshot } from '../hooks/params.js';
|
||||||
|
import type { SettingsStore } from '../settings.js';
|
||||||
|
import { Runner } from './runner.js';
|
||||||
|
import type { ExecConfig, ExecutionOutcome, ExecutionStore, NewExecution } from './store.js';
|
||||||
|
|
||||||
|
interface RunningTask {
|
||||||
|
hookId: number;
|
||||||
|
runner: Runner;
|
||||||
|
done: Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerDeps {
|
||||||
|
config: AppConfig;
|
||||||
|
store: ExecutionStore;
|
||||||
|
hooks: HookRepo;
|
||||||
|
settings: SettingsStore;
|
||||||
|
log: { error: (obj: unknown, msg: string) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
function failed(error: string): ExecutionOutcome {
|
||||||
|
return { status: 'failed', exitCode: null, signal: null, error, stdoutBytes: 0, stderrBytes: 0, truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务调度:SQLite 中的排队任务按接收顺序启动,
|
||||||
|
* 同时受全局并发上限与各接口并发数限制。
|
||||||
|
*/
|
||||||
|
export class Scheduler {
|
||||||
|
private readonly running = new Map<number, RunningTask>();
|
||||||
|
private readonly waiters = new Map<number, (() => void)[]>();
|
||||||
|
private scheduled = false;
|
||||||
|
private stopping = false;
|
||||||
|
|
||||||
|
constructor(private readonly deps: SchedulerDeps) {}
|
||||||
|
|
||||||
|
/** 启动时调用,返回被标记为中断的任务数 */
|
||||||
|
recover(): number {
|
||||||
|
return this.deps.store.markInterrupted();
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(input: NewExecution): number {
|
||||||
|
const id = this.deps.store.insert(input);
|
||||||
|
this.schedule();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(): void {
|
||||||
|
if (this.scheduled || this.stopping) return;
|
||||||
|
this.scheduled = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
this.scheduled = false;
|
||||||
|
try {
|
||||||
|
this.tick();
|
||||||
|
} catch (err) {
|
||||||
|
this.deps.log.error({ err }, '任务调度失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消排队任务或终止运行中的任务 */
|
||||||
|
cancel(id: number): 'cancelled' | 'killing' | 'inactive' {
|
||||||
|
if (this.deps.store.cancelQueued(id)) {
|
||||||
|
this.notify(id);
|
||||||
|
this.schedule();
|
||||||
|
return 'cancelled';
|
||||||
|
}
|
||||||
|
const task = this.running.get(id);
|
||||||
|
if (task) {
|
||||||
|
task.runner.kill('killed');
|
||||||
|
return 'killing';
|
||||||
|
}
|
||||||
|
return 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelQueuedForHook(hookId: number, reason: string): number {
|
||||||
|
const ids = this.deps.store.cancelQueuedForHook(hookId, reason);
|
||||||
|
ids.forEach((id) => this.notify(id));
|
||||||
|
return ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRunning(hookId: number): boolean {
|
||||||
|
for (const task of this.running.values()) if (task.hookId === hookId) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等待任务进入终态(成功、失败、取消等) */
|
||||||
|
waitFor(id: number): Promise<void> {
|
||||||
|
const row = this.deps.store.get(id);
|
||||||
|
if (!row || (row.status !== 'queued' && row.status !== 'running')) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const list = this.waiters.get(id) ?? [];
|
||||||
|
list.push(resolve);
|
||||||
|
this.waiters.set(id, list);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务停止:终止运行中的任务并标记为中断,排队任务保留到下次启动 */
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
this.stopping = true;
|
||||||
|
const tasks = [...this.running.values()];
|
||||||
|
tasks.forEach((task) => task.runner.kill('shutdown'));
|
||||||
|
const deadline = new Promise((resolve) => setTimeout(resolve, this.deps.config.killGraceMs + 2000).unref());
|
||||||
|
await Promise.race([Promise.all(tasks.map((t) => t.done)), deadline]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick(): void {
|
||||||
|
if (this.stopping) return;
|
||||||
|
const limit = this.deps.settings.get().globalConcurrency;
|
||||||
|
if (this.running.size >= limit) return;
|
||||||
|
|
||||||
|
const perHook = new Map<number, number>();
|
||||||
|
for (const task of this.running.values()) perHook.set(task.hookId, (perHook.get(task.hookId) ?? 0) + 1);
|
||||||
|
|
||||||
|
for (const item of this.deps.store.queued()) {
|
||||||
|
if (this.running.size >= limit) break;
|
||||||
|
const count = perHook.get(item.hook_id) ?? 0;
|
||||||
|
if (count >= item.concurrency) continue;
|
||||||
|
if (this.start(item.id)) perHook.set(item.hook_id, count + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private start(id: number): boolean {
|
||||||
|
const { store, hooks, settings, config, log } = this.deps;
|
||||||
|
if (!store.markRunning(id)) return false;
|
||||||
|
|
||||||
|
let runner: Runner;
|
||||||
|
let hookId: number;
|
||||||
|
try {
|
||||||
|
const execution = store.get(id)!;
|
||||||
|
hookId = execution.hook_id;
|
||||||
|
const hook = hooks.get(execution.hook_id);
|
||||||
|
const version = hooks.version(execution.script_version_id);
|
||||||
|
if (!hook || !version) throw new Error('接口或脚本版本不存在');
|
||||||
|
const execConfig = JSON.parse(execution.config) as ExecConfig;
|
||||||
|
runner = new Runner(
|
||||||
|
{
|
||||||
|
executionId: id,
|
||||||
|
hookSlug: hook.slug,
|
||||||
|
trigger: execution.trigger_type,
|
||||||
|
scriptPath: hooks.ensureScriptFile(version),
|
||||||
|
env: JSON.parse(execution.env) as Record<string, string>,
|
||||||
|
request: JSON.parse(execution.request) as RequestSnapshot,
|
||||||
|
workingDir: execConfig.workingDir,
|
||||||
|
timeoutSec: execConfig.timeoutSec,
|
||||||
|
outputLimitBytes: settings.get().outputLimitBytes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bash: config.bash,
|
||||||
|
execPath: config.execPath,
|
||||||
|
dataDir: config.dataDir,
|
||||||
|
killGraceMs: config.killGraceMs,
|
||||||
|
writeOutput: (stream, offset, data) => store.appendOutput(id, stream, offset, data),
|
||||||
|
onError: (err) => log.error({ err, executionId: id }, '写入任务输出失败'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
store.finish(id, failed(err instanceof Error ? err.message : String(err)));
|
||||||
|
this.notify(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = runner
|
||||||
|
.run()
|
||||||
|
.catch((err: unknown) => failed(err instanceof Error ? err.message : String(err)))
|
||||||
|
.then((outcome) => {
|
||||||
|
try {
|
||||||
|
store.finish(id, outcome);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err, executionId: id }, '更新任务状态失败');
|
||||||
|
}
|
||||||
|
this.running.delete(id);
|
||||||
|
this.notify(id);
|
||||||
|
this.schedule();
|
||||||
|
});
|
||||||
|
this.running.set(id, { hookId, runner, done });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(id: number): void {
|
||||||
|
const list = this.waiters.get(id);
|
||||||
|
if (!list) return;
|
||||||
|
this.waiters.delete(id);
|
||||||
|
list.forEach((resolve) => resolve());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import type { Db, SqlParam } from '../db.js';
|
||||||
|
import type { ParamDef, RequestSnapshot } from '../hooks/params.js';
|
||||||
|
|
||||||
|
export type ExecutionStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'success'
|
||||||
|
| 'failed'
|
||||||
|
| 'timeout'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'killed'
|
||||||
|
| 'interrupted';
|
||||||
|
export type FinalStatus = Exclude<ExecutionStatus, 'queued' | 'running'>;
|
||||||
|
export type TriggerType = 'webhook' | 'test' | 'rerun';
|
||||||
|
export type OutputStream = 'stdout' | 'stderr';
|
||||||
|
|
||||||
|
export const EXECUTION_STATUSES: ExecutionStatus[] = [
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'success',
|
||||||
|
'failed',
|
||||||
|
'timeout',
|
||||||
|
'cancelled',
|
||||||
|
'killed',
|
||||||
|
'interrupted',
|
||||||
|
];
|
||||||
|
export const TRIGGER_TYPES: TriggerType[] = ['webhook', 'test', 'rerun'];
|
||||||
|
|
||||||
|
export interface ExecConfig {
|
||||||
|
workingDir: string;
|
||||||
|
timeoutSec: number;
|
||||||
|
responseMode: 'sync' | 'async';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionRow {
|
||||||
|
id: number;
|
||||||
|
hook_id: number;
|
||||||
|
script_version_id: number;
|
||||||
|
trigger_type: TriggerType;
|
||||||
|
source_execution_id: number | null;
|
||||||
|
status: ExecutionStatus;
|
||||||
|
env: string;
|
||||||
|
param_defs: string;
|
||||||
|
request: string;
|
||||||
|
config: string;
|
||||||
|
request_ip: string;
|
||||||
|
exit_code: number | null;
|
||||||
|
signal: string | null;
|
||||||
|
error: string | null;
|
||||||
|
stdout_bytes: number;
|
||||||
|
stderr_bytes: number;
|
||||||
|
output_truncated: number;
|
||||||
|
created_at: number;
|
||||||
|
started_at: number | null;
|
||||||
|
finished_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表与详情查询附带的接口与版本信息 */
|
||||||
|
export interface ExecutionListRow extends ExecutionRow {
|
||||||
|
hook_slug: string;
|
||||||
|
hook_name: string;
|
||||||
|
script_version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewExecution {
|
||||||
|
hookId: number;
|
||||||
|
scriptVersionId: number;
|
||||||
|
trigger: TriggerType;
|
||||||
|
sourceExecutionId: number | null;
|
||||||
|
env: Record<string, string>;
|
||||||
|
paramDefs: ParamDef[];
|
||||||
|
request: RequestSnapshot;
|
||||||
|
config: ExecConfig;
|
||||||
|
requestIp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionOutcome {
|
||||||
|
status: FinalStatus;
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: string | null;
|
||||||
|
error: string | null;
|
||||||
|
stdoutBytes: number;
|
||||||
|
stderrBytes: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionFilter {
|
||||||
|
hookId?: number;
|
||||||
|
status?: ExecutionStatus;
|
||||||
|
trigger?: TriggerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIST_SELECT = `
|
||||||
|
SELECT e.*, h.slug AS hook_slug, h.name AS hook_name, v.version AS script_version
|
||||||
|
FROM executions e
|
||||||
|
JOIN hooks h ON h.id = e.hook_id
|
||||||
|
JOIN script_versions v ON v.id = e.script_version_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class ExecutionStore {
|
||||||
|
constructor(private readonly db: Db) {}
|
||||||
|
|
||||||
|
insert(input: NewExecution): number {
|
||||||
|
const { lastInsertRowid } = this.db.run(
|
||||||
|
`INSERT INTO executions (hook_id, script_version_id, trigger_type, source_execution_id, status, env, param_defs,
|
||||||
|
request, config, request_ip, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)`,
|
||||||
|
input.hookId,
|
||||||
|
input.scriptVersionId,
|
||||||
|
input.trigger,
|
||||||
|
input.sourceExecutionId,
|
||||||
|
JSON.stringify(input.env),
|
||||||
|
JSON.stringify(input.paramDefs),
|
||||||
|
JSON.stringify(input.request),
|
||||||
|
JSON.stringify(input.config),
|
||||||
|
input.requestIp,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return lastInsertRowid;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: number): ExecutionRow | undefined {
|
||||||
|
return this.db.get<ExecutionRow>('SELECT * FROM executions WHERE id = ?', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDetailed(id: number): ExecutionListRow | undefined {
|
||||||
|
return this.db.get<ExecutionListRow>(`${LIST_SELECT} WHERE e.id = ?`, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(filter: ExecutionFilter, limit: number, offset: number): { items: ExecutionListRow[]; total: number } {
|
||||||
|
const where: string[] = [];
|
||||||
|
const params: SqlParam[] = [];
|
||||||
|
if (filter.hookId) {
|
||||||
|
where.push('e.hook_id = ?');
|
||||||
|
params.push(filter.hookId);
|
||||||
|
}
|
||||||
|
if (filter.status) {
|
||||||
|
where.push('e.status = ?');
|
||||||
|
params.push(filter.status);
|
||||||
|
}
|
||||||
|
if (filter.trigger) {
|
||||||
|
where.push('e.trigger_type = ?');
|
||||||
|
params.push(filter.trigger);
|
||||||
|
}
|
||||||
|
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
const total = this.db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM executions e ${clause}`, ...params)?.n ?? 0;
|
||||||
|
const items = this.db.all<ExecutionListRow>(
|
||||||
|
`${LIST_SELECT} ${clause} ORDER BY e.id DESC LIMIT ? OFFSET ?`,
|
||||||
|
...params,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
recent(limit: number, statuses?: ExecutionStatus[]): ExecutionListRow[] {
|
||||||
|
if (!statuses?.length) return this.db.all<ExecutionListRow>(`${LIST_SELECT} ORDER BY e.id DESC LIMIT ?`, limit);
|
||||||
|
const marks = statuses.map(() => '?').join(', ');
|
||||||
|
return this.db.all<ExecutionListRow>(
|
||||||
|
`${LIST_SELECT} WHERE e.status IN (${marks}) ORDER BY e.id DESC LIMIT ?`,
|
||||||
|
...statuses,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
countByStatus(since?: number): Record<string, number> {
|
||||||
|
const rows = since
|
||||||
|
? this.db.all<{ status: string; n: number }>(
|
||||||
|
'SELECT status, COUNT(*) AS n FROM executions WHERE created_at >= ? GROUP BY status',
|
||||||
|
since,
|
||||||
|
)
|
||||||
|
: this.db.all<{ status: string; n: number }>('SELECT status, COUNT(*) AS n FROM executions GROUP BY status');
|
||||||
|
return Object.fromEntries(rows.map((r) => [r.status, r.n]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按接收顺序列出排队任务及其接口当前的并发配置 */
|
||||||
|
queued(): { id: number; hook_id: number; concurrency: number }[] {
|
||||||
|
return this.db.all(
|
||||||
|
`SELECT e.id, e.hook_id, h.concurrency FROM executions e JOIN hooks h ON h.id = e.hook_id
|
||||||
|
WHERE e.status = 'queued' ORDER BY e.id`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
markRunning(id: number): boolean {
|
||||||
|
return (
|
||||||
|
this.db.run("UPDATE executions SET status = 'running', started_at = ? WHERE id = ? AND status = 'queued'", Date.now(), id)
|
||||||
|
.changes > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(id: number, outcome: ExecutionOutcome): void {
|
||||||
|
this.db.run(
|
||||||
|
`UPDATE executions SET status = ?, exit_code = ?, signal = ?, error = ?, stdout_bytes = ?, stderr_bytes = ?,
|
||||||
|
output_truncated = ?, finished_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
outcome.status,
|
||||||
|
outcome.exitCode,
|
||||||
|
outcome.signal,
|
||||||
|
outcome.error,
|
||||||
|
outcome.stdoutBytes,
|
||||||
|
outcome.stderrBytes,
|
||||||
|
outcome.truncated ? 1 : 0,
|
||||||
|
Date.now(),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelQueued(id: number): boolean {
|
||||||
|
return (
|
||||||
|
this.db.run(
|
||||||
|
"UPDATE executions SET status = 'cancelled', finished_at = ?, error = '排队中被取消' WHERE id = ? AND status = 'queued'",
|
||||||
|
Date.now(),
|
||||||
|
id,
|
||||||
|
).changes > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelQueuedForHook(hookId: number, reason: string): number[] {
|
||||||
|
return this.db.transaction(() => {
|
||||||
|
const ids = this.db
|
||||||
|
.all<{ id: number }>("SELECT id FROM executions WHERE hook_id = ? AND status = 'queued'", hookId)
|
||||||
|
.map((r) => r.id);
|
||||||
|
this.db.run(
|
||||||
|
"UPDATE executions SET status = 'cancelled', finished_at = ?, error = ? WHERE hook_id = ? AND status = 'queued'",
|
||||||
|
Date.now(),
|
||||||
|
reason,
|
||||||
|
hookId,
|
||||||
|
);
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务启动时调用:上次异常退出时仍在运行的任务标记为中断,不自动重跑 */
|
||||||
|
markInterrupted(): number {
|
||||||
|
return this.db.run(
|
||||||
|
"UPDATE executions SET status = 'interrupted', finished_at = ?, error = '服务重启,执行被中断' WHERE status = 'running'",
|
||||||
|
Date.now(),
|
||||||
|
).changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendOutput(id: number, stream: OutputStream, offset: number, data: string): void {
|
||||||
|
this.db.run(
|
||||||
|
'INSERT INTO execution_output (execution_id, stream, byte_offset, data) VALUES (?, ?, ?, ?)',
|
||||||
|
id,
|
||||||
|
stream,
|
||||||
|
offset,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从指定字节偏移量起读取输出,单次最多返回约 maxBytes 字节 */
|
||||||
|
readOutput(id: number, stream: OutputStream, offset: number, maxBytes: number): { data: string; nextOffset: number; more: boolean } {
|
||||||
|
const batch = 200;
|
||||||
|
const rows = this.db.all<{ byte_offset: number; data: string }>(
|
||||||
|
`SELECT byte_offset, data FROM execution_output
|
||||||
|
WHERE execution_id = ? AND stream = ? AND byte_offset >= ? ORDER BY byte_offset LIMIT ?`,
|
||||||
|
id,
|
||||||
|
stream,
|
||||||
|
offset,
|
||||||
|
batch,
|
||||||
|
);
|
||||||
|
let data = '';
|
||||||
|
let size = 0;
|
||||||
|
let nextOffset = offset;
|
||||||
|
let more = rows.length === batch;
|
||||||
|
for (const row of rows) {
|
||||||
|
const length = Buffer.byteLength(row.data);
|
||||||
|
if (size > 0 && size + length > maxBytes) {
|
||||||
|
more = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
data += row.data;
|
||||||
|
size += length;
|
||||||
|
nextOffset = row.byte_offset + length;
|
||||||
|
}
|
||||||
|
return { data, nextOffset, more };
|
||||||
|
}
|
||||||
|
|
||||||
|
purgeFinishedBefore(cutoff: number): number {
|
||||||
|
return this.db.run(
|
||||||
|
"DELETE FROM executions WHERE finished_at IS NOT NULL AND finished_at < ? AND status NOT IN ('queued', 'running')",
|
||||||
|
cutoff,
|
||||||
|
).changes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import net from 'node:net';
|
||||||
|
import { badRequest } from '../util/http-error.js';
|
||||||
|
|
||||||
|
export const PARAM_SOURCES = ['json', 'form', 'query', 'header'] as const;
|
||||||
|
export const PARAM_TYPES = ['string', 'number', 'boolean', 'ip'] as const;
|
||||||
|
export type ParamSource = (typeof PARAM_SOURCES)[number];
|
||||||
|
export type ParamType = (typeof PARAM_TYPES)[number];
|
||||||
|
|
||||||
|
export interface ParamDef {
|
||||||
|
/** 脚本中的环境变量名,统一以 HOOK_ 开头 */
|
||||||
|
env: string;
|
||||||
|
source: ParamSource;
|
||||||
|
/** JSON 使用点路径(如 data.ip、items.0.name),其余为字段名或请求头名 */
|
||||||
|
key: string;
|
||||||
|
type: ParamType;
|
||||||
|
required: boolean;
|
||||||
|
default: string;
|
||||||
|
sensitive: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestSnapshot {
|
||||||
|
method: string;
|
||||||
|
contentType: string;
|
||||||
|
query: Record<string, string | string[]>;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
bodyKind: 'json' | 'form' | 'text' | 'none';
|
||||||
|
body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParamValue {
|
||||||
|
env: string;
|
||||||
|
value: string;
|
||||||
|
origin: 'request' | 'default' | 'empty';
|
||||||
|
sensitive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractResult {
|
||||||
|
env: Record<string, string>;
|
||||||
|
values: ParamValue[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台自动注入的变量,不允许参数占用 */
|
||||||
|
export const RESERVED_ENV = new Set(['HOOK_REQUEST_FILE', 'HOOK_EXECUTION_ID', 'HOOK_SLUG', 'HOOK_TRIGGER']);
|
||||||
|
/** 鉴权相关请求头:不写入执行记录,也不能作为参数来源 */
|
||||||
|
export const SECRET_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie', 'x-hook-token']);
|
||||||
|
export const TOKEN_QUERY_KEY = 'token';
|
||||||
|
export const MASK = '******';
|
||||||
|
|
||||||
|
const ENV_PATTERN = /^HOOK_[A-Z0-9_]+$/;
|
||||||
|
const SOURCE_LABELS: Record<ParamSource, string> = { json: 'JSON', form: '表单', query: 'Query', header: 'Header' };
|
||||||
|
const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']);
|
||||||
|
const FALSE_VALUES = new Set(['false', '0', 'no', 'off']);
|
||||||
|
const NUMBER_PATTERN = /^[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?$/;
|
||||||
|
|
||||||
|
type Coerced = { ok: true; value: string } | { ok: false; reason: string };
|
||||||
|
|
||||||
|
function coerce(raw: unknown, type: ParamType): Coerced {
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
if (typeof raw === 'string') return { ok: true, value: raw };
|
||||||
|
return { ok: true, value: typeof raw === 'object' ? JSON.stringify(raw) : String(raw) };
|
||||||
|
case 'number':
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw)) return { ok: true, value: String(raw) };
|
||||||
|
if (typeof raw === 'string' && NUMBER_PATTERN.test(raw.trim())) return { ok: true, value: raw.trim() };
|
||||||
|
return { ok: false, reason: '不是有效的数字' };
|
||||||
|
case 'boolean': {
|
||||||
|
if (typeof raw === 'boolean') return { ok: true, value: String(raw) };
|
||||||
|
const text = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
||||||
|
if (TRUE_VALUES.has(text)) return { ok: true, value: 'true' };
|
||||||
|
if (FALSE_VALUES.has(text)) return { ok: true, value: 'false' };
|
||||||
|
return { ok: false, reason: '不是有效的布尔值(可用 true/false、1/0、yes/no、on/off)' };
|
||||||
|
}
|
||||||
|
case 'ip':
|
||||||
|
if (typeof raw === 'string' && net.isIP(raw.trim()) !== 0) return { ok: true, value: raw.trim() };
|
||||||
|
return { ok: false, reason: '不是合法的 IP 地址' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeParamDefs(input: unknown, method: string): ParamDef[] {
|
||||||
|
if (!Array.isArray(input)) throw badRequest('参数配置格式错误');
|
||||||
|
if (input.length > 100) throw badRequest('参数数量不能超过 100 个');
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return input.map((item, index) => {
|
||||||
|
const label = `第 ${index + 1} 个参数`;
|
||||||
|
if (!item || typeof item !== 'object') throw badRequest(`${label}格式错误`);
|
||||||
|
const raw = item as Record<string, unknown>;
|
||||||
|
|
||||||
|
const env = String(raw.env ?? '').trim().toUpperCase();
|
||||||
|
if (!ENV_PATTERN.test(env)) {
|
||||||
|
throw badRequest(`${label}:变量名需以 HOOK_ 开头,且只包含大写字母、数字和下划线`);
|
||||||
|
}
|
||||||
|
if (RESERVED_ENV.has(env)) throw badRequest(`${label}:${env} 是平台保留变量`);
|
||||||
|
if (seen.has(env)) throw badRequest(`${label}:变量名 ${env} 重复`);
|
||||||
|
seen.add(env);
|
||||||
|
|
||||||
|
const source = raw.source as ParamSource;
|
||||||
|
if (!PARAM_SOURCES.includes(source)) throw badRequest(`${label}:参数来源无效`);
|
||||||
|
if (method === 'GET' && (source === 'json' || source === 'form')) {
|
||||||
|
throw badRequest(`${label}:GET 接口只能从 Query 或 Header 提取参数`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = String(raw.key ?? '').trim();
|
||||||
|
if (!key || key.length > 200) throw badRequest(`${label}:请填写来源字段名(最多 200 字符)`);
|
||||||
|
if (source === 'header') {
|
||||||
|
key = key.toLowerCase();
|
||||||
|
if (SECRET_HEADERS.has(key)) throw badRequest(`${label}:不能从鉴权相关请求头 ${key} 提取参数`);
|
||||||
|
}
|
||||||
|
if (source === 'query' && key === TOKEN_QUERY_KEY) {
|
||||||
|
throw badRequest(`${label}:Query 字段 token 用于接口鉴权,不能作为参数`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = (raw.type ?? 'string') as ParamType;
|
||||||
|
if (!PARAM_TYPES.includes(type)) throw badRequest(`${label}:参数类型无效`);
|
||||||
|
|
||||||
|
const defaultValue = raw.default === undefined || raw.default === null ? '' : String(raw.default);
|
||||||
|
if (defaultValue !== '') {
|
||||||
|
const result = coerce(defaultValue, type);
|
||||||
|
if (!result.ok) throw badRequest(`${label}:默认值${result.reason}`);
|
||||||
|
if (result.value.includes('\0')) throw badRequest(`${label}:默认值包含非法字符`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
env,
|
||||||
|
source,
|
||||||
|
key,
|
||||||
|
type,
|
||||||
|
required: Boolean(raw.required),
|
||||||
|
default: defaultValue,
|
||||||
|
sensitive: Boolean(raw.sensitive),
|
||||||
|
description: String(raw.description ?? '').slice(0, 500),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPath(path: string): string[] {
|
||||||
|
return path.split('.').filter((segment) => segment !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPath(value: unknown, path: string): unknown {
|
||||||
|
let current = value;
|
||||||
|
for (const segment of splitPath(path)) {
|
||||||
|
if (Array.isArray(current) && /^\d+$/.test(segment)) {
|
||||||
|
current = current[Number(segment)];
|
||||||
|
} else if (current !== null && typeof current === 'object' && Object.hasOwn(current, segment)) {
|
||||||
|
current = (current as Record<string, unknown>)[segment];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function first(value: unknown): unknown {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookup(def: ParamDef, request: RequestSnapshot): unknown {
|
||||||
|
switch (def.source) {
|
||||||
|
case 'json':
|
||||||
|
return request.bodyKind === 'json' ? readPath(request.body, def.key) : undefined;
|
||||||
|
case 'form': {
|
||||||
|
if (request.bodyKind !== 'form' || !request.body || typeof request.body !== 'object') return undefined;
|
||||||
|
const body = request.body as Record<string, unknown>;
|
||||||
|
return Object.hasOwn(body, def.key) ? first(body[def.key]) : undefined;
|
||||||
|
}
|
||||||
|
case 'query':
|
||||||
|
return Object.hasOwn(request.query, def.key) ? first(request.query[def.key]) : undefined;
|
||||||
|
case 'header':
|
||||||
|
return Object.hasOwn(request.headers, def.key) ? request.headers[def.key] : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按参数配置从请求中提取并校验参数。
|
||||||
|
* 每个参数只从管理员指定的来源读取,不在 Body、Query、Header 之间隐式回退。
|
||||||
|
*/
|
||||||
|
export function extractParams(defs: ParamDef[], request: RequestSnapshot): ExtractResult {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
const values: ParamValue[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const def of defs) {
|
||||||
|
const raw = lookup(def, request);
|
||||||
|
const missing = raw === undefined || raw === null || raw === '';
|
||||||
|
let value = '';
|
||||||
|
let origin: ParamValue['origin'] = 'request';
|
||||||
|
|
||||||
|
if (missing) {
|
||||||
|
if (def.default !== '') {
|
||||||
|
const result = coerce(def.default, def.type);
|
||||||
|
value = result.ok ? result.value : def.default;
|
||||||
|
origin = 'default';
|
||||||
|
} else if (def.required) {
|
||||||
|
errors.push(`${def.env}:缺少必填参数(${SOURCE_LABELS[def.source]} 字段 ${def.key})`);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
origin = 'empty';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = coerce(raw, def.type);
|
||||||
|
if (!result.ok) {
|
||||||
|
errors.push(`${def.env}:${result.reason}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
value = result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes('\0')) {
|
||||||
|
errors.push(`${def.env}:包含非法的空字符`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
env[def.env] = value;
|
||||||
|
values.push({ env: def.env, value, origin, sensitive: def.sensitive });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { env, values, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskEnv(env: Record<string, string>, defs: ParamDef[]): Record<string, string> {
|
||||||
|
const sensitive = new Set(defs.filter((d) => d.sensitive).map((d) => d.env));
|
||||||
|
const masked: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
masked[key] = sensitive.has(key) && value !== '' ? MASK : value;
|
||||||
|
}
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPath(value: unknown, path: string): void {
|
||||||
|
const segments = splitPath(path);
|
||||||
|
let current = value;
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
if (current === null || typeof current !== 'object') return;
|
||||||
|
const segment = segments[i]!;
|
||||||
|
const container = current as Record<string, unknown>;
|
||||||
|
if (!Object.hasOwn(container, segment)) return;
|
||||||
|
if (i === segments.length - 1) container[segment] = MASK;
|
||||||
|
else current = container[segment];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回隐藏了敏感参数原值的请求快照副本,用于在管理界面展示 */
|
||||||
|
export function maskRequest(request: RequestSnapshot, defs: ParamDef[]): RequestSnapshot {
|
||||||
|
const copy = structuredClone(request);
|
||||||
|
for (const def of defs) {
|
||||||
|
if (!def.sensitive) continue;
|
||||||
|
switch (def.source) {
|
||||||
|
case 'json':
|
||||||
|
if (copy.bodyKind === 'json') maskPath(copy.body, def.key);
|
||||||
|
break;
|
||||||
|
case 'form':
|
||||||
|
if (copy.bodyKind === 'form' && copy.body && typeof copy.body === 'object') {
|
||||||
|
const body = copy.body as Record<string, unknown>;
|
||||||
|
if (Object.hasOwn(body, def.key)) body[def.key] = MASK;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'query':
|
||||||
|
if (Object.hasOwn(copy.query, def.key)) copy.query[def.key] = MASK;
|
||||||
|
break;
|
||||||
|
case 'header':
|
||||||
|
if (Object.hasOwn(copy.headers, def.key)) copy.headers[def.key] = MASK;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由原始字符串组装请求快照,用于在线测试 */
|
||||||
|
export function buildSnapshot(input: {
|
||||||
|
method: string;
|
||||||
|
query: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
bodyType: RequestSnapshot['bodyKind'];
|
||||||
|
body: string;
|
||||||
|
}): RequestSnapshot {
|
||||||
|
const query: Record<string, string | string[]> = {};
|
||||||
|
for (const [key, value] of new URLSearchParams(input.query.replace(/^\?/, ''))) {
|
||||||
|
if (key === TOKEN_QUERY_KEY) continue;
|
||||||
|
appendValue(query, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const [name, value] of Object.entries(input.headers)) {
|
||||||
|
const key = name.trim().toLowerCase();
|
||||||
|
if (!key || SECRET_HEADERS.has(key)) continue;
|
||||||
|
headers[key] = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = null;
|
||||||
|
let contentType = '';
|
||||||
|
switch (input.bodyType) {
|
||||||
|
case 'json':
|
||||||
|
contentType = 'application/json';
|
||||||
|
try {
|
||||||
|
body = JSON.parse(input.body);
|
||||||
|
} catch {
|
||||||
|
throw badRequest('请求体不是合法的 JSON');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'form': {
|
||||||
|
contentType = 'application/x-www-form-urlencoded';
|
||||||
|
const form: Record<string, string | string[]> = {};
|
||||||
|
for (const [key, value] of new URLSearchParams(input.body)) appendValue(form, key, value);
|
||||||
|
body = form;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text':
|
||||||
|
contentType = 'text/plain';
|
||||||
|
body = input.body;
|
||||||
|
break;
|
||||||
|
case 'none':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (contentType) headers['content-type'] = contentType;
|
||||||
|
|
||||||
|
return { method: input.method, contentType, query, headers, bodyKind: input.bodyType, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendValue(target: Record<string, string | string[]>, key: string, value: string): void {
|
||||||
|
const existing = target[key];
|
||||||
|
if (existing === undefined) target[key] = value;
|
||||||
|
else if (Array.isArray(existing)) existing.push(value);
|
||||||
|
else target[key] = [existing, value];
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { Db } from '../db.js';
|
||||||
|
import { generateHookToken, sha256, tokenHint } from '../util/crypto.js';
|
||||||
|
import { badRequest, conflict, notFound } from '../util/http-error.js';
|
||||||
|
import { normalizeParamDefs, type ParamDef } from './params.js';
|
||||||
|
|
||||||
|
export type HttpMethod = 'GET' | 'POST';
|
||||||
|
export type ResponseMode = 'sync' | 'async';
|
||||||
|
|
||||||
|
export interface HookRow {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
group_name: string;
|
||||||
|
method: HttpMethod;
|
||||||
|
enabled: number;
|
||||||
|
token_hash: string;
|
||||||
|
token_hint: string;
|
||||||
|
params: string;
|
||||||
|
current_version_id: number | null;
|
||||||
|
working_dir: string;
|
||||||
|
timeout_sec: number;
|
||||||
|
response_mode: ResponseMode;
|
||||||
|
concurrency: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookListRow extends HookRow {
|
||||||
|
current_version: number | null;
|
||||||
|
last_status: string | null;
|
||||||
|
last_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptVersionRow {
|
||||||
|
id: number;
|
||||||
|
hook_id: number;
|
||||||
|
version: number;
|
||||||
|
content: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookInput {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
group: string;
|
||||||
|
method: HttpMethod;
|
||||||
|
params: ParamDef[];
|
||||||
|
script: string;
|
||||||
|
workingDir: string;
|
||||||
|
timeoutSec: number;
|
||||||
|
responseMode: ResponseMode;
|
||||||
|
concurrency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
const MAX_SCRIPT_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
function intInRange(value: unknown, min: number, max: number, label: string): number {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isInteger(n) || n < min || n > max) throw badRequest(`${label}需为 ${min} 到 ${max} 之间的整数`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSlug(value: unknown): string {
|
||||||
|
const slug = String(value ?? '').trim();
|
||||||
|
if (!SLUG_PATTERN.test(slug)) {
|
||||||
|
throw badRequest('接口标识只能包含小写字母、数字、下划线和连字符,以字母或数字开头,最多 64 个字符');
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHookInput(body: unknown): HookInput {
|
||||||
|
if (!body || typeof body !== 'object') throw badRequest('请求内容格式错误');
|
||||||
|
const raw = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
const name = String(raw.name ?? '').trim();
|
||||||
|
if (!name || name.length > 100) throw badRequest('请填写接口名称(最多 100 个字符)');
|
||||||
|
|
||||||
|
const method = String(raw.method ?? 'POST').toUpperCase();
|
||||||
|
if (method !== 'GET' && method !== 'POST') throw badRequest('请求方式只能是 GET 或 POST');
|
||||||
|
|
||||||
|
const script = String(raw.script ?? '').replace(/\r\n?/g, '\n');
|
||||||
|
if (!script.trim()) throw badRequest('脚本内容不能为空');
|
||||||
|
if (Buffer.byteLength(script) > MAX_SCRIPT_BYTES) throw badRequest('脚本内容不能超过 256 KiB');
|
||||||
|
|
||||||
|
const workingDir = String(raw.workingDir ?? '').trim();
|
||||||
|
if (workingDir && !path.isAbsolute(workingDir)) throw badRequest('工作目录必须是绝对路径');
|
||||||
|
|
||||||
|
const responseMode = String(raw.responseMode ?? 'async');
|
||||||
|
if (responseMode !== 'sync' && responseMode !== 'async') throw badRequest('响应模式无效');
|
||||||
|
|
||||||
|
const description = String(raw.description ?? '').trim();
|
||||||
|
if (description.length > 1000) throw badRequest('描述最多 1000 个字符');
|
||||||
|
const group = String(raw.group ?? '').trim();
|
||||||
|
if (group.length > 50) throw badRequest('分组名称最多 50 个字符');
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: validateSlug(raw.slug),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
group,
|
||||||
|
method,
|
||||||
|
params: normalizeParamDefs(raw.params ?? [], method),
|
||||||
|
script,
|
||||||
|
workingDir,
|
||||||
|
timeoutSec: intInRange(raw.timeoutSec ?? 30, 1, 86400, '超时时间(秒)'),
|
||||||
|
responseMode,
|
||||||
|
concurrency: intInRange(raw.concurrency ?? 1, 1, 64, '接口并发数'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseParamDefs(row: HookRow): ParamDef[] {
|
||||||
|
return JSON.parse(row.params) as ParamDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HookRepo {
|
||||||
|
constructor(
|
||||||
|
private readonly db: Db,
|
||||||
|
private readonly scriptsDir: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list(): HookListRow[] {
|
||||||
|
return this.db.all<HookListRow>(`
|
||||||
|
SELECT h.*, v.version AS current_version,
|
||||||
|
(SELECT e.status FROM executions e WHERE e.hook_id = h.id ORDER BY e.id DESC LIMIT 1) AS last_status,
|
||||||
|
(SELECT e.created_at FROM executions e WHERE e.hook_id = h.id ORDER BY e.id DESC LIMIT 1) AS last_at
|
||||||
|
FROM hooks h
|
||||||
|
LEFT JOIN script_versions v ON v.id = h.current_version_id
|
||||||
|
ORDER BY h.group_name, h.name, h.id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: number): HookRow | undefined {
|
||||||
|
return this.db.get<HookRow>('SELECT * FROM hooks WHERE id = ?', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
require(id: number): HookRow {
|
||||||
|
const hook = this.get(id);
|
||||||
|
if (!hook) throw notFound('接口不存在');
|
||||||
|
return hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBySlug(slug: string): HookRow | undefined {
|
||||||
|
return this.db.get<HookRow>('SELECT * FROM hooks WHERE slug = ?', slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
groups(): string[] {
|
||||||
|
return this.db
|
||||||
|
.all<{ group_name: string }>("SELECT DISTINCT group_name FROM hooks WHERE group_name <> '' ORDER BY group_name")
|
||||||
|
.map((r) => r.group_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: HookInput, enabled = true): { hook: HookRow; token: string } {
|
||||||
|
if (this.getBySlug(input.slug)) throw conflict(`接口标识 ${input.slug} 已被使用`);
|
||||||
|
const token = generateHookToken();
|
||||||
|
const now = Date.now();
|
||||||
|
const hook = this.db.transaction(() => {
|
||||||
|
const { lastInsertRowid: id } = this.db.run(
|
||||||
|
`INSERT INTO hooks (slug, name, description, group_name, method, enabled, token_hash, token_hint, params,
|
||||||
|
working_dir, timeout_sec, response_mode, concurrency, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
input.slug,
|
||||||
|
input.name,
|
||||||
|
input.description,
|
||||||
|
input.group,
|
||||||
|
input.method,
|
||||||
|
enabled ? 1 : 0,
|
||||||
|
sha256(token),
|
||||||
|
tokenHint(token),
|
||||||
|
JSON.stringify(input.params),
|
||||||
|
input.workingDir,
|
||||||
|
input.timeoutSec,
|
||||||
|
input.responseMode,
|
||||||
|
input.concurrency,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const versionId = this.addVersion(id, input.script);
|
||||||
|
this.db.run('UPDATE hooks SET current_version_id = ? WHERE id = ?', versionId, id);
|
||||||
|
return this.get(id)!;
|
||||||
|
});
|
||||||
|
return { hook, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新配置;脚本内容变化时生成新版本,已入队任务仍使用各自固定的版本 */
|
||||||
|
update(id: number, input: HookInput): HookRow {
|
||||||
|
const current = this.require(id);
|
||||||
|
if (input.slug !== current.slug && this.getBySlug(input.slug)) {
|
||||||
|
throw conflict(`接口标识 ${input.slug} 已被使用`);
|
||||||
|
}
|
||||||
|
return this.db.transaction(() => {
|
||||||
|
let versionId = current.current_version_id;
|
||||||
|
const currentVersion = versionId ? this.version(versionId) : undefined;
|
||||||
|
if (!currentVersion || currentVersion.content !== input.script) {
|
||||||
|
versionId = this.addVersion(id, input.script);
|
||||||
|
}
|
||||||
|
this.db.run(
|
||||||
|
`UPDATE hooks SET slug = ?, name = ?, description = ?, group_name = ?, method = ?, params = ?,
|
||||||
|
working_dir = ?, timeout_sec = ?, response_mode = ?, concurrency = ?, current_version_id = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
input.slug,
|
||||||
|
input.name,
|
||||||
|
input.description,
|
||||||
|
input.group,
|
||||||
|
input.method,
|
||||||
|
JSON.stringify(input.params),
|
||||||
|
input.workingDir,
|
||||||
|
input.timeoutSec,
|
||||||
|
input.responseMode,
|
||||||
|
input.concurrency,
|
||||||
|
versionId,
|
||||||
|
Date.now(),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return this.get(id)!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(id: number, slug: string, name: string): { hook: HookRow; token: string } {
|
||||||
|
const source = this.require(id);
|
||||||
|
const script = source.current_version_id ? this.version(source.current_version_id)?.content : undefined;
|
||||||
|
return this.create(
|
||||||
|
{
|
||||||
|
slug: validateSlug(slug),
|
||||||
|
name: name.trim() || `${source.name}(副本)`,
|
||||||
|
description: source.description,
|
||||||
|
group: source.group_name,
|
||||||
|
method: source.method,
|
||||||
|
params: parseParamDefs(source),
|
||||||
|
script: script ?? '#!/usr/bin/env bash\n',
|
||||||
|
workingDir: source.working_dir,
|
||||||
|
timeoutSec: source.timeout_sec,
|
||||||
|
responseMode: source.response_mode,
|
||||||
|
concurrency: source.concurrency,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: number): void {
|
||||||
|
this.require(id);
|
||||||
|
this.db.transaction(() => {
|
||||||
|
this.db.run('DELETE FROM executions WHERE hook_id = ?', id);
|
||||||
|
this.db.run('DELETE FROM script_versions WHERE hook_id = ?', id);
|
||||||
|
this.db.run('DELETE FROM hooks WHERE id = ?', id);
|
||||||
|
});
|
||||||
|
fs.rmSync(path.join(this.scriptsDir, String(id)), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(id: number, enabled: boolean): HookRow {
|
||||||
|
this.require(id);
|
||||||
|
this.db.run('UPDATE hooks SET enabled = ?, updated_at = ? WHERE id = ?', enabled ? 1 : 0, Date.now(), id);
|
||||||
|
return this.get(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
regenerateToken(id: number): string {
|
||||||
|
this.require(id);
|
||||||
|
const token = generateHookToken();
|
||||||
|
this.db.run(
|
||||||
|
'UPDATE hooks SET token_hash = ?, token_hint = ?, updated_at = ? WHERE id = ?',
|
||||||
|
sha256(token),
|
||||||
|
tokenHint(token),
|
||||||
|
Date.now(),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
versions(hookId: number): Omit<ScriptVersionRow, 'content'>[] {
|
||||||
|
return this.db.all(
|
||||||
|
'SELECT id, hook_id, version, created_at FROM script_versions WHERE hook_id = ? ORDER BY version DESC',
|
||||||
|
hookId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
version(id: number): ScriptVersionRow | undefined {
|
||||||
|
return this.db.get<ScriptVersionRow>('SELECT * FROM script_versions WHERE id = ?', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确保版本对应的脚本文件存在且与数据库内容一致,返回文件路径 */
|
||||||
|
ensureScriptFile(version: ScriptVersionRow): string {
|
||||||
|
const dir = path.join(this.scriptsDir, String(version.hook_id));
|
||||||
|
const file = path.join(dir, `v${version.version}.sh`);
|
||||||
|
let existing: string | null = null;
|
||||||
|
try {
|
||||||
|
existing = fs.readFileSync(file, 'utf8');
|
||||||
|
} catch {
|
||||||
|
existing = null;
|
||||||
|
}
|
||||||
|
if (existing !== version.content) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
fs.writeFileSync(file, version.content, { mode: 0o700 });
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addVersion(hookId: number, content: string): number {
|
||||||
|
const latest = this.db.get<{ v: number | null }>(
|
||||||
|
'SELECT MAX(version) AS v FROM script_versions WHERE hook_id = ?',
|
||||||
|
hookId,
|
||||||
|
);
|
||||||
|
const { lastInsertRowid } = this.db.run(
|
||||||
|
'INSERT INTO script_versions (hook_id, version, content, created_at) VALUES (?, ?, ?, ?)',
|
||||||
|
hookId,
|
||||||
|
(latest?.v ?? 0) + 1,
|
||||||
|
content,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return lastInsertRowid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import './warnings.js';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { ensureAdmin, purgeExpiredSessions } from './auth/admin.js';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { buildApp } from './app.js';
|
||||||
|
import { config, dataPath } from './config.js';
|
||||||
|
import { Db } from './db.js';
|
||||||
|
import { Scheduler } from './executor/scheduler.js';
|
||||||
|
import { ExecutionStore } from './executor/store.js';
|
||||||
|
import { HookRepo } from './hooks/repo.js';
|
||||||
|
import { SettingsStore } from './settings.js';
|
||||||
|
|
||||||
|
const MAINTENANCE_INTERVAL_MS = 3600 * 1000;
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
fs.mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
||||||
|
fs.mkdirSync(dataPath('scripts'), { recursive: true, mode: 0o700 });
|
||||||
|
fs.mkdirSync(dataPath('workspace'), { recursive: true });
|
||||||
|
// 任务临时目录只在执行期间存在,启动时清理上次残留
|
||||||
|
fs.rmSync(dataPath('runs'), { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(dataPath('runs'), { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
const db = new Db(dataPath('hookpilot.db'));
|
||||||
|
const settings = new SettingsStore(db);
|
||||||
|
const hooks = new HookRepo(db, dataPath('scripts'));
|
||||||
|
const store = new ExecutionStore(db);
|
||||||
|
|
||||||
|
// 路由注册时就会取用调度器,因此先创建调度器;其日志在应用构建完成后才会被调用
|
||||||
|
let app: FastifyInstance | null = null;
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
hooks,
|
||||||
|
settings,
|
||||||
|
log: { error: (obj: unknown, msg: string) => app?.log.error(obj, msg) },
|
||||||
|
});
|
||||||
|
app = await buildApp({ config, db, settings, hooks, store, scheduler });
|
||||||
|
|
||||||
|
const generatedPassword = await ensureAdmin(db, config.initialAdminUser, config.initialAdminPassword);
|
||||||
|
if (generatedPassword) {
|
||||||
|
app.log.warn(`已创建管理员账户 ${config.initialAdminUser},初始密码:${generatedPassword}(请登录后立即修改)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const interrupted = scheduler.recover();
|
||||||
|
if (interrupted) app.log.warn(`${interrupted} 个任务在上次服务停止时仍在运行,已标记为中断`);
|
||||||
|
|
||||||
|
const maintenance = () => {
|
||||||
|
try {
|
||||||
|
const cutoff = Date.now() - settings.get().retentionDays * 86400 * 1000;
|
||||||
|
const purged = store.purgeFinishedBefore(cutoff);
|
||||||
|
purgeExpiredSessions(db);
|
||||||
|
if (purged) app.log.info(`已清理 ${purged} 条过期执行记录`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err }, '定期清理失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
maintenance();
|
||||||
|
const timer = setInterval(maintenance, MAINTENANCE_INTERVAL_MS);
|
||||||
|
timer.unref();
|
||||||
|
|
||||||
|
await app.listen({ host: config.host, port: config.port });
|
||||||
|
scheduler.schedule();
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
app.log.info(`收到 ${signal},正在停止服务`);
|
||||||
|
clearInterval(timer);
|
||||||
|
await scheduler.shutdown();
|
||||||
|
await app.close();
|
||||||
|
db.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import type { AppContext } from '../app.js';
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
deleteOtherSessions,
|
||||||
|
deleteSession,
|
||||||
|
findSession,
|
||||||
|
getAdmin,
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
setAdminCredentials,
|
||||||
|
} from '../auth/admin.js';
|
||||||
|
import { verifyPassword } from '../auth/password.js';
|
||||||
|
import { LoginLimiter } from '../auth/rate-limit.js';
|
||||||
|
import { badRequest, HttpError } from '../util/http-error.js';
|
||||||
|
|
||||||
|
function cookieSecure(ctx: AppContext, req: FastifyRequest): boolean {
|
||||||
|
return ctx.config.cookieSecure === 'auto' ? req.protocol === 'https' : ctx.config.cookieSecure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理 API 鉴权:除标记为 public 的路由外,都要求有效的登录会话 */
|
||||||
|
export function registerSessionGuard(app: FastifyInstance, ctx: AppContext): void {
|
||||||
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (req.routeOptions.config?.public) return;
|
||||||
|
const token = req.cookies[SESSION_COOKIE];
|
||||||
|
const session = token ? findSession(ctx.db, token) : undefined;
|
||||||
|
if (!session) {
|
||||||
|
return reply.code(401).send({ error: '未登录或登录已过期' });
|
||||||
|
}
|
||||||
|
req.sessionId = session.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authRoutes(app: FastifyInstance, ctx: AppContext): Promise<void> {
|
||||||
|
const limiter = new LoginLimiter();
|
||||||
|
|
||||||
|
app.post<{ Body: { username?: string; password?: string } }>(
|
||||||
|
'/auth/login',
|
||||||
|
{ config: { public: true } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const locked = limiter.lockedFor(req.ip);
|
||||||
|
if (locked > 0) throw new HttpError(429, `登录失败次数过多,请 ${Math.ceil(locked / 60)} 分钟后再试`);
|
||||||
|
|
||||||
|
const username = String(req.body?.username ?? '');
|
||||||
|
const password = String(req.body?.password ?? '');
|
||||||
|
const admin = getAdmin(ctx.db);
|
||||||
|
const ok = !!admin && admin.username === username && (await verifyPassword(password, admin.password_hash));
|
||||||
|
if (!ok) {
|
||||||
|
limiter.fail(req.ip);
|
||||||
|
req.log.warn({ ip: req.ip }, '管理员登录失败');
|
||||||
|
throw new HttpError(401, '用户名或密码错误');
|
||||||
|
}
|
||||||
|
limiter.succeed(req.ip);
|
||||||
|
|
||||||
|
const token = createSession(ctx.db, ctx.config.sessionTtlMs, req.ip, String(req.headers['user-agent'] ?? ''));
|
||||||
|
reply.setCookie(SESSION_COOKIE, token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: cookieSecure(ctx, req),
|
||||||
|
maxAge: Math.floor(ctx.config.sessionTtlMs / 1000),
|
||||||
|
});
|
||||||
|
return { username: admin!.username };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post('/auth/logout', async (req, reply) => {
|
||||||
|
deleteSession(ctx.db, req.sessionId);
|
||||||
|
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/auth/me', async () => {
|
||||||
|
return { username: getAdmin(ctx.db)?.username ?? '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 修改用户名或密码;修改密码后其他会话全部失效 */
|
||||||
|
app.put<{ Body: { currentPassword?: string; username?: string; newPassword?: string } }>(
|
||||||
|
'/auth/account',
|
||||||
|
async (req) => {
|
||||||
|
const admin = getAdmin(ctx.db)!;
|
||||||
|
const currentPassword = String(req.body?.currentPassword ?? '');
|
||||||
|
if (!(await verifyPassword(currentPassword, admin.password_hash))) throw badRequest('当前密码不正确');
|
||||||
|
|
||||||
|
const username = req.body?.username === undefined ? admin.username : String(req.body.username).trim();
|
||||||
|
if (!/^[A-Za-z0-9_.-]{1,32}$/.test(username)) throw badRequest('用户名只能包含字母、数字、下划线、点和连字符,最多 32 个字符');
|
||||||
|
|
||||||
|
const newPassword = req.body?.newPassword ? String(req.body.newPassword) : '';
|
||||||
|
if (newPassword && newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
throw badRequest(`新密码至少 ${MIN_PASSWORD_LENGTH} 个字符`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setAdminCredentials(ctx.db, username, newPassword || currentPassword);
|
||||||
|
if (newPassword) deleteOtherSessions(ctx.db, req.sessionId);
|
||||||
|
return { username };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { maskEnv, maskRequest, type ParamDef, type RequestSnapshot } from '../hooks/params.js';
|
||||||
|
import { parseParamDefs, type HookListRow, type HookRow, type ScriptVersionRow } from '../hooks/repo.js';
|
||||||
|
import type { ExecConfig, ExecutionListRow } from '../executor/store.js';
|
||||||
|
|
||||||
|
export function hookDto(row: HookRow, script: ScriptVersionRow | undefined) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
slug: row.slug,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
group: row.group_name,
|
||||||
|
method: row.method,
|
||||||
|
enabled: row.enabled === 1,
|
||||||
|
tokenHint: row.token_hint,
|
||||||
|
params: parseParamDefs(row),
|
||||||
|
workingDir: row.working_dir,
|
||||||
|
timeoutSec: row.timeout_sec,
|
||||||
|
responseMode: row.response_mode,
|
||||||
|
concurrency: row.concurrency,
|
||||||
|
currentVersion: script ? { id: script.id, version: script.version, createdAt: script.created_at } : null,
|
||||||
|
script: script?.content ?? '',
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hookListDto(row: HookListRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
slug: row.slug,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
group: row.group_name,
|
||||||
|
method: row.method,
|
||||||
|
enabled: row.enabled === 1,
|
||||||
|
responseMode: row.response_mode,
|
||||||
|
concurrency: row.concurrency,
|
||||||
|
currentVersion: row.current_version,
|
||||||
|
lastStatus: row.last_status,
|
||||||
|
lastAt: row.last_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramList(row: ExecutionListRow, truncate: number | null) {
|
||||||
|
const defs = JSON.parse(row.param_defs) as ParamDef[];
|
||||||
|
const sensitive = new Set(defs.filter((d) => d.sensitive).map((d) => d.env));
|
||||||
|
const masked = maskEnv(JSON.parse(row.env) as Record<string, string>, defs);
|
||||||
|
return Object.entries(masked).map(([env, value]) => ({
|
||||||
|
env,
|
||||||
|
value: truncate !== null && value.length > truncate ? `${value.slice(0, truncate)}…` : value,
|
||||||
|
sensitive: sensitive.has(env),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationMs(row: ExecutionListRow): number | null {
|
||||||
|
if (!row.started_at) return null;
|
||||||
|
return (row.finished_at ?? Date.now()) - row.started_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executionSummaryDto(row: ExecutionListRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
hookId: row.hook_id,
|
||||||
|
hookSlug: row.hook_slug,
|
||||||
|
hookName: row.hook_name,
|
||||||
|
scriptVersionId: row.script_version_id,
|
||||||
|
scriptVersion: row.script_version,
|
||||||
|
trigger: row.trigger_type,
|
||||||
|
sourceExecutionId: row.source_execution_id,
|
||||||
|
status: row.status,
|
||||||
|
requestIp: row.request_ip,
|
||||||
|
params: paramList(row, 80),
|
||||||
|
exitCode: row.exit_code,
|
||||||
|
signal: row.signal,
|
||||||
|
error: row.error,
|
||||||
|
stdoutBytes: row.stdout_bytes,
|
||||||
|
stderrBytes: row.stderr_bytes,
|
||||||
|
truncated: row.output_truncated === 1,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
startedAt: row.started_at,
|
||||||
|
finishedAt: row.finished_at,
|
||||||
|
durationMs: durationMs(row),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行详情:敏感参数与请求中对应字段均以掩码展示 */
|
||||||
|
export function executionDetailDto(row: ExecutionListRow, script: ScriptVersionRow | undefined) {
|
||||||
|
const defs = JSON.parse(row.param_defs) as ParamDef[];
|
||||||
|
return {
|
||||||
|
...executionSummaryDto(row),
|
||||||
|
params: paramList(row, null),
|
||||||
|
paramDefs: defs,
|
||||||
|
request: maskRequest(JSON.parse(row.request) as RequestSnapshot, defs),
|
||||||
|
config: JSON.parse(row.config) as ExecConfig,
|
||||||
|
script: script?.content ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { AppContext } from '../app.js';
|
||||||
|
import {
|
||||||
|
EXECUTION_STATUSES,
|
||||||
|
TRIGGER_TYPES,
|
||||||
|
type ExecutionStatus,
|
||||||
|
type ExecConfig,
|
||||||
|
type TriggerType,
|
||||||
|
} from '../executor/store.js';
|
||||||
|
import { badRequest, notFound } from '../util/http-error.js';
|
||||||
|
import { executionDetailDto, executionSummaryDto } from './dto.js';
|
||||||
|
|
||||||
|
const idParams = {
|
||||||
|
type: 'object',
|
||||||
|
properties: { id: { type: 'integer', minimum: 1 } },
|
||||||
|
required: ['id'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const LOG_CHUNK_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
export async function executionRoutes(app: FastifyInstance, ctx: AppContext): Promise<void> {
|
||||||
|
const { store, hooks, scheduler } = ctx;
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Querystring: { hookId?: number; status?: ExecutionStatus; trigger?: TriggerType; page?: number; pageSize?: number };
|
||||||
|
}>(
|
||||||
|
'/executions',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
hookId: { type: 'integer', minimum: 1 },
|
||||||
|
status: { type: 'string', enum: EXECUTION_STATUSES },
|
||||||
|
trigger: { type: 'string', enum: TRIGGER_TYPES },
|
||||||
|
page: { type: 'integer', minimum: 1, default: 1 },
|
||||||
|
pageSize: { type: 'integer', minimum: 1, maximum: 200, default: 20 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
const page = req.query.page ?? 1;
|
||||||
|
const pageSize = req.query.pageSize ?? 20;
|
||||||
|
const { items, total } = store.list(
|
||||||
|
{ hookId: req.query.hookId, status: req.query.status, trigger: req.query.trigger },
|
||||||
|
pageSize,
|
||||||
|
(page - 1) * pageSize,
|
||||||
|
);
|
||||||
|
return { items: items.map(executionSummaryDto), total, page, pageSize };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: number } }>('/executions/:id', { schema: { params: idParams } }, async (req) => {
|
||||||
|
const row = store.getDetailed(req.params.id);
|
||||||
|
if (!row) throw notFound('执行记录不存在');
|
||||||
|
return executionDetailDto(row, hooks.version(row.script_version_id));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 按字节偏移量增量读取 stdout / stderr,执行期间可持续轮询 */
|
||||||
|
app.get<{ Params: { id: number }; Querystring: { stdout?: number; stderr?: number } }>(
|
||||||
|
'/executions/:id/logs',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: idParams,
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
stdout: { type: 'integer', minimum: 0, default: 0 },
|
||||||
|
stderr: { type: 'integer', minimum: 0, default: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
const row = store.get(req.params.id);
|
||||||
|
if (!row) throw notFound('执行记录不存在');
|
||||||
|
const stdout = store.readOutput(row.id, 'stdout', req.query.stdout ?? 0, LOG_CHUNK_BYTES);
|
||||||
|
const stderr = store.readOutput(row.id, 'stderr', req.query.stderr ?? 0, LOG_CHUNK_BYTES);
|
||||||
|
return {
|
||||||
|
status: row.status,
|
||||||
|
finished: row.status !== 'queued' && row.status !== 'running',
|
||||||
|
truncated: row.output_truncated === 1,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: number } }>('/executions/:id/cancel', { schema: { params: idParams } }, async (req) => {
|
||||||
|
if (!store.get(req.params.id)) throw notFound('执行记录不存在');
|
||||||
|
const result = scheduler.cancel(req.params.id);
|
||||||
|
if (result === 'inactive') throw badRequest('任务已结束,无法取消');
|
||||||
|
return { result };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 使用原脚本版本与参数创建新任务;执行策略取接口当前配置 */
|
||||||
|
app.post<{ Params: { id: number } }>('/executions/:id/rerun', { schema: { params: idParams } }, async (req, reply) => {
|
||||||
|
const source = store.get(req.params.id);
|
||||||
|
if (!source) throw notFound('执行记录不存在');
|
||||||
|
const hook = hooks.require(source.hook_id);
|
||||||
|
const config: ExecConfig = {
|
||||||
|
workingDir: hook.working_dir,
|
||||||
|
timeoutSec: hook.timeout_sec,
|
||||||
|
responseMode: hook.response_mode,
|
||||||
|
};
|
||||||
|
const executionId = scheduler.enqueue({
|
||||||
|
hookId: hook.id,
|
||||||
|
scriptVersionId: source.script_version_id,
|
||||||
|
trigger: 'rerun',
|
||||||
|
sourceExecutionId: source.id,
|
||||||
|
env: JSON.parse(source.env) as Record<string, string>,
|
||||||
|
paramDefs: JSON.parse(source.param_defs),
|
||||||
|
request: JSON.parse(source.request),
|
||||||
|
config,
|
||||||
|
requestIp: req.ip,
|
||||||
|
});
|
||||||
|
reply.code(202);
|
||||||
|
return { executionId };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { AppContext } from '../app.js';
|
||||||
|
import { buildSnapshot, extractParams, normalizeParamDefs, type RequestSnapshot } from '../hooks/params.js';
|
||||||
|
import { normalizeHookInput, parseParamDefs } from '../hooks/repo.js';
|
||||||
|
import { badRequest, conflict, notFound } from '../util/http-error.js';
|
||||||
|
import { hookDto, hookListDto } from './dto.js';
|
||||||
|
|
||||||
|
const idParams = {
|
||||||
|
type: 'object',
|
||||||
|
properties: { id: { type: 'integer', minimum: 1 } },
|
||||||
|
required: ['id'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface TestBody {
|
||||||
|
dryRun?: boolean;
|
||||||
|
/** 仅预览时使用:以尚未保存的参数配置预览映射结果 */
|
||||||
|
params?: unknown;
|
||||||
|
request?: {
|
||||||
|
query?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
bodyType?: RequestSnapshot['bodyKind'];
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BODY_TYPES: RequestSnapshot['bodyKind'][] = ['json', 'form', 'text', 'none'];
|
||||||
|
|
||||||
|
export async function hookRoutes(app: FastifyInstance, ctx: AppContext): Promise<void> {
|
||||||
|
const { hooks, scheduler } = ctx;
|
||||||
|
|
||||||
|
const detail = (id: number) => {
|
||||||
|
const hook = hooks.require(id);
|
||||||
|
return hookDto(hook, hook.current_version_id ? hooks.version(hook.current_version_id) : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/hooks', async () => {
|
||||||
|
return { items: hooks.list().map(hookListDto), groups: hooks.groups() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/hooks', async (req, reply) => {
|
||||||
|
const { hook, token } = hooks.create(normalizeHookInput(req.body));
|
||||||
|
reply.code(201);
|
||||||
|
return { hook: detail(hook.id), token };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: number } }>('/hooks/:id', { schema: { params: idParams } }, async (req) => {
|
||||||
|
return detail(req.params.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: number } }>('/hooks/:id', { schema: { params: idParams } }, async (req) => {
|
||||||
|
hooks.update(req.params.id, normalizeHookInput(req.body));
|
||||||
|
scheduler.schedule();
|
||||||
|
return detail(req.params.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: number } }>('/hooks/:id', { schema: { params: idParams } }, async (req) => {
|
||||||
|
hooks.require(req.params.id);
|
||||||
|
if (scheduler.hasRunning(req.params.id)) throw conflict('该接口有正在运行的任务,请先终止后再删除');
|
||||||
|
hooks.delete(req.params.id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: number }; Body: { slug?: string; name?: string } }>(
|
||||||
|
'/hooks/:id/copy',
|
||||||
|
{ schema: { params: idParams } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { hook, token } = hooks.copy(req.params.id, String(req.body?.slug ?? ''), String(req.body?.name ?? ''));
|
||||||
|
reply.code(201);
|
||||||
|
return { hook: detail(hook.id), token };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 启用或停用;停用时取消该接口尚未开始的任务 */
|
||||||
|
app.post<{ Params: { id: number }; Body: { enabled?: boolean } }>(
|
||||||
|
'/hooks/:id/enabled',
|
||||||
|
{ schema: { params: idParams } },
|
||||||
|
async (req) => {
|
||||||
|
const enabled = req.body?.enabled === true;
|
||||||
|
hooks.setEnabled(req.params.id, enabled);
|
||||||
|
const cancelled = enabled ? 0 : scheduler.cancelQueuedForHook(req.params.id, '接口已停用,排队任务被取消');
|
||||||
|
return { hook: detail(req.params.id), cancelled };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: number } }>('/hooks/:id/token', { schema: { params: idParams } }, async (req) => {
|
||||||
|
return { token: hooks.regenerateToken(req.params.id) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: number } }>('/hooks/:id/versions', { schema: { params: idParams } }, async (req) => {
|
||||||
|
const hook = hooks.require(req.params.id);
|
||||||
|
return {
|
||||||
|
currentVersionId: hook.current_version_id,
|
||||||
|
items: hooks.versions(hook.id).map((v) => ({ id: v.id, version: v.version, createdAt: v.created_at })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: number; versionId: number } }>(
|
||||||
|
'/hooks/:id/versions/:versionId',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { id: { type: 'integer' }, versionId: { type: 'integer' } },
|
||||||
|
required: ['id', 'versionId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
const version = hooks.version(req.params.versionId);
|
||||||
|
if (!version || version.hook_id !== req.params.id) throw notFound('脚本版本不存在');
|
||||||
|
return { id: version.id, version: version.version, content: version.content, createdAt: version.created_at };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在线测试:dryRun 只预览参数映射;否则使用当前保存的脚本版本真实执行。
|
||||||
|
* 测试任务跳过 Token 校验,其余流程与外部调用一致。
|
||||||
|
*/
|
||||||
|
app.post<{ Params: { id: number }; Body: TestBody }>(
|
||||||
|
'/hooks/:id/test',
|
||||||
|
{ schema: { params: idParams } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const hook = hooks.require(req.params.id);
|
||||||
|
const body = req.body ?? {};
|
||||||
|
const input = body.request ?? {};
|
||||||
|
const bodyType = input.bodyType ?? 'none';
|
||||||
|
if (!BODY_TYPES.includes(bodyType)) throw badRequest('请求体类型无效');
|
||||||
|
|
||||||
|
const snapshot = buildSnapshot({
|
||||||
|
method: hook.method,
|
||||||
|
query: String(input.query ?? ''),
|
||||||
|
headers: input.headers && typeof input.headers === 'object' ? input.headers : {},
|
||||||
|
bodyType,
|
||||||
|
body: String(input.body ?? ''),
|
||||||
|
});
|
||||||
|
const defs =
|
||||||
|
body.dryRun && body.params !== undefined ? normalizeParamDefs(body.params, hook.method) : parseParamDefs(hook);
|
||||||
|
const result = extractParams(defs, snapshot);
|
||||||
|
|
||||||
|
if (body.dryRun) {
|
||||||
|
return { ok: result.errors.length === 0, values: result.values, errors: result.errors, request: snapshot };
|
||||||
|
}
|
||||||
|
if (result.errors.length) throw badRequest('参数校验未通过,脚本未执行', result.errors);
|
||||||
|
if (!hook.current_version_id) throw badRequest('接口尚未保存脚本');
|
||||||
|
|
||||||
|
const executionId = scheduler.enqueue({
|
||||||
|
hookId: hook.id,
|
||||||
|
scriptVersionId: hook.current_version_id,
|
||||||
|
trigger: 'test',
|
||||||
|
sourceExecutionId: null,
|
||||||
|
env: result.env,
|
||||||
|
paramDefs: defs,
|
||||||
|
request: snapshot,
|
||||||
|
config: { workingDir: hook.working_dir, timeoutSec: hook.timeout_sec, responseMode: hook.response_mode },
|
||||||
|
requestIp: req.ip,
|
||||||
|
});
|
||||||
|
reply.code(202);
|
||||||
|
return { executionId };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { AppContext } from '../app.js';
|
||||||
|
import { executionSummaryDto } from './dto.js';
|
||||||
|
|
||||||
|
const FAILURE_STATUSES = ['failed', 'timeout', 'killed', 'interrupted'] as const;
|
||||||
|
|
||||||
|
export async function systemRoutes(app: FastifyInstance, ctx: AppContext): Promise<void> {
|
||||||
|
const { db, store, settings, scheduler } = ctx;
|
||||||
|
|
||||||
|
app.get('/overview', async () => {
|
||||||
|
const hookCounts = db.get<{ total: number; enabled: number }>(
|
||||||
|
'SELECT COUNT(*) AS total, COALESCE(SUM(enabled), 0) AS enabled FROM hooks',
|
||||||
|
)!;
|
||||||
|
const active = store.countByStatus();
|
||||||
|
const last24h = store.countByStatus(Date.now() - 24 * 3600 * 1000);
|
||||||
|
return {
|
||||||
|
hooks: hookCounts,
|
||||||
|
running: active.running ?? 0,
|
||||||
|
queued: active.queued ?? 0,
|
||||||
|
last24h: {
|
||||||
|
success: last24h.success ?? 0,
|
||||||
|
failed: FAILURE_STATUSES.reduce((sum, s) => sum + (last24h[s] ?? 0), 0),
|
||||||
|
total: Object.values(last24h).reduce((sum, n) => sum + n, 0),
|
||||||
|
},
|
||||||
|
recent: store.recent(10).map(executionSummaryDto),
|
||||||
|
recentFailures: store.recent(5, [...FAILURE_STATUSES]).map(executionSummaryDto),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/settings', async () => settings.get());
|
||||||
|
|
||||||
|
app.put('/settings', async (req) => {
|
||||||
|
const updated = settings.update((req.body ?? {}) as Record<string, unknown>);
|
||||||
|
scheduler.schedule();
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import formbody from '@fastify/formbody';
|
||||||
|
import type { FastifyError, FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
|
import type { AppContext } from '../app.js';
|
||||||
|
import type { ExecutionStatus } from '../executor/store.js';
|
||||||
|
import { extractParams, SECRET_HEADERS, TOKEN_QUERY_KEY, type RequestSnapshot } from '../hooks/params.js';
|
||||||
|
import { parseParamDefs } from '../hooks/repo.js';
|
||||||
|
import { safeEqual, sha256 } from '../util/crypto.js';
|
||||||
|
|
||||||
|
/** 同步模式下各终态对应的 HTTP 状态码 */
|
||||||
|
const SYNC_STATUS_CODES: Partial<Record<ExecutionStatus, number>> = {
|
||||||
|
success: 200,
|
||||||
|
failed: 500,
|
||||||
|
timeout: 504,
|
||||||
|
killed: 409,
|
||||||
|
cancelled: 409,
|
||||||
|
interrupted: 503,
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractToken(req: FastifyRequest): string {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (typeof auth === 'string' && /^bearer\s+/i.test(auth)) return auth.replace(/^bearer\s+/i, '').trim();
|
||||||
|
const header = req.headers['x-hook-token'];
|
||||||
|
if (typeof header === 'string' && header) return header.trim();
|
||||||
|
const query = (req.query as Record<string, unknown>)[TOKEN_QUERY_KEY];
|
||||||
|
return typeof query === 'string' ? query : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求快照:不包含鉴权头、Cookie 与 query 中的 token */
|
||||||
|
function snapshotRequest(req: FastifyRequest): RequestSnapshot {
|
||||||
|
const contentType = String(req.headers['content-type'] ?? '').toLowerCase();
|
||||||
|
|
||||||
|
const query: Record<string, string | string[]> = {};
|
||||||
|
for (const [key, value] of Object.entries((req.query ?? {}) as Record<string, unknown>)) {
|
||||||
|
if (key === TOKEN_QUERY_KEY || value === undefined) continue;
|
||||||
|
query[key] = Array.isArray(value) ? value.map(String) : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (SECRET_HEADERS.has(key) || value === undefined) continue;
|
||||||
|
headers[key] = Array.isArray(value) ? value.join(', ') : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = req.body ?? null;
|
||||||
|
let bodyKind: RequestSnapshot['bodyKind'];
|
||||||
|
if (body === null || body === '') {
|
||||||
|
body = null;
|
||||||
|
bodyKind = 'none';
|
||||||
|
} else if (contentType.includes('json') && typeof body === 'object') {
|
||||||
|
bodyKind = 'json';
|
||||||
|
} else if (contentType.includes('application/x-www-form-urlencoded') && typeof body === 'object') {
|
||||||
|
bodyKind = 'form';
|
||||||
|
body = { ...(body as Record<string, unknown>) };
|
||||||
|
} else {
|
||||||
|
bodyKind = 'text';
|
||||||
|
body = Buffer.isBuffer(body) ? body.toString('utf8') : typeof body === 'string' ? body : JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { method: req.method, contentType, query, headers, bodyKind, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function webhookRoutes(app: FastifyInstance, ctx: AppContext): Promise<void> {
|
||||||
|
const { hooks, scheduler, store } = ctx;
|
||||||
|
|
||||||
|
await app.register(formbody);
|
||||||
|
// 其他类型的请求体按文本接收,脚本可通过 HOOK_REQUEST_FILE 读取
|
||||||
|
app.addContentTypeParser('*', { parseAs: 'string' }, (_req, body, done) => done(null, body));
|
||||||
|
|
||||||
|
app.setErrorHandler((err: FastifyError, req, reply) => {
|
||||||
|
const statusCode = err.statusCode && err.statusCode >= 400 && err.statusCode < 500 ? err.statusCode : 500;
|
||||||
|
if (statusCode === 500) req.log.error({ err }, 'Webhook 处理失败');
|
||||||
|
reply.code(statusCode).send({
|
||||||
|
ok: false,
|
||||||
|
error: statusCode === 413 ? 'payload_too_large' : statusCode === 500 ? 'internal_error' : 'bad_request',
|
||||||
|
message: statusCode === 500 ? '服务器内部错误' : err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route<{ Params: { slug: string } }>({
|
||||||
|
method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||||
|
url: '/hooks/:slug',
|
||||||
|
handler: async (req, reply) => {
|
||||||
|
// 1. 查找配置,检查启用状态与请求方式
|
||||||
|
const hook = hooks.getBySlug(req.params.slug);
|
||||||
|
if (!hook) return reply.code(404).send({ ok: false, error: 'not_found', message: '接口不存在' });
|
||||||
|
if (!hook.enabled) return reply.code(403).send({ ok: false, error: 'disabled', message: '接口已停用' });
|
||||||
|
if (req.method !== hook.method) {
|
||||||
|
reply.header('Allow', hook.method);
|
||||||
|
return reply.code(405).send({ ok: false, error: 'method_not_allowed', message: `该接口只接受 ${hook.method} 请求` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 校验 Token(请求大小由 bodyLimit 限制)
|
||||||
|
const token = extractToken(req);
|
||||||
|
if (!token || !safeEqual(sha256(token), hook.token_hash)) {
|
||||||
|
return reply.code(401).send({ ok: false, error: 'unauthorized', message: 'Token 无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 提取并校验参数
|
||||||
|
const snapshot = snapshotRequest(req);
|
||||||
|
const defs = parseParamDefs(hook);
|
||||||
|
const result = extractParams(defs, snapshot);
|
||||||
|
if (result.errors.length) {
|
||||||
|
return reply.code(400).send({ ok: false, error: 'invalid_params', message: '参数校验未通过', details: result.errors });
|
||||||
|
}
|
||||||
|
if (!hook.current_version_id) {
|
||||||
|
return reply.code(500).send({ ok: false, error: 'no_script', message: '接口尚未配置脚本' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 持久化任务,固定参数与脚本版本;5. 由调度器按并发限制执行
|
||||||
|
const executionId = scheduler.enqueue({
|
||||||
|
hookId: hook.id,
|
||||||
|
scriptVersionId: hook.current_version_id,
|
||||||
|
trigger: 'webhook',
|
||||||
|
sourceExecutionId: null,
|
||||||
|
env: result.env,
|
||||||
|
paramDefs: defs,
|
||||||
|
request: snapshot,
|
||||||
|
config: { workingDir: hook.working_dir, timeoutSec: hook.timeout_sec, responseMode: hook.response_mode },
|
||||||
|
requestIp: req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. 异步模式在任务持久化后立即返回
|
||||||
|
if (hook.response_mode === 'async') {
|
||||||
|
return reply.code(202).send({ ok: true, executionId, status: 'queued' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await scheduler.waitFor(executionId);
|
||||||
|
const row = store.get(executionId)!;
|
||||||
|
const durationMs = row.started_at && row.finished_at ? row.finished_at - row.started_at : null;
|
||||||
|
return reply.code(SYNC_STATUS_CODES[row.status] ?? 500).send({
|
||||||
|
ok: row.status === 'success',
|
||||||
|
executionId,
|
||||||
|
status: row.status,
|
||||||
|
exitCode: row.exit_code,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Db } from './db.js';
|
||||||
|
import { badRequest } from './util/http-error.js';
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
/** 全局同时运行的任务上限 */
|
||||||
|
globalConcurrency: number;
|
||||||
|
/** 执行记录保留天数 */
|
||||||
|
retentionDays: number;
|
||||||
|
/** 单次执行输出保留上限(字节,stdout 与 stderr 合计) */
|
||||||
|
outputLimitBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
globalConcurrency: 4,
|
||||||
|
retentionDays: 30,
|
||||||
|
outputLimitBytes: 5 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
const RANGES: Record<keyof Settings, [min: number, max: number, label: string]> = {
|
||||||
|
globalConcurrency: [1, 64, '全局并发上限'],
|
||||||
|
retentionDays: [1, 3650, '日志保留天数'],
|
||||||
|
outputLimitBytes: [64 * 1024, 100 * 1024 * 1024, '任务输出大小上限'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEYS = Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[];
|
||||||
|
|
||||||
|
export class SettingsStore {
|
||||||
|
private cache: Settings;
|
||||||
|
|
||||||
|
constructor(private readonly db: Db) {
|
||||||
|
this.cache = this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): Settings {
|
||||||
|
return { ...this.cache };
|
||||||
|
}
|
||||||
|
|
||||||
|
update(patch: Record<string, unknown>): Settings {
|
||||||
|
const next = { ...this.cache };
|
||||||
|
for (const key of KEYS) {
|
||||||
|
if (patch[key] === undefined) continue;
|
||||||
|
const value = Number(patch[key]);
|
||||||
|
const [min, max, label] = RANGES[key];
|
||||||
|
if (!Number.isInteger(value) || value < min || value > max) {
|
||||||
|
throw badRequest(`${label}需为 ${min} 到 ${max} 之间的整数`);
|
||||||
|
}
|
||||||
|
next[key] = value;
|
||||||
|
}
|
||||||
|
this.db.transaction(() => {
|
||||||
|
for (const key of KEYS) {
|
||||||
|
this.db.run(
|
||||||
|
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value',
|
||||||
|
key,
|
||||||
|
JSON.stringify(next[key]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.cache = next;
|
||||||
|
return this.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): Settings {
|
||||||
|
const settings = { ...DEFAULT_SETTINGS };
|
||||||
|
for (const row of this.db.all<{ key: string; value: string }>('SELECT key, value FROM settings')) {
|
||||||
|
if ((KEYS as string[]).includes(row.key)) {
|
||||||
|
const value = Number(JSON.parse(row.value));
|
||||||
|
if (Number.isInteger(value)) settings[row.key as keyof Settings] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
export function sha256(value: string): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomToken(bytes = 24): string {
|
||||||
|
return randomBytes(bytes).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeEqual(a: string, b: string): boolean {
|
||||||
|
const left = Buffer.from(a);
|
||||||
|
const right = Buffer.from(b);
|
||||||
|
return left.length === right.length && timingSafeEqual(left, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Webhook 调用凭证,数据库只保存其哈希 */
|
||||||
|
export function generateHookToken(): string {
|
||||||
|
return `hp_${randomToken(24)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenHint(token: string): string {
|
||||||
|
return token.slice(-4);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly statusCode: number,
|
||||||
|
message: string,
|
||||||
|
public readonly details?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const badRequest = (message: string, details?: unknown) => new HttpError(400, message, details);
|
||||||
|
export const notFound = (message = '资源不存在') => new HttpError(404, message);
|
||||||
|
export const conflict = (message: string) => new HttpError(409, message);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// node:sqlite 在 Node 22 中仍标记为实验特性,只屏蔽它的启动警告,其余警告照常输出。
|
||||||
|
// 必须作为入口的第一个 import,才能在 node:sqlite 加载前生效。
|
||||||
|
const originalEmitWarning = process.emitWarning.bind(process) as (...args: unknown[]) => void;
|
||||||
|
|
||||||
|
process.emitWarning = ((warning: string | Error, ...rest: unknown[]) => {
|
||||||
|
const text = typeof warning === 'string' ? warning : warning?.message;
|
||||||
|
if (typeof text === 'string' && text.includes('SQLite is an experimental feature')) return;
|
||||||
|
originalEmitWarning(warning, ...rest);
|
||||||
|
}) as typeof process.emitWarning;
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%232563eb'/><path d='M9 17l5 5 9-12' stroke='white' stroke-width='3.2' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>" />
|
||||||
|
<title>HookPilot</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@hookpilot/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "^6.11.1",
|
||||||
|
"@codemirror/language": "^6.12.4",
|
||||||
|
"@codemirror/legacy-modes": "^6.5.4",
|
||||||
|
"@codemirror/state": "^6.7.6",
|
||||||
|
"@codemirror/view": "^6.43.13",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
|
"vue": "^3.5.43",
|
||||||
|
"vue-router": "^5.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.20.4",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.9",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^8.3.1",
|
||||||
|
"vue-tsc": "^3.3.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DialogHost from './components/DialogHost.vue';
|
||||||
|
import ToastHost from './components/ToastHost.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
<DialogHost />
|
||||||
|
<ToastHost />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type {
|
||||||
|
ExecutionDetail,
|
||||||
|
ExecutionStatus,
|
||||||
|
ExecutionSummary,
|
||||||
|
HookDetail,
|
||||||
|
HookForm,
|
||||||
|
HookSummary,
|
||||||
|
LogsResponse,
|
||||||
|
Overview,
|
||||||
|
Paged,
|
||||||
|
ParamDef,
|
||||||
|
PreviewResult,
|
||||||
|
ScriptVersion,
|
||||||
|
ScriptVersionSummary,
|
||||||
|
Settings,
|
||||||
|
TestRequest,
|
||||||
|
TriggerType,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
public readonly details?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unauthorizedHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function onUnauthorized(handler: () => void): void {
|
||||||
|
unauthorizedHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
|
||||||
|
const init: RequestInit = { method, credentials: 'same-origin', headers: {} };
|
||||||
|
if (method !== 'GET') {
|
||||||
|
init.headers = { 'Content-Type': 'application/json' };
|
||||||
|
init.body = JSON.stringify(body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, init);
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(0, '无法连接到服务器');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let data: unknown = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (data ?? {}) as { error?: string; details?: unknown };
|
||||||
|
if (response.status === 401 && url !== '/api/auth/login') unauthorizedHandler?.();
|
||||||
|
throw new ApiError(response.status, payload.error ?? `请求失败(HTTP ${response.status})`, payload.details);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(params: Record<string, string | number | undefined>): string {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== '') search.set(key, String(value));
|
||||||
|
}
|
||||||
|
const text = search.toString();
|
||||||
|
return text ? `?${text}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
request<{ username: string }>('POST', '/api/auth/login', { username, password }),
|
||||||
|
logout: () => request<{ ok: boolean }>('POST', '/api/auth/logout'),
|
||||||
|
me: () => request<{ username: string }>('GET', '/api/auth/me'),
|
||||||
|
updateAccount: (body: { currentPassword: string; username?: string; newPassword?: string }) =>
|
||||||
|
request<{ username: string }>('PUT', '/api/auth/account', body),
|
||||||
|
|
||||||
|
overview: () => request<Overview>('GET', '/api/overview'),
|
||||||
|
|
||||||
|
listHooks: () => request<{ items: HookSummary[]; groups: string[] }>('GET', '/api/hooks'),
|
||||||
|
getHook: (id: number) => request<HookDetail>('GET', `/api/hooks/${id}`),
|
||||||
|
createHook: (form: HookForm) => request<{ hook: HookDetail; token: string }>('POST', '/api/hooks', form),
|
||||||
|
updateHook: (id: number, form: HookForm) => request<HookDetail>('PUT', `/api/hooks/${id}`, form),
|
||||||
|
deleteHook: (id: number) => request<{ ok: boolean }>('DELETE', `/api/hooks/${id}`),
|
||||||
|
copyHook: (id: number, slug: string, name: string) =>
|
||||||
|
request<{ hook: HookDetail; token: string }>('POST', `/api/hooks/${id}/copy`, { slug, name }),
|
||||||
|
setHookEnabled: (id: number, enabled: boolean) =>
|
||||||
|
request<{ hook: HookDetail; cancelled: number }>('POST', `/api/hooks/${id}/enabled`, { enabled }),
|
||||||
|
regenerateToken: (id: number) => request<{ token: string }>('POST', `/api/hooks/${id}/token`),
|
||||||
|
listVersions: (id: number) =>
|
||||||
|
request<{ currentVersionId: number | null; items: ScriptVersionSummary[] }>('GET', `/api/hooks/${id}/versions`),
|
||||||
|
getVersion: (id: number, versionId: number) =>
|
||||||
|
request<ScriptVersion>('GET', `/api/hooks/${id}/versions/${versionId}`),
|
||||||
|
previewTest: (id: number, requestBody: TestRequest, params: ParamDef[]) =>
|
||||||
|
request<PreviewResult>('POST', `/api/hooks/${id}/test`, { dryRun: true, params, request: requestBody }),
|
||||||
|
runTest: (id: number, requestBody: TestRequest) =>
|
||||||
|
request<{ executionId: number }>('POST', `/api/hooks/${id}/test`, { dryRun: false, request: requestBody }),
|
||||||
|
|
||||||
|
listExecutions: (filter: {
|
||||||
|
hookId?: number;
|
||||||
|
status?: ExecutionStatus | '';
|
||||||
|
trigger?: TriggerType | '';
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) => request<Paged<ExecutionSummary>>('GET', `/api/executions${query(filter)}`),
|
||||||
|
getExecution: (id: number) => request<ExecutionDetail>('GET', `/api/executions/${id}`),
|
||||||
|
getLogs: (id: number, stdout: number, stderr: number) =>
|
||||||
|
request<LogsResponse>('GET', `/api/executions/${id}/logs${query({ stdout, stderr })}`),
|
||||||
|
cancelExecution: (id: number) =>
|
||||||
|
request<{ result: 'cancelled' | 'killing' }>('POST', `/api/executions/${id}/cancel`),
|
||||||
|
rerunExecution: (id: number) => request<{ executionId: number }>('POST', `/api/executions/${id}/rerun`),
|
||||||
|
|
||||||
|
getSettings: () => request<Settings>('GET', '/api/settings'),
|
||||||
|
updateSettings: (settings: Settings) => request<Settings>('PUT', '/api/settings', settings),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function errorMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
if (Array.isArray(err.details) && err.details.length) return `${err.message}:${err.details.join(';')}`;
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
export type HttpMethod = 'GET' | 'POST';
|
||||||
|
export type ResponseMode = 'sync' | 'async';
|
||||||
|
export type ParamSource = 'json' | 'form' | 'query' | 'header';
|
||||||
|
export type ParamType = 'string' | 'number' | 'boolean' | 'ip';
|
||||||
|
export type BodyKind = 'json' | 'form' | 'text' | 'none';
|
||||||
|
export type ExecutionStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'success'
|
||||||
|
| 'failed'
|
||||||
|
| 'timeout'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'killed'
|
||||||
|
| 'interrupted';
|
||||||
|
export type TriggerType = 'webhook' | 'test' | 'rerun';
|
||||||
|
|
||||||
|
export interface ParamDef {
|
||||||
|
env: string;
|
||||||
|
source: ParamSource;
|
||||||
|
key: string;
|
||||||
|
type: ParamType;
|
||||||
|
required: boolean;
|
||||||
|
default: string;
|
||||||
|
sensitive: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookForm {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
group: string;
|
||||||
|
method: HttpMethod;
|
||||||
|
params: ParamDef[];
|
||||||
|
script: string;
|
||||||
|
workingDir: string;
|
||||||
|
timeoutSec: number;
|
||||||
|
responseMode: ResponseMode;
|
||||||
|
concurrency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookDetail extends HookForm {
|
||||||
|
id: number;
|
||||||
|
enabled: boolean;
|
||||||
|
tokenHint: string;
|
||||||
|
currentVersion: { id: number; version: number; createdAt: number } | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookSummary {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
group: string;
|
||||||
|
method: HttpMethod;
|
||||||
|
enabled: boolean;
|
||||||
|
responseMode: ResponseMode;
|
||||||
|
concurrency: number;
|
||||||
|
currentVersion: number | null;
|
||||||
|
lastStatus: ExecutionStatus | null;
|
||||||
|
lastAt: number | null;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptVersionSummary {
|
||||||
|
id: number;
|
||||||
|
version: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptVersion extends ScriptVersionSummary {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParamValue {
|
||||||
|
env: string;
|
||||||
|
value: string;
|
||||||
|
origin: 'request' | 'default' | 'empty';
|
||||||
|
sensitive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestSnapshot {
|
||||||
|
method: string;
|
||||||
|
contentType: string;
|
||||||
|
query: Record<string, string | string[]>;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
bodyKind: BodyKind;
|
||||||
|
body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionSummary {
|
||||||
|
id: number;
|
||||||
|
hookId: number;
|
||||||
|
hookSlug: string;
|
||||||
|
hookName: string;
|
||||||
|
scriptVersionId: number;
|
||||||
|
scriptVersion: number;
|
||||||
|
trigger: TriggerType;
|
||||||
|
sourceExecutionId: number | null;
|
||||||
|
status: ExecutionStatus;
|
||||||
|
requestIp: string;
|
||||||
|
params: { env: string; value: string; sensitive: boolean }[];
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: string | null;
|
||||||
|
error: string | null;
|
||||||
|
stdoutBytes: number;
|
||||||
|
stderrBytes: number;
|
||||||
|
truncated: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
startedAt: number | null;
|
||||||
|
finishedAt: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionDetail extends ExecutionSummary {
|
||||||
|
paramDefs: ParamDef[];
|
||||||
|
request: RequestSnapshot;
|
||||||
|
config: { workingDir: string; timeoutSec: number; responseMode: ResponseMode };
|
||||||
|
script: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogChunk {
|
||||||
|
data: string;
|
||||||
|
nextOffset: number;
|
||||||
|
more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogsResponse {
|
||||||
|
status: ExecutionStatus;
|
||||||
|
finished: boolean;
|
||||||
|
truncated: boolean;
|
||||||
|
stdout: LogChunk;
|
||||||
|
stderr: LogChunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Overview {
|
||||||
|
hooks: { total: number; enabled: number };
|
||||||
|
running: number;
|
||||||
|
queued: number;
|
||||||
|
last24h: { success: number; failed: number; total: number };
|
||||||
|
recent: ExecutionSummary[];
|
||||||
|
recentFailures: ExecutionSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
globalConcurrency: number;
|
||||||
|
retentionDays: number;
|
||||||
|
outputLimitBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paged<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRequest {
|
||||||
|
query: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
bodyType: BodyKind;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewResult {
|
||||||
|
ok: boolean;
|
||||||
|
values: ParamValue[];
|
||||||
|
errors: string[];
|
||||||
|
request: RequestSnapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { currentUser, setUser } from '../composables/auth';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ name: 'overview', label: '概览', icon: 'M3 12l9-8 9 8M5 10v10h14V10' },
|
||||||
|
{ name: 'hooks', label: '接口管理', icon: 'M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7' },
|
||||||
|
{ name: 'executions', label: '执行记录', icon: 'M4 6h16M4 12h16M4 18h10' },
|
||||||
|
{ name: 'settings', label: '系统设置', icon: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z' },
|
||||||
|
{ name: 'account', label: '账户设置', icon: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeNav = computed(() => (route.meta.nav as string | undefined) ?? (route.name as string));
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await api.logout();
|
||||||
|
} finally {
|
||||||
|
setUser(null);
|
||||||
|
await router.push({ name: 'login' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<svg viewBox="0 0 32 32" width="26" height="26" aria-hidden="true">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#2563eb" />
|
||||||
|
<path d="M9 17l5 5 9-12" stroke="#fff" stroke-width="3.2" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>HookPilot</span>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in NAV"
|
||||||
|
:key="item.name"
|
||||||
|
:to="{ name: item.name }"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: activeNav === item.name }"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path :d="item.icon" />
|
||||||
|
</svg>
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<span class="user">{{ currentUser }}</span>
|
||||||
|
<button class="btn btn-ghost btn-sm logout" @click="logout">退出</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="content">
|
||||||
|
<RouterView :key="route.path" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 208px;
|
||||||
|
height: 100vh;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--sidebar);
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px 18px 22px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--sidebar-active);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.sidebar-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-top: 1px solid #1e293b;
|
||||||
|
}
|
||||||
|
.user {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.logout {
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
}
|
||||||
|
.logout:hover:not(:disabled) {
|
||||||
|
background: var(--sidebar-active);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 24px 28px 40px;
|
||||||
|
max-width: 1320px;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 18px 14px 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref, watch } from 'vue';
|
||||||
|
import { closeDialog, dialogState } from '../composables/dialog';
|
||||||
|
import ModalDialog from './ModalDialog.vue';
|
||||||
|
|
||||||
|
const firstInput = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => dialogState.open,
|
||||||
|
async (open) => {
|
||||||
|
if (open && dialogState.options?.input) {
|
||||||
|
await nextTick();
|
||||||
|
firstInput.value?.focus();
|
||||||
|
firstInput.value?.select();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ModalDialog :open="dialogState.open" :title="dialogState.options?.title ?? ''" @close="closeDialog(false)">
|
||||||
|
<template v-if="dialogState.options">
|
||||||
|
<div v-if="dialogState.options.warning" class="alert alert-warning">
|
||||||
|
<span class="alert-icon">!</span>
|
||||||
|
<span>{{ dialogState.options.warning }}</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 0; white-space: pre-line">{{ dialogState.options.message }}</p>
|
||||||
|
<form v-if="dialogState.options.input" class="stack" @submit.prevent="closeDialog(true)">
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ dialogState.options.input.label }}</label>
|
||||||
|
<input ref="firstInput" v-model="dialogState.value" :placeholder="dialogState.options.input.placeholder" />
|
||||||
|
</div>
|
||||||
|
<div v-if="dialogState.options.input2" class="field">
|
||||||
|
<label>{{ dialogState.options.input2.label }}</label>
|
||||||
|
<input v-model="dialogState.value2" :placeholder="dialogState.options.input2.placeholder" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" hidden />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<button class="btn" @click="closeDialog(false)">取消</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
:class="dialogState.options?.danger ? 'btn-danger' : 'btn-primary'"
|
||||||
|
@click="closeDialog(true)"
|
||||||
|
>
|
||||||
|
{{ dialogState.options?.confirmText ?? '确定' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ open: boolean; title: string; wide?: boolean; closable?: boolean }>(), {
|
||||||
|
wide: false,
|
||||||
|
closable: true,
|
||||||
|
});
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && props.closable) emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (open) window.addEventListener('keydown', onKey);
|
||||||
|
else window.removeEventListener('keydown', onKey);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="modal-backdrop" @mousedown.self="closable && emit('close')">
|
||||||
|
<div class="modal" :class="{ wide }" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
<span class="spacer" />
|
||||||
|
<button v-if="closable" class="btn btn-ghost btn-sm" aria-label="关闭" @click="emit('close')">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.footer" class="modal-footer">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { api, errorMessage } from '../api/client';
|
||||||
|
import type { ExecutionStatus } from '../api/types';
|
||||||
|
import { formatBytes } from '../utils/format';
|
||||||
|
|
||||||
|
const props = defineProps<{ executionId: number }>();
|
||||||
|
const emit = defineEmits<{ status: [status: ExecutionStatus, finished: boolean] }>();
|
||||||
|
|
||||||
|
const POLL_MS = 1000;
|
||||||
|
|
||||||
|
const output = ref({ stdout: '', stderr: '' });
|
||||||
|
const offsets = { stdout: 0, stderr: 0 };
|
||||||
|
const active = ref<'stdout' | 'stderr'>('stdout');
|
||||||
|
const truncated = ref(false);
|
||||||
|
const finished = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const follow = ref(true);
|
||||||
|
const pane = ref<HTMLPreElement>();
|
||||||
|
let timer: number | undefined;
|
||||||
|
let disposed = false;
|
||||||
|
let lastStatus: ExecutionStatus | null = null;
|
||||||
|
|
||||||
|
async function poll(): Promise<void> {
|
||||||
|
if (disposed) return;
|
||||||
|
try {
|
||||||
|
const res = await api.getLogs(props.executionId, offsets.stdout, offsets.stderr);
|
||||||
|
if (disposed) return;
|
||||||
|
error.value = '';
|
||||||
|
if (res.stdout.data) output.value.stdout += res.stdout.data;
|
||||||
|
if (res.stderr.data) output.value.stderr += res.stderr.data;
|
||||||
|
offsets.stdout = res.stdout.nextOffset;
|
||||||
|
offsets.stderr = res.stderr.nextOffset;
|
||||||
|
truncated.value = res.truncated;
|
||||||
|
|
||||||
|
if (res.status !== lastStatus) {
|
||||||
|
lastStatus = res.status;
|
||||||
|
emit('status', res.status, res.finished);
|
||||||
|
}
|
||||||
|
if (follow.value) void scrollToEnd();
|
||||||
|
|
||||||
|
const more = res.stdout.more || res.stderr.more;
|
||||||
|
// 结束后再读一轮,确保最后一批输出已入库
|
||||||
|
if (res.finished && !more) {
|
||||||
|
if (finished.value) return;
|
||||||
|
finished.value = true;
|
||||||
|
}
|
||||||
|
timer = window.setTimeout(poll, more ? 0 : POLL_MS);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = errorMessage(err);
|
||||||
|
timer = window.setTimeout(poll, POLL_MS * 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToEnd() {
|
||||||
|
await nextTick();
|
||||||
|
if (pane.value) pane.value.scrollTop = pane.value.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const el = pane.value;
|
||||||
|
if (el) follow.value = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(stream: 'stdout' | 'stderr') {
|
||||||
|
active.value = stream;
|
||||||
|
follow.value = true;
|
||||||
|
void scrollToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(poll);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="output-viewer">
|
||||||
|
<div class="output-toolbar">
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab" :class="{ active: active === 'stdout' }" @click="select('stdout')">
|
||||||
|
stdout <span class="count">{{ formatBytes(offsets.stdout) }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" :class="{ active: active === 'stderr' }" @click="select('stderr')">
|
||||||
|
stderr <span class="count">{{ formatBytes(offsets.stderr) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="spacer" />
|
||||||
|
<span v-if="!finished" class="badge badge-info dot pulse">实时更新中</span>
|
||||||
|
<span v-if="truncated" class="badge badge-warning" title="输出超过上限,超出部分已丢弃">输出已截断</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="alert alert-danger small">读取输出失败:{{ error }}</div>
|
||||||
|
<pre ref="pane" class="code-block output-pane" @scroll="onScroll">{{ output[active] || (finished ? '(无输出)' : '等待输出…') }}</pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.output-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.output-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.output-toolbar .tabs {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.output-pane {
|
||||||
|
min-height: 220px;
|
||||||
|
max-height: 560px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { EditorView, basicSetup } from 'codemirror';
|
||||||
|
import { Compartment, EditorState } from '@codemirror/state';
|
||||||
|
import { keymap } from '@codemirror/view';
|
||||||
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
|
import { StreamLanguage, indentUnit } from '@codemirror/language';
|
||||||
|
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ modelValue: string; readonly?: boolean; height?: string }>(), {
|
||||||
|
readonly: false,
|
||||||
|
height: '460px',
|
||||||
|
});
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||||
|
|
||||||
|
const host = ref<HTMLDivElement>();
|
||||||
|
const editable = new Compartment();
|
||||||
|
let view: EditorView | null = null;
|
||||||
|
|
||||||
|
function editableExtensions(readonly: boolean) {
|
||||||
|
return [EditorState.readOnly.of(readonly), EditorView.editable.of(!readonly)];
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
view = new EditorView({
|
||||||
|
parent: host.value!,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: props.modelValue,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
keymap.of([indentWithTab]),
|
||||||
|
StreamLanguage.define(shell),
|
||||||
|
indentUnit.of(' '),
|
||||||
|
EditorState.tabSize.of(2),
|
||||||
|
editable.of(editableExtensions(props.readonly)),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) emit('update:modelValue', update.state.doc.toString());
|
||||||
|
}),
|
||||||
|
EditorView.theme({
|
||||||
|
'&': { height: props.height, fontSize: '13px' },
|
||||||
|
'.cm-scroller': { fontFamily: 'var(--mono)', lineHeight: '1.6' },
|
||||||
|
'.cm-gutters': { background: 'var(--surface-2)', borderRight: '1px solid var(--border)' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
if (view && value !== view.state.doc.toString()) {
|
||||||
|
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.readonly,
|
||||||
|
(readonly) => view?.dispatch({ effects: editable.reconfigure(editableExtensions(readonly)) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => view?.destroy());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="host" class="script-editor" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.script-editor {
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.script-editor :deep(.cm-focused) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { ExecutionStatus } from '../api/types';
|
||||||
|
import { STATUS_LABELS } from '../utils/format';
|
||||||
|
|
||||||
|
const props = defineProps<{ status: ExecutionStatus }>();
|
||||||
|
|
||||||
|
const variant = computed(() => {
|
||||||
|
switch (props.status) {
|
||||||
|
case 'success':
|
||||||
|
return 'badge-success';
|
||||||
|
case 'failed':
|
||||||
|
case 'timeout':
|
||||||
|
case 'killed':
|
||||||
|
return 'badge-danger';
|
||||||
|
case 'interrupted':
|
||||||
|
return 'badge-warning';
|
||||||
|
case 'running':
|
||||||
|
case 'queued':
|
||||||
|
return 'badge-info';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="badge dot" :class="[variant, { pulse: status === 'running' }]">{{ STATUS_LABELS[status] }}</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { dismissToast, toasts } from '../composables/toast';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toast-host">
|
||||||
|
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type" @click="dismissToast(t.id)">
|
||||||
|
{{ t.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { HttpMethod } from '../api/types';
|
||||||
|
import { toast } from '../composables/toast';
|
||||||
|
import { copyText, webhookUrl } from '../utils/format';
|
||||||
|
import ModalDialog from './ModalDialog.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ token: string | null; slug: string; method: HttpMethod }>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const url = computed(() => webhookUrl(props.slug));
|
||||||
|
const curl = computed(() => {
|
||||||
|
const lines = [`curl -X ${props.method} '${url.value}'`, ` -H 'Authorization: Bearer ${props.token}'`];
|
||||||
|
if (props.method === 'POST') {
|
||||||
|
lines.push(` -H 'Content-Type: application/json'`, ` -d '{}'`);
|
||||||
|
}
|
||||||
|
return lines.join(' \\\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copy(text: string, label: string) {
|
||||||
|
if (await copyText(text)) toast.success(`${label}已复制`);
|
||||||
|
else toast.error('复制失败,请手动选择复制');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ModalDialog :open="!!token" title="接口 Token" :closable="false" @close="emit('close')">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<span class="alert-icon">!</span>
|
||||||
|
<span>Token 只在此处完整显示一次,关闭后无法再次查看。请立即保存到调用方配置中;遗失后只能重新生成。</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Token</label>
|
||||||
|
<div class="row">
|
||||||
|
<input :value="token ?? ''" readonly class="mono" @focus="($event.target as HTMLInputElement).select()" />
|
||||||
|
<button class="btn" @click="copy(token ?? '', 'Token ')">复制</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>调用示例</label>
|
||||||
|
<pre class="code-block">{{ curl }}</pre>
|
||||||
|
<div class="hint">
|
||||||
|
也可通过请求头 <code>X-Hook-Token</code> 或 Query 参数 <code>?token=</code> 传递(Query 方式可能被代理日志记录,不推荐)。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<button class="btn" @click="copy(curl, '调用示例')">复制示例</button>
|
||||||
|
<button class="btn btn-primary" @click="emit('close')">我已保存</button>
|
||||||
|
</template>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
export const currentUser = ref<string | null>(null);
|
||||||
|
let checked = false;
|
||||||
|
|
||||||
|
/** 首次访问时向后端确认登录状态 */
|
||||||
|
export async function ensureSession(): Promise<boolean> {
|
||||||
|
if (checked) return currentUser.value !== null;
|
||||||
|
try {
|
||||||
|
currentUser.value = (await api.me()).username;
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
checked = true;
|
||||||
|
return currentUser.value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUser(username: string | null): void {
|
||||||
|
currentUser.value = username;
|
||||||
|
checked = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
|
export interface DialogOptions {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
/** 醒目提示,用于标明会真实操作服务器的动作 */
|
||||||
|
warning?: string;
|
||||||
|
input?: { label: string; placeholder?: string; value?: string };
|
||||||
|
input2?: { label: string; placeholder?: string; value?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogState {
|
||||||
|
open: boolean;
|
||||||
|
options: DialogOptions | null;
|
||||||
|
value: string;
|
||||||
|
value2: string;
|
||||||
|
resolve: ((result: { value: string; value2: string } | null) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dialogState = reactive<DialogState>({ open: false, options: null, value: '', value2: '', resolve: null });
|
||||||
|
|
||||||
|
function openDialog(options: DialogOptions): Promise<{ value: string; value2: string } | null> {
|
||||||
|
dialogState.resolve?.(null);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dialogState.options = options;
|
||||||
|
dialogState.value = options.input?.value ?? '';
|
||||||
|
dialogState.value2 = options.input2?.value ?? '';
|
||||||
|
dialogState.resolve = resolve;
|
||||||
|
dialogState.open = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDialog(confirmed: boolean): void {
|
||||||
|
const resolve = dialogState.resolve;
|
||||||
|
dialogState.open = false;
|
||||||
|
dialogState.resolve = null;
|
||||||
|
resolve?.(confirmed ? { value: dialogState.value, value2: dialogState.value2 } : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmDialog(options: DialogOptions): Promise<boolean> {
|
||||||
|
return (await openDialog(options)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptDialog(options: DialogOptions): Promise<{ value: string; value2: string } | null> {
|
||||||
|
return openDialog(options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
type: 'success' | 'error' | 'info';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
export const toasts = reactive<Toast[]>([]);
|
||||||
|
|
||||||
|
function push(type: Toast['type'], message: string, timeout: number): void {
|
||||||
|
const id = nextId++;
|
||||||
|
toasts.push({ id, type, message });
|
||||||
|
setTimeout(() => dismissToast(id), timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissToast(id: number): void {
|
||||||
|
const index = toasts.findIndex((t) => t.id === id);
|
||||||
|
if (index >= 0) toasts.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toast = {
|
||||||
|
success: (message: string) => push('success', message, 3000),
|
||||||
|
error: (message: string) => push('error', message, 6000),
|
||||||
|
info: (message: string) => push('info', message, 4000),
|
||||||
|
};
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue';
|
||||||
|
const component: DefineComponent<object, object, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
import { onUnauthorized } from './api/client';
|
||||||
|
import { setUser } from './composables/auth';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
onUnauthorized(() => {
|
||||||
|
setUser(null);
|
||||||
|
const current = router.currentRoute.value;
|
||||||
|
if (current.name !== 'login') {
|
||||||
|
void router.push({ name: 'login', query: { redirect: current.fullPath } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app');
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { ensureSession } from './composables/auth';
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { public: true } },
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('./components/AppLayout.vue'),
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'overview', component: () => import('./views/OverviewView.vue'), meta: { title: '概览' } },
|
||||||
|
{
|
||||||
|
path: 'webhooks',
|
||||||
|
name: 'hooks',
|
||||||
|
component: () => import('./views/HookListView.vue'),
|
||||||
|
meta: { title: '接口管理' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'webhooks/new',
|
||||||
|
name: 'hook-new',
|
||||||
|
component: () => import('./views/HookDetailView.vue'),
|
||||||
|
meta: { title: '新建接口', nav: 'hooks' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'webhooks/:id(\\d+)',
|
||||||
|
name: 'hook-detail',
|
||||||
|
component: () => import('./views/HookDetailView.vue'),
|
||||||
|
meta: { title: '接口详情', nav: 'hooks' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'executions',
|
||||||
|
name: 'executions',
|
||||||
|
component: () => import('./views/ExecutionListView.vue'),
|
||||||
|
meta: { title: '执行记录' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'executions/:id(\\d+)',
|
||||||
|
name: 'execution-detail',
|
||||||
|
component: () => import('./views/ExecutionDetailView.vue'),
|
||||||
|
meta: { title: '执行详情', nav: 'executions' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('./views/SettingsView.vue'),
|
||||||
|
meta: { title: '系统设置' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'account',
|
||||||
|
name: 'account',
|
||||||
|
component: () => import('./views/AccountView.vue'),
|
||||||
|
meta: { title: '账户设置' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
if (to.meta.public) return true;
|
||||||
|
if (await ensureSession()) return true;
|
||||||
|
return { name: 'login', query: to.fullPath !== '/' ? { redirect: to.fullPath } : {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
const title = to.meta.title as string | undefined;
|
||||||
|
document.title = title ? `${title} · HookPilot` : 'HookPilot';
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f8fafc;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--border-strong: #cbd5e1;
|
||||||
|
--text: #0f172a;
|
||||||
|
--text-2: #475569;
|
||||||
|
--text-3: #94a3b8;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-hover: #1d4ed8;
|
||||||
|
--primary-soft: #eff6ff;
|
||||||
|
--success: #16a34a;
|
||||||
|
--success-soft: #f0fdf4;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-hover: #b91c1c;
|
||||||
|
--danger-soft: #fef2f2;
|
||||||
|
--warning: #b45309;
|
||||||
|
--warning-soft: #fffbeb;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
--neutral-soft: #f1f5f9;
|
||||||
|
--sidebar: #0f172a;
|
||||||
|
--sidebar-text: #cbd5e1;
|
||||||
|
--sidebar-active: #1e293b;
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 1px 2px rgba(15, 23, 42, 0.05), 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||||
|
--shadow-lg: 0 20px 45px rgba(15, 23, 42, 0.18);
|
||||||
|
--mono: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre,
|
||||||
|
.mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--neutral-soft);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.faint {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.break {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.row.wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮 */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: var(--text-3);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
border-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: var(--danger-hover);
|
||||||
|
border-color: var(--danger-hover);
|
||||||
|
}
|
||||||
|
.btn-danger-outline {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
.btn-danger-outline:hover:not(:disabled) {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--neutral-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单 */
|
||||||
|
input[type='text'],
|
||||||
|
input[type='password'],
|
||||||
|
input[type='number'],
|
||||||
|
input:not([type]),
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
padding: 8px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
textarea.mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
input:disabled,
|
||||||
|
select:disabled,
|
||||||
|
textarea:disabled {
|
||||||
|
background: var(--neutral-soft);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
input[type='checkbox'],
|
||||||
|
input[type='radio'] {
|
||||||
|
accent-color: var(--primary);
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.field > label,
|
||||||
|
.field-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.field .hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.field .required::after {
|
||||||
|
content: ' *';
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px 20px;
|
||||||
|
}
|
||||||
|
.form-grid .full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.input-group .addon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
background: var(--neutral-soft);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.input-group input {
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.radio-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.radio-card.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-soft);
|
||||||
|
}
|
||||||
|
.radio-card input {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片 */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.card-body.flush {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面头部 */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page-header .subtitle {
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格 */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.table th {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.table tbody tr.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.table tbody tr.clickable:hover td {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.table.compact th,
|
||||||
|
.table.compact td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
.table .group-row td {
|
||||||
|
background: var(--neutral-soft);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 徽标 */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--neutral-soft);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.badge.dot::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
.badge-success {
|
||||||
|
background: var(--success-soft);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
.badge-danger {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.badge-warning {
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
.badge-info {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.badge-method {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.badge.pulse::before {
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提示条 */
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.alert-warning {
|
||||||
|
background: var(--warning-soft);
|
||||||
|
border-color: var(--warning-border);
|
||||||
|
color: #78350f;
|
||||||
|
}
|
||||||
|
.alert-danger {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
.alert-info {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
.alert-success {
|
||||||
|
background: var(--success-soft);
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
color: #14532d;
|
||||||
|
}
|
||||||
|
.alert .alert-icon {
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签页 */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-2);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
.tab .count {
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计卡片 */
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.stat-card .label {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat-card .sub {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 键值列表 */
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px minmax(0, 1fr);
|
||||||
|
gap: 10px 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.kv dt {
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.kv dd {
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块 */
|
||||||
|
.code-block {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 520px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.code-block.light {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹窗 */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10vh 16px 16px;
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.modal.wide {
|
||||||
|
max-width: 820px;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px 0;
|
||||||
|
}
|
||||||
|
.modal-body {
|
||||||
|
padding: 14px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 20px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通知 */
|
||||||
|
.toast-host {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 200;
|
||||||
|
max-width: min(420px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.toast.success {
|
||||||
|
border-left-color: var(--success);
|
||||||
|
}
|
||||||
|
.toast.error {
|
||||||
|
border-left-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.form-grid,
|
||||||
|
.radio-cards {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.kv {
|
||||||
|
grid-template-columns: 90px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { ExecutionStatus, ParamSource, ParamType, TriggerType } from '../api/types';
|
||||||
|
|
||||||
|
export const STATUS_LABELS: Record<ExecutionStatus, string> = {
|
||||||
|
queued: '排队中',
|
||||||
|
running: '运行中',
|
||||||
|
success: '成功',
|
||||||
|
failed: '失败',
|
||||||
|
timeout: '超时',
|
||||||
|
cancelled: '已取消',
|
||||||
|
killed: '已终止',
|
||||||
|
interrupted: '中断',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRIGGER_LABELS: Record<TriggerType, string> = {
|
||||||
|
webhook: '外部调用',
|
||||||
|
test: '在线测试',
|
||||||
|
rerun: '再次执行',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_LABELS: Record<ParamSource, string> = {
|
||||||
|
json: 'JSON 请求体',
|
||||||
|
form: '表单请求体',
|
||||||
|
query: 'Query 参数',
|
||||||
|
header: '请求头',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TYPE_LABELS: Record<ParamType, string> = {
|
||||||
|
string: '字符串',
|
||||||
|
number: '数字',
|
||||||
|
boolean: '布尔值',
|
||||||
|
ip: 'IP 地址',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isActive(status: ExecutionStatus): boolean {
|
||||||
|
return status === 'queued' || status === 'running';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(ms: number | null | undefined): string {
|
||||||
|
if (!ms) return '—';
|
||||||
|
const d = new Date(ms);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(ms: number | null | undefined): string {
|
||||||
|
if (!ms) return '—';
|
||||||
|
const diff = Date.now() - ms;
|
||||||
|
if (diff < 60_000) return '刚刚';
|
||||||
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
|
||||||
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
|
||||||
|
if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`;
|
||||||
|
return formatTime(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number | null | undefined): string {
|
||||||
|
if (ms === null || ms === undefined) return '—';
|
||||||
|
if (ms < 1000) return `${ms} ms`;
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`;
|
||||||
|
const minutes = Math.floor(ms / 60_000);
|
||||||
|
const seconds = Math.round((ms % 60_000) / 1000);
|
||||||
|
return `${minutes} 分 ${seconds} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webhookUrl(slug: string): string {
|
||||||
|
return `${window.location.origin}/hooks/${slug || '<标识>'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyText(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// 非 HTTPS 环境下 clipboard API 不可用,退回旧方法
|
||||||
|
const area = document.createElement('textarea');
|
||||||
|
area.value = text;
|
||||||
|
area.style.position = 'fixed';
|
||||||
|
area.style.opacity = '0';
|
||||||
|
document.body.appendChild(area);
|
||||||
|
area.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
area.remove();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export interface ScriptTemplate {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCRIPT_TEMPLATES: ScriptTemplate[] = [
|
||||||
|
{
|
||||||
|
name: '基础模板',
|
||||||
|
description: '严格模式、打印平台注入的变量',
|
||||||
|
content: `#!/usr/bin/env bash
|
||||||
|
# 参数通过环境变量传入(HOOK_ 前缀),请始终用双引号引用,如 "$HOOK_NAME"
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "执行 ID:$HOOK_EXECUTION_ID"
|
||||||
|
echo "接口标识:$HOOK_SLUG"
|
||||||
|
echo "触发方式:$HOOK_TRIGGER"
|
||||||
|
echo "完整请求:$HOOK_REQUEST_FILE"
|
||||||
|
|
||||||
|
# 在这里编写你的操作
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '更新家庭 IP',
|
||||||
|
description: '接收 HOOK_IP 并写入文件(配合 Lucky DDNS Webhook)',
|
||||||
|
content: `#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 需要在「参数」中配置:HOOK_IP ← JSON 字段 ip,类型 IP,必填
|
||||||
|
TARGET_FILE="/var/lib/hookpilot/workspace/home-ip.txt"
|
||||||
|
|
||||||
|
old_ip=""
|
||||||
|
if [[ -f "$TARGET_FILE" ]]; then
|
||||||
|
old_ip="$(cat "$TARGET_FILE")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$old_ip" == "$HOOK_IP" ]]; then
|
||||||
|
echo "IP 未变化:$HOOK_IP"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 先写临时文件再重命名,避免读到写了一半的内容
|
||||||
|
printf '%s\\n' "$HOOK_IP" > "$TARGET_FILE.tmp"
|
||||||
|
mv "$TARGET_FILE.tmp" "$TARGET_FILE"
|
||||||
|
echo "IP 已更新:\${old_ip:-无} -> $HOOK_IP"
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Git 拉取并部署',
|
||||||
|
description: '在工作目录中拉取代码后执行构建',
|
||||||
|
content: `#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 请在「执行策略」中把工作目录设置为仓库路径,并适当调大超时时间
|
||||||
|
echo "当前目录:$(pwd)"
|
||||||
|
git fetch --prune
|
||||||
|
git reset --hard "origin/\${HOOK_BRANCH:-main}"
|
||||||
|
git log -1 --oneline
|
||||||
|
|
||||||
|
# npm ci && npm run build
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '重启系统服务',
|
||||||
|
description: '通过预先配置的 sudo 规则重启服务',
|
||||||
|
content: `#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 需要部署者在 sudoers 中为服务账户单独授权该命令,例如:
|
||||||
|
# hookpilot ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx
|
||||||
|
SERVICE="nginx"
|
||||||
|
|
||||||
|
sudo -n /usr/bin/systemctl restart "$SERVICE"
|
||||||
|
systemctl is-active "$SERVICE"
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '读取完整请求',
|
||||||
|
description: '用 jq 从 HOOK_REQUEST_FILE 读取复杂字段',
|
||||||
|
content: `#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# HOOK_REQUEST_FILE 是本次任务专属的 JSON 文件,结构:
|
||||||
|
# { method, contentType, query, headers, bodyKind, body }
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
echo "需要安装 jq" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq '.body' "$HOOK_REQUEST_FILE"
|
||||||
|
jq -r '.headers["user-agent"] // "unknown"' "$HOOK_REQUEST_FILE"
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
import { api, errorMessage } from '../api/client';
|
||||||
|
import { currentUser, setUser } from '../composables/auth';
|
||||||
|
import { toast } from '../composables/toast';
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: currentUser.value ?? '',
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.currentPassword) {
|
||||||
|
toast.error('请输入当前密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.newPassword && form.newPassword !== form.confirmPassword) {
|
||||||
|
toast.error('两次输入的新密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.updateAccount({
|
||||||
|
currentPassword: form.currentPassword,
|
||||||
|
username: form.username.trim(),
|
||||||
|
newPassword: form.newPassword || undefined,
|
||||||
|
});
|
||||||
|
setUser(res.username);
|
||||||
|
toast.success(form.newPassword ? '账户已更新,其他设备上的登录已失效' : '账户已更新');
|
||||||
|
form.currentPassword = '';
|
||||||
|
form.newPassword = '';
|
||||||
|
form.confirmPassword = '';
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>账户设置</h1>
|
||||||
|
<div class="subtitle">平台只有一个管理员账户,不开放注册</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width: 560px">
|
||||||
|
<form class="card-body stack" autocomplete="off" @submit.prevent="save">
|
||||||
|
<div class="field">
|
||||||
|
<label class="required">用户名</label>
|
||||||
|
<input v-model="form.username" autocomplete="username" maxlength="32" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>新密码</label>
|
||||||
|
<input v-model="form.newPassword" type="password" autocomplete="new-password" placeholder="不修改请留空" />
|
||||||
|
<div class="hint">至少 8 个字符。修改后其他设备上的登录会话全部失效。</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.newPassword" class="field">
|
||||||
|
<label class="required">确认新密码</label>
|
||||||
|
<input v-model="form.confirmPassword" type="password" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="required">当前密码</label>
|
||||||
|
<input v-model="form.currentPassword" type="password" autocomplete="current-password" />
|
||||||
|
<div class="hint">修改任何账户信息都需要验证当前密码。</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" type="submit" :disabled="saving">{{ saving ? '保存中…' : '保存' }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="small faint">忘记密码时,可在服务器上执行 <code>npm run reset-password</code> 重置。</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { api, errorMessage } from '../api/client';
|
||||||
|
import type { ExecutionDetail, ExecutionStatus } from '../api/types';
|
||||||
|
import OutputViewer from '../components/OutputViewer.vue';
|
||||||
|
import ScriptEditor from '../components/ScriptEditor.vue';
|
||||||
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
|
import { confirmDialog } from '../composables/dialog';
|
||||||
|
import { toast } from '../composables/toast';
|
||||||
|
import { formatBytes, formatDuration, formatTime, isActive, TRIGGER_LABELS } from '../utils/format';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const id = Number(route.params.id);
|
||||||
|
|
||||||
|
const detail = ref<ExecutionDetail | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const section = ref<'output' | 'request' | 'script'>('output');
|
||||||
|
const acting = ref(false);
|
||||||
|
|
||||||
|
const bodyText = computed(() => {
|
||||||
|
const req = detail.value?.request;
|
||||||
|
if (!req || req.bodyKind === 'none') return '';
|
||||||
|
return typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
detail.value = await api.getExecution(id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatus(status: ExecutionStatus, finished: boolean) {
|
||||||
|
if (!detail.value) return;
|
||||||
|
// 状态变化时刷新详情,获取退出码、耗时等结果
|
||||||
|
if (status !== detail.value.status || finished) void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel() {
|
||||||
|
if (!detail.value) return;
|
||||||
|
const running = detail.value.status === 'running';
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: running ? '终止任务' : '取消任务',
|
||||||
|
message: running
|
||||||
|
? '将终止该任务的整个进程组(先 SIGTERM,3 秒后 SIGKILL)。终止只能停止后续执行,无法撤销脚本已经完成的操作(如已写入的文件)。'
|
||||||
|
: '该任务尚未开始,取消后不会执行。',
|
||||||
|
confirmText: running ? '终止' : '取消任务',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
acting.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.cancelExecution(id);
|
||||||
|
toast.success(res.result === 'cancelled' ? '任务已取消' : '已发送终止信号');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
acting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rerun() {
|
||||||
|
if (!detail.value) return;
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: '再次执行',
|
||||||
|
message: `将使用本次记录的脚本 v${detail.value.scriptVersion} 与相同参数创建新任务,执行策略(工作目录、超时)采用接口当前配置。新任务会记录来源 #${detail.value.id}。`,
|
||||||
|
warning: '再次执行会在服务器上真实运行脚本,其修改会实际生效且无法撤销。',
|
||||||
|
confirmText: '确认执行',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
acting.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.rerunExecution(id);
|
||||||
|
toast.success(`已创建任务 #${res.executionId}`);
|
||||||
|
await router.push({ name: 'execution-detail', params: { id: res.executionId } });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
acting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="loading" class="empty">加载中…</div>
|
||||||
|
<div v-else-if="!detail" class="empty">
|
||||||
|
执行记录不存在或已被清理。<RouterLink :to="{ name: 'executions' }">返回列表</RouterLink>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="small muted">
|
||||||
|
<RouterLink :to="{ name: 'executions' }">执行记录</RouterLink>
|
||||||
|
<span> / #{{ detail.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top: 4px">
|
||||||
|
<h1>执行 #{{ detail.id }}</h1>
|
||||||
|
<StatusBadge :status="detail.status" />
|
||||||
|
</div>
|
||||||
|
<div class="subtitle">
|
||||||
|
<RouterLink :to="{ name: 'hook-detail', params: { id: detail.hookId } }">{{ detail.hookName }}</RouterLink>
|
||||||
|
<span class="mono faint"> /hooks/{{ detail.hookSlug }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="spacer" />
|
||||||
|
<button v-if="isActive(detail.status)" class="btn btn-danger-outline" :disabled="acting" @click="cancel">
|
||||||
|
{{ detail.status === 'running' ? '终止任务' : '取消排队' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn" :disabled="acting" @click="rerun">再次执行</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h2>结果</h2></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>状态</dt>
|
||||||
|
<dd><StatusBadge :status="detail.status" /></dd>
|
||||||
|
<dt>退出码</dt>
|
||||||
|
<dd class="mono">{{ detail.exitCode ?? '—' }}<span v-if="detail.signal" class="faint"> (信号 {{ detail.signal }})</span></dd>
|
||||||
|
<template v-if="detail.error">
|
||||||
|
<dt>说明</dt>
|
||||||
|
<dd style="color: var(--danger)">{{ detail.error }}</dd>
|
||||||
|
</template>
|
||||||
|
<dt>耗时</dt>
|
||||||
|
<dd>{{ formatDuration(detail.durationMs) }}</dd>
|
||||||
|
<dt>接收时间</dt>
|
||||||
|
<dd>{{ formatTime(detail.createdAt) }}</dd>
|
||||||
|
<dt>开始时间</dt>
|
||||||
|
<dd>{{ formatTime(detail.startedAt) }}</dd>
|
||||||
|
<dt>结束时间</dt>
|
||||||
|
<dd>{{ formatTime(detail.finishedAt) }}</dd>
|
||||||
|
<dt>输出大小</dt>
|
||||||
|
<dd>
|
||||||
|
stdout {{ formatBytes(detail.stdoutBytes) }} · stderr {{ formatBytes(detail.stderrBytes) }}
|
||||||
|
<span v-if="detail.truncated" class="badge badge-warning">已截断</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h2>请求摘要</h2></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>触发方式</dt>
|
||||||
|
<dd>
|
||||||
|
{{ TRIGGER_LABELS[detail.trigger] }}
|
||||||
|
<RouterLink
|
||||||
|
v-if="detail.sourceExecutionId"
|
||||||
|
:to="{ name: 'execution-detail', params: { id: detail.sourceExecutionId } }"
|
||||||
|
>
|
||||||
|
(来源 #{{ detail.sourceExecutionId }})
|
||||||
|
</RouterLink>
|
||||||
|
</dd>
|
||||||
|
<dt>请求来源</dt>
|
||||||
|
<dd class="mono">{{ detail.requestIp || '—' }}</dd>
|
||||||
|
<dt>脚本版本</dt>
|
||||||
|
<dd>v{{ detail.scriptVersion }}</dd>
|
||||||
|
<dt>执行配置</dt>
|
||||||
|
<dd class="small">
|
||||||
|
{{ detail.config.responseMode === 'sync' ? '同步' : '异步' }} · 超时 {{ detail.config.timeoutSec }} 秒 ·
|
||||||
|
工作目录 <span class="mono">{{ detail.config.workingDir || '默认 workspace' }}</span>
|
||||||
|
</dd>
|
||||||
|
<dt>参数</dt>
|
||||||
|
<dd>
|
||||||
|
<span v-if="!detail.params.length" class="faint">无</span>
|
||||||
|
<div v-for="p in detail.params" :key="p.env" class="mono small">
|
||||||
|
<span class="muted">{{ p.env }}=</span><span v-if="p.value === ''" class="faint">(空)</span>{{ p.value }}
|
||||||
|
<span v-if="p.sensitive" class="badge" style="margin-left: 4px">敏感</span>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top: 16px">
|
||||||
|
<div class="tabs" style="padding: 0 10px">
|
||||||
|
<button class="tab" :class="{ active: section === 'output' }" @click="section = 'output'">输出</button>
|
||||||
|
<button class="tab" :class="{ active: section === 'request' }" @click="section = 'request'">请求详情</button>
|
||||||
|
<button class="tab" :class="{ active: section === 'script' }" @click="section = 'script'">脚本 v{{ detail.scriptVersion }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<OutputViewer v-show="section === 'output'" :execution-id="detail.id" @status="onStatus" />
|
||||||
|
|
||||||
|
<div v-if="section === 'request'" class="stack">
|
||||||
|
<div class="small muted">
|
||||||
|
鉴权头、Cookie 与 Query 中的 token 不会被记录;敏感参数对应的字段以 ****** 显示。
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>方法与内容类型</label>
|
||||||
|
<div class="mono">{{ detail.request.method }} · {{ detail.request.contentType || '(无)' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Query</label>
|
||||||
|
<pre class="code-block light">{{ Object.keys(detail.request.query).length ? JSON.stringify(detail.request.query, null, 2) : '(无)' }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>请求头</label>
|
||||||
|
<pre class="code-block light">{{ JSON.stringify(detail.request.headers, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>请求体</label>
|
||||||
|
<pre class="code-block light">{{ bodyText || '(无)' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="section === 'script'" class="stack">
|
||||||
|
<div class="small muted">本次执行固定使用的脚本版本,后续修改脚本不会影响该记录。</div>
|
||||||
|
<ScriptEditor :model-value="detail.script" readonly height="440px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { api, errorMessage } from '../api/client';
|
||||||
|
import type { ExecutionStatus, ExecutionSummary, HookSummary, TriggerType } from '../api/types';
|
||||||
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
|
import { toast } from '../composables/toast';
|
||||||
|
import { formatDuration, formatTime, isActive, STATUS_LABELS, TRIGGER_LABELS } from '../utils/format';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const filter = reactive<{ hookId?: number; status: ExecutionStatus | ''; trigger: TriggerType | ''; page: number }>({
|
||||||
|
hookId: route.query.hookId ? Number(route.query.hookId) : undefined,
|
||||||
|
status: (route.query.status as ExecutionStatus | undefined) ?? '',
|
||||||
|
trigger: (route.query.trigger as TriggerType | undefined) ?? '',
|
||||||
|
page: route.query.page ? Number(route.query.page) : 1,
|
||||||
|
});
|
||||||
|
const items = ref<ExecutionSummary[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const hooks = ref<HookSummary[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const autoRefresh = ref(true);
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
const pages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await api.listExecutions({ ...filter, pageSize: PAGE_SIZE });
|
||||||
|
items.value = res.items;
|
||||||
|
total.value = res.total;
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
if (!autoRefresh.value) return;
|
||||||
|
timer = window.setTimeout(async () => {
|
||||||
|
await load();
|
||||||
|
schedule();
|
||||||
|
}, items.value.some((e) => isActive(e.status)) ? 2000 : 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [filter.hookId, filter.status, filter.trigger],
|
||||||
|
() => {
|
||||||
|
filter.page = 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
filter,
|
||||||
|
async () => {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
if (filter.hookId) query.hookId = String(filter.hookId);
|
||||||
|
if (filter.status) query.status = filter.status;
|
||||||
|
if (filter.trigger) query.trigger = filter.trigger;
|
||||||
|
if (filter.page > 1) query.page = String(filter.page);
|
||||||
|
void router.replace({ query });
|
||||||
|
await load();
|
||||||
|
schedule();
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(autoRefresh, schedule);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
api
|
||||||
|
.listHooks()
|
||||||
|
.then((res) => (hooks.value = res.items))
|
||||||
|
.catch(() => undefined);
|
||||||
|
await load();
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => window.clearTimeout(timer));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>执行记录</h1>
|
||||||
|
<div class="subtitle">每次调用、测试与再次执行都会生成一条记录,保留天数可在系统设置中调整</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header" style="flex-wrap: wrap">
|
||||||
|
<select v-model="filter.hookId" style="max-width: 220px">
|
||||||
|
<option :value="undefined">全部接口</option>
|
||||||
|
<option v-for="h in hooks" :key="h.id" :value="h.id">{{ h.name }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filter.status" style="max-width: 140px">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option v-for="(label, key) in STATUS_LABELS" :key="key" :value="key">{{ label }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filter.trigger" style="max-width: 140px">
|
||||||
|
<option value="">全部来源</option>
|
||||||
|
<option v-for="(label, key) in TRIGGER_LABELS" :key="key" :value="key">{{ label }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="spacer" />
|
||||||
|
<label class="check small muted"><input v-model="autoRefresh" type="checkbox" /> 自动刷新</label>
|
||||||
|
<button class="btn btn-sm" @click="load">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body flush table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>接口</th>
|
||||||
|
<th>请求来源</th>
|
||||||
|
<th>参数摘要</th>
|
||||||
|
<th>版本</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>耗时</th>
|
||||||
|
<th>退出码</th>
|
||||||
|
<th>时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="e in items"
|
||||||
|
:key="e.id"
|
||||||
|
class="clickable"
|
||||||
|
@click="router.push({ name: 'execution-detail', params: { id: e.id } })"
|
||||||
|
>
|
||||||
|
<td class="mono">#{{ e.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ e.hookName }}</div>
|
||||||
|
<div class="small faint mono">/hooks/{{ e.hookSlug }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="nowrap">
|
||||||
|
<div>{{ TRIGGER_LABELS[e.trigger] }}<span v-if="e.sourceExecutionId" class="faint"> ← #{{ e.sourceExecutionId }}</span></div>
|
||||||
|
<div class="small faint mono">{{ e.requestIp || '—' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="params-cell">
|
||||||
|
<span v-if="!e.params.length" class="faint">—</span>
|
||||||
|
<div v-for="p in e.params.slice(0, 3)" :key="p.env" class="small mono break">
|
||||||
|
<span class="muted">{{ p.env }}=</span>{{ p.value }}
|
||||||
|
</div>
|
||||||
|
<div v-if="e.params.length > 3" class="small faint">等 {{ e.params.length }} 个</div>
|
||||||
|
</td>
|
||||||
|
<td class="muted">v{{ e.scriptVersion }}</td>
|
||||||
|
<td><StatusBadge :status="e.status" /></td>
|
||||||
|
<td class="nowrap">{{ formatDuration(e.durationMs) }}</td>
|
||||||
|
<td class="mono">{{ e.exitCode ?? '—' }}</td>
|
||||||
|
<td class="muted nowrap small">{{ formatTime(e.createdAt) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!loading && !items.length">
|
||||||
|
<td colspan="9" class="empty">暂无执行记录</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="small">共 {{ total }} 条</span>
|
||||||
|
<span class="spacer" />
|
||||||
|
<button class="btn btn-sm" :disabled="filter.page <= 1" @click="filter.page--">上一页</button>
|
||||||
|
<span class="small">{{ filter.page }} / {{ pages }}</span>
|
||||||
|
<button class="btn btn-sm" :disabled="filter.page >= pages" @click="filter.page++">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.params-cell {
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
|
||||||
|
import { api, errorMessage } from '../api/client';
|
||||||
|
import type { HookDetail, HookForm } from '../api/types';
|
||||||
|
import TokenDialog from '../components/TokenDialog.vue';
|
||||||
|
import { confirmDialog, promptDialog } from '../composables/dialog';
|
||||||
|
import { toast } from '../composables/toast';
|
||||||
|
import { SCRIPT_TEMPLATES } from '../utils/templates';
|
||||||
|
import BasicTab from './hook/BasicTab.vue';
|
||||||
|
import ParamsTab from './hook/ParamsTab.vue';
|
||||||
|
import PolicyTab from './hook/PolicyTab.vue';
|
||||||
|
import ScriptTab from './hook/ScriptTab.vue';
|
||||||
|
import TestTab from './hook/TestTab.vue';
|
||||||
|
|
||||||
|
type TabKey = 'basic' | 'params' | 'script' | 'policy' | 'test';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const isNew = route.name === 'hook-new';
|
||||||
|
const hookId = isNew ? 0 : Number(route.params.id);
|
||||||
|
|
||||||
|
const TABS: { key: TabKey; label: string }[] = [
|
||||||
|
{ key: 'basic', label: '基本配置' },
|
||||||
|
{ key: 'params', label: '参数' },
|
||||||
|
{ key: 'script', label: '脚本' },
|
||||||
|
{ key: 'policy', label: '执行策略' },
|
||||||
|
{ key: 'test', label: '测试' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function emptyForm(): HookForm {
|
||||||
|
return {
|
||||||
|
slug: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
group: '',
|
||||||
|
method: 'POST',
|
||||||
|
params: [],
|
||||||
|
script: SCRIPT_TEMPLATES[0]!.content,
|
||||||
|
workingDir: '',
|
||||||
|
timeoutSec: 30,
|
||||||
|
responseMode: 'async',
|
||||||
|
concurrency: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toForm(hook: HookDetail): HookForm {
|
||||||
|
return {
|
||||||
|
slug: hook.slug,
|
||||||
|
name: hook.name,
|
||||||
|
description: hook.description,
|
||||||
|
group: hook.group,
|
||||||
|
method: hook.method,
|
||||||
|
params: hook.params.map((p) => ({ ...p })),
|
||||||
|
script: hook.script,
|
||||||
|
workingDir: hook.workingDir,
|
||||||
|
timeoutSec: hook.timeoutSec,
|
||||||
|
responseMode: hook.responseMode,
|
||||||
|
concurrency: hook.concurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialTab = TABS.some((t) => t.key === route.query.tab) ? (route.query.tab as TabKey) : 'basic';
|
||||||
|
const tab = ref<TabKey>(initialTab);
|
||||||
|
const hook = ref<HookDetail | null>(null);
|
||||||
|
const form = reactive<HookForm>(emptyForm());
|
||||||
|
const original = ref(JSON.stringify(form));
|
||||||
|
const groups = ref<string[]>([]);
|
||||||
|
const loading = ref(!isNew);
|
||||||
|
const saving = ref(false);
|
||||||
|
const versionsKey = ref(0);
|
||||||
|
const tokenInfo = ref<{ token: string; slug: string; method: HookForm['method'] } | null>(null);
|
||||||
|
let pendingNavigation: number | null = null;
|
||||||
|
|
||||||
|
const dirty = computed(() => JSON.stringify(form) !== original.value);
|
||||||
|
|
||||||
|
function applyHook(detail: HookDetail) {
|
||||||
|
hook.value = detail;
|
||||||
|
Object.assign(form, toForm(detail));
|
||||||
|
original.value = JSON.stringify(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [list, detail] = await Promise.all([api.listHooks(), isNew ? Promise.resolve(null) : api.getHook(hookId)]);
|
||||||
|
groups.value = list.groups;
|
||||||
|
if (detail) applyHook(detail);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
const res = await api.createHook({ ...form });
|
||||||
|
original.value = JSON.stringify(form);
|
||||||
|
pendingNavigation = res.hook.id;
|
||||||
|
tokenInfo.value = { token: res.token, slug: res.hook.slug, method: res.hook.method };
|
||||||
|
toast.success('接口已创建');
|
||||||
|
} else {
|
||||||
|
const previousVersion = hook.value?.currentVersion?.id;
|
||||||
|
const detail = await api.updateHook(hookId, { ...form });
|
||||||
|
applyHook(detail);
|
||||||
|
versionsKey.value++;
|
||||||
|
const newVersion = detail.currentVersion && detail.currentVersion.id !== previousVersion;
|
||||||
|
toast.success(newVersion ? `已保存,脚本生成新版本 v${detail.currentVersion!.version}` : '已保存');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeToken() {
|
||||||
|
tokenInfo.value = null;
|
||||||
|
if (pendingNavigation) {
|
||||||
|
const id = pendingNavigation;
|
||||||
|
pendingNavigation = null;
|
||||||
|
void router.replace({ name: 'hook-detail', params: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateToken() {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: '重新生成 Token',
|
||||||
|
message: '旧 Token 将立即失效,所有调用方需要更新为新 Token 才能继续调用。',
|
||||||
|
confirmText: '重新生成',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok || !hook.value) return;
|
||||||
|
try {
|
||||||
|
const { token } = await api.regenerateToken(hookId);
|
||||||
|
tokenInfo.value = { token, slug: hook.value.slug, method: hook.value.method };
|
||||||
|
hook.value.tokenHint = token.slice(-4);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled() {
|
||||||
|
if (!hook.value) return;
|
||||||
|
if (hook.value.enabled) {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: '停用接口',
|
||||||
|
message: '停用后将拒绝新的外部请求,并取消该接口所有尚未开始的任务。运行中的任务不受影响,可在执行记录中单独终止。',
|
||||||
|
confirmText: '停用',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.setHookEnabled(hookId, !hook.value.enabled);
|
||||||
|
hook.value.enabled = res.hook.enabled;
|
||||||
|
toast.success(res.hook.enabled ? '接口已启用' : `接口已停用${res.cancelled ? `,取消了 ${res.cancelled} 个排队任务` : ''}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyHook() {
|
||||||
|
if (!hook.value) return;
|
||||||
|
const result = await promptDialog({
|
||||||
|
title: '复制接口',
|
||||||
|
message: '将复制已保存的参数、脚本与执行策略,并生成新的 Token。副本默认处于停用状态。',
|
||||||
|
input: { label: '新接口标识', value: `${hook.value.slug}-copy` },
|
||||||
|
input2: { label: '新接口名称', value: `${hook.value.name}(副本)` },
|
||||||
|
confirmText: '复制',
|
||||||
|
});
|
||||||
|
if (!result) return;
|
||||||
|
try {
|
||||||
|
const res = await api.copyHook(hookId, result.value.trim(), result.value2.trim());
|
||||||
|
pendingNavigation = res.hook.id;
|
||||||
|
tokenInfo.value = { token: res.token, slug: res.hook.slug, method: res.hook.method };
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeHook() {
|
||||||
|
if (!hook.value) return;
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: '删除接口',
|
||||||
|
message: `确定删除「${hook.value.name}」吗?该接口的全部脚本版本与执行记录都会被删除,且无法恢复。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api.deleteHook(hookId);
|
||||||
|
original.value = JSON.stringify(form);
|
||||||
|
toast.success('接口已删除');
|
||||||
|
await router.replace({ name: 'hooks' });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTab(key: TabKey) {
|
||||||
|
tab.value = key;
|
||||||
|
void router.replace({ query: { ...route.query, tab: key } });
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeRouteLeave(async (to) => {
|
||||||
|
if (!dirty.value || to.name === 'login') return true;
|
||||||
|
return confirmDialog({
|
||||||
|
title: '放弃未保存的修改?',
|
||||||
|
message: '当前接口有尚未保存的修改,离开后这些修改将丢失。',
|
||||||
|
confirmText: '放弃修改',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="loading" class="empty">加载中…</div>
|
||||||
|
<template v-else-if="isNew || hook">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="small muted">
|
||||||
|
<RouterLink :to="{ name: 'hooks' }">接口管理</RouterLink>
|
||||||
|
<span> / </span>
|
||||||
|
<span>{{ isNew ? '新建接口' : hook!.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top: 4px">
|
||||||
|
<h1>{{ isNew ? '新建接口' : hook!.name }}</h1>
|
||||||
|
<span v-if="hook" class="badge dot" :class="hook.enabled ? 'badge-success' : ''">
|
||||||
|
{{ hook.enabled ? '已启用' : '已停用' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="dirty" class="badge badge-warning">未保存</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="spacer" />
|
||||||
|
<template v-if="hook">
|
||||||
|
<button class="btn" @click="toggleEnabled">{{ hook.enabled ? '停用' : '启用' }}</button>
|
||||||
|
<button class="btn" @click="copyHook">复制</button>
|
||||||
|
<button class="btn btn-danger-outline" @click="removeHook">删除</button>
|
||||||
|
</template>
|
||||||
|
<button class="btn btn-primary" :disabled="saving || (!isNew && !dirty)" @click="save">
|
||||||
|
{{ saving ? '保存中…' : isNew ? '创建接口' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="tabs" style="padding: 0 10px">
|
||||||
|
<button
|
||||||
|
v-for="t in TABS"
|
||||||
|
:key="t.key"
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: tab === t.key }"
|
||||||
|
:disabled="isNew && t.key === 'test'"
|
||||||
|
:title="isNew && t.key === 'test' ? '请先创建接口' : ''"
|
||||||
|
@click="selectTab(t.key)"
|
||||||
|
>
|
||||||
|
{{ t.label }}
|
||||||
|
<span v-if="t.key === 'params'" class="count">{{ form.params.length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<BasicTab
|
||||||
|
v-show="tab === 'basic'"
|
||||||
|
:form="form"
|
||||||
|
:hook="hook"
|
||||||
|
:groups="groups"
|
||||||
|
@regenerate-token="regenerateToken"
|
||||||
|
/>
|
||||||
|
<ParamsTab v-show="tab === 'params'" :form="form" />
|
||||||
|
<ScriptTab v-show="tab === 'script'" :form="form" :hook="hook" :versions-key="versionsKey" />
|
||||||
|
<PolicyTab v-show="tab === 'policy'" :form="form" />
|
||||||
|
<TestTab v-if="hook && tab === 'test'" :form="form" :hook="hook" :dirty="dirty" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty">接口不存在或已被删除。<RouterLink :to="{ name: 'hooks' }">返回列表</RouterLink></div>
|
||||||
|
|
||||||
|
<TokenDialog :token="tokenInfo?.token ?? null" :slug="tokenInfo?.slug ?? ''" :method="tokenInfo?.method ?? 'POST'" @close="closeToken" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { api, errorMessage } from '../api/client';
|
||||||
|
import type { HookSummary } from '../api/types';
|
||||||
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
|
import TokenDialog from '../components/TokenDialog.vue';
|
||||||
|
import { confirmDialog, promptDialog } from '../composables/dialog';
|
||||||
|
import { toast } from '../composables/toast';
|
||||||
|
import { formatRelative } from '../utils/format';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const hooks = ref<HookSummary[]>([]);
|
||||||
|
const groups = ref<string[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const search = ref('');
|
||||||
|
const groupFilter = ref('');
|
||||||
|
const copied = ref<{ token: string; slug: string; method: HookSummary['method']; id: number } | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await api.listHooks();
|
||||||
|
hooks.value = res.items;
|
||||||
|
groups.value = res.groups;
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = computed(() => {
|
||||||
|
const keyword = search.value.trim().toLowerCase();
|
||||||
|
const filtered = hooks.value.filter((h) => {
|
||||||
|
if (groupFilter.value && h.group !== groupFilter.value) return false;
|
||||||
|
if (!keyword) return true;
|
||||||
|
return [h.name, h.slug, h.description].some((v) => v.toLowerCase().includes(keyword));
|
||||||
|
});
|
||||||
|
const map = new Map<string, HookSummary[]>();
|
||||||
|
for (const hook of filtered) {
|
||||||
|
const list = map.get(hook.group) ?? [];
|
||||||
|
list.push(hook);
|
||||||
|
map.set(hook.group, list);
|
||||||
|
}
|
||||||
|
return [...map.entries()].map(([group, items]) => ({ group, items }));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggle(hook: HookSummary) {
|
||||||
|
if (hook.enabled) {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: '停用接口',
|
||||||
|
message: `停用「${hook.name}」后将拒绝新的外部请求,并取消该接口所有尚未开始的任务。运行中的任务不受影响,可在执行记录中单独终止。`,
|
||||||
|
confirmText: '停用',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.setHookEnabled(hook.id, !hook.enabled);
|
||||||
|
hook.enabled = res.hook.enabled;
|
||||||
|
toast.success(hook.enabled ? '接口已启用' : `接口已停用${res.cancelled ? `,取消了 ${res.cancelled} 个排队任务` : ''}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(hook: HookSummary) {
|
||||||
|
const result = await promptDialog({
|
||||||
|
title: '复制接口',
|
||||||
|
message: '将复制参数、脚本与执行策略,并生成新的 Token。副本默认处于停用状态。',
|
||||||
|
input: { label: '新接口标识', value: `${hook.slug}-copy`, placeholder: '小写字母、数字、-、_' },
|
||||||
|
input2: { label: '新接口名称', value: `${hook.name}(副本)` },
|
||||||
|
confirmText: '复制',
|
||||||
|
});
|
||||||
|
if (!result) return;
|
||||||
|
try {
|
||||||
|
const res = await api.copyHook(hook.id, result.value.trim(), result.value2.trim());
|
||||||
|
copied.value = { token: res.token, slug: res.hook.slug, method: res.hook.method, id: res.hook.id };
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(hook: HookSummary) {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: '删除接口',
|
||||||
|
message: `确定删除「${hook.name}」吗?该接口的全部脚本版本与执行记录都会被删除,且无法恢复。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api.deleteHook(hook.id);
|
||||||
|
toast.success('接口已删除');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCopied() {
|
||||||
|
const id = copied.value?.id;
|
||||||
|
copied.value = null;
|
||||||
|
if (id) void router.push({ name: 'hook-detail', params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>接口管理</h1>
|
||||||
|
<div class="subtitle">每个接口 = 调用地址 + 鉴权凭证 + 参数映射 + Bash 脚本 + 执行策略</div>
|
||||||
|
</div>
|
||||||
|
<span class="spacer" />
|
||||||
|
<RouterLink :to="{ name: 'hook-new' }" class="btn btn-primary">+ 新建接口</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<input v-model="search" placeholder="搜索名称、标识或描述" style="max-width: 280px" />
|
||||||
|
<select v-model="groupFilter" style="max-width: 180px">
|
||||||
|
<option value="">全部分组</option>
|
||||||
|
<option v-for="g in groups" :key="g" :value="g">{{ g }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="spacer" />
|
||||||
|
<span class="muted small">共 {{ hooks.length }} 个接口</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body flush table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>调用地址</th>
|
||||||
|
<th>响应</th>
|
||||||
|
<th>脚本</th>
|
||||||
|
<th>最近执行</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th style="text-align: right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="block in grouped" :key="block.group">
|
||||||
|
<tr v-if="groups.length" class="group-row">
|
||||||
|
<td colspan="7">{{ block.group || '未分组' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="h in block.items" :key="h.id">
|
||||||
|
<td>
|
||||||
|
<RouterLink :to="{ name: 'hook-detail', params: { id: h.id } }">
|
||||||
|
<strong>{{ h.name }}</strong>
|
||||||
|
</RouterLink>
|
||||||
|
<div v-if="h.description" class="small muted">{{ h.description }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="nowrap">
|
||||||
|
<span class="badge badge-method">{{ h.method }}</span>
|
||||||
|
<span class="mono">/hooks/{{ h.slug }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="muted nowrap">
|
||||||
|
{{ h.responseMode === 'sync' ? '同步' : '异步' }}
|
||||||
|
<span class="faint">· 并发 {{ h.concurrency }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="muted">v{{ h.currentVersion ?? '—' }}</td>
|
||||||
|
<td class="nowrap">
|
||||||
|
<template v-if="h.lastStatus">
|
||||||
|
<StatusBadge :status="h.lastStatus" />
|
||||||
|
<span class="small faint"> {{ formatRelative(h.lastAt) }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="faint">从未执行</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge dot" :class="h.enabled ? 'badge-success' : ''">{{ h.enabled ? '已启用' : '已停用' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="row" style="justify-content: flex-end; gap: 4px">
|
||||||
|
<RouterLink :to="{ name: 'hook-detail', params: { id: h.id } }" class="btn btn-ghost btn-sm">编辑</RouterLink>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="toggle(h)">{{ h.enabled ? '停用' : '启用' }}</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="copy(h)">复制</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" style="color: var(--danger)" @click="remove(h)">删除</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-if="!loading && !grouped.length">
|
||||||
|
<td colspan="7" class="empty">
|
||||||
|
{{ hooks.length ? '没有匹配的接口' : '还没有接口,点击右上角「新建接口」开始' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TokenDialog :token="copied?.token ?? null" :slug="copied?.slug ?? ''" :method="copied?.method ?? 'POST'" @close="closeCopied" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { api, errorMessage } from '../api/client';
|
||||||
|
import { setUser } from '../composables/auth';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const username = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!username.value || !password.value) {
|
||||||
|
error.value = '请输入用户名和密码';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const res = await api.login(username.value, password.value);
|
||||||
|
setUser(res.username);
|
||||||
|
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') ? route.query.redirect : '/';
|
||||||
|
await router.replace(redirect);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = errorMessage(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<form class="card login-card" @submit.prevent="submit">
|
||||||
|
<div class="brand">
|
||||||
|
<svg viewBox="0 0 32 32" width="36" height="36" aria-hidden="true">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#2563eb" />
|
||||||
|
<path d="M9 17l5 5 9-12" stroke="#fff" stroke-width="3.2" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h1>HookPilot</h1>
|
||||||
|
<div class="muted small">Webhook 脚本管理平台</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">用户名</label>
|
||||||
|
<input id="username" v-model="username" autocomplete="username" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<input id="password" v-model="password" type="password" autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit" :disabled="loading">{{ loading ? '登录中…' : '登录' }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
height: 38px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { api, errorMessage } from '../api/client';
|
||||||
|
import type { Overview } from '../api/types';
|
||||||
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
|
import { toast } from '../composables/toast';
|
||||||
|
import { formatDuration, formatRelative, TRIGGER_LABELS } from '../utils/format';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const data = ref<Overview | null>(null);
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
data.value = await api.overview();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
timer = window.setInterval(load, 5000);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => window.clearInterval(timer));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>概览</h1>
|
||||||
|
<div class="subtitle">接口与任务的整体运行状态</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="data">
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="label">接口数量</div>
|
||||||
|
<div class="value">{{ data.hooks.total }}</div>
|
||||||
|
<div class="sub">已启用 {{ data.hooks.enabled }} 个</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="label">运行中任务</div>
|
||||||
|
<div class="value" :style="{ color: data.running ? 'var(--primary)' : undefined }">{{ data.running }}</div>
|
||||||
|
<div class="sub">排队中 {{ data.queued }} 个</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="label">近 24 小时成功</div>
|
||||||
|
<div class="value" style="color: var(--success)">{{ data.last24h.success }}</div>
|
||||||
|
<div class="sub">共 {{ data.last24h.total }} 次执行</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="label">近 24 小时失败</div>
|
||||||
|
<div class="value" :style="{ color: data.last24h.failed ? 'var(--danger)' : undefined }">{{ data.last24h.failed }}</div>
|
||||||
|
<div class="sub">含超时、终止与中断</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overview-grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>近期执行</h2>
|
||||||
|
<span class="spacer" />
|
||||||
|
<RouterLink :to="{ name: 'executions' }" class="small">查看全部</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="card-body flush table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>接口</th>
|
||||||
|
<th>来源</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>耗时</th>
|
||||||
|
<th>时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="e in data.recent"
|
||||||
|
:key="e.id"
|
||||||
|
class="clickable"
|
||||||
|
@click="router.push({ name: 'execution-detail', params: { id: e.id } })"
|
||||||
|
>
|
||||||
|
<td class="mono">#{{ e.id }}</td>
|
||||||
|
<td>{{ e.hookName }}</td>
|
||||||
|
<td class="muted">{{ TRIGGER_LABELS[e.trigger] }}</td>
|
||||||
|
<td><StatusBadge :status="e.status" /></td>
|
||||||
|
<td class="nowrap">{{ formatDuration(e.durationMs) }}</td>
|
||||||
|
<td class="muted nowrap">{{ formatRelative(e.createdAt) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!data.recent.length">
|
||||||
|
<td colspan="6" class="empty">暂无执行记录</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h2>近期失败</h2></div>
|
||||||
|
<div class="card-body stack">
|
||||||
|
<RouterLink
|
||||||
|
v-for="e in data.recentFailures"
|
||||||
|
:key="e.id"
|
||||||
|
:to="{ name: 'execution-detail', params: { id: e.id } }"
|
||||||
|
class="failure-item"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<strong>{{ e.hookName }}</strong>
|
||||||
|
<span class="spacer" />
|
||||||
|
<StatusBadge :status="e.status" />
|
||||||
|
</div>
|
||||||
|
<div class="small muted">
|
||||||
|
#{{ e.id }} · {{ formatRelative(e.createdAt) }}
|
||||||
|
<template v-if="e.exitCode !== null"> · 退出码 {{ e.exitCode }}</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="e.error" class="small failure-error">{{ e.error }}</div>
|
||||||
|
</RouterLink>
|
||||||
|
<div v-if="!data.recentFailures.length" class="empty" style="padding: 20px">暂无失败记录</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.failure-item {
|
||||||
|
display: block;
|
||||||
|
color: inherit;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.failure-item:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.failure-error {
|
||||||
|
color: var(--danger);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.overview-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
import { api, errorMessage } from '../api/client';
|
||||||
|
import { toast } from '../composables/toast';
|
||||||
|
|
||||||
|
const MIB = 1024 * 1024;
|
||||||
|
const form = reactive({ globalConcurrency: 4, retentionDays: 30, outputLimitMiB: 5 });
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const s = await api.getSettings();
|
||||||
|
form.globalConcurrency = s.globalConcurrency;
|
||||||
|
form.retentionDays = s.retentionDays;
|
||||||
|
form.outputLimitMiB = Math.round((s.outputLimitBytes / MIB) * 100) / 100;
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await api.updateSettings({
|
||||||
|
globalConcurrency: form.globalConcurrency,
|
||||||
|
retentionDays: form.retentionDays,
|
||||||
|
outputLimitBytes: Math.round(form.outputLimitMiB * MIB),
|
||||||
|
});
|
||||||
|
toast.success('设置已保存');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>系统设置</h1>
|
||||||
|
<div class="subtitle">影响所有接口的全局执行参数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading" class="card" style="max-width: 720px">
|
||||||
|
<form class="card-body stack" @submit.prevent="save">
|
||||||
|
<div class="field">
|
||||||
|
<label class="required">全局并发上限</label>
|
||||||
|
<input v-model.number="form.globalConcurrency" type="number" min="1" max="64" style="max-width: 200px" />
|
||||||
|
<div class="hint">所有接口合计最多同时运行的任务数(1–64)。超出的任务按接收顺序排队。</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="required">日志保留天数</label>
|
||||||
|
<input v-model.number="form.retentionDays" type="number" min="1" max="3650" style="max-width: 200px" />
|
||||||
|
<div class="hint">超过天数的已结束执行记录及其输出每小时自动清理一次。</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="required">任务输出大小上限(MiB)</label>
|
||||||
|
<input v-model.number="form.outputLimitMiB" type="number" min="0.0625" max="100" step="0.5" style="max-width: 200px" />
|
||||||
|
<div class="hint">
|
||||||
|
单次执行 stdout 与 stderr 合计保留的上限。超出部分继续读取并丢弃(不会阻塞脚本),页面会标记「已截断」。修改只影响之后开始的任务。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" type="submit" :disabled="saving">{{ saving ? '保存中…' : '保存设置' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { HookDetail, HookForm } from '../../api/types';
|
||||||
|
import { toast } from '../../composables/toast';
|
||||||
|
import { copyText, formatTime, webhookUrl } from '../../utils/format';
|
||||||
|
|
||||||
|
const props = defineProps<{ form: HookForm; hook: HookDetail | null; groups: string[] }>();
|
||||||
|
const emit = defineEmits<{ regenerateToken: [] }>();
|
||||||
|
|
||||||
|
const url = computed(() => webhookUrl(props.form.slug));
|
||||||
|
const slugChanged = computed(() => props.hook !== null && props.hook.slug !== props.form.slug);
|
||||||
|
const hasBodyParams = computed(() => props.form.params.some((p) => p.source === 'json' || p.source === 'form'));
|
||||||
|
|
||||||
|
async function copyUrl() {
|
||||||
|
if (await copyText(url.value)) toast.success('调用地址已复制');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="required">接口名称</label>
|
||||||
|
<input v-model="form.name" maxlength="100" placeholder="如:更新家庭 IP" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>分组</label>
|
||||||
|
<input v-model="form.group" list="hook-groups" maxlength="50" placeholder="可选,用于在列表中归类" />
|
||||||
|
<datalist id="hook-groups">
|
||||||
|
<option v-for="g in groups" :key="g" :value="g" />
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="required">接口标识</label>
|
||||||
|
<input v-model="form.slug" maxlength="64" placeholder="如:home-ip" class="mono" />
|
||||||
|
<div class="hint">小写字母、数字、下划线和连字符,以字母或数字开头。</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="required">请求方式</label>
|
||||||
|
<select v-model="form.method">
|
||||||
|
<option value="POST">POST</option>
|
||||||
|
<option value="GET">GET</option>
|
||||||
|
</select>
|
||||||
|
<div v-if="form.method === 'GET' && hasBodyParams" class="hint" style="color: var(--danger)">
|
||||||
|
GET 接口只能从 Query 或 Header 提取参数,请调整参数配置。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full">
|
||||||
|
<label>调用地址</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="input-group" style="flex: 1">
|
||||||
|
<span class="addon">{{ form.method }}</span>
|
||||||
|
<input :value="url" readonly class="mono" />
|
||||||
|
</div>
|
||||||
|
<button class="btn" :disabled="!form.slug" @click="copyUrl">复制</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="slugChanged" class="hint" style="color: var(--warning)">
|
||||||
|
修改标识后原地址 /hooks/{{ hook!.slug }} 将失效,调用方需同步更新。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full">
|
||||||
|
<label>描述</label>
|
||||||
|
<textarea v-model="form.description" rows="3" maxlength="1000" placeholder="接口用途、调用方等说明" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full">
|
||||||
|
<label>接口 Token</label>
|
||||||
|
<template v-if="hook">
|
||||||
|
<div class="row">
|
||||||
|
<input :value="`hp_••••••••••••••••••••••••${hook.tokenHint}`" readonly class="mono" style="max-width: 360px" />
|
||||||
|
<button class="btn btn-danger-outline" @click="emit('regenerateToken')">重新生成</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
Token 仅在创建或重新生成时完整显示一次。调用时通过 <code>Authorization: Bearer <token></code> 或
|
||||||
|
<code>X-Hook-Token</code> 请求头传递。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="hint">创建接口后自动生成,届时只显示一次,请妥善保存。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hook" class="field full">
|
||||||
|
<div class="small faint">创建于 {{ formatTime(hook.createdAt) }} · 最后修改 {{ formatTime(hook.updatedAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HookForm, ParamDef, ParamSource } from '../../api/types';
|
||||||
|
import { SOURCE_LABELS, TYPE_LABELS } from '../../utils/format';
|
||||||
|
|
||||||
|
const props = defineProps<{ form: HookForm }>();
|
||||||
|
|
||||||
|
const KEY_PLACEHOLDERS: Record<ParamSource, string> = {
|
||||||
|
json: '如 ip 或 data.ip',
|
||||||
|
form: '表单字段名',
|
||||||
|
query: 'Query 参数名',
|
||||||
|
header: '如 x-client-id',
|
||||||
|
};
|
||||||
|
|
||||||
|
function addParam() {
|
||||||
|
props.form.params.push({
|
||||||
|
env: 'HOOK_',
|
||||||
|
source: props.form.method === 'GET' ? 'query' : 'json',
|
||||||
|
key: '',
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
sensitive: false,
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeParam(index: number) {
|
||||||
|
props.form.params.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(index: number, delta: number) {
|
||||||
|
const target = index + delta;
|
||||||
|
if (target < 0 || target >= props.form.params.length) return;
|
||||||
|
const [item] = props.form.params.splice(index, 1);
|
||||||
|
props.form.params.splice(target, 0, item!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function envSuffix(param: ParamDef): string {
|
||||||
|
return param.env.replace(/^HOOK_/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnv(param: ParamDef, value: string) {
|
||||||
|
param.env = `HOOK_${value.toUpperCase().replace(/[^A-Z0-9_]/g, '_')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceDisabled(source: ParamSource): boolean {
|
||||||
|
return props.form.method === 'GET' && (source === 'json' || source === 'form');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<span class="alert-icon">i</span>
|
||||||
|
<div>
|
||||||
|
每个参数从<strong>指定来源</strong>读取(不会在 Body、Query、Header 之间自动回退),校验通过后作为环境变量传给脚本,
|
||||||
|
脚本中用 <code>"$HOOK_XXX"</code> 引用。请求数据不会拼接进脚本文本,含引号、换行等特殊字符也按原样作为数据传递。
|
||||||
|
缺失且非必填的参数会设置为空字符串。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table compact params-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="min-width: 190px">环境变量</th>
|
||||||
|
<th style="min-width: 130px">来源</th>
|
||||||
|
<th style="min-width: 150px">字段</th>
|
||||||
|
<th style="min-width: 100px">类型</th>
|
||||||
|
<th>必填</th>
|
||||||
|
<th style="min-width: 120px">默认值</th>
|
||||||
|
<th>敏感</th>
|
||||||
|
<th style="min-width: 160px">说明</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(p, index) in form.params" :key="index">
|
||||||
|
<td>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="addon">HOOK_</span>
|
||||||
|
<input
|
||||||
|
:value="envSuffix(p)"
|
||||||
|
class="mono"
|
||||||
|
style="text-transform: uppercase"
|
||||||
|
placeholder="IP"
|
||||||
|
@input="setEnv(p, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select v-model="p.source">
|
||||||
|
<option v-for="(label, key) in SOURCE_LABELS" :key="key" :value="key" :disabled="sourceDisabled(key)">
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input v-model="p.key" class="mono" :placeholder="KEY_PLACEHOLDERS[p.source]" /></td>
|
||||||
|
<td>
|
||||||
|
<select v-model="p.type">
|
||||||
|
<option v-for="(label, key) in TYPE_LABELS" :key="key" :value="key">{{ label }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: center"><input v-model="p.required" type="checkbox" /></td>
|
||||||
|
<td><input v-model="p.default" placeholder="无" /></td>
|
||||||
|
<td style="text-align: center">
|
||||||
|
<input v-model="p.sensitive" type="checkbox" title="在执行记录中隐藏该参数的值" />
|
||||||
|
</td>
|
||||||
|
<td><input v-model="p.description" placeholder="可选" /></td>
|
||||||
|
<td class="nowrap">
|
||||||
|
<button class="btn btn-ghost btn-sm" title="上移" :disabled="index === 0" @click="move(index, -1)">↑</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" title="下移" :disabled="index === form.params.length - 1" @click="move(index, 1)">↓</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" style="color: var(--danger)" @click="removeParam(index)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!form.params.length">
|
||||||
|
<td colspan="9" class="empty">暂无参数。脚本仍可通过 HOOK_REQUEST_FILE 读取完整请求。</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class="btn" @click="addParam">+ 添加参数</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small muted help">
|
||||||
|
<div><strong>字段说明:</strong>JSON 使用点路径访问嵌套字段,如 <code>data.ip</code>、<code>items.0.name</code>;请求头名称不区分大小写。</div>
|
||||||
|
<div><strong>类型校验:</strong>数字接受整数与小数;布尔值接受 true/false、1/0、yes/no、on/off,传给脚本时统一为 true 或 false;IP 接受 IPv4 与 IPv6。</div>
|
||||||
|
<div><strong>敏感参数:</strong>在执行记录与请求摘要中以掩码显示;但脚本主动打印的内容仍会出现在输出里。</div>
|
||||||
|
<div>
|
||||||
|
<strong>平台变量:</strong><code>HOOK_EXECUTION_ID</code>、<code>HOOK_SLUG</code>、<code>HOOK_TRIGGER</code>、
|
||||||
|
<code>HOOK_REQUEST_FILE</code>(完整请求的 JSON 文件路径)自动注入,不可占用。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.params-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.help {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HookForm } from '../../api/types';
|
||||||
|
|
||||||
|
defineProps<{ form: HookForm }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<label class="field-label">响应模式</label>
|
||||||
|
<div class="radio-cards">
|
||||||
|
<label class="radio-card" :class="{ active: form.responseMode === 'sync' }">
|
||||||
|
<input v-model="form.responseMode" type="radio" value="sync" />
|
||||||
|
<div>
|
||||||
|
<strong>同步</strong>
|
||||||
|
<div class="small muted">
|
||||||
|
等待脚本执行结束再响应:成功返回 HTTP 200,脚本失败返回 500,超时返回 504。适合执行较快、调用方需要知道结果的场景。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="radio-card" :class="{ active: form.responseMode === 'async' }">
|
||||||
|
<input v-model="form.responseMode" type="radio" value="async" />
|
||||||
|
<div>
|
||||||
|
<strong>异步</strong>
|
||||||
|
<div class="small muted">
|
||||||
|
任务持久化后立即返回 HTTP 202 与任务 ID,仅表示已接收。适合耗时较长的脚本,结果在执行记录中查看。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="hint">两种模式都只返回任务 ID 与状态,脚本输出只在管理界面查看,不返回给调用方。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="required">超时时间(秒)</label>
|
||||||
|
<input v-model.number="form.timeoutSec" type="number" min="1" max="86400" />
|
||||||
|
<div class="hint">超时后终止整个进程组(先 SIGTERM,3 秒后 SIGKILL)。脚本不会自动重试。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="required">接口并发数</label>
|
||||||
|
<input v-model.number="form.concurrency" type="number" min="1" max="64" />
|
||||||
|
<div class="hint">同一接口最多同时运行的任务数。为 1 时按接收顺序串行执行,同时受全局并发上限约束。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full">
|
||||||
|
<label>工作目录</label>
|
||||||
|
<input v-model="form.workingDir" class="mono" placeholder="留空使用数据目录下的 workspace" />
|
||||||
|
<div class="hint">必须是绝对路径,且服务账户有权访问;目录不存在时任务直接失败,不会自动创建。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full">
|
||||||
|
<div class="alert">
|
||||||
|
<span class="alert-icon">i</span>
|
||||||
|
<div class="small">
|
||||||
|
脚本以服务账户身份直接运行在宿主机上,可访问该账户有权限的文件与命令。
|
||||||
|
执行环境只包含 <code>PATH</code>、<code>HOME</code>、<code>USER</code>、<code>LANG</code> 等基础变量和 <code>HOOK_</code> 前缀变量,
|
||||||
|
不继承服务自身的配置与凭证。需要提权的命令请由部署者在 sudoers 中按命令单独授权。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
import { api, errorMessage } from '../../api/client';
|
||||||
|
import type { HookDetail, HookForm, ScriptVersion, ScriptVersionSummary } from '../../api/types';
|
||||||
|
import ModalDialog from '../../components/ModalDialog.vue';
|
||||||
|
import ScriptEditor from '../../components/ScriptEditor.vue';
|
||||||
|
import { confirmDialog } from '../../composables/dialog';
|
||||||
|
import { toast } from '../../composables/toast';
|
||||||
|
import { formatTime } from '../../utils/format';
|
||||||
|
import { SCRIPT_TEMPLATES } from '../../utils/templates';
|
||||||
|
|
||||||
|
const props = defineProps<{ form: HookForm; hook: HookDetail | null; versionsKey: number }>();
|
||||||
|
|
||||||
|
const versions = ref<ScriptVersionSummary[]>([]);
|
||||||
|
const viewing = ref<ScriptVersion | null>(null);
|
||||||
|
const templateIndex = ref('');
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
if (!props.hook) return;
|
||||||
|
try {
|
||||||
|
versions.value = (await api.listVersions(props.hook.id)).items;
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTemplate() {
|
||||||
|
const template = SCRIPT_TEMPLATES[Number(templateIndex.value)];
|
||||||
|
templateIndex.value = '';
|
||||||
|
if (!template) return;
|
||||||
|
if (props.form.script.trim()) {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: `使用模板「${template.name}」`,
|
||||||
|
message: '当前编辑器中的内容将被替换(已保存的版本不受影响)。',
|
||||||
|
confirmText: '替换',
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
props.form.script = template.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function view(version: ScriptVersionSummary) {
|
||||||
|
if (!props.hook) return;
|
||||||
|
try {
|
||||||
|
viewing.value = await api.getVersion(props.hook.id, version.id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadIntoEditor() {
|
||||||
|
if (!viewing.value) return;
|
||||||
|
props.form.script = viewing.value.content;
|
||||||
|
toast.info(`已载入 v${viewing.value.version} 的内容,保存后将生成新版本`);
|
||||||
|
viewing.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.versionsKey, loadVersions);
|
||||||
|
onMounted(loadVersions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="script-layout">
|
||||||
|
<div class="stack" style="min-width: 0">
|
||||||
|
<div class="row wrap">
|
||||||
|
<span class="muted small">
|
||||||
|
Bash 脚本。Tab 键缩进,参数以环境变量传入,请用 <code>"$HOOK_XXX"</code> 引用。
|
||||||
|
</span>
|
||||||
|
<span class="spacer" />
|
||||||
|
<select v-model="templateIndex" style="width: auto" @change="applyTemplate">
|
||||||
|
<option value="" disabled>插入示例模板…</option>
|
||||||
|
<option v-for="(t, i) in SCRIPT_TEMPLATES" :key="t.name" :value="String(i)">{{ t.name }} — {{ t.description }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<ScriptEditor v-model="form.script" height="520px" />
|
||||||
|
<div class="small faint">
|
||||||
|
保存时若脚本内容有变化会生成新版本;已入队的任务仍使用入队时的版本执行。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="versions card">
|
||||||
|
<div class="card-header"><h3>版本记录</h3></div>
|
||||||
|
<div v-if="!hook" class="empty small">创建后生成 v1</div>
|
||||||
|
<ul v-else class="version-list">
|
||||||
|
<li v-for="v in versions" :key="v.id">
|
||||||
|
<button class="version-item" @click="view(v)">
|
||||||
|
<span class="row">
|
||||||
|
<strong>v{{ v.version }}</strong>
|
||||||
|
<span v-if="hook.currentVersion?.id === v.id" class="badge badge-info">当前</span>
|
||||||
|
</span>
|
||||||
|
<span class="small faint">{{ formatTime(v.createdAt) }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalDialog :open="!!viewing" :title="`脚本版本 v${viewing?.version ?? ''}`" wide @close="viewing = null">
|
||||||
|
<div class="small muted">保存于 {{ formatTime(viewing?.createdAt) }}</div>
|
||||||
|
<ScriptEditor v-if="viewing" :model-value="viewing.content" readonly height="420px" />
|
||||||
|
<template #footer>
|
||||||
|
<button class="btn" @click="viewing = null">关闭</button>
|
||||||
|
<button class="btn btn-primary" @click="loadIntoEditor">载入到编辑器</button>
|
||||||
|
</template>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.script-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 220px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.versions {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.version-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px;
|
||||||
|
max-height: 520px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.version-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.version-item:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.script-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { api, errorMessage } from '../../api/client';
|
||||||
|
import type { BodyKind, ExecutionStatus, HookDetail, HookForm, ParamDef, PreviewResult, TestRequest } from '../../api/types';
|
||||||
|
import OutputViewer from '../../components/OutputViewer.vue';
|
||||||
|
import StatusBadge from '../../components/StatusBadge.vue';
|
||||||
|
import { confirmDialog } from '../../composables/dialog';
|
||||||
|
import { toast } from '../../composables/toast';
|
||||||
|
|
||||||
|
const props = defineProps<{ form: HookForm; hook: HookDetail; dirty: boolean }>();
|
||||||
|
|
||||||
|
const SAMPLE_VALUES: Record<ParamDef['type'], unknown> = {
|
||||||
|
string: 'example',
|
||||||
|
number: 1,
|
||||||
|
boolean: true,
|
||||||
|
ip: '203.0.113.10',
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = ref('');
|
||||||
|
const headersText = ref('');
|
||||||
|
const bodyType = ref<BodyKind>(props.hook.method === 'GET' ? 'none' : 'json');
|
||||||
|
const body = ref(props.hook.method === 'GET' ? '' : '{}');
|
||||||
|
const preview = ref<PreviewResult | null>(null);
|
||||||
|
const previewing = ref(false);
|
||||||
|
const running = ref(false);
|
||||||
|
const executionId = ref<number | null>(null);
|
||||||
|
const executionStatus = ref<ExecutionStatus | null>(null);
|
||||||
|
|
||||||
|
const isGet = computed(() => props.hook.method === 'GET');
|
||||||
|
|
||||||
|
function parseHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const line of headersText.value.split('\n')) {
|
||||||
|
const index = line.indexOf(':');
|
||||||
|
if (index <= 0) continue;
|
||||||
|
headers[line.slice(0, index).trim()] = line.slice(index + 1).trim();
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequest(): TestRequest {
|
||||||
|
return { query: query.value, headers: parseHeaders(), bodyType: bodyType.value, body: body.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPath(target: Record<string, unknown>, path: string, value: unknown) {
|
||||||
|
const segments = path.split('.').filter(Boolean);
|
||||||
|
let current = target;
|
||||||
|
segments.forEach((segment, i) => {
|
||||||
|
if (i === segments.length - 1) current[segment] = value;
|
||||||
|
else {
|
||||||
|
if (typeof current[segment] !== 'object' || current[segment] === null) current[segment] = {};
|
||||||
|
current = current[segment] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据当前参数配置生成示例请求 */
|
||||||
|
function generateSample() {
|
||||||
|
const json: Record<string, unknown> = {};
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
const headers: string[] = [];
|
||||||
|
for (const p of props.form.params) {
|
||||||
|
const value = SAMPLE_VALUES[p.type];
|
||||||
|
if (p.source === 'json') setPath(json, p.key, value);
|
||||||
|
else if (p.source === 'form') form.set(p.key, String(value));
|
||||||
|
else if (p.source === 'query') search.set(p.key, String(value));
|
||||||
|
else headers.push(`${p.key}: ${String(value)}`);
|
||||||
|
}
|
||||||
|
query.value = search.toString();
|
||||||
|
headersText.value = headers.join('\n');
|
||||||
|
if (!isGet.value) {
|
||||||
|
if ([...form.keys()].length && !Object.keys(json).length) {
|
||||||
|
bodyType.value = 'form';
|
||||||
|
body.value = form.toString();
|
||||||
|
} else {
|
||||||
|
bodyType.value = 'json';
|
||||||
|
body.value = JSON.stringify(json, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preview.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPreview() {
|
||||||
|
previewing.value = true;
|
||||||
|
try {
|
||||||
|
preview.value = await api.previewTest(props.hook.id, buildRequest(), props.form.params);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
previewing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute() {
|
||||||
|
const warning = props.dirty
|
||||||
|
? '当前有未保存的修改。测试将使用已保存的脚本 v' + (props.hook.currentVersion?.version ?? '?') + ' 和已保存的参数配置,而不是编辑器中的内容。'
|
||||||
|
: undefined;
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: '真实执行脚本',
|
||||||
|
message: `将以服务账户身份在服务器上实际运行「${props.hook.name}」的脚本(v${props.hook.currentVersion?.version ?? '?'}),脚本对文件、服务等的修改会真实生效,且无法撤销。`,
|
||||||
|
warning,
|
||||||
|
confirmText: '确认执行',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
running.value = true;
|
||||||
|
try {
|
||||||
|
executionId.value = null;
|
||||||
|
executionStatus.value = null;
|
||||||
|
const res = await api.runTest(props.hook.id, buildRequest());
|
||||||
|
executionId.value = res.executionId;
|
||||||
|
executionStatus.value = 'queued';
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
running.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatus(status: ExecutionStatus) {
|
||||||
|
executionStatus.value = status;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<span class="alert-icon">!</span>
|
||||||
|
<div>
|
||||||
|
<strong>注意:点击「执行」会在服务器上真实运行脚本。</strong>
|
||||||
|
「预览参数映射」只校验参数、不执行脚本;「执行」使用<strong>已保存</strong>的脚本版本与参数配置,跳过 Token 校验,其余流程与外部调用一致,并生成一条执行记录。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-grid">
|
||||||
|
<div class="stack">
|
||||||
|
<div class="row">
|
||||||
|
<h3>模拟请求</h3>
|
||||||
|
<span class="badge badge-method">{{ hook.method }}</span>
|
||||||
|
<span class="mono small muted">/hooks/{{ hook.slug }}</span>
|
||||||
|
<span class="spacer" />
|
||||||
|
<button class="btn btn-sm" @click="generateSample">按参数生成示例</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Query 参数</label>
|
||||||
|
<input v-model="query" class="mono" placeholder="a=1&b=2" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>请求头</label>
|
||||||
|
<textarea v-model="headersText" class="mono" rows="3" placeholder="每行一个,如:X-Client-Id: abc" />
|
||||||
|
<div class="hint">鉴权相关请求头(Authorization、X-Hook-Token、Cookie)会被忽略。</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>请求体</label>
|
||||||
|
<select v-model="bodyType" :disabled="isGet" style="max-width: 220px">
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="form">表单(urlencoded)</option>
|
||||||
|
<option value="text">纯文本</option>
|
||||||
|
<option value="none">无</option>
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
v-if="bodyType !== 'none'"
|
||||||
|
v-model="body"
|
||||||
|
class="mono"
|
||||||
|
rows="8"
|
||||||
|
:placeholder="bodyType === 'form' ? 'ip=203.0.113.10&name=home' : '{"ip": "203.0.113.10"}'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" :disabled="previewing" @click="runPreview">{{ previewing ? '预览中…' : '预览参数映射' }}</button>
|
||||||
|
<button class="btn btn-danger" :disabled="running || !hook.currentVersion" @click="execute">
|
||||||
|
{{ running ? '提交中…' : '执行(真实运行)' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="dirty" class="small" style="color: var(--warning)">
|
||||||
|
有未保存的修改:预览使用编辑中的参数配置;执行只使用已保存的配置与脚本。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
<h3>参数映射预览</h3>
|
||||||
|
<div v-if="!preview" class="empty small card" style="box-shadow: none">点击「预览参数映射」查看脚本将收到的环境变量</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="preview.ok" class="alert alert-success small">参数校验通过</div>
|
||||||
|
<div v-else class="alert alert-danger small">
|
||||||
|
<div>
|
||||||
|
<strong>参数校验未通过,脚本不会执行:</strong>
|
||||||
|
<ul style="margin: 4px 0 0; padding-left: 18px">
|
||||||
|
<li v-for="e in preview.errors" :key="e">{{ e }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap card" style="box-shadow: none">
|
||||||
|
<table class="table compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>环境变量</th>
|
||||||
|
<th>值</th>
|
||||||
|
<th>来源</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="v in preview.values" :key="v.env">
|
||||||
|
<td class="mono nowrap">{{ v.env }}</td>
|
||||||
|
<td class="mono break">
|
||||||
|
<span v-if="v.value === ''" class="faint">(空)</span>
|
||||||
|
<template v-else>{{ v.value }}</template>
|
||||||
|
</td>
|
||||||
|
<td class="small muted nowrap">
|
||||||
|
{{ v.origin === 'request' ? '请求' : v.origin === 'default' ? '默认值' : '未提供' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!preview.values.length">
|
||||||
|
<td colspan="3" class="empty">无参数</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="executionId" class="card" style="box-shadow: none">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>测试执行 #{{ executionId }}</h3>
|
||||||
|
<StatusBadge v-if="executionStatus" :status="executionStatus" />
|
||||||
|
<span class="spacer" />
|
||||||
|
<RouterLink :to="{ name: 'execution-detail', params: { id: executionId } }" class="btn btn-sm">查看执行详情</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<OutputViewer :key="executionId" :execution-id="executionId" @status="onStatus" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.test-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.test-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
const backend = process.env.HOOKPILOT_DEV_BACKEND ?? 'http://127.0.0.1:8787';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: backend, changeOrigin: false },
|
||||||
|
'^/hooks/': { target: backend, changeOrigin: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
chunkSizeWarningLimit: 1024,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Webhook 脚本管理平台:功能列表与架构设计
|
||||||
|
|
||||||
|
## 1. 项目定位与技术方案
|
||||||
|
|
||||||
|
面向**单管理员、单台 Linux 服务器**,通过网页创建多个 Webhook 接口,为每个接口配置参数和 Bash 脚本。外部应用发起请求后,平台执行脚本并记录结果,脚本可以直接操作宿主机。
|
||||||
|
|
||||||
|
核心模型:
|
||||||
|
|
||||||
|
**一个接口 = 调用地址 + 鉴权凭证 + 参数映射 + Bash 脚本 + 执行策略。**
|
||||||
|
|
||||||
|
| 层级 | 技术选择 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| 前端 | Vite 8 + Vue 3 + TypeScript | 接口配置、脚本编辑、执行记录 |
|
||||||
|
| 后端 | Node.js + TypeScript + Fastify | 管理 API、Webhook 接收、鉴权、参数处理 |
|
||||||
|
| 数据库 | SQLite | 接口配置、脚本版本、任务和执行记录 |
|
||||||
|
| 执行层 | 宿主机 Bash 子进程 | 执行脚本、收集输出、处理超时 |
|
||||||
|
| 部署 | Linux 原生部署,以 systemd 管理服务 | 开机启动、进程管理 |
|
||||||
|
| 入口 | HTTPS 反向代理 | 提供统一域名,转发到后端 |
|
||||||
|
|
||||||
|
前端采用 Vue 单文件组件与 Composition API。生产环境由后端托管构建后的静态文件;Vite 用于开发和构建,`vite preview` 不作为生产服务器。[Vue 官方文档](https://vuejs.org/guide/quick-start)、[Vite 8 部署文档](https://github.com/vitejs/vite/blob/v8.0.10/docs/guide/static-deploy.md)
|
||||||
|
|
||||||
|
## 2. 功能列表
|
||||||
|
|
||||||
|
### 首版功能
|
||||||
|
|
||||||
|
| 模块 | 功能 |
|
||||||
|
|---|---|
|
||||||
|
| 管理员登录 | 单管理员账户、登录退出、修改密码;不开放注册 |
|
||||||
|
| 概览 | 接口数量、运行中任务、近期成功与失败记录 |
|
||||||
|
| 接口管理 | 创建、编辑、复制、启用、停用、删除;支持名称、描述和分组 |
|
||||||
|
| 接口地址 | 自定义唯一标识,如 `aaa`,生成 `/hooks/aaa` |
|
||||||
|
| 请求方式 | 每个接口选择 GET 或 POST;默认 POST |
|
||||||
|
| 接口鉴权 | 每个接口独立 Token,支持重新生成;创建时展示一次 |
|
||||||
|
| 参数配置 | 从 JSON、表单、Query 或指定 Header 提取参数,映射成脚本环境变量 |
|
||||||
|
| 参数校验 | 必填、默认值,以及字符串、数字、布尔值、IP 类型校验 |
|
||||||
|
| 脚本编辑 | 多行 Bash 编辑器,提供行号、缩进和示例模板 |
|
||||||
|
| 脚本版本 | 保存时生成版本;执行记录关联当时使用的版本 |
|
||||||
|
| 执行配置 | 工作目录、超时时间、同步或异步响应、接口并发数 |
|
||||||
|
| 在线测试 | 填写模拟请求,预览参数映射;点击执行后真实运行脚本 |
|
||||||
|
| 执行记录 | 请求来源、参数摘要、脚本版本、状态、耗时、退出码 |
|
||||||
|
| 输出查看 | 分别查看 stdout、stderr,支持执行期间增量查看 |
|
||||||
|
| 任务控制 | 取消排队任务、终止运行任务、手动再次执行 |
|
||||||
|
| 系统设置 | 全局并发上限、日志保留天数、任务输出大小上限 |
|
||||||
|
|
||||||
|
参数来源由管理员明确选择,避免同名参数在 Body、Query、Header 之间产生隐式覆盖。
|
||||||
|
|
||||||
|
“在线测试”和“再次执行”都会实际操作服务器,页面必须清楚标明这一点;再次执行创建新任务并保留来源记录。
|
||||||
|
|
||||||
|
### 后续扩展
|
||||||
|
|
||||||
|
第一版预留扩展位置,暂不实现:
|
||||||
|
|
||||||
|
- 第三方服务专用签名验证,如 GitHub Webhook 签名。
|
||||||
|
- 定时执行、成功或失败通知。
|
||||||
|
- 可复用脚本库、配置导入导出。
|
||||||
|
- 多服务器节点、多用户与权限管理。
|
||||||
|
- 可视化工作流和多步骤任务。
|
||||||
|
|
||||||
|
## 3. 架构与执行流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[管理员浏览器<br/>Vue 3 管理界面] --> B[HTTPS 入口]
|
||||||
|
C[Lucky / 其他外部应用] --> B
|
||||||
|
|
||||||
|
B --> D[Node.js + Fastify]
|
||||||
|
D --> E[管理 API<br/>登录、配置、执行记录]
|
||||||
|
D --> F[Webhook 接收<br/>鉴权、参数提取与校验]
|
||||||
|
|
||||||
|
E --> G[(SQLite)]
|
||||||
|
F --> H[任务调度模块]
|
||||||
|
H --> G
|
||||||
|
H --> I[执行器模块]
|
||||||
|
I --> J[宿主机 Bash 子进程]
|
||||||
|
J --> K[文件 / 系统服务 / 其他命令]
|
||||||
|
I --> G
|
||||||
|
|
||||||
|
G --> L[状态与输出查询]
|
||||||
|
L --> A
|
||||||
|
```
|
||||||
|
|
||||||
|
**首版采用模块化单体后端。** 管理 API、任务调度和执行器在同一个项目内分模块实现,每个任务启动独立 Bash 子进程。SQLite 保存待执行任务,不额外引入 Redis 或消息队列。
|
||||||
|
|
||||||
|
创建接口时只保存配置。后端统一注册 `/hooks/:slug` 路由,请求到来时读取对应配置,因此新增或修改接口无需重启服务。Fastify 负责 HTTP 路由及基础请求校验。[Fastify 官方文档](https://github.com/fastify/fastify/blob/main/docs/Reference/Validation-and-Serialization.md)
|
||||||
|
|
||||||
|
每次调用依次完成:
|
||||||
|
|
||||||
|
1. 根据接口标识查找配置,检查启用状态和请求方式。
|
||||||
|
2. 验证接口 Token,限制请求大小。
|
||||||
|
3. 提取参数并校验,生成执行所需环境变量。
|
||||||
|
4. 保存任务,固定本次使用的参数和脚本版本。
|
||||||
|
5. 调度器按并发限制启动 Bash。
|
||||||
|
6. 收集输出、退出码和耗时,更新执行状态。
|
||||||
|
7. 根据同步或异步模式返回结果。
|
||||||
|
|
||||||
|
### 参数如何进入脚本
|
||||||
|
|
||||||
|
以家庭 IP 更新为例:
|
||||||
|
|
||||||
|
| 配置项 | 示例 |
|
||||||
|
|---|---|
|
||||||
|
| 接口 | `POST /hooks/home-ip` |
|
||||||
|
| 请求体 | `{"ip":"203.0.113.10"}` |
|
||||||
|
| 提取来源 | JSON 中的 `ip` |
|
||||||
|
| 校验 | 必填、合法 IP |
|
||||||
|
| 脚本变量 | `HOOK_IP` |
|
||||||
|
| 执行模式 | 同步 |
|
||||||
|
| 并发 | 1,按接收顺序执行 |
|
||||||
|
|
||||||
|
脚本通过 `"$HOOK_IP"` 获取参数。复杂请求内容另存为任务专属 JSON 文件,通过 `HOOK_REQUEST_FILE` 提供路径。
|
||||||
|
|
||||||
|
**请求数据不替换进脚本文本,也不拼接成 Shell 命令。** 执行器启动保存好的脚本文件,并显式传入参数环境;自定义变量统一使用 `HOOK_` 前缀,避免覆盖 `PATH` 等运行环境。
|
||||||
|
|
||||||
|
### 执行与失败处理
|
||||||
|
|
||||||
|
- 默认每个接口串行执行,全局最多同时运行 4 个任务,均可配置。
|
||||||
|
- 默认脚本超时 30 秒;超时或终止时清理任务进程组。
|
||||||
|
- 不自动重试脚本,避免重复部署、重启服务等副作用。
|
||||||
|
- 服务异常退出后,未开始的任务继续排队;执行中的任务标记为“中断”,不自动重跑。
|
||||||
|
- 停用接口后拒绝新请求,并取消该接口尚未开始的任务;已运行任务可单独终止。
|
||||||
|
- 修改脚本不改变已入队任务使用的脚本版本。
|
||||||
|
- 终止任务只能停止后续执行,不能撤销已写入文件等操作。
|
||||||
|
|
||||||
|
### 宿主机操作权限
|
||||||
|
|
||||||
|
服务直接运行在宿主机上,脚本继承服务账户的权限,可访问该账户有权限访问的文件与命令。
|
||||||
|
|
||||||
|
默认使用专用服务账户;确实需要重启系统服务或修改受保护文件时,由部署者按实际命令配置提权权限。页面中不提供任意切换系统用户的功能。
|
||||||
|
|
||||||
|
管理端通过登录会话访问,Webhook 通过独立 Token 调用;两种凭证分开。执行子进程使用明确构造的环境,不继承后台的全部配置和凭证。该平台面向可信管理员,不将任意 Bash 脚本视为可隔离的不可信代码。
|
||||||
|
|
||||||
|
## 4. 页面、接口与数据设计
|
||||||
|
|
||||||
|
前端导航保持五个入口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
概览
|
||||||
|
接口管理
|
||||||
|
└─ 接口详情:基本配置 / 参数 / 脚本 / 执行策略 / 测试
|
||||||
|
执行记录
|
||||||
|
└─ 执行详情:请求摘要 / 脚本版本 / 输出 / 结果
|
||||||
|
系统设置
|
||||||
|
账户设置
|
||||||
|
```
|
||||||
|
|
||||||
|
主要 HTTP 接口:
|
||||||
|
|
||||||
|
| 接口 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `/api/auth/*` | 管理员登录、退出和账户操作 |
|
||||||
|
| `/api/hooks`、`/api/hooks/:id` | 接口列表与配置管理 |
|
||||||
|
| `/api/hooks/:id/versions` | 脚本版本记录 |
|
||||||
|
| `/api/hooks/:id/test` | 从管理界面创建测试任务 |
|
||||||
|
| `/api/executions`、`/api/executions/:id` | 查询执行记录与详情 |
|
||||||
|
| `/api/executions/:id/logs` | 按偏移量增量读取输出 |
|
||||||
|
| `/api/executions/:id/cancel` | 取消或终止任务 |
|
||||||
|
| `/api/executions/:id/rerun` | 使用原脚本版本和参数创建新任务 |
|
||||||
|
| `/hooks/:slug` | 外部 Webhook 调用入口 |
|
||||||
|
|
||||||
|
所有 `/api/*` 管理接口都要求管理员身份,登录入口除外;外部调用方不通过这些接口查询日志。
|
||||||
|
|
||||||
|
同步模式等待执行结束,成功返回 HTTP 200 与任务 ID,脚本失败返回非成功状态;异步模式在任务持久化后返回 HTTP 202 与任务 ID,仅表示已接收。脚本输出默认只在管理界面查看,不返回给外部调用方。
|
||||||
|
|
||||||
|
SQLite 保存六类数据:管理员与会话、接口配置、脚本版本、执行任务、分块执行输出、系统设置。任务本身兼作持久化队列,保存参数及配置快照。
|
||||||
|
|
||||||
|
默认请求体上限 1 MiB,单次执行输出保留上限 5 MiB,执行记录保留 30 天。超限输出继续读取并丢弃,避免阻塞脚本,同时在页面标记截断。鉴权头不写入执行记录;敏感参数在界面隐藏,但脚本主动打印出的内容仍可能出现在输出中。
|
||||||
|
|
||||||
|
## 5. 实施顺序与验收场景
|
||||||
|
|
||||||
|
按以下顺序实现:
|
||||||
|
|
||||||
|
1. 登录、接口管理、配置存储。
|
||||||
|
2. Webhook 接收、参数映射、Bash 执行。
|
||||||
|
3. 持久化队列、同步与异步响应、超时和取消。
|
||||||
|
4. 执行记录、输出查看、脚本版本、在线测试。
|
||||||
|
5. 宿主机部署说明与 Lucky 对接示例。
|
||||||
|
|
||||||
|
主要验收场景:
|
||||||
|
|
||||||
|
- 创建 `home-ip` 接口,合法请求执行脚本并更新指定文件。
|
||||||
|
- Token 错误、参数缺失或 IP 非法时,不执行脚本。
|
||||||
|
- 多个接口分别运行自己的脚本,参数和输出互不混用。
|
||||||
|
- 含引号、换行或 Shell 特殊字符的参数仍作为数据传递。
|
||||||
|
- 同一接口的连续请求按照配置排队;脚本修改不影响已入队任务。
|
||||||
|
- 非零退出码、超时、手动终止、输出超限均能正确显示。
|
||||||
|
- 重启服务后,排队和中断任务状态符合设计。
|
||||||
|
- 未登录者不能读取脚本、修改接口或查询执行输出。
|
||||||
|
|
||||||
|
本次只确定设计,不修改文件。后续验收由你执行,不替你运行静态类型检查或运行监测。
|
||||||
Reference in New Issue
Block a user