feat: initial commit for HookPilot

This commit is contained in:
吴璨
2026-09-27 20:09:10 +08:00
commit e998d4defc
73 changed files with 10782 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
data/
*.log
.env
+125
View File
@@ -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)、定时执行与通知、可复用脚本库、配置导入导出、多节点与多用户、可视化工作流。
+16
View File
@@ -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
}
}
}
+38
View File
@@ -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
+26
View File
@@ -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
+37
View File
@@ -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;
}
}
+155
View File
@@ -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
View File
@@ -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 与退出码。
+3281
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+28
View File
@@ -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"
}
}
+106
View File
@@ -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;
}
+70
View File
@@ -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());
}
+28
View File
@@ -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);
}
+45
View File
@@ -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);
}
}
}
+42
View File
@@ -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);
}
+62
View File
@@ -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);
}
+168
View File
@@ -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}`);
});
}
}
}
+300
View File
@@ -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,
};
}
}
+187
View File
@@ -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());
}
}
+285
View File
@@ -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;
}
}
+326
View File
@@ -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];
}
+315
View File
@@ -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;
}
}
+82
View File
@@ -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);
});
+98
View File
@@ -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 };
},
);
}
+98
View File
@@ -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 ?? '',
};
}
+121
View File
@@ -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 };
});
}
+161
View File
@@ -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 };
},
);
}
+37
View File
@@ -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;
});
}
+139
View File
@@ -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,
});
},
});
}
+72
View File
@@ -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;
}
}
+24
View File
@@ -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);
}
+13
View File
@@ -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);
+11
View File
@@ -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 {};
+18
View File
@@ -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"]
}
+13
View File
@@ -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>
+30
View File
@@ -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"
}
}
+10
View File
@@ -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>
+130
View File
@@ -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);
}
+172
View File
@@ -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;
}
+158
View File
@@ -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>
+51
View File
@@ -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>
+43
View File
@@ -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>
+116
View File
@@ -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>
+80
View File
@@ -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>
+29
View File
@@ -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>
+11
View File
@@ -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>
+51
View File
@@ -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>
+22
View File
@@ -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;
}
+48
View File
@@ -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);
}
+27
View File
@@ -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),
};
+7
View File
@@ -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;
}
+16
View File
@@ -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');
+72
View File
@@ -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;
+663
View File
@@ -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);
}
}
+90
View File
@@ -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;
}
}
+94
View File
@@ -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"
`,
},
];
+78
View 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>
+239
View File
@@ -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>
+175
View File
@@ -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>
+283
View File
@@ -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>
+196
View File
@@ -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>
+87
View File
@@ -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>
+158
View File
@@ -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>
+75
View File
@@ -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>
+87
View File
@@ -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 &lt;token&gt;</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>
+148
View File
@@ -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>
+63
View File
@@ -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>
+148
View File
@@ -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>
+253
View File
@@ -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' : '{&quot;ip&quot;: &quot;203.0.113.10&quot;}'"
/>
</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>
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"strict": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+20
View File
@@ -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,
},
});
+189
View File
@@ -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 特殊字符的参数仍作为数据传递。
- 同一接口的连续请求按照配置排队;脚本修改不影响已入队任务。
- 非零退出码、超时、手动终止、输出超限均能正确显示。
- 重启服务后,排队和中断任务状态符合设计。
- 未登录者不能读取脚本、修改接口或查询执行输出。
本次只确定设计,不修改文件。后续验收由你执行,不替你运行静态类型检查或运行监测。