Initial commit: TaskPool React panel

- React frontend with route-level code splitting
- Backend rebranded from Baihu to TaskPool
- DB brand migration script and local compatibility
This commit is contained in:
2026-07-26 08:43:52 +08:00
commit e6956aa001
397 changed files with 73621 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "bin"
[build]
args_bin = []
entrypoint = "./bin/taskpool"
cmd = ":"
full_bin = "go run main.go server"
delay = 1000
exclude_dir = ["internal/static/dist", "assets", "tmp", "vendor", "testdata", "web", "data", "envs", "configs", "bin"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true
+11
View File
@@ -0,0 +1,11 @@
# 排除构建产物
dist/* linguist-generated=true
# 排除第三方
node_modules/* linguist-vendored=true
# 排除文档
docs/* linguist-documentation=true
# SQL 统计为 SQL
*.sql linguist-language=SQL
+50
View File
@@ -0,0 +1,50 @@
name: Build and Deploy
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
env:
REGISTRY: git.viaeon.com
IMAGE_NAME: admin/taskpool-react
jobs:
build-and-push:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Build
working-directory: web
run: pnpm build
- name: Login to Registry
run: echo "${{ secrets.TOKEN }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
- name: Build and push Docker image
run: |
docker build -f Dockerfile.standalone -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
+81
View File
@@ -0,0 +1,81 @@
name: Bug 反馈
description: 提交一个可复现的问题报告
title: "[Bug] "
labels:
- bug
body:
- type: markdown
attributes:
value: |
请尽量提供完整信息,便于我们复现问题并验证修复结果。
如果可以,请优先附上报错信息,或提供关键步骤截图。
- type: textarea
id: description
attributes:
label: 问题描述
description: 请说明当前出现了什么问题,以及你期望的行为是什么。
placeholder: 请简要清晰地描述你遇到的 bug。
validations:
required: true
- type: textarea
id: steps
attributes:
label: 复现步骤
description: 请按顺序填写可以稳定复现问题的步骤,最好附上关键步骤截图。
placeholder: |
1. 进入 ...
2. 点击 ...
3. 看到 ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: 预期行为
placeholder: 请描述你预期看到的结果。
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
placeholder: 请描述实际结果,如有报错提示、异常信息或界面截图,请一并提供。
validations:
required: true
- type: input
id: version
attributes:
label: 版本信息
description: 请填写程序版本、commit hash 或镜像标签。
placeholder: 例如:v1.2.3
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker
- Docker Compose
- 源码构建
- 二进制发布版
- 其他
validations:
required: true
- type: input
id: environment
attributes:
label: 运行环境
description: 请填写操作系统、浏览器、CPU 架构或其他相关环境信息。
placeholder: 例如:Ubuntu 24.04、Chrome 134、amd64
- type: textarea
id: logs
attributes:
label: 错误日志或截图
description: 请粘贴错误日志、堆栈信息,或上传能帮助定位问题的截图。
render: shell
- type: textarea
id: extra
attributes:
label: 补充信息
description: 其他有助于理解或修复该问题的内容。
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 使用文档
url: https://github.com/engigu/taskpool/blob/main/README.md
about: 提交 issue 前请先查阅文档。
@@ -0,0 +1,47 @@
name: 需求与建议
description: 提交新的功能需求、改进建议或特性请求
title: "[Feature/Suggestion] "
labels:
- feature
- enhancement
body:
- type: markdown
attributes:
value: |
感谢你为项目提供宝贵意见!请详细描述你的想法,以便我们评估和实现。
- type: dropdown
id: type
attributes:
label: 类型
options:
- 功能需求 (New Feature)
- 改进建议 (Enhancement/Suggestion)
- 其他优化
validations:
required: true
- type: textarea
id: background
attributes:
label: 需求场景/背景
description: 你的需求是为了解决什么样的问题?或者是在什么场景下产生的优化想法?
placeholder: 请简要说明背景,帮助我们理解痛点。
validations:
required: true
- type: textarea
id: description
attributes:
label: 详细描述
description: 请具体说明你心目中这个功能应该如何工作,或者改进的具体细节。
placeholder: 请具体描述功能的运作逻辑或具体的改进细节。
validations:
required: true
- type: textarea
id: benefit
attributes:
label: 预期收益
description: 如果实现了该需求或建议,会带来哪些提升(如效率、视觉、体验等)?
- type: textarea
id: extra
attributes:
label: 补充信息
description: 其他有助于理解该需求的内容,比如参考链接、概念图、截图等。
+184
View File
@@ -0,0 +1,184 @@
name: Build-Base-Image
on:
workflow_dispatch: # 手动触发
schedule:
- cron: '0 3 * * 0' # 每周日凌晨3点自动构建一次
push:
branches:
- main
paths:
- 'docker/Dockerfile.base'
- 'docker/Dockerfile.debian13.base'
- 'docker/Dockerfile.debian13.minimal'
env:
REGISTRY: ghcr.io
IMAGE_NAME: taskpool
jobs:
build:
if: github.event.repository.fork == false
runs-on: ubuntu-latest
strategy:
matrix:
base_type: [debian, debian13, minimal]
platform:
- linux/amd64
- linux/arm64
include:
- base_type: debian
platform: linux/amd64
dockerfile: docker/Dockerfile.base
tag_suffix: ""
- base_type: debian
platform: linux/arm64
dockerfile: docker/Dockerfile.base
tag_suffix: ""
# - base_type: alpine
# platform: linux/amd64
# dockerfile: docker/Dockerfile.alpine.base
# tag_suffix: "-alpine"
# - base_type: alpine
# platform: linux/arm64
# dockerfile: docker/Dockerfile.alpine.base
# tag_suffix: "-alpine"
- base_type: debian13
platform: linux/amd64
dockerfile: docker/Dockerfile.debian13.base
tag_suffix: "-debian13"
- base_type: debian13
platform: linux/arm64
dockerfile: docker/Dockerfile.debian13.base
tag_suffix: "-debian13"
- base_type: minimal
platform: linux/amd64
dockerfile: docker/Dockerfile.debian13.minimal
tag_suffix: "-minimal"
- base_type: minimal
platform: linux/arm64
dockerfile: docker/Dockerfile.debian13.minimal
tag_suffix: "-minimal"
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set platform pair
id: platform
run: |
platform=${{ matrix.platform }}
echo "pair=${platform//\//-}-${{ matrix.base_type }}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=base${{ matrix.tag_suffix }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=base-${{ steps.platform.outputs.pair }}
cache-to: type=gha,scope=base-${{ steps.platform.outputs.pair }},mode=max
provenance: false
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v7
with:
name: digests-${{ steps.platform.outputs.pair }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
strategy:
matrix:
base_type: [debian, debian13, minimal]
include:
- base_type: debian
tag: base
# - base_type: alpine
# tag: base-alpine
- base_type: debian13
tag: base-debian13
- base_type: minimal
tag: base-minimal
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*-${{ matrix.base_type }}
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ matrix.tag }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ matrix.tag }}
+103
View File
@@ -0,0 +1,103 @@
name: Build-Deploy-Server
on:
push:
branches: [ main, dev ]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: taskpool
jobs:
build:
if: github.event.repository.fork == false
runs-on: ubuntu-latest
strategy:
matrix:
base_type: [debian]
include:
- base_type: debian
base_tag: base
tag_suffix: ""
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=raw,value=latest-amd64${{ matrix.tag_suffix }},enable={{is_default_branch}}
type=ref,event=branch,suffix=-amd64${{ matrix.tag_suffix }}
type=sha,prefix={{branch}}-,suffix=-amd64${{ matrix.tag_suffix }}
- name: Get build time
id: build_time
run: echo "time=$(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
BUILD_TIME=${{ steps.build_time.outputs.time }}
BASE_TAG=${{ matrix.base_tag }}
cache-from: type=gha,scope=linux-amd64-${{ matrix.base_type }}
cache-to: type=gha,scope=linux-amd64-${{ matrix.base_type }},mode=max
provenance: false
- name: Deploy to Demo server
if: github.ref == 'refs/heads/main' && matrix.base_type == 'debian'
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
password: ${{ secrets.DEPLOY_PASSWORD }}
port: ${{ secrets.DEPLOY_PORT }}
script: |
${{ secrets.DEPLOY_SCRIPT }}
# - name: Deploy to X server
# if: github.ref == 'refs/heads/main' && matrix.base_type == 'debian'
# uses: appleboy/ssh-action@v1.2.0
# with:
# host: ${{ secrets.DEPLOY_HOST }}
# username: ${{ secrets.DEPLOY_USER }}
# password: ${{ secrets.DEPLOY_PASSWORD }}
# port: ${{ secrets.DEPLOY_PORT }}
# script: |
# cd ${{ secrets.DEPLOY_X_PATH }}
# docker-compose -f docker-compose.yml pull
# docker-compose -f docker-compose.yml up -d
+80
View File
@@ -0,0 +1,80 @@
name: Build-Docker-AMD64
on:
# push:
# tags: [ 'v*' ]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: taskpool
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
base_type: [debian, debian13, minimal]
include:
- base_type: debian
base_tag: base
tag_suffix: ""
# - base_type: alpine
# base_tag: base-alpine
# tag_suffix: "-alpine"
- base_type: debian13
base_tag: base-debian13
tag_suffix: "-debian13"
- base_type: minimal
base_tag: base-minimal
tag_suffix: "-minimal"
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=ref,event=branch,suffix=-amd64${{ matrix.tag_suffix }}
- name: Get build time
id: build_time
run: echo "time=$(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.base_type == 'minimal' && './docker/Dockerfile.minimal' || './docker/Dockerfile' }}
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
BUILD_TIME=${{ steps.build_time.outputs.time }}
BASE_TAG=${{ matrix.base_tag }}
cache-from: type=gha,scope=linux-amd64-${{ matrix.base_type }}
cache-to: type=gha,scope=linux-amd64-${{ matrix.base_type }},mode=max
provenance: false
+89
View File
@@ -0,0 +1,89 @@
name: Build-Docker-ARM64
# on:
# # push:
# # tags: [ 'v*' ]
# workflow_dispatch:
on:
push:
branches: [ main, dev ]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: taskpool
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
base_type: [debian, debian13, minimal]
include:
- base_type: debian
base_tag: base
tag_suffix: ""
# - base_type: alpine
# base_tag: base-alpine
# tag_suffix: "-alpine"
- base_type: debian13
base_tag: base-debian13
tag_suffix: "-debian13"
- base_type: minimal
base_tag: base-minimal
tag_suffix: "-minimal"
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=ref,event=branch,suffix=-arm64${{ matrix.tag_suffix }}
- name: Get build time
id: build_time
run: echo "time=$(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.base_type == 'minimal' && './docker/Dockerfile.minimal' || './docker/Dockerfile' }}
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
BUILD_TIME=${{ steps.build_time.outputs.time }}
BASE_TAG=${{ matrix.base_tag }}
cache-from: type=gha,scope=linux-arm64-${{ matrix.base_type }}
cache-to: type=gha,scope=linux-arm64-${{ matrix.base_type }},mode=max
provenance: false
+74
View File
@@ -0,0 +1,74 @@
name: Deploy VitePress to Pages
on:
push:
branches: [main, docs]
workflow_dispatch:
schedule:
- cron: '0 1 * * *'
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
if: github.event.repository.fork == false
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: docs/package-lock.json
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: true
- name: Generate Swagger Docs
run: make swag
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Install dependencies
run: npm ci
working-directory: docs
- name: Fetch Package Download Stats
run: node docs/fetch-stats.js
- name: Build with VitePress
run: npm run docs:build
working-directory: docs
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: docs/.vitepress/dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
+285
View File
@@ -0,0 +1,285 @@
name: Release-and-Build-Docker-Image
on:
push:
tags:
- 'v*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: taskpool
jobs:
build-binaries:
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Build Web
run: make build-web
- name: Generate Swagger
run: make swag
- name: Build Binaries
run: |
VERSION=${{ github.ref_name }}
BUILD_TIME=$(TZ='Asia/Shanghai' date '+%Y/%m/%d %H:%M:%S')
LDFLAGS="-s -w -X 'github.com/engigu/taskpool/internal/constant.Version=$VERSION' -X 'github.com/engigu/taskpool/internal/constant.BuildTime=$BUILD_TIME'"
# Prepare static assets for embedding
rm -rf internal/static/dist
cp -r web/dist internal/static/dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags web -ldflags="$LDFLAGS" -o taskpool-linux-amd64 main.go
tar -czvf taskpool-linux-amd64.tar.gz taskpool-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags web -ldflags="$LDFLAGS" -o taskpool-linux-arm64 main.go
tar -czvf taskpool-linux-arm64.tar.gz taskpool-linux-arm64
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -tags web -ldflags="$LDFLAGS" -o taskpool-android-arm64 main.go
tar -czvf taskpool-android-arm64.tar.gz taskpool-android-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -trimpath -tags web -ldflags="$LDFLAGS" -o taskpool-linux-armv7 main.go
tar -czvf taskpool-linux-armv7.tar.gz taskpool-linux-armv7
- name: Build Agent Binaries
run: |
VERSION=${{ github.ref_name }}
BUILD_TIME=$(TZ='Asia/Shanghai' date '+%Y/%m/%d %H:%M:%S')
AGENT_LDFLAGS="-s -w -X 'main.Version=$VERSION' -X 'main.BuildTime=$BUILD_TIME'"
mkdir -p data/agent
echo "$VERSION" > data/agent/version.txt
# Linux AMD64
cd agent && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-linux-amd64 . && cd ..
cd data/agent && cp ../../agent/config.example.ini . && tar -czvf taskpool-agent-linux-amd64.tar.gz taskpool-agent-linux-amd64 config.example.ini && rm taskpool-agent-linux-amd64 && cd ../..
# Linux ARM64
cd agent && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-linux-arm64 . && cd ..
cd data/agent && tar -czvf taskpool-agent-linux-arm64.tar.gz taskpool-agent-linux-arm64 config.example.ini && rm taskpool-agent-linux-arm64 && cd ../..
# Windows AMD64
cd agent && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-windows-amd64.exe . && cd ..
cd data/agent && zip taskpool-agent-windows-amd64.zip taskpool-agent-windows-amd64.exe config.example.ini && rm taskpool-agent-windows-amd64.exe && cd ../..
# Darwin AMD64
cd agent && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-darwin-amd64 . && cd ..
cd data/agent && tar -czvf taskpool-agent-darwin-amd64.tar.gz taskpool-agent-darwin-amd64 config.example.ini && rm taskpool-agent-darwin-amd64 && cd ../..
# Darwin ARM64
cd agent && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-darwin-arm64 . && cd ..
cd data/agent && tar -czvf taskpool-agent-darwin-arm64.tar.gz taskpool-agent-darwin-arm64 config.example.ini && rm taskpool-agent-darwin-arm64 && cd ../..
# Android ARM64
cd agent && CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-android-arm64 . && cd ..
cd data/agent && tar -czvf taskpool-agent-android-arm64.tar.gz taskpool-agent-android-arm64 config.example.ini && rm taskpool-agent-android-arm64 && cd ../..
- name: Prepare Release Notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view ${{ github.ref_name }} > /dev/null 2>&1; then
echo "$(cat CHANGELOG.md)" > FINAL_CHANGELOG.md
echo -e "\n\n" >> FINAL_CHANGELOG.md
gh release view ${{ github.ref_name }} --json body -q .body >> FINAL_CHANGELOG.md
else
cp CHANGELOG.md FINAL_CHANGELOG.md
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
body_path: FINAL_CHANGELOG.md
files: |
taskpool-linux-amd64.tar.gz
taskpool-linux-arm64.tar.gz
taskpool-linux-armv7.tar.gz
taskpool-android-arm64.tar.gz
data/agent/taskpool-agent-*.tar.gz
data/agent/taskpool-agent-*.zip
build:
runs-on: ubuntu-latest
strategy:
matrix:
base_type: [debian, debian13, minimal]
platform:
- linux/amd64
- linux/arm64
include:
- base_type: debian
platform: linux/amd64
base_tag: base
tag_suffix: ""
- base_type: debian
platform: linux/arm64
base_tag: base
tag_suffix: ""
# - base_type: alpine
# platform: linux/amd64
# base_tag: base-alpine
# tag_suffix: "-alpine"
# - base_type: alpine
# platform: linux/arm64
# base_tag: base-alpine
# tag_suffix: "-alpine"
- base_type: debian13
platform: linux/amd64
base_tag: base-debian13
tag_suffix: "-debian13"
- base_type: debian13
platform: linux/arm64
base_tag: base-debian13
tag_suffix: "-debian13"
- base_type: minimal
platform: linux/amd64
base_tag: base-minimal
tag_suffix: "-minimal"
- base_type: minimal
platform: linux/arm64
base_tag: base-minimal
tag_suffix: "-minimal"
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set platform pair
id: platform
run: |
platform=${{ matrix.platform }}
echo "pair=${platform//\//-}-${{ matrix.base_type }}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
- name: Get build time
id: build_time
run: echo "time=$(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.base_type == 'minimal' && './docker/Dockerfile.minimal' || './docker/Dockerfile' }}
platforms: ${{ matrix.platform }}
build-args: |
VERSION=${{ github.ref_name }}
BUILD_TIME=${{ steps.build_time.outputs.time }}
BASE_TAG=${{ matrix.base_tag }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ steps.platform.outputs.pair }}
cache-to: type=gha,scope=${{ steps.platform.outputs.pair }},mode=max
provenance: false
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v7
with:
name: digests-${{ steps.platform.outputs.pair }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [build, build-binaries]
strategy:
matrix:
base_type: [debian, debian13, minimal]
include:
- base_type: debian
tag_suffix: ""
# - base_type: alpine
# tag_suffix: "-alpine"
- base_type: debian13
tag_suffix: "-debian13"
- base_type: minimal
tag_suffix: "-minimal"
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*-${{ matrix.base_type }}
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=semver,pattern={{version}}${{ matrix.tag_suffix }}
type=raw,value=latest${{ matrix.tag_suffix }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+78
View File
@@ -0,0 +1,78 @@
# Binaries
taskpool
*.exe
*.dll
*.so
*.dylib
# Go
# go.sum should be committed for reproducible builds
# Data & Logs
data/
agent/logs/
!data/agent/
data/agent/*
!data/agent/version.txt
envs/
.kiro/
# logs/
# scripts/
configs/config.ini
agent/config.ini
agent/agent.pid
agent/taskpool-agent
web/dist/
web/dev-dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# Node
node_modules/
# Embedded static files (built during CI/Docker)
internal/static/dist/
# Config (sensitive)
configs/config.local.json
# Python
__pycache__/
# Misc
.stfolder
bin/
*.log
# VitePress
docs/.vitepress/dist/
docs/.vitepress/cache/
docs/node_modules/
docs/public/swagger.json
docs/public/swagger.yaml
# OpenAPI
openapi_docs/
# Builtin SDK (Force Include)
!builtin/
!builtin/**
builtin/**/__pycache__/
builtin/**/*.pyc
.agents/
# Local secrets / runtime
cookies.txt
debug.log
backend-dev.log
.ref-baihu/
scripts/e2e-check.mjs.bak
+4
View File
@@ -0,0 +1,4 @@
.git/
web/node_modules
web/dist
envs/
+54
View File
@@ -0,0 +1,54 @@
# 更新日志 (v1.1.20)
### 2026.07.13 - 日志 ZSTD 压缩、依赖补全与计划任务排序
🎉 **新增与优化**
* **日志 ZSTD 压缩升级**:日志流式压缩机制由 zlib 全面升级至 ZSTD,显著降低磁盘开销与带宽占用;前端集成 `fzstd` 无缝支持新格式解码,并实现了对旧版 zlib 的向后兼容;针对小于 128 字节的短日志自动绕过压缩,避免资源浪费。
* **依赖自动补全与交互终端 (#147)**:全新上线依赖分析与自动补全安装 CLI,并提供终端安装引导;在定时任务日志页支持通过“补全依赖”一键调出内嵌终端进行交互式依赖补全。
* **计划任务表头排序 (#148)**:大屏与中屏布局支持点击“名称”、“执行时间”(下次执行时间)和“状态”表头进行排序,后端支持 `sort_by``order` 传参并保证置顶任务最高优先级;移动端顶栏同步新增了“排序规则”下拉选择器。
* **过滤视图联动**:自定义过滤视图功能全面支持排序参数(`sort_by` / `order`)联动保存,应用视图时自动还原当时的排序配置。
* **全局 ESC 关闭弹窗**:底层通用 Dialog 组件集成了非侵入式全局 Escape 按键拦截机制,优先退出最顶层弹窗,避免输入框/Monaco等组件焦点被抢占时 ESC 失效。
**✨ 修复与改进**
* **样式与体验**:将大屏及中屏下的状态列宽度由 `w-8` 扩大至 `w-14`,彻底消除因加入排序图标导致的文字折行与表头挤压;在 DialogContent 上追加了聚焦样式清除,消除了窗口边缘的白色聚焦边框;为新建任务的日志清理配置默认设置为保留最近 30 条记录,防爆盘;日志详情弹窗增加了最大高度及滚动条优化。
---
---
> 出于安全及环境隔离考虑,推荐使用 Docker/Compose 部署方式。[镜像地址](https://github.com/engigu/taskpool/pkgs/container/taskpool)
### 🐳 方式一:Docker 部署(推荐)
[部署文档](https://github.com/engigu/taskpool?tab=readme-ov-file#%E5%BF%AB%E9%80%9F%E9%83%A8%E7%BD%B2)
### 🚀 方式二:单文件部署
从当前 Release 的附件中下载对应架构的部署压缩包(如 `taskpool-linux-amd64.tar.gz`),然后使用以下命令提取并运行:
**⚠️ 重要前置依赖:手动安装 `mise`**
单文件直接运行依赖宿主机系统环境,请务必先安装 [mise](https://mise.jdx.dev/getting-started.html) 供任务调度及环境管理使用:
```bash
curl https://mise.run | sh
export PATH="~/.local/share/mise/bin:~/.local/share/mise/shims:$PATH"
```
**运行面板:**
```bash
tar -xzvf taskpool-linux-amd64.tar.gz
chmod +x taskpool-linux-amd64
./taskpool-linux-amd64 server
```
---
**访问面板:**
启动后访问:http://localhost:8052
**登录信息:**
默认账号:用户名 `admin`,密码见面板首次启动时的控制台日志。
+12
View File
@@ -0,0 +1,12 @@
# Production stage - only needs nginx and built files
FROM nginx:alpine
# Copy built files (from CI artifact or local build)
COPY web/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+40
View File
@@ -0,0 +1,40 @@
# Build frontend
FROM node:22-alpine AS frontend-builder
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY web/ .
RUN pnpm build
# Build backend
FROM golang:1.24-alpine AS backend-builder
WORKDIR /app
RUN apk add --no-cache git
ENV GOTOOLCHAIN=auto
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /taskpool .
# Production stage
FROM alpine:3.19
RUN apk add --no-cache nginx supervisor
# Copy frontend
COPY --from=frontend-builder /app/web/dist /usr/share/nginx/html
# Copy backend
COPY --from=backend-builder /taskpool /app/taskpool
# Copy nginx config (modified for localhost)
COPY nginx-standalone.conf /etc/nginx/http.d/default.conf
# Copy supervisor config
COPY supervisord.conf /etc/supervisord.conf
# Create data directories
RUN mkdir -p /app/data /app/configs /app/envs /app/logs
EXPOSE 3000 8052
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or
conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS
FOR A PARTICULAR PURPOSE. You are solely responsible for determining
the appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 engigu
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+230
View File
@@ -0,0 +1,230 @@
# Variables
BINARY=bin/taskpool
GOBUILD=go build
GOCLEAN=go clean
GOGET=go get
GOMOD=go mod
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date '+%Y/%m/%d %H:%M:%S')
LDFLAGS=-ldflags="-s -w -X 'github.com/engigu/taskpool/internal/constant.Version=$(VERSION)' -X 'github.com/engigu/taskpool/internal/constant.BuildTime=$(BUILD_TIME)'"
TAGS_WEB=-tags web
DEV_UID ?= $(shell id -u 2>/dev/null || echo 1000)
DEV_GID ?= $(shell id -g 2>/dev/null || echo 1000)
export DEV_UID
export DEV_GID
# Default target
all: build
# Build frontend
build-web:
cd web && npm ci && npm run build
pack-webui:
@echo "==> [1/6] 验证参数有效性..."
@if [ -z "$(NAME)" ] || [ -z "$(VERSION)" ] || [ -z "$(AUTHOR)" ] || [ -z "$(DESC)" ]; then \
echo "Error: Missing required arguments!"; \
echo "Usage: make pack-webui NAME=<name> VERSION=<version> AUTHOR=<author> DESC=<description>"; \
exit 1; \
fi
@if [ "$(NAME)" = "default" ]; then \
echo "Error: WebUI name cannot be 'default' ('default' is reserved for the built-in system identifier)."; \
exit 1; \
fi
@echo "==> [2/6] 正在安装前端依赖包 (npm i)..."
cd web && npm i
@echo "==> [3/6] 正在编译构建前端资源文件 (npm run build)..."
cd web && npm run build
@echo "==> [4/6] 正在准备归档输出目录与清理旧包..."
@mkdir -p bin
@rm -f bin/webui-$(NAME)-$(VERSION).tar.gz
@echo "==> [5/6] 正在生成包配置文件 uimanifest.json..."
@echo '{"name": "$(NAME)", "version": "$(VERSION)", "author": "$(AUTHOR)", "description": "$(DESC)"}' > web/dist/uimanifest.json
@echo "==> [6/6] 正在压缩打包为 tar.gz 归档包..."
@sleep 2
cd web/dist && tar -czf ../../bin/webui-$(NAME)-$(VERSION).tar.gz *
@echo "==> 打包成功!资源包已创建于: bin/webui-$(NAME)-$(VERSION).tar.gz"
# Build the application (requires frontend to be built first)
build:
@mkdir -p bin
CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o $(BINARY) main.go
# Build release version (Frontend + Backend with embedded assets)
release:
cd web && npm ci && npm run build
@mkdir -p bin
rm -rf internal/static/dist
cp -r web/dist internal/static/dist
CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) $(TAGS_WEB) -o $(BINARY) main.go
# Build release version for Android Termux
release-android:
cd web && npm ci && npm run build
@mkdir -p bin
rm -rf internal/static/dist
cp -r web/dist internal/static/dist
CGO_ENABLED=0 GOOS=android GOARCH=arm64 $(GOBUILD) -trimpath $(LDFLAGS) $(TAGS_WEB) -o bin/taskpool-android-arm64 main.go
# Build release version (Frontend + Backend with embedded assets)
release-binary:
cd web && npm ci && VITE_RELEASE_OPTIMIZE=true npm run build
@mkdir -p bin
rm -rf internal/static/dist
cp -r web/dist internal/static/dist
CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) $(TAGS_WEB) -o $(BINARY) main.go
# Alias for backward compatibility
build-all: release
# Build agent for all platforms
build-agent: build-agent-linux-amd64 build-agent-linux-arm64 build-agent-android-arm64 build-agent-windows-amd64 build-agent-darwin-amd64 build-agent-darwin-arm64
@echo "All agent packages built in data/agent/"
@ls -lh data/agent/taskpool-agent-*
AGENT_LDFLAGS=-s -w -X 'main.Version=$(VERSION)' -X 'main.BuildTime=$(BUILD_TIME)'
build-agent-linux-amd64:
@mkdir -p data/agent
@echo "$(VERSION)" > data/agent/version.txt
cd agent && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-linux-amd64 .
build-agent-linux-arm64:
@mkdir -p data/agent
@echo "$(VERSION)" > data/agent/version.txt
cd agent && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-linux-arm64 .
build-agent-android-arm64:
@mkdir -p data/agent
@echo "$(VERSION)" > data/agent/version.txt
cd agent && CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-android-arm64 .
build-agent-windows-amd64:
@mkdir -p data/agent
@echo "$(VERSION)" > data/agent/version.txt
cd agent && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-windows-amd64.exe .
build-agent-darwin-amd64:
@mkdir -p data/agent
@echo "$(VERSION)" > data/agent/version.txt
cd agent && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-darwin-amd64 .
build-agent-darwin-arm64:
@mkdir -p data/agent
@echo "$(VERSION)" > data/agent/version.txt
cd agent && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-darwin-arm64 .
# Clean built files
clean:
$(GOCLEAN)
rm -rf bin/
rm -rf internal/static/dist
rm -rf web/dist
# Clean everything: local artifacts and Docker development environment (including volumes)
clean-all: clean docker-dev-clean
rm -rf web/node_modules
@echo "All local artifacts and Docker dev caches have been completely wiped."
# Run the application
run:
@mkdir -p bin
$(GOBUILD) -o $(BINARY) main.go
./$(BINARY) server
# Development run with hot reload (both frontend and backend)
dev:
@command -v concurrently > /dev/null 2>&1 || npm install -g concurrently
@mkdir -p envs web/node_modules
concurrently --kill-others \
"go tool air" \
"cd web && npm ci && npm run dev"
# Run agent with hot reload
agent-dev:
go tool air -c agent.air.toml
# Run agent
agent-run:
@mkdir -p bin
$(GOBUILD) -o bin/taskpool-agent ./agent
./bin/taskpool-agent run -c ../agent/config.ini
# Install dependencies
deps:
$(GOMOD) tidy
# Generate swagger documentation
swag:
@mkdir -p docs/public
go run github.com/swaggo/swag/cmd/swag@latest init -g main.go -o ./docs/public --ot json,yaml
docs-dev:
cd docs && npm run docs:dev
docs-build:
cd docs && npm run docs:build
# Docker build
docker-build:
docker build -t taskpool:dev -f docker/Dockerfile .
# Docker run
docker-run:
docker run -p 8052:8052 taskpool:dev
# Docker compose up
docker-up:
docker compose up -d
# Docker compose down
docker-down:
docker compose down
# Start isolated Docker dev environment (foreground with logs, Ctrl+C to stop)
docker-dev:
@command -v concurrently > /dev/null 2>&1 || npm install -g concurrently
@mkdir -p envs web/node_modules
docker compose -f docker-compose.dev.yml up --build
# Start isolated Docker dev environment (background)
docker-dev-d:
docker compose -f docker-compose.dev.yml up -d --build
# Stop Docker dev environment (preserves cached volumes for fast restart)
docker-dev-down:
docker compose -f docker-compose.dev.yml down
# Stop and completely clean Docker dev environment (removes all cached volumes)
# Use this if your environment is broken or you want a fresh start
docker-dev-clean:
docker compose -f docker-compose.dev.yml down -v
# Help
help:
@echo "Available targets:"
@echo " all - Build backend only (default)"
@echo " build - Build backend binary (no UI embedded)"
@echo " release - Build full release binary (with UI embedded)"
@echo " build-web - Build frontend assets only"
@echo " pack-webui - Build and package custom WebUI tar.gz"
@echo " build-agent - Build agent packages (tar.gz) for all platforms"
@echo " clean - Clean built files"
@echo " clean-all - Clean local files and Docker dev environment (including volumes)"
@echo " run - Run the application locally"
@echo " dev - Run local development with hot reload"
@echo " deps - Install Go dependencies"
@echo " docker-build - Build production Docker image"
@echo " docker-run - Run production Docker container"
@echo " docker-up - Start production Docker Compose stack"
@echo " docker-down - Stop production Docker Compose stack"
@echo " docker-dev - Start isolated Docker dev environment (foreground)"
@echo " docker-dev-d - Start isolated Docker dev environment (background)"
@echo " docker-dev-down - Stop Docker dev environment (keep caches)"
@echo " docker-dev-clean - Stop and clean Docker dev environment (remove caches)"
@echo " swag - Generate swagger documentation and sync with docs"
@echo " docs-dev - Run documentation development server"
@echo " docs-build - Build documentation"
@echo " help - Show this help message"
+21
View File
@@ -0,0 +1,21 @@
TaskPool (任务池)
Copyright 2025 engigu
This product includes software developed by engigu.
=========================================================================
ATTRIBUTION NOTICE
=========================================================================
According to the Apache License, Version 2.0 (the "License"), any
redistribution of this Work or Derivative Works thereof must retain
this NOTICE file and the attribution contained within.
YOU MUST NOT REMOVE THE ORIGINAL AUTHOR'S NAME ("engigu"), COPYRIGHT
NOTICES, OR THE PROJECT NAME ("TaskPool" / "任务池") FROM ANY
PART OF THE SOURCE CODE, UI, OR DOCUMENTATION.
Any derivative work or modified version must include a prominent and
verifiable attribution to the original author.
=========================================================================
+778
View File
@@ -0,0 +1,778 @@
# 任务池
[![Hits](https://hits.sh/github.com/engigu/taskpool.svg?view=today-total)](https://hits.sh/github.com/engigu/taskpool/)
![Version](https://img.shields.io/github/v/tag/engigu/taskpool?color=44cc11&label=docker%20version&sort=semver)
![Image Size](https://ghcr-badge.egpl.dev/engigu/taskpool/size?color=%2344cc11&tag=latest&label=docker+image&trim=)
![Image pulls](https://img.shields.io/badge/dynamic/json?url=https://ghcr-badge.elias.eu.org/api/engigu/taskpool/taskpool&query=downloadCount&style=flat&label=docker%20pulls&color=44cc11)
任务池 (TaskPool) 是一款极致轻量、高性能的自动化任务调度平台。采用 Go + Vue3 架构,专注于高性能与低系统开销。通过深度集成 Mise 运行时管理,它原生支持 Python、Node.js、Go、Rust、PHP 等所有主流语言环境的动态安装(几乎所有的版本)与统一依赖管理。支持 Docker/Docker-Compose 一键部署,开箱即用,是您理想的轻量化脚本托管与任务调度解决方案。
演示站点(演示站点的服务器比较烂,见谅) [演示站点](https://taskpool-demo-site.qwapi.eu.org/)
文档说明 [文档说明](https://engigu.github.io/taskpool/)
## 更新日志 ☕
### 最近更新
**2026.07.13** - **ZSTD 日志与任务排序**:流式日志升级至 ZSTD 压缩,支持 Zlib 兼容;上线依赖自动补全交互式终端;支持任务列表及移动端、视图联动排序;全局 ESC 非侵入式退出弹窗。
**2026.06.28** - **节点互联与终端 UX**:全面升级节点互联架构(支持变量同步、OpenConnect 协议及同步大屏);优化移动端终端交互,引入 Worker 边界防护防 OOM。
**2026.06.12** - **调度器与资源实时监控**:新增对面板运行资源、并发调度池(Worker)及内存堆栈的实时高频监控指标展示。
**2026.05.29** - **自定义前端 (WebUI)**:新增 WebUI 插件化管理机制,支持上传管理第三方前端包 (`.tar.gz`) 接管内置界面,支持深度主题化定制。详情请看[前端定制说明文档](docs/guide/webui.md)
**2026.04.16** - **内建脚本助手库 (Built-in SDK)**:新增 Python 与 Node.js 的轻量化助手库,实现脚本内 “零代码配置” 通知投递;配套新增 `taskpool builtininstall` 自动化安装命令。
**2026.04.14** - **PWA 与通知渠道增强**:支持 PWA (Progressive Web App) 动态配置,站点标题与图标可由后端实时控制;新增 **VoceChat** 通知渠道支持;增强 **Bark** 推送,支持自建服务器配置。
**2026.03.27** - **安全机密管理 (GitHub Secrets 风格)**:新增系统级机密(Secret)管理功能。支持 AES-GCM 工业级加密存储,秘钥内存留存销毁;支持执行日志自动脱敏打码;支持仅在计划任务调度时按需注入,终端与测试运行物理隔离,全面提升敏感配置安全性。
**2026.03.19** - **仓库同步增强**:新增对青龙仓库格式指令的深度兼容,支持从远程 Git 仓库自动同步脚本并基于注释解析自动创建面板任务,支持白名单、黑名单、依赖保留等高级筛选特性。
**2026.03.05** - **API 文档重构** 重构 OpenAPI 认证体系,支持站点级 Token 配置与 Basic Auth 保护。
**2026.03.04** - 新增内置消息推送系统:全新原生支持企业微信、钉钉、飞书、Telegram、Bark、邮件等十余种主流渠道的推送,接入系统级事件通知自动捕获,告别原有必配外部推送服务的繁琐历史
**2026.02.13** - 重构任务执行引擎:深度集成 Mise 运行时管理,支持 Python, Node.js, Go, Rust, PHP 等几乎所有主流语言的动态安装与多版本切换,同步上线跨语言统一依赖管理系统
**2026.02.11** - 增强安全性:首次启动使用随机密码并打印在日志中,登录接口增加防暴力破解,文件系统操作增加路径穿越锁定
**2026.02.10** - 重构任务调度系统,完善并发控制,优化文件树交互体验,支持任务执行实时日志流
**2026.02.06** - 整理 Docker 目录结构,增加 Debian 13 (Trixie) 镜像支持
[查看完整更新日志](./CHANGELOG.md)
## 项目来由
多少和青龙面板有点关系,我自己也是青龙面板的使用者,但是现在的青龙面板性能我觉得有点难以接受。以我自己的使用(`机器1C2G`)为例,一个`python``requests`脚本每隔`30s`执行一次,有时候cpu执行的时候能跳变到`50%`以上。可以看看下面gif图片(如果不动,点击图片查看)
![qinglong.gif](https://f.pz.al/pzal/2025/12/24/2d245b0a77f26.gif)
我觉得一个内存和性能占用低的面板更合适自己,所以做了这个项目。
如果你和我一样需要一个性能和内存占用低的定时面板,这个项目你可以体验下。
同样的定时场景和代码,这个项目的情况如下(cpu执行定时跳变不超过`20%`):
![taskpool.gif](https://f.pz.al/pzal/2025/12/24/f0d171f9a686d.gif)
如果项目有用,请帮忙点个star。
## 特色
- **轻量级:** docker/compose部署,无需复杂配置,开箱即用
- **任务调度:** 支持标准 Cron 表达式,常用时间规则快捷选择。日志不落文件,没有磁盘频繁io的问题
- **脚本管理:** 在线代码编辑器,支持文件上传、压缩包解压
- **在线终端:** WebSocket 实时终端,命令执行结果实时输出
- **消息推送:** 内置强大消息推送与通知引擎,无缝兼容主流渠道,支持系统级事件告警
- **机密管理:** **(New)** 类似 GitHub Secrets 的安全存储,支持 AES-GCM 加密,日志自动打码,仅在调度时注入
- **环境变量:** 存储普通配置,任务执行时自动注入
- **现代UI:** 响应式设计,深色/浅色主题切换
- **移动端:** 适配移动小屏样式
- **远程执行:** 支持远程agent执行任务,展示执行结果
- **多语言支持:** 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理
- **内建助手库:** **(New)** 为 Python/Node.js 提供零配置助手库,简单 import 即可实现一键推信,无需手动管理 API Token 和 URL
## 功能特性
<details>
<summary><b>点击展开查看详细功能</b></summary>
### 定时任务管理
- 支持标准 Cron 表达式调度
- 常用时间规则快捷选择
- 任务启用/禁用状态切换
- 手动触发执行
- 任务超时控制
### 脚本文件管理
- 在线代码编辑器
- 文件树形结构展示
- 支持创建、重命名、删除文件/文件夹
- 支持压缩包上传解压
- 支持多文件批量上传
### 在线终端
- WebSocket 实时终端
- 支持常用 Shell 命令
- 命令执行结果实时输出
### 执行日志
- 任务执行历史记录
- 执行状态追踪(成功/失败/超时)
- 执行耗时统计
- 日志内容压缩存储
- 日志自动清理
### 消息推送与系统通知
- 原生内置各大主流平台渠道(钉钉、企业微信、Telegram、Server酱等)
- 支持系统级事件条件触发通知(例如任务失败报警、服务下线提醒)
- 自动生成跨语言调用 API 示例代码供脚本集成
### 变量与机密
- 支持普通环境变量与安全机密(Secret)分类管理
- 机密使用 **AES-GCM** 加密存储,数据库不存明文
- 秘钥仅在内存中留存,启动读取后立即销毁(Unset)
- 执行日志自动搜索并**屏蔽(********)**敏感机密内容
- 严格权限隔离:机密仅在定时任务调度时注入,终端/测试环境不可见
### 节点互联体系 (New)
- 支持 OpenConnect 协议,轻松实现多台任务池之间的互联互通
- 支持节点间的环境变量无缝同步(保留原有结构与 ID)
- 集成同步管理控制台,统一查看与控制各节点状态与负载指标
- 底层路由深度适配穿透隧道,完美支持分布式前端代理访问
### 仓库任务同步 (New)
- 支持 青龙 仓库命令格式快捷导入
- 自动解析脚本注释中的 Cron 表达式和环境变量名
- 支持基于正则表达式的白名单、黑名单文件筛选
- 支持脚本依赖文件的识别与保留
- 自动同步远程 Git 仓库变更,增量更新面板任务
### 系统设置
- 站点标题、标语、图标自定义
- 分页大小、Cookie 有效期配置
- 调度参数热重载
- 数据备份与恢复
</details>
## 支持语言脚本和依赖
<details>
<summary><b>点击展开查看已支持的语言及依赖管理详情</b></summary>
### 脚本运行环境
任务池原生支持以下脚本的定时执行:
- **Python3**, **Node.js**, **Bash** (标准版镜像内置环境)
- 通过 **Mise** 扩展:支持几乎所有主流编程语言的动态安装与切换。
- **Minimal 版**:不预置 Python/Node,仅内置 Mise 底座,由用户按需安装。
### 依赖管理支持
系统内置了高度集成的跨语言依赖管理器,支持自动化安装和管理以下语言的依赖项,并确保在容器内全局可用:
| 语言 | 包管理器 | 功能说明 |
| :--- | :--- | :--- |
| **Python** | pip | 自动使用内置虚拟环境,支持清华源 |
| **Node.js** | npm | 全局安装模式,自动配置 npmmirror 镜像 |
| **Go** | go install | 通过 `go install` 安装二进制工具 |
| **Rust** | cargo | 通过 `cargo install` 安装 Rust 依赖 |
| **Ruby** | gem | 支持 `gem install` 本地安装 |
| **Bun** | bun | 支持 `bun add -g` 全局模式 |
| **PHP** | composer | 支持 `composer global require` |
| **Deno** | deno | 支持 `deno install -g` |
| **.NET** | dotnet | 支持 `dotnet tool install -g` |
| **Elixir/Erlang** | mix | 支持 `mix archive.install` |
| **Lua** | luarocks | 通过 `luarocks` 管理 Lua 包 |
| **Nim** | nimble | 支持 `nimble install` |
| **Dart/Flutter** | pub | 支持 `pub global activate` |
| **Perl** | cpanm | 简单的 `cpanm` 安装支持 |
| **Crystal** | shards | `shards` 项目级别或工具安装 |
### 使用方法
1. **安装环境**:进入「编程语言」页面,使用 `mise` 一键安装所需的语言及版本。
2. **依赖管理**:在已安装列表点击「依赖管理」,输入名称(可选版本)即可自动在对应环境内完成安装。
3. **隔离机制**:系统基于 `mise exec` 实现了完善的环境隔离,不同版本的依赖包互不冲突。
</details>
## 效果图
![taskpool-display.gif](https://raw.githubusercontent.com/engigu/resources/refs/heads/images/taskpool-display.gif)
<!-- TODO: 添加效果图 -->
## 快速部署
项目提供多种基础镜像,可根据具体环境选择:
| 标签 (Tag) | 基础镜像 | 说明 |
| :--- | :--- | :--- |
| `latest` | Debian 12 | **默认推荐**:集成 Python 3.13 与 Node.js 23,开箱即用 |
| `latest-debian13` | Debian 13 | 尝鲜版本,基于 Debian Trixie |
| `latest-minimal` | Debian 13 | **最小化版**:不预置语言环境,由用户通过面板自主按需安装 |
> **提示**:下方部署示例默认使用 `latest` 标签,如需换用 Debian 13 版,只需将 `latest` 替换为 `latest-debian13` 即可。
> **警告**:**架构升级破坏性变更**
>
> 本版本(2026.02.13+)对底层运行时环境进行了彻底重构,弃用了原有的静态 Python/Node 环境,转为使用 **Mise** 进行动态版本管理。
>
> 1. **不再提供 Alpine 镜像**:由于 glibc 兼容性问题,Mise 无法在 Alpine 上完美运行,因此暂时取消 Alpine 镜像支持。
> 2. **环境数据不兼容**:如果您是从旧版本升级上来,原有的 Python/Node 环境数据将无法迁移。升级后您需要:
> - 清空或备份原有的 `envs/` 挂载目录
> - 启动新容器,让系统自动初始化新的 Mise 环境
> - 在面板中重新安装所需的语言和依赖
<details>
<summary><b>方式一:环境变量部署(推荐)</b></summary>
通过环境变量指定配置,简单灵活,适合容器编排场景。
**使用 SQLite(默认):**
```bash
docker run -d \
--name taskpool \
-p 8052:8052 \
-v $(pwd)/data:/app/data \
-v $(pwd)/envs:/app/envs \
-e TZ=Asia/Shanghai \
-e BH_SERVER_PORT=8052 \
-e BH_SERVER_HOST=0.0.0.0 \
-e BH_DB_TYPE=sqlite \
-e BH_DB_PATH=/app/data/taskpool.db \
-e BH_DB_TABLE_PREFIX=taskpool_ \
-e TASKPOOL_SECRET_KEY=your_secret_key_here \
--restart unless-stopped \
ghcr.io/engigu/taskpool:latest
```
> **提示**:如需通过反向代理部署在子路径(如 `/taskpool`),添加环境变量:
> ```bash
> -e BH_SERVER_URL_PREFIX=/taskpool
> ```
> 配置后访问地址为 `http://your-domain.com/taskpool/`,详见下方「URL 前缀配置」说明。
**Docker ComposeSQLite):**
```yaml
services:
taskpool:
image: ghcr.io/engigu/taskpool:latest
container_name: taskpool
ports:
- "8052:8052"
volumes:
- ./data:/app/data
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
- BH_SERVER_PORT=8052
- BH_SERVER_HOST=0.0.0.0
- BH_DB_TYPE=sqlite
- BH_DB_PATH=/app/data/taskpool.db
- BH_DB_TABLE_PREFIX=taskpool_
- TASKPOOL_SECRET_KEY=your_secret_key_here
# - BH_SERVER_URL_PREFIX=/taskpool # 可选:配置 URL 前缀用于反向代理
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped
```
**使用 MySQL**
```bash
docker run -d \
--name taskpool \
-p 8052:8052 \
-v $(pwd)/data:/app/data \
-v $(pwd)/envs:/app/envs \
-e TZ=Asia/Shanghai \
-e BH_SERVER_PORT=8052 \
-e BH_SERVER_HOST=0.0.0.0 \
-e BH_DB_TYPE=mysql \
-e BH_DB_HOST=mysql-server \
-e BH_DB_PORT=3306 \
-e BH_DB_USER=root \
-e BH_DB_PASSWORD=your_password \
-e BH_DB_NAME=taskpool \
-e BH_DB_TABLE_PREFIX=taskpool_ \
-e TASKPOOL_SECRET_KEY=your_secret_key_here \
--restart unless-stopped \
ghcr.io/engigu/taskpool:latest
```
> **提示**:如需配置 URL 前缀,添加 `-e BH_SERVER_URL_PREFIX=/taskpool`
**Docker ComposeMySQL):**
```yaml
services:
taskpool:
image: ghcr.io/engigu/taskpool:latest
container_name: taskpool
ports:
- "8052:8052"
volumes:
- ./data:/app/data
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
- BH_SERVER_PORT=8052
- BH_SERVER_HOST=0.0.0.0
# - BH_SERVER_URL_PREFIX=/taskpool # 可选:配置 URL 前缀
- BH_DB_TYPE=mysql
- BH_DB_HOST=mysql-server
- BH_DB_PORT=3306
- BH_DB_USER=root
- BH_DB_PASSWORD=your_password
- BH_DB_NAME=taskpool
- BH_DB_TABLE_PREFIX=taskpool_
- TASKPOOL_SECRET_KEY=your_secret_key_here
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped
```
</details>
<details>
<summary><b>方式二:配置文件部署</b></summary>
通过挂载 `config.ini` 配置文件来管理配置,适合需要持久化配置的场景。
**Docker 命令:**
```bash
docker run -d \
--name taskpool \
-p 8052:8052 \
-v $(pwd)/data:/app/data \
-v $(pwd)/configs:/app/configs \
-v $(pwd)/envs:/app/envs \
-e TZ=Asia/Shanghai \
-e TASKPOOL_SECRET_KEY=your_secret_key_here \
--restart unless-stopped \
ghcr.io/engigu/taskpool:latest
```
**Docker Compose**
```yaml
services:
taskpool:
image: ghcr.io/engigu/taskpool:latest
container_name: taskpool
ports:
- "8052:8052"
volumes:
- ./data:/app/data
- ./configs:/app/configs
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
- TASKPOOL_SECRET_KEY=your_secret_key_here
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped
```
首次使用需要复制 `configs/config.example.ini``configs/config.ini`,然后根据需要修改配置。
**配置文件示例(`configs/config.ini`):**
```ini
[server]
port = 8052
host = 0.0.0.0
# 可选:配置 URL 前缀用于反向代理,例如 /taskpool
url_prefix =
# 全局会话 Cookie 名称
cookie_name = BHToken
[database]
type = sqlite
path = ./data/taskpool.db
table_prefix = taskpool_
```
</details>
<details>
<summary><b>方式三:配合独立中心化消息服务部署(非必需/仅供参考)</b></summary>
> 🎉 **好消息**:自任务池最新版本起,系统已**原生内置**了完整强大的消息推送功能!您可直接在面板「消息推送」菜单内绑定十余种主流渠道和系统通知事件,原配合外置的 `Message-Push-Nest` 部署方式已不再是使用面板的基础要求。您可随时直接使用上方的第一种简单命令开箱即用体验。
>
> 以下「任务池 + 消息聚合服务」的联合部署内容被予以保留,专为仍然需要「中心化通知网关」的重度企业解耦用户作为参考:
任务池通过系统集成也可轻松连接独立的消息聚合服务。这里推荐使用 [Message-Push-Nest](https://github.com/engigu/Message-Push-Nest) 作为分布式的统一消息推送中心。
**使用 SQLite**
创建 `docker-compose.yml` 文件:
```yaml
services:
# 任务池
taskpool:
image: ghcr.io/engigu/taskpool:latest
container_name: taskpool
ports:
- "8052:8052"
volumes:
- ./data:/app/data
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
- BH_SERVER_PORT=8052
- BH_SERVER_HOST=0.0.0.0
- BH_DB_TYPE=sqlite
- BH_DB_PATH=/app/data/taskpool.db
- BH_DB_TABLE_PREFIX=taskpool_
- TASKPOOL_SECRET_KEY=your_secret_key_here
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped
depends_on:
- message-nest
# 消息推送服务
message-nest:
image: ghcr.io/engigu/message-nest:latest
# 或使用 Docker Hub 镜像
# image: engigu/message-nest:latest
container_name: message-nest
ports:
- "8053:8000"
environment:
- TZ=Asia/Shanghai
- DB_TYPE=sqlite
volumes:
- ./message-nest-data:/app/data
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped
```
**使用 MySQL(适合生产环境,需要已有 MySQL 服务)**
创建 `docker-compose.yml` 文件:
```yaml
services:
# 任务池
taskpool:
image: ghcr.io/engigu/taskpool:latest
container_name: taskpool
ports:
- "8052:8052"
volumes:
- ./data:/app/data
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
- BH_SERVER_PORT=8052
- BH_SERVER_HOST=0.0.0.0
- BH_DB_TYPE=mysql
- BH_DB_HOST=192.168.1.100 # 修改为你的 MySQL 地址
- BH_DB_PORT=3306
- BH_DB_USER=root
- BH_DB_PASSWORD=your_password # 修改为你的 MySQL 密码
- BH_DB_NAME=taskpool
- BH_DB_TABLE_PREFIX=taskpool_
- TASKPOOL_SECRET_KEY=your_secret_key_here
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped
depends_on:
- message-nest
# 消息推送服务
message-nest:
image: ghcr.io/engigu/message-nest:latest
# 或使用 Docker Hub 镜像
# image: engigu/message-nest:latest
container_name: message-nest
ports:
- "8053:8000"
environment:
- TZ=Asia/Shanghai
- DB_TYPE=mysql
- MYSQL_HOST=192.168.1.100 # 修改为你的 MySQL 地址
- MYSQL_PORT=3306
- MYSQL_USER=root
- MYSQL_PASSWORD=your_password # 修改为你的 MySQL 密码
- MYSQL_DB=message_nest
- MYSQL_TABLE_PREFIX=message_
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped
```
启动服务:
```bash
docker-compose up -d
```
**访问地址:**
- 任务池:http://localhost:8052
- 消息推送服务:http://localhost:8053
> 注意:使用 MySQL 方式时,请先在 MySQL 中创建 `taskpool` 和 `message_nest` 两个数据库,并修改配置中的 MySQL 地址和密码。也可以使用同一个数据库。
**在任务中使用推送**
如今你无需依赖任何外部服务,可以通过面板自身完成:
1. 进入任务池「消息推送」模块,新建你需要通知的渠道。
2. 可以在「事件绑定」中直接设定**自动化系统通知**(如监控任务失败或脚本异常中止时进行自动提醒)。
3. 如需在自己的脚本逻辑内动态推送,可以点击「脚本调用」页面,立刻获取一键调用的 Shell / API 代码,内嵌进你的业务逻辑中即可完成推信!
*(如继续使用 `Message-Push-Nest`,你可以通过其管理界面中「消息模板」的「复制推送代码」提取旧有版集成样例。)*
![1768143124572.png](https://f.pz.al/pzal/2026/01/11/1360cd334ff20.png)
> 提示:在 Docker Compose 部署的环境中,推送服务地址使用 `http://message-nest:8000`(容器内部通信)。如果是独立部署,请使用实际的服务地址。
</details>
> 环境变量优先级高于配置文件,两种方式可以混合使用。
<details>
<summary><b>方式四:Nginx 反向代理部署(HTTPS</b></summary>
如果需要通过域名和 HTTPS 访问任务池,可以使用 Nginx 作为反向代理。
**Nginx 配置示例:**
```nginx
# 在 http 块中添加 WebSocket 升级配置
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
access_log /var/log/nginx/example.access.log;
error_log /var/log/nginx/example.error.log warn;
location / {
proxy_pass http://172.17.0.1:8052;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# WebSocket 支持(终端功能需要)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 60s;
}
}
# HTTP 自动跳转 HTTPS(可选)
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
```
**配置说明:**
1.`example.com` 替换为你的域名
2. 修改 SSL 证书路径为你的实际路径
3. `172.17.0.1:8052` 是 Docker 容器的宿主机地址和端口,根据实际情况修改
4. WebSocket 配置是必需的,否则在线终端功能无法使用
**重载 Nginx 配置:**
```bash
nginx -t && nginx -s reload
```
</details>
### 访问面板
启动后访问:http://localhost:8052
**默认账号:** 用户名 `admin`,密码见启动日志(首次启动会自动生成 12 位随机密码并打印在日志中)
> **注意**:出于安全性考虑,系统不再使用固定默认密码。请在容器启动日志中搜索 `管理员账号创建成功` 找到您的随机密码,并登录后及时修改。
<details>
<summary><b>命令行工具 (CLI)</b></summary>
任务池在环境内内置了同名的 `taskpool` 命令行工具。如果您在终端内需要执行系统级别的操作,可以使用以下命令:
```bash
taskpool server # 以前台方式启动面板的后台进程服务(面板启动指令)
taskpool reposync # 供定时任务调用,将远程 Git 仓库的高级特性同步到本地目录中
taskpool resetpwd # 交互式重置系统 admin 账号密码(密码丢失时可通过进入终端重置)
taskpool restore <file> # 使用本地的 .zip 备份压缩包文件,一条命令直接全量恢复系统数据
```
终端执行 `taskpool` 会直接打印内置支持的高级命令帮助列表。
</details>
### 数据目录
```
./
├── taskpool # 可执行文件
├── data/ # 数据目录(自动创建)
│ ├── taskpool.db # SQLite 数据库
│ └── scripts/ # 脚本文件存储
├── configs/
│ └── config.ini # 配置文件(自动创建)
└── envs/ # 运行环境挂载目录(自动创建)
└── mise/ # Mise 运行时核心目录 (包含所有语言环境及依赖)
```
### Docker 启动流程
容器启动时 `docker-entrypoint.sh` 会执行以下操作:
1. **目录就绪**:检查并创建 `/app/data``/app/configs``/app/envs` 等核心目录。
2. **Mise 环境同步**:自动从镜像内置基础环境同步初始化文件至 `/app/envs/mise`,确保持久化挂载后运行时依然可用。
3. **运行时激活**
- 自动注入 `MISE_DATA_DIR` 等环境变量,确保运行时数据指向持久化目录。
-`mise shims` 路径加入系统 `PATH`,实现 Python、Node.js 等多版本环境的全局无感调用。
4. **依赖管理预设**:默认配置 Python 清华源(PIP)镜像,优化 Node.js 默认内存上限。
5. **启动应用**:运行 `taskpool` 面板主进程。
> **提示**:通过挂载 `./envs:/app/envs`,您通过面板安装的所有编程语言运行时以及通过「依赖管理」安装的所有第三方库都会永久保留,容器升级或重启后无需重新安装。
## 配置说明
<details>
<summary><b>点击展开查看配置详情</b></summary>
### 配置文件
配置文件路径:`configs/config.ini`
```ini
[server]
port = 8052
host = 0.0.0.0
url_prefix =
[database]
type = sqlite
host = localhost
port = 3306
user = root
password =
dbname = bh_panel
table_prefix = taskpool_
```
### 环境变量
所有配置项都支持通过环境变量覆盖,环境变量优先级高于配置文件:
| 环境变量 | 对应配置 | 说明 | 默认值 |
|----------|----------|------|--------|
| `BH_SERVER_PORT` | server.port | 服务端口 | 8052 |
| `BH_SERVER_HOST` | server.host | 监听地址 | 0.0.0.0 |
| `BH_SERVER_URL_PREFIX` | server.url_prefix | URL 前缀,用于反向代理子路径部署 | - |
| `BH_COOKIE_NAME` | server.cookie_name | 全局会话 Cookie 名称 | BHToken |
| `BH_DB_TYPE` | database.type | 数据库类型 (sqlite/mysql) | sqlite |
| `BH_DB_HOST` | database.host | 数据库地址 | localhost |
| `BH_DB_PORT` | database.port | 数据库端口 | 3306 |
| `BH_DB_USER` | database.user | 数据库用户 | root |
| `BH_DB_PASSWORD` | database.password | 数据库密码 | - |
| `BH_DB_NAME` | database.dbname | 数据库名称 | bh_panel |
| `BH_DB_PATH` | database.path | SQLite 文件路径 | ./data/taskpool.db |
| `BH_DB_TABLE_PREFIX` | database.table_prefix | 表前缀 | taskpool_ |
| `TASKPOOL_SECRET_KEY` | - | 系统加密秘钥,用于机密功能(**注:仅支持环境变量设置,不支持配置文件**) | - |
### URL 前缀配置
如果需要通过反向代理(如 Nginx)将任务池部署在子路径下,可以配置 URL 前缀。
**配置方式:**
```bash
# 方式一:配置文件
[server]
url_prefix = /taskpool
# 方式二:环境变量
-e BH_SERVER_URL_PREFIX=/taskpool
```
**配置效果:**
配置 `url_prefix = /taskpool` 后,访问路径变为:
| 类型 | 路径示例 |
|------|---------|
| 前端页面 | `http://your-domain.com/taskpool/` |
| 登录页面 | `http://your-domain.com/taskpool/login` |
| 任务管理 | `http://your-domain.com/taskpool/tasks` |
| API 接口 | `http://your-domain.com/taskpool/api/v1/*` |
| WebSocket | `ws://your-domain.com/taskpool/api/v1/terminal/ws` |
**Nginx 反向代理配置示例:**
```nginx
location /taskpool/ {
proxy_pass http://localhost:8052/taskpool/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
**MySQL 示例:**
参考上方「方式一:环境变量部署」中的 MySQL 配置示例。
### 调度设置
系统采用 Worker Pool + 任务队列的架构来控制任务执行,可在「系统设置 > 调度设置」中配置:
| 设置项 | 说明 | 默认值 |
|--------|------|--------|
| Worker 数量 | 并发执行任务的 worker 数量 | 4 |
| 队列大小 | 任务队列缓冲区大小 | 100 |
| 速率间隔 | 任务启动间隔(毫秒) | 200 |
修改调度设置后立即生效,无需重启服务。
</details>
## 免责声明 ⚠️
任务池(TaskPool)仅作为一个轻量级的任务托管与调度平台,本项目及相关代码**不提供、不内置任何具有实际业务逻辑的第三方脚本**。
在使用本项目时,请您务必知悉并同意以下条款:
1. **脚本来源审核**:请勿轻易执行任何来源不明或不可信的外部脚本。所有在平台上运行的脚本及代码均需由用户自行添加或配置,用户必须在执行前仔细阅读并审核其源代码,确保其安全性。
2. **安全责任自负**:本项目作为基础调度工具,**无法且不保证任何被执行任务的安全性**。因运行不安全、违规脚本带来的一切数据泄露、系统损坏、财产损失及法律责任等后果,均由使用者自行承担,与本项目及开发者无关。
3. **软件按“原样”提供**:本项目为业余开源开发,按“原样”提供,**不保证不存在 Bug 或漏洞**。开发者不对因使用本项目而引起的任何直接或间接损失负责。
## 贡献
欢迎提交 Issue 和 Pull Request!如果觉得本项目对你有帮助,不求大富大贵,只求顺手点个 Star,大家的 Star 是我持续更新的动力!
<img src="https://f.pz.al/pzal/2026/01/07/83be93eb4e2a3.png" width="200" />
## 许可证
本项目采用 [Apache License 2.0](LICENSE) 协议发布,并包含额外的 [NOTICE](NOTICE) 说明。
**强制要求:** 在任何分发、修改或二次开发中,**必须完整保留原作者署名及项目名称**(详见 NOTICE 文件)。
+44
View File
@@ -0,0 +1,44 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "bin"
[build]
args_bin = []
bin = "./bin/taskpool-agent"
cmd = "go build -o ./bin/taskpool-agent ./agent"
full_bin = "./bin/taskpool-agent run -c ../agent/config.ini"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "web", "data", "envs", "configs", "bin"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "agent-build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true
+844
View File
@@ -0,0 +1,844 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"runtime"
"strings"
"sync"
"time"
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/executor"
"github.com/engigu/taskpool/internal/logger"
"github.com/engigu/taskpool/internal/utils"
"github.com/gorilla/websocket"
)
// WebSocket 消息类型
const (
WSTypeHeartbeat = constant.WSTypeHeartbeat
WSTypeHeartbeatAck = constant.WSTypeHeartbeatAck
WSTypeTasks = constant.WSTypeTasks
WSTypeTaskResult = constant.WSTypeTaskResult
WSTypeUpdate = constant.WSTypeUpdate
WSTypeConnected = constant.WSTypeConnected
WSTypeDisabled = constant.WSTypeDisabled
WSTypeEnabled = constant.WSTypeEnabled
WSTypeFetchTasks = constant.WSTypeFetchTasks
WSTypeTaskLog = constant.WSTypeTaskLog
WSTypeExecute = constant.WSTypeExecute
WSTypeTaskHeartbeat = constant.WSTypeTaskHeartbeat
WSTypeStop = constant.WSTypeStop
)
type WSMessage struct {
Type string `json:"type"`
Data json.RawMessage `json:"data,omitempty"`
}
type AgentTask struct {
ID string `json:"id"`
Name string `json:"name"`
Command string `json:"command"`
PreCommand string `json:"pre_command"`
PostCommand string `json:"post_command"`
Schedule string `json:"schedule"`
Cron string `json:"cron"`
Timeout int `json:"timeout"`
WorkDir string `json:"work_dir"`
Envs string `json:"envs"`
Languages []map[string]string `json:"languages"`
RandomRange int `json:"random_range"`
Secrets []string `json:"secrets"`
Enabled bool `json:"enabled"`
}
func (t *AgentTask) GetID() string {
return t.ID
}
func (t *AgentTask) GetName() string {
return t.Name
}
func (t *AgentTask) GetCommand() string {
return t.Command
}
func (t *AgentTask) GetPreCommand() string {
return t.PreCommand
}
func (t *AgentTask) GetPostCommand() string {
return t.PostCommand
}
func (t *AgentTask) GetTimeout() int {
return t.Timeout
}
func (t *AgentTask) GetWorkDir() string {
return t.WorkDir
}
func (t *AgentTask) GetEnvs() string {
return t.Envs
}
func (t *AgentTask) GetEnvVars() []string {
return nil
}
func (t *AgentTask) GetSecrets() []string {
return t.Secrets
}
func (t *AgentTask) GetLanguages() []map[string]string {
return t.Languages
}
func (t *AgentTask) GetUseMise() bool {
return false
}
func (t *AgentTask) UseMise() bool {
return false
}
func (t *AgentTask) GetSchedule() string {
if t.Schedule != "" {
return t.Schedule
}
return t.Cron
}
func (t *AgentTask) GetRandomRange() int {
return t.RandomRange
}
type TaskResult struct {
TaskID string `json:"task_id"`
LogID string `json:"log_id"`
AgentID string `json:"agent_id"` // 仅用于 HTTP 上报时后端补充
Command string `json:"command"`
Output string `json:"output"`
Error string `json:"error"`
Status string `json:"status"`
Duration int64 `json:"duration"`
ExitCode int `json:"exit_code"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
}
type Agent struct {
config *Config
configFile string
machineID string
scheduler *executor.Scheduler
cronManager *executor.CronManager
tasks map[string]*AgentTask // 本地任务缓存,用于执行 lookup
lastTaskCount int
mu sync.RWMutex
client *http.Client
wsConn *websocket.Conn
wsMu sync.Mutex
stopCh chan struct{}
wsStopCh chan struct{} // 用于停止当前 WebSocket 相关的 goroutine
taskLogs map[string][]string // 记录最近的日志行,用于失败显示
logMu sync.Mutex // taskLogs 的锁
schedulerStarted bool // 调度器是否已经启动
}
func NewAgent(config *Config, configFile string) *Agent {
a := &Agent{
config: config,
configFile: configFile,
machineID: utils.GenerateMachineID(),
tasks: make(map[string]*AgentTask),
client: &http.Client{Timeout: 30 * time.Second},
stopCh: make(chan struct{}),
lastTaskCount: -1,
taskLogs: make(map[string][]string),
}
// 初始化调度器
handler := &AgentHandler{agent: a}
schedCfg := executor.SchedulerConfig{
WorkerCount: runtime.NumCPU(),
QueueSize: 100,
RateInterval: 100 * time.Millisecond,
Verbose: true,
}
a.scheduler = executor.NewScheduler(schedCfg, handler)
a.scheduler.SetLogger(logger.NewSchedulerLogger())
a.cronManager = executor.NewCronManager(a.scheduler)
a.cronManager.SetLogger(logger.NewSchedulerLogger())
return a
}
// AgentHandler 实现 executor.SchedulerEventHandler
type AgentHandler struct {
agent *Agent
}
func (h *AgentHandler) OnTaskScheduled(req *executor.ExecutionRequest) {}
func (h *AgentHandler) OnTaskExecuting(req *executor.ExecutionRequest) (io.Writer, io.Writer, error) {
if req.LogID != "" {
writer := &RealTimeLogWriter{agent: h.agent, logID: req.LogID}
return writer, writer, nil
}
return nil, nil, nil
}
func (h *AgentHandler) OnTaskHeartbeat(req *executor.ExecutionRequest, duration int64) {
if req.LogID != "" {
h.agent.sendWSMessage(WSTypeTaskHeartbeat, map[string]interface{}{
"log_id": req.LogID,
"duration": duration,
})
}
// 每分钟打印一次任务还在运行的日志,提升长任务的存在感
if duration >= 60000 && (duration/60000 > (duration-3000)/60000) {
logger.Infof("[Scheduler] 任务 #%s 仍在运行中... (已耗时: %v)",
req.TaskID, (time.Duration(duration) * time.Millisecond).Round(time.Second))
}
}
func (h *AgentHandler) OnTaskStarted(req *executor.ExecutionRequest) {}
func (h *AgentHandler) OnTaskCompleted(req *executor.ExecutionRequest, result *executor.ExecutionResult) {
h.agent.sendTaskResult(&TaskResult{
TaskID: req.TaskID,
LogID: result.LogID,
Command: req.Command,
Output: result.Output,
Error: result.Error,
Status: result.Status,
Duration: result.Duration,
ExitCode: result.ExitCode,
StartTime: result.StartTime.Unix(),
EndTime: result.EndTime.Unix(),
})
if result.Status == constant.TaskStatusFailed {
h.agent.printLastLogs(result.LogID)
}
h.agent.clearTaskLog(result.LogID)
}
func (h *AgentHandler) OnTaskFailed(req *executor.ExecutionRequest, err error) {
errMsg := fmt.Sprintf("任务执行失败: %v", err)
// 先发送日志,确保服务端能收到错误信息
h.agent.sendWSMessage(WSTypeTaskLog, map[string]interface{}{
"log_id": req.LogID,
"content": errMsg,
})
h.agent.sendTaskResult(&TaskResult{
TaskID: req.TaskID,
LogID: req.LogID,
Command: req.Command,
Output: "",
Error: err.Error(),
Status: constant.TaskStatusFailed,
Duration: 0,
ExitCode: 1,
StartTime: time.Now().Unix(),
EndTime: time.Now().Unix(),
})
h.agent.printLastLogs(req.LogID)
h.agent.clearTaskLog(req.LogID)
}
func (h *AgentHandler) OnCronNextRun(req *executor.ExecutionRequest, nextRun time.Time) {}
func (a *Agent) Start() error {
if a.config.Token == "" {
return fmt.Errorf("缺少令牌,请在配置文件中设置 token")
}
logger.Infof("机器识别码: %s", a.machineID[:16]+"...")
// 调度器暂不在此启动,等待 WebSocket 连接成功并获取到调度配置后再启动
go a.wsLoop()
logger.Info("Agent 已启动 (时区: Asia/Shanghai, 模式: WebSocket)")
return nil
}
func (a *Agent) Stop() {
close(a.stopCh)
a.closeWS()
a.mu.Lock()
started := a.schedulerStarted
a.schedulerStarted = false
a.mu.Unlock()
if started {
a.cronManager.Stop()
a.scheduler.Stop()
}
logger.Info("Agent 已停止")
}
// wsLoop WebSocket 连接循环
func (a *Agent) wsLoop() {
for {
select {
case <-a.stopCh:
return
default:
}
if err := a.connectWS(); err != nil {
logger.Warnf("WebSocket 连接失败: %v5秒后重试...", err)
time.Sleep(5 * time.Second)
continue
}
a.readWS()
logger.Warn("WebSocket 连接断开,5秒后重连...")
time.Sleep(5 * time.Second)
}
}
func (a *Agent) connectWS() error {
serverURL := a.config.ServerURL
wsURL := strings.Replace(serverURL, "http://", "ws://", 1)
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
wsURL = fmt.Sprintf("%s/api/agent/ws?token=%s&machine_id=%s", wsURL, url.QueryEscape(a.config.Token), url.QueryEscape(a.machineID))
logger.Infof("正在连接 WebSocket: %s", wsURL)
logger.Infof("Token: %s..., MachineID: %s...", a.config.Token[:8], a.machineID[:16])
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
conn, resp, err := dialer.Dial(wsURL, nil)
if err != nil {
if resp != nil {
bodyBytes, _ := io.ReadAll(resp.Body)
logger.Errorf("WebSocket 握手失败: HTTP %d, Body: %s", resp.StatusCode, string(bodyBytes))
resp.Body.Close()
} else {
logger.Errorf("WebSocket 连接失败: %v", err)
}
return err
}
a.wsMu.Lock()
a.wsConn = conn
a.wsStopCh = make(chan struct{})
a.wsMu.Unlock()
logger.Info("WebSocket 已连接")
a.sendHeartbeat()
go a.heartbeatLoop()
return nil
}
func (a *Agent) closeWS() {
a.wsMu.Lock()
defer a.wsMu.Unlock()
if a.wsStopCh != nil {
close(a.wsStopCh)
a.wsStopCh = nil
}
if a.wsConn != nil {
a.wsConn.Close()
a.wsConn = nil
}
}
func (a *Agent) readWS() {
defer func() {
logger.Info("readWS 退出,准备关闭连接")
a.closeWS()
}()
for {
a.wsMu.Lock()
conn := a.wsConn
a.wsMu.Unlock()
if conn == nil {
logger.Warn("readWS: wsConn 为 nil")
return
}
_, message, err := conn.ReadMessage()
if err != nil {
logger.Warnf("WebSocket 读取错误: %v", err)
return
}
var msg WSMessage
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
a.handleWSMessage(&msg)
}
}
func (a *Agent) handleWSMessage(msg *WSMessage) {
switch msg.Type {
case WSTypeConnected:
a.handleConnected(msg.Data)
case WSTypeHeartbeatAck:
a.handleHeartbeatAck(msg.Data)
case WSTypeTasks:
a.handleTasks(msg.Data)
case WSTypeUpdate:
logger.Info("收到更新指令,开始更新...")
go a.selfUpdate()
case WSTypeDisabled:
logger.Warn("Agent 已被禁用,清空所有任务")
a.clearAllTasks()
case WSTypeEnabled:
logger.Info("Agent 已被启用,主动拉取任务")
a.fetchTasks()
case WSTypeExecute:
a.handleExecute(msg.Data)
case WSTypeStop:
a.handleStop(msg.Data)
}
}
func (a *Agent) fetchTasks() {
logger.Info("正在从服务器拉取任务列表...")
if err := a.sendWSMessage(WSTypeFetchTasks, map[string]interface{}{}); err != nil {
logger.Warnf("请求任务列表失败: %v", err)
}
}
func (a *Agent) handleConnected(data json.RawMessage) {
var resp struct {
AgentID string `json:"agent_id"`
Name string `json:"name"`
IsNewAgent bool `json:"is_new_agent"`
MachineID string `json:"machine_id"`
SchedulerConfig map[string]interface{} `json:"scheduler_config"`
}
json.Unmarshal(data, &resp)
if resp.IsNewAgent {
logger.Infof("注册成功: Agent #%s, 机器码: %s", resp.AgentID, a.machineID[:16]+"...")
} else {
logger.Infof("连接成功: Agent #%s (已存在), 机器码: %s", resp.AgentID, a.machineID[:16]+"...")
}
// 更新调度器配置
if resp.SchedulerConfig != nil {
a.updateSchedulerConfig(resp.SchedulerConfig)
}
a.fetchTasks()
}
func (a *Agent) updateSchedulerConfig(config map[string]interface{}) {
// 获取当前配置作为基础
currentCfg := a.scheduler.GetConfig()
newCfg := currentCfg
// 更新配置项
if val, ok := config["worker_count"]; ok {
if v, ok := val.(float64); ok { // JSON 数字解析为 float64
newCfg.WorkerCount = int(v)
}
}
if val, ok := config["queue_size"]; ok {
if v, ok := val.(float64); ok {
newCfg.QueueSize = int(v)
}
}
if val, ok := config["rate_interval"]; ok {
if v, ok := val.(float64); ok {
newCfg.RateInterval = time.Duration(v) * time.Millisecond
}
}
if val, ok := config["strict_queue"]; ok {
if v, ok := val.(bool); ok {
newCfg.StrictQueue = v
}
}
a.mu.Lock()
started := a.schedulerStarted
a.schedulerStarted = true
a.mu.Unlock()
if !started {
logger.Infof("首次连接成功,启动调度器配置: workers=%d, queue=%d, rate=%v, strict=%t",
newCfg.WorkerCount, newCfg.QueueSize, newCfg.RateInterval, newCfg.StrictQueue)
// 用下发的最新配置加载并启动调度器与计划任务管理器
a.scheduler.Reload(newCfg)
a.cronManager.Start()
} else if newCfg != currentCfg {
logger.Infof("收到调度配置更新: workers=%d, queue=%d, rate=%v, strict=%t",
newCfg.WorkerCount, newCfg.QueueSize, newCfg.RateInterval, newCfg.StrictQueue)
a.scheduler.Reload(newCfg)
} else {
logger.Infof("当前调度配置未改变: workers=%d, queue=%d, rate=%v, strict=%t",
newCfg.WorkerCount, newCfg.QueueSize, newCfg.RateInterval, newCfg.StrictQueue)
}
}
func (a *Agent) handleHeartbeatAck(data json.RawMessage) {
var resp struct {
AgentID string `json:"agent_id"`
Name string `json:"name"`
NeedUpdate bool `json:"need_update"`
ForceUpdate bool `json:"force_update"`
LatestVersion string `json:"latest_version"`
}
json.Unmarshal(data, &resp)
if resp.NeedUpdate && (a.config.AutoUpdate || resp.ForceUpdate) {
logger.Infof("发现新版本 %s,开始更新...", resp.LatestVersion)
go a.selfUpdate()
}
}
func (a *Agent) handleTasks(data json.RawMessage) {
var resp struct {
Tasks []AgentTask `json:"tasks"`
}
json.Unmarshal(data, &resp)
newCount := len(resp.Tasks)
if newCount != a.lastTaskCount || newCount == 0 {
logger.Infof("任务列表同步成功: 共获取到 %d 个任务", newCount)
a.lastTaskCount = newCount
}
a.updateTasks(resp.Tasks)
}
func (a *Agent) handleExecute(data json.RawMessage) {
var req struct {
TaskID string `json:"task_id"`
LogID string `json:"log_id"`
Envs string `json:"envs"`
Secrets []string `json:"secrets"`
Command string `json:"command"`
PreCommand string `json:"pre_command"`
PostCommand string `json:"post_command"`
}
if err := json.Unmarshal(data, &req); err != nil {
logger.Errorf("解析立即执行请求失败: %v", err)
return
}
// 查找任务
a.mu.RLock()
task, exists := a.tasks[req.TaskID]
a.mu.RUnlock()
if !exists {
logger.Warnf("任务 #%s 不存在,无法执行", req.TaskID)
return
}
// 准备执行请求
// 如果消息中携带了环境变量或指令,则优先使用(确保即时生效)
envs := task.Envs
if req.Envs != "" {
envs = req.Envs
}
command := task.Command
if req.Command != "" {
command = req.Command
}
preCommand := task.PreCommand
if req.PreCommand != "" {
preCommand = req.PreCommand
}
postCommand := task.PostCommand
if req.PostCommand != "" {
postCommand = req.PostCommand
}
execReq := &executor.ExecutionRequest{
TaskID: task.ID,
LogID: req.LogID,
Name: task.Name,
Command: command,
PreCommand: preCommand,
PostCommand: postCommand,
WorkDir: task.WorkDir,
Envs: executor.ParseEnvVars(envs),
Secrets: req.Secrets,
Timeout: task.Timeout,
Languages: task.Languages,
UseMise: task.UseMise(),
Type: executor.TaskTypeManual,
}
// 立即执行任务(加入队列)
a.scheduler.EnqueueOrExecute(execReq)
}
func (a *Agent) handleStop(data json.RawMessage) {
var req struct {
LogID string `json:"log_id"`
}
if err := json.Unmarshal(data, &req); err != nil {
logger.Errorf("解析停止请求失败: %v", err)
return
}
logger.Infof("[Agent] 收到停止指令 LogID: %s", req.LogID)
if a.scheduler.StopLog(req.LogID) {
logger.Infof("[Agent] 任务执行 #%s 已成功停止", req.LogID)
} else {
logger.Warnf("[Agent] 任务执行 #%s 停止失败(可能已完成或不在运行队列中)", req.LogID)
}
}
// RealTimeLogWriter 实时日志写入器,通过 WebSocket 发送日志
type RealTimeLogWriter struct {
agent *Agent
logID string
}
func (w *RealTimeLogWriter) Write(p []byte) (n int, err error) {
if len(p) == 0 {
return 0, nil
}
// 记录到本地缓存,用于失败时显示
w.agent.addTaskLog(w.logID, p)
// 构造消息
msg := map[string]interface{}{
"log_id": w.logID,
"content": string(p),
}
// 发送消息
if err := w.agent.sendWSMessage(WSTypeTaskLog, msg); err != nil {
// 如果发送失败,不阻塞程序执行,只记录日志
return len(p), nil
}
return len(p), nil
}
func (a *Agent) sendWSMessage(msgType string, data interface{}) error {
a.wsMu.Lock()
defer a.wsMu.Unlock()
if a.wsConn == nil {
return fmt.Errorf("WebSocket 未连接")
}
dataBytes, _ := json.Marshal(data)
msg := WSMessage{Type: msgType, Data: dataBytes}
msgBytes, _ := json.Marshal(msg)
a.wsConn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := a.wsConn.WriteMessage(websocket.TextMessage, msgBytes); err != nil {
logger.Warnf("发送消息失败 (%s): %v", msgType, err)
return err
}
return nil
}
func (a *Agent) heartbeatLoop() {
ticker := time.NewTicker(time.Duration(a.config.Interval) * time.Second)
defer ticker.Stop()
a.wsMu.Lock()
wsStopCh := a.wsStopCh
a.wsMu.Unlock()
if wsStopCh == nil {
return
}
for {
select {
case <-a.stopCh:
return
case <-wsStopCh:
return
case <-ticker.C:
a.wsMu.Lock()
conn := a.wsConn
a.wsMu.Unlock()
if conn == nil {
return
}
a.sendHeartbeat()
}
}
}
func (a *Agent) sendHeartbeat() {
hostname, _ := os.Hostname()
data := map[string]interface{}{
"version": Version,
"build_time": BuildTime,
"hostname": hostname,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"auto_update": a.config.AutoUpdate,
}
if err := a.sendWSMessage(WSTypeHeartbeat, data); err != nil {
logger.Warnf("发送心跳失败: %v", err)
}
}
func (a *Agent) sendTaskResult(result *TaskResult) {
if err := a.sendWSMessage(WSTypeTaskResult, result); err != nil {
logger.Warnf("发送任务结果失败: %v,尝试 HTTP 上报", err)
a.reportResultHTTP(result)
}
}
func (a *Agent) reportResultHTTP(result *TaskResult) error {
resp, err := a.doRequest("POST", "/api/agent/report", result)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
func (a *Agent) updateTasks(tasks []AgentTask) {
a.mu.Lock()
defer a.mu.Unlock()
newTasks := make(map[string]*AgentTask)
for i := range tasks {
newTasks[tasks[i].ID] = &tasks[i]
}
// 1. 移除不再存在的任务
for id := range a.tasks {
if _, exists := newTasks[id]; !exists {
a.cronManager.RemoveTask(id)
delete(a.tasks, id)
logger.Infof("移除调度任务 #%s", id)
}
}
// 2. 添加或更新任务
for id, task := range newTasks {
oldTask, exists := a.tasks[id]
if !exists || oldTask.Schedule != task.Schedule || oldTask.Command != task.Command ||
oldTask.PreCommand != task.PreCommand || oldTask.PostCommand != task.PostCommand ||
oldTask.Enabled != task.Enabled || oldTask.Timeout != task.Timeout ||
oldTask.WorkDir != task.WorkDir || oldTask.Envs != task.Envs ||
oldTask.RandomRange != task.RandomRange {
if task.Enabled {
err := a.cronManager.AddTask(task)
if err != nil {
logger.Errorf("添加调度任务 #%s 失败: %v", id, err)
continue
}
logger.Infof("已添加调度任务 #%s %s (%s)", id, task.Name, task.GetSchedule())
} else {
a.cronManager.RemoveTask(id)
logger.Infof("调度任务 #%s 已禁用", id)
}
a.tasks[id] = task
}
}
}
func (a *Agent) clearAllTasks() {
a.mu.Lock()
defer a.mu.Unlock()
for id := range a.tasks {
a.cronManager.RemoveTask(id)
logger.Infof("移除任务 #%s", id)
}
a.tasks = make(map[string]*AgentTask)
a.lastTaskCount = 0
logger.Info("所有任务已清空")
}
func (a *Agent) addTaskLog(logID string, p []byte) {
if logID == "" {
return
}
a.logMu.Lock()
defer a.logMu.Unlock()
content := string(p)
lines := strings.Split(strings.TrimSuffix(content, "\n"), "\n")
a.taskLogs[logID] = append(a.taskLogs[logID], lines...)
if len(a.taskLogs[logID]) > 50 {
a.taskLogs[logID] = a.taskLogs[logID][len(a.taskLogs[logID])-50:]
}
}
func (a *Agent) printLastLogs(logID string) {
if logID == "" {
return
}
a.logMu.Lock()
lines, ok := a.taskLogs[logID]
a.logMu.Unlock()
if !ok || len(lines) == 0 {
return
}
logger.Errorf("--- 任务 #%s 失败日志预览 (最近 %d 行) ---", logID, len(lines))
for _, line := range lines {
fmt.Println(" " + line)
}
logger.Errorf("--- 任务 #%s 结束 ---", logID)
}
func (a *Agent) clearTaskLog(logID string) {
if logID == "" {
return
}
a.logMu.Lock()
defer a.logMu.Unlock()
delete(a.taskLogs, logID)
}
// executeTask 已被 AgentHandler.OnTaskCompleted 代替,此处删除旧实现
func (a *Agent) doRequest(method, path string, body interface{}) (*http.Response, error) {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, a.config.ServerURL+path, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+a.config.Token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Machine-ID", a.machineID)
return a.client.Do(req)
}
+15
View File
@@ -0,0 +1,15 @@
[agent]
# 主服务器地址(http/httpsAgent 会自动转换为 WebSocket 连接)
# 如果主服务配置了url_prefix, 这里要也要加上路径
server_url = http://192.168.1.100:8052
# 比如 url_prefix=/taskpool
; server_url = http://192.168.1.100:8052/taskpool
# Agent 名称(留空则使用主机名)
name =
# 注册令牌(首次注册时填写,注册成功后会自动替换为认证 Token)
token =
# 心跳间隔(秒),默认 30
interval = 30
# 自动更新(true/false
auto_update = true
+66
View File
@@ -0,0 +1,66 @@
package main
import (
"os"
"path/filepath"
"strconv"
"gopkg.in/ini.v1"
)
// Config Agent 配置
type Config struct {
ServerURL string
Name string
Token string
Interval int
AutoUpdate bool
}
func loadConfigFile(path string, config *Config) error {
cfg, err := ini.Load(path)
if err != nil {
return err
}
section := cfg.Section("agent")
if v := section.Key("server_url").String(); v != "" {
config.ServerURL = v
}
if v := section.Key("name").String(); v != "" {
config.Name = v
}
if v := section.Key("token").String(); v != "" {
config.Token = v
}
if v := section.Key("interval").String(); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 {
config.Interval = i
}
}
if v := section.Key("auto_update").String(); v != "" {
config.AutoUpdate = v == "true" || v == "1"
}
return nil
}
func saveConfigFile(path string, config *Config) error {
dir := filepath.Dir(path)
if dir != "" && dir != "." {
os.MkdirAll(dir, 0755)
}
cfg := ini.Empty()
section := cfg.Section("agent")
section.Key("server_url").SetValue(config.ServerURL)
section.Key("name").SetValue(config.Name)
section.Key("token").SetValue(config.Token)
section.Key("interval").SetValue(strconv.Itoa(config.Interval))
if config.AutoUpdate {
section.Key("auto_update").SetValue("true")
} else {
section.Key("auto_update").SetValue("false")
}
return cfg.SaveTo(path)
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/engigu/taskpool/internal/systime"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
// 日志实例
var loggerInstance *zap.Logger
var log *zap.SugaredLogger
// ANSI 颜色代码
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorYellow = "\033[33m"
colorBlue = "\033[36m"
colorGray = "\033[37m"
)
// customCore 实现 zapcore.Core 以提供与 logrus 一模一样的格式
type customCore struct {
level zapcore.LevelEnabler
writer zapcore.WriteSyncer
}
func (c *customCore) Enabled(l zapcore.Level) bool {
return c.level.Enabled(l)
}
func (c *customCore) With(fields []zapcore.Field) zapcore.Core {
return c
}
func (c *customCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if c.Enabled(ent.Level) {
return ce.AddCore(ent, c)
}
return ce
}
func (c *customCore) Write(ent zapcore.Entry, fields []zapcore.Field) error {
// 统一使用东八区时间
timestamp := systime.InCST(ent.Time).Format("2006-01-02 15:04:05")
level := strings.ToUpper(ent.Level.String())
var levelColor string
switch ent.Level {
case zapcore.DebugLevel:
levelColor = colorGray
case zapcore.InfoLevel:
levelColor = colorBlue
case zapcore.WarnLevel:
levelColor = colorYellow
case zapcore.ErrorLevel, zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel:
levelColor = colorRed
default:
levelColor = colorBlue
}
msg := fmt.Sprintf("[%s]%s[%s]%s %s\n", timestamp, levelColor, level, colorReset, ent.Message)
_, err := c.writer.Write([]byte(msg))
return err
}
func (c *customCore) Sync() error {
return c.writer.Sync()
}
func initLogger(logFile string, fileOnly bool) {
logDir := filepath.Dir(logFile)
if logDir != "" && logDir != "." {
os.MkdirAll(logDir, 0755)
}
lumberjackLogger := &lumberjack.Logger{
Filename: logFile,
MaxSize: 5,
MaxBackups: 3,
MaxAge: 0,
Compress: false,
}
var output zapcore.WriteSyncer
// fileOnly 模式下只输出到文件(daemon 模式或重启模式)
if fileOnly {
output = zapcore.AddSync(lumberjackLogger)
} else {
// 前台运行时同时输出到终端和文件
output = zapcore.NewMultiWriteSyncer(zapcore.AddSync(os.Stdout), zapcore.AddSync(lumberjackLogger))
}
core := &customCore{
level: zap.NewAtomicLevelAt(zap.InfoLevel),
writer: output,
}
loggerInstance = zap.New(core)
log = loggerInstance.Sugar()
}
+437
View File
@@ -0,0 +1,437 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"reflect"
"syscall"
"time"
internalLogger "github.com/engigu/taskpool/internal/logger"
"github.com/engigu/taskpool/internal/systime"
"github.com/engigu/taskpool/internal/utils"
)
const ServiceName = "taskpool-agent"
const ServiceDesc = "TaskPool Agent Service"
// 版本信息(通过 ldflags 注入)
var (
Version = "dev"
BuildTime = ""
)
// 全局配置
var (
configFile = "config.ini"
logFile = "logs/agent.log"
dataDir = "data"
)
func main() {
// 强制设置全局时区为东八区
time.Local = systime.CST
exePath, _ := os.Executable()
exeDir := filepath.Dir(exePath)
os.Chdir(exeDir)
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
cmd := os.Args[1]
// 解析额外参数
for i := 2; i < len(os.Args); i++ {
switch os.Args[i] {
case "-c", "--config":
if i+1 < len(os.Args) {
configFile = os.Args[i+1]
i++
}
case "-l", "--log":
if i+1 < len(os.Args) {
logFile = os.Args[i+1]
i++
}
case "-d", "--daemon":
isDaemon = true
case "--restart":
isRestart = true
}
}
switch cmd {
case "start":
cmdStart()
case "run":
cmdRun()
case "stop":
cmdStop()
case "status":
cmdStatus()
case "tasks":
cmdTasks()
case "logs":
cmdLogs()
case "install":
cmdInstall()
case "uninstall":
cmdUninstall()
case "version", "-v", "--version":
fmt.Printf("TaskPool Agent v%s\n", Version)
if BuildTime != "" {
fmt.Printf("Build Time: %s\n", BuildTime)
}
case "help", "-h", "--help":
printUsage()
default:
fmt.Printf("未知命令: %s\n", cmd)
printUsage()
os.Exit(1)
}
}
func printUsage() {
var binName = filepath.Base(os.Args[0])
if binName == "main" || binName == "debug" {
binName = "taskpool-agent"
}
fmt.Printf(`TaskPool Agent v%s
用法:
%s <命令> [选项]
命令:
start 启动 Agent(后台运行)
run 前台运行 Agent
stop 停止 Agent
status 查看运行状态
tasks 查看已下发的任务列表
logs 查看日志(实时跟踪)
install 安装为系统服务(开机自启)
uninstall 卸载系统服务
version 显示版本信息
help 显示帮助信息
选项:
-c, --config <file> 配置文件路径 (默认: config.ini)
-l, --log <file> 日志文件路径 (默认: logs/agent.log)
示例:
%s start
%s run
%s stop
%s logs
%s start -c /etc/taskpool/config.ini
%s install
%s status
%s tasks
`, Version, binName, binName, binName, binName, binName, binName, binName, binName, binName)
}
// daemon 模式标记
var isDaemon = false
// 是否从 daemon 重启(用于自动更新后重启)
var isRestart = false
func cmdStart() {
// 检查是否已经在运行(使用文件锁)
pid := readPidFile()
if pid != 0 && isProcessRunning(pid) {
fmt.Printf("Agent 已在运行 (PID: %d)\n", pid)
return
}
// 如果不是 daemon 子进程,则启动 daemon
if !isDaemon {
startDaemon()
return
}
// 以下是 daemon 子进程的逻辑
// 尝试获取文件锁
if !tryLock() {
fmt.Println("Agent 已在运行(无法获取锁)")
return
}
defer unlock()
initLogger(logFile, true)
internalLogger.SetOutput(loggerInstance)
config := &Config{Interval: 30}
if err := loadConfigFile(configFile, config); err != nil {
if !os.IsNotExist(err) {
log.Warnf("加载配置文件失败: %v", err)
}
}
// 从环境变量加载
if v := os.Getenv("AGENT_SERVER"); v != "" {
config.ServerURL = v
}
if v := os.Getenv("AGENT_NAME"); v != "" {
config.Name = v
}
if config.ServerURL == "" {
log.Fatal("请在配置文件中设置 server_url")
}
if config.Name == "" {
hostname, _ := os.Hostname()
config.Name = hostname
}
log.Infof("TaskPool Agent Version: %s", Version)
if BuildTime != "" {
log.Infof("构建时间: %s", BuildTime)
}
log.Infof("服务器: %s", config.ServerURL)
log.Infof("名称: %s", config.Name)
writePidFile()
agent := NewAgent(config, configFile)
if err := agent.Start(); err != nil {
log.Fatalf("启动失败: %v", err)
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("正在停止...")
agent.Stop()
removePidFile()
}
// cmdRun 前台运行
func cmdRun() {
// 检查是否已经在运行(重启模式下跳过检查)
if !isRestart {
pid := readPidFile()
if pid != 0 && isProcessRunning(pid) {
fmt.Printf("Agent 已在运行 (PID: %d)\n", pid)
return
}
}
// 尝试获取文件锁
if !tryLock() {
fmt.Println("Agent 已在运行(无法获取锁)")
return
}
defer unlock()
// 前台模式始终输出到终端+文件
initLogger(logFile, false)
internalLogger.SetOutput(loggerInstance)
config := &Config{Interval: 30}
if err := loadConfigFile(configFile, config); err != nil {
if !os.IsNotExist(err) {
log.Warnf("加载配置文件失败: %v", err)
}
}
if v := os.Getenv("AGENT_SERVER"); v != "" {
config.ServerURL = v
}
if v := os.Getenv("AGENT_NAME"); v != "" {
config.Name = v
}
if config.ServerURL == "" {
log.Fatal("请在配置文件中设置 server_url")
}
if config.Name == "" {
hostname, _ := os.Hostname()
config.Name = hostname
}
log.Infof("TaskPool Agent Version: %s", Version)
if BuildTime != "" {
log.Infof("构建时间: %s", BuildTime)
}
log.Infof("服务器: %s", config.ServerURL)
log.Infof("名称: %s", config.Name)
writePidFile()
agent := NewAgent(config, configFile)
if err := agent.Start(); err != nil {
log.Fatalf("启动失败: %v", err)
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("正在停止...")
agent.Stop()
removePidFile()
}
func startDaemon() {
exePath, err := os.Executable()
if err != nil {
fmt.Printf("获取可执行文件路径失败: %v\n", err)
return
}
// 构建子进程参数,添加 --daemon 标记
args := []string{"start", "--daemon"}
for i := 2; i < len(os.Args); i++ {
if os.Args[i] != "--daemon" && os.Args[i] != "-d" {
args = append(args, os.Args[i])
}
}
// 打开 /dev/null 用于丢弃输出(日志由 logger 写入文件)
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
fmt.Printf("打开 /dev/null 失败: %v\n", err)
return
}
// 启动子进程
cmd := &exec.Cmd{
Path: exePath,
Args: append([]string{exePath}, args...),
Dir: filepath.Dir(exePath),
Stdout: devNull,
Stderr: devNull,
}
// 设置进程组,使子进程独立运行 (跨平台兼容写法)
attr := &syscall.SysProcAttr{}
if field := reflect.ValueOf(attr).Elem().FieldByName("Setsid"); field.IsValid() {
field.SetBool(true)
}
cmd.SysProcAttr = attr
if err := cmd.Start(); err != nil {
fmt.Printf("启动失败: %v\n", err)
devNull.Close()
return
}
devNull.Close()
fmt.Printf("Agent 已启动 (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("日志文件: %s\n", logFile)
}
func cmdTasks() {
config := &Config{Interval: 30}
if err := loadConfigFile(configFile, config); err != nil {
fmt.Printf("加载配置文件失败: %v\n", err)
return
}
if config.ServerURL == "" {
fmt.Println("错误: 缺少服务器地址,请在配置文件中设置 server_url")
return
}
if config.Token == "" {
fmt.Println("错误: 缺少令牌,请在配置文件中设置 token")
return
}
agent := &Agent{
config: config,
machineID: utils.GenerateMachineID(),
client: &http.Client{Timeout: 30 * time.Second},
}
resp, err := agent.doRequest("GET", "/api/agent/tasks", nil)
if err != nil {
fmt.Printf("获取任务列表失败: %v\n", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
fmt.Printf("获取任务列表失败 (HTTP %d): %s\n", resp.StatusCode, string(body))
return
}
// 解析服务端响应(包含 code/msg/data 包装)
var apiResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
AgentID string `json:"agent_id"`
Tasks []AgentTask `json:"tasks"`
} `json:"data"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
fmt.Printf("解析响应失败: %v\n", err)
return
}
if apiResp.Code != 200 {
fmt.Printf("获取任务列表失败: %s\n", apiResp.Msg)
return
}
tasks := apiResp.Data.Tasks
if len(tasks) == 0 {
fmt.Println("当前没有下发的任务")
return
}
fmt.Printf("共 %d 个任务:\n\n", len(tasks))
for i, task := range tasks {
fmt.Printf("[%d] ID: %s\n", i+1, task.ID)
fmt.Printf(" 名称: %s\n", task.Name)
fmt.Printf(" Cron: %s\n", task.Schedule)
fmt.Printf(" 命令: %s\n", task.Command)
if task.WorkDir != "" {
fmt.Printf(" 工作目录: %s\n", task.WorkDir)
}
fmt.Printf(" 启用: %v\n", task.Enabled)
fmt.Println()
}
}
func cmdLogs() {
// 检查日志文件是否存在
if _, err := os.Stat(logFile); os.IsNotExist(err) {
fmt.Printf("日志文件不存在: %s\n", logFile)
return
}
fmt.Printf("日志文件: %s\n", logFile)
fmt.Println("按 Ctrl+C 退出")
// 使用 tail -f 实时跟踪日志
cmd := exec.Command("tail", "-f", "-n", "50", logFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 处理中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-quit
if cmd.Process != nil {
cmd.Process.Kill()
}
}()
cmd.Run()
}
+241
View File
@@ -0,0 +1,241 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"syscall"
"github.com/gofrs/flock"
)
// ========== PID 文件管理 ==========
var fileLock *flock.Flock
func getPidFile() string {
return filepath.Join(dataDir, "agent.pid")
}
func getLockFile() string {
return filepath.Join(dataDir, "agent.lock")
}
// tryLock 尝试获取文件锁,确保只有一个实例运行
func tryLock() bool {
os.MkdirAll(dataDir, 0755)
fileLock = flock.New(getLockFile())
locked, err := fileLock.TryLock()
if err != nil || !locked {
fileLock = nil
return false
}
return true
}
// unlock 释放文件锁
func unlock() {
if fileLock != nil {
fileLock.Unlock()
fileLock = nil
os.Remove(getLockFile())
}
}
func writePidFile() {
os.MkdirAll(dataDir, 0755)
pidFile := getPidFile()
os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())), 0644)
}
func readPidFile() int {
pidFile := getPidFile()
data, err := os.ReadFile(pidFile)
if err != nil {
return 0
}
pid, _ := strconv.Atoi(string(data))
return pid
}
func removePidFile() {
os.Remove(getPidFile())
}
// ========== 命令实现 ==========
func cmdStop() {
pid := readPidFile()
if pid == 0 {
fmt.Println("Agent 未运行")
return
}
process, err := os.FindProcess(pid)
if err != nil {
fmt.Printf("找不到进程 %d\n", pid)
removePidFile()
return
}
if runtime.GOOS == "windows" {
err = process.Kill()
} else {
err = process.Signal(syscall.SIGTERM)
}
if err != nil {
fmt.Printf("停止失败: %v\n", err)
return
}
fmt.Println("Agent 已停止")
removePidFile()
}
func cmdStatus() {
pid := readPidFile()
if pid == 0 {
fmt.Println("状态: 未运行")
return
}
if !isProcessRunning(pid) {
fmt.Println("状态: 未运行")
removePidFile()
return
}
fmt.Printf("状态: 运行中 (PID: %d)\n", pid)
}
func isProcessRunning(pid int) bool {
process, err := os.FindProcess(pid)
if err != nil {
return false
}
// Unix 系统发送信号 0 检查进程
if runtime.GOOS != "windows" {
err = process.Signal(syscall.Signal(0))
return err == nil
}
// Windows 下 FindProcess 成功即表示进程存在
return true
}
func cmdInstall() {
exePath, _ := os.Executable()
exeDir := filepath.Dir(exePath)
if runtime.GOOS == "windows" {
installWindows(exePath, exeDir)
} else {
installLinux(exePath, exeDir)
}
}
func cmdUninstall() {
if runtime.GOOS == "windows" {
uninstallWindows()
} else {
uninstallLinux()
}
}
// ========== Linux systemd ==========
func installLinux(exePath, exeDir string) {
serviceContent := fmt.Sprintf(`[Unit]
Description=%s
After=network.target
[Service]
Type=simple
WorkingDirectory=%s
ExecStart=%s run
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
`, ServiceDesc, exeDir, exePath)
servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", ServiceName)
if err := os.WriteFile(servicePath, []byte(serviceContent), 0644); err != nil {
fmt.Printf("创建服务文件失败: %v\n", err)
fmt.Println("请使用 sudo 运行")
return
}
// 重载 systemd
exec.Command("systemctl", "daemon-reload").Run()
exec.Command("systemctl", "enable", ServiceName).Run()
fmt.Printf("服务已安装: %s\n", servicePath)
fmt.Println("使用以下命令管理服务:")
fmt.Printf(" 启动: sudo systemctl start %s\n", ServiceName)
fmt.Printf(" 停止: sudo systemctl stop %s\n", ServiceName)
fmt.Printf(" 状态: sudo systemctl status %s\n", ServiceName)
}
func uninstallLinux() {
// 停止服务
exec.Command("systemctl", "stop", ServiceName).Run()
exec.Command("systemctl", "disable", ServiceName).Run()
servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", ServiceName)
if err := os.Remove(servicePath); err != nil {
fmt.Printf("删除服务文件失败: %v\n", err)
fmt.Println("请使用 sudo 运行")
return
}
exec.Command("systemctl", "daemon-reload").Run()
fmt.Println("服务已卸载")
}
// ========== Windows 服务 ==========
func installWindows(exePath, exeDir string) {
// 使用 sc.exe 创建服务
cmd := exec.Command("sc", "create", ServiceName,
"binPath=", fmt.Sprintf(`"%s" run`, exePath),
"start=", "auto",
"DisplayName=", ServiceDesc)
if err := cmd.Run(); err != nil {
fmt.Printf("创建服务失败: %v\n", err)
fmt.Println("请以管理员身份运行")
return
}
// 设置服务描述
exec.Command("sc", "description", ServiceName, ServiceDesc).Run()
fmt.Println("服务已安装")
fmt.Println("使用以下命令管理服务:")
fmt.Printf(" 启动: sc start %s\n", ServiceName)
fmt.Printf(" 停止: sc stop %s\n", ServiceName)
fmt.Printf(" 状态: sc query %s\n", ServiceName)
}
func uninstallWindows() {
// 停止服务
exec.Command("sc", "stop", ServiceName).Run()
// 删除服务
cmd := exec.Command("sc", "delete", ServiceName)
if err := cmd.Run(); err != nil {
fmt.Printf("删除服务失败: %v\n", err)
fmt.Println("请以管理员身份运行")
return
}
fmt.Println("服务已卸载")
}
+161
View File
@@ -0,0 +1,161 @@
package main
import (
"archive/tar"
"compress/gzip"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
)
// selfUpdate 自动更新
func (a *Agent) selfUpdate() {
// 获取当前可执行文件路径
exePath, err := os.Executable()
if err != nil {
log.Errorf("获取可执行文件路径失败: %v", err)
return
}
exePath, _ = filepath.Abs(exePath)
// 下载新版本 tar.gz
downloadURL := a.config.ServerURL + "/api/agent/download?os=" + runtime.GOOS + "&arch=" + runtime.GOARCH
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
log.Errorf("创建下载请求失败: %v", err)
return
}
req.Header.Set("Authorization", "Bearer "+a.config.Token)
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
log.Errorf("下载新版本失败: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Errorf("下载新版本失败: HTTP %d", resp.StatusCode)
return
}
// 读取 tar.gz 内容
gzReader, err := gzip.NewReader(resp.Body)
if err != nil {
log.Errorf("解压 gzip 失败: %v", err)
return
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
// 解压并找到二进制文件
var newBinary []byte
binaryName := "taskpool-agent"
if runtime.GOOS == "windows" {
binaryName = "taskpool-agent.exe"
}
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
log.Errorf("读取 tar 失败: %v", err)
return
}
if header.Typeflag == tar.TypeReg && header.Name == binaryName {
newBinary, err = io.ReadAll(tarReader)
if err != nil {
log.Errorf("读取二进制文件失败: %v", err)
return
}
break
}
}
if newBinary == nil {
log.Errorf("tar.gz 中未找到 %s", binaryName)
return
}
// 保存到临时文件(放到 data 目录)
os.MkdirAll(dataDir, 0755)
tmpFile := filepath.Join(dataDir, binaryName+".new")
if err := os.WriteFile(tmpFile, newBinary, 0755); err != nil {
log.Errorf("保存新版本失败: %v", err)
return
}
// 计算基础路径(去掉所有 .bak 后缀)
basePath := exePath
for strings.HasSuffix(basePath, ".bak") {
basePath = strings.TrimSuffix(basePath, ".bak")
}
backupFile := basePath + ".bak"
// 如果当前运行的就是 .bak 文件,直接删除它(更新后会用新版本)
// 否则需要备份当前文件
if exePath != backupFile {
os.Remove(backupFile)
if err := os.Rename(exePath, backupFile); err != nil {
log.Errorf("备份旧版本失败: %v", err)
os.Remove(tmpFile)
return
}
}
// 替换为新版本(放到 basePath,即不带 .bak 的路径)
if err := os.Rename(tmpFile, basePath); err != nil {
log.Errorf("替换新版本失败: %v", err)
if exePath != backupFile {
os.Rename(backupFile, exePath) // 恢复旧版本
}
return
}
// 如果之前运行的是 .bak 文件,现在可以删除它了
if exePath == backupFile {
os.Remove(exePath)
}
log.Info("更新完成,正在重启...")
// 重启服务
a.restart()
}
// restart 重启服务
func (a *Agent) restart() {
exePath, _ := os.Executable()
// 计算基础路径(去掉所有 .bak 后缀),确保启动的是正确的可执行文件
basePath := exePath
for strings.HasSuffix(basePath, ".bak") {
basePath = strings.TrimSuffix(basePath, ".bak")
}
// 删除 PID 文件,避免新进程检测到旧 PID 而拒绝启动
removePidFile()
if runtime.GOOS == "windows" {
// Windows: 启动新进程后退出
cmd := exec.Command(basePath, "start")
cmd.Start()
os.Exit(0)
} else {
// Linux/macOS: 使用 exec 替换当前进程,直接运行(不需要 daemon)
// 因为 syscall.Exec 会替换当前进程,当前进程本身就是 daemon
// --restart 标记告诉新进程这是重启,只输出到文件
syscall.Exec(basePath, []string{basePath, "run", "--restart"}, os.Environ())
}
}
+187
View File
@@ -0,0 +1,187 @@
const http = require('http');
const https = require('https');
const { URL } = require('url');
/**
* 内部 API 请求辅助函数
*/
function request(urlStr, method = 'GET', data = null) {
const token = process.env.BHPKG_OPENAPI_TOKEN || process.env.OPENAPI_TOKEN || process.env.BHPKG_NOTIFY_TOKEN;
if (!token) {
throw new Error(`没有正确配置或缺少 BHPKG_OPENAPI_TOKEN 环境变量以使用 env 函数。请在任务池的任务设置中配置这些 Key。`);
}
const parsedUrl = new URL(urlStr);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
let payload = '';
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
if (data !== null) {
payload = JSON.stringify(data);
headers['Content-Length'] = Buffer.byteLength(payload);
}
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname + (parsedUrl.search || ''),
method: method,
headers: headers
};
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const parsed = body ? JSON.parse(body) : {};
if (parsed && typeof parsed === 'object' && parsed.code !== undefined && parsed.code !== 200) {
reject(new Error(`请求失败 [${parsed.code}]: ${parsed.msg || parsed.message || '未知错误'}`));
} else {
resolve(parsed);
}
} catch (e) {
resolve(body);
}
} else {
let errMsg = body;
try {
const parsed = JSON.parse(body);
errMsg = parsed.msg || parsed.message || body;
} catch(e) {}
reject(new Error(`请求失败 [${res.statusCode}]: ${errMsg}`));
}
});
});
req.on('error', (e) => reject(e));
if (payload) {
req.write(payload);
}
req.end();
});
}
function getEnvsUrl() {
const url = process.env.BHPKG_OPENAPI_URL || process.env.OPENAPI_URL;
if (url) return url;
const notifyUrl = process.env.BHPKG_NOTIFY_URL || 'http://localhost:8052/api/v1/notify/send';
const targets = ['/api/v1/notify/send/', '/api/v1/notify/send', '/api/v1/notify/', '/api/v1/notify'];
for (const target of targets) {
if (notifyUrl.includes(target)) {
return notifyUrl.replace(target, '/open2api/v1/env');
}
}
return 'http://localhost:8052/open2api/v1/env';
}
/**
* 获取所有的环境变量列表
*/
async function getEnvs() {
const url = `${getEnvsUrl()}/all`;
const res = await request(url, 'GET');
return res.data || [];
}
/**
* 根据变量名获取环境变量,不存在则返回 null
*/
async function getEnv(name) {
const envs = await getEnvs();
for (const env of envs) {
if (env.name === name) {
return env;
}
}
return null;
}
/**
* 批量添加环境变量
*/
async function addEnvs(envsList) {
const url = getEnvsUrl();
const addedEnvs = [];
for (const env of envsList) {
if (!env.name || !env.value) {
throw new Error("环境变量必须包含 'name' 和 'value'");
}
const res = await request(url, 'POST', env);
if (res.data) {
addedEnvs.push(res.data);
}
}
return addedEnvs;
}
/**
* 添加单个环境变量
*/
async function addEnv(name, value, remark = "", type = "normal", hidden = true, enabled = true) {
const url = getEnvsUrl();
const payload = {
name,
value,
remark,
type,
hidden,
enabled
};
const res = await request(url, 'POST', payload);
return res.data;
}
/**
* 根据 ID 更新环境变量
*/
async function updateEnv(id, name, value, remark = null, type = null, hidden = null, enabled = null) {
const url = `${getEnvsUrl()}/${id}`;
const payload = {};
if (name !== null) payload.name = name;
if (value !== null) payload.value = value;
if (remark !== null) payload.remark = remark;
if (type !== null) payload.type = type;
if (hidden !== null) payload.hidden = hidden;
if (enabled !== null) payload.enabled = enabled;
const res = await request(url, 'PUT', payload);
return res.data;
}
/**
* 批量删除环境变量
*/
async function deleteEnvs(ids) {
for (const id of ids) {
await deleteEnv(id);
}
return true;
}
/**
* 根据 ID 删除指定环境变量
*/
async function deleteEnv(id) {
const url = `${getEnvsUrl()}/${id}`;
await request(url, 'DELETE');
return true;
}
module.exports = {
getEnvs,
getEnv,
addEnvs,
addEnv,
updateEnv,
deleteEnvs,
deleteEnv
};
+37
View File
@@ -0,0 +1,37 @@
const { notify } = require('./notify');
const {
getEnvs,
getEnv,
addEnvs,
addEnv,
updateEnv,
deleteEnvs,
deleteEnv
} = require('./env');
const {
getTasks,
getTask,
updateTask,
deleteTask,
executeTask,
stopTask,
getLastResults
} = require('./task');
module.exports = {
notify,
getEnvs,
getEnv,
addEnvs,
addEnv,
updateEnv,
deleteEnvs,
deleteEnv,
getTasks,
getTask,
updateTask,
deleteTask,
executeTask,
stopTask,
getLastResults
};
+51
View File
@@ -0,0 +1,51 @@
const http = require('http');
const https = require('https');
const { URL } = require('url');
/**
* 发送通知的辅助函数 (仅使用 Node.js 标准库)
*/
function notify(title, text, channelId) {
const token = process.env.BHPKG_NOTIFY_TOKEN;
const channel = process.env.BHPKG_NOTIFY_CHANNEL;
if (!token || !channel) {
const missing = [];
if (!token) missing.push("BHPKG_NOTIFY_TOKEN");
if (!channel) missing.push("BHPKG_NOTIFY_CHANNEL");
throw new Error(`没有正确配置或缺少 ${missing.join(" 和 ")} 环境变量以使用 notify 函数。请在任务池的任务设置中配置这些 Key。`);
}
const notifyUrl = process.env.BHPKG_NOTIFY_URL || 'http://localhost:8052/api/v1/notify/send';
const cid = channelId || channel;
if (!notifyUrl || !token || !cid) return;
const parsedUrl = new URL(notifyUrl);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const data = JSON.stringify({
channel_id: cid,
title: title || '系统通知',
text: text
});
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname + (parsedUrl.search || ''),
method: 'POST',
headers: {
'Content-Type': 'application/json',
'notify-token': token,
'Content-Length': Buffer.byteLength(data)
}
};
const req = protocol.request(options);
req.on('error', (e) => {});
req.write(data);
req.end();
}
module.exports = { notify };
+7
View File
@@ -0,0 +1,7 @@
{
"name": "taskpool",
"version": "1.0.0",
"description": "TaskPool internal helper for Node.js",
"main": "index.js",
"license": "MIT"
}
+175
View File
@@ -0,0 +1,175 @@
const http = require('http');
const https = require('https');
const { URL } = require('url');
/**
* 内部 API 请求辅助函数
*/
function request(urlStr, method = 'GET', data = null) {
const token = process.env.BHPKG_OPENAPI_TOKEN || process.env.OPENAPI_TOKEN || process.env.BHPKG_NOTIFY_TOKEN;
if (!token) {
throw new Error(`没有正确配置或缺少 BHPKG_OPENAPI_TOKEN 环境变量以使用 task 函数。请在任务池的任务设置中配置这些 Key。`);
}
const parsedUrl = new URL(urlStr);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
let payload = '';
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
if (data !== null) {
payload = JSON.stringify(data);
headers['Content-Length'] = Buffer.byteLength(payload);
}
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname + (parsedUrl.search || ''),
method: method,
headers: headers
};
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const parsed = body ? JSON.parse(body) : {};
if (parsed && typeof parsed === 'object' && parsed.code !== undefined && parsed.code !== 200) {
reject(new Error(`请求失败 [${parsed.code}]: ${parsed.msg || parsed.message || '未知错误'}`));
} else {
resolve(parsed);
}
} catch (e) {
resolve(body);
}
} else {
let errMsg = body;
try {
const parsed = JSON.parse(body);
errMsg = parsed.msg || parsed.message || body;
} catch(e) {}
reject(new Error(`请求失败 [${res.statusCode}]: ${errMsg}`));
}
});
});
req.on('error', (e) => reject(e));
if (payload) {
req.write(payload);
}
req.end();
});
}
function getBaseUrl() {
const url = process.env.BHPKG_OPENAPI_URL || process.env.OPENAPI_URL;
if (url) {
if (url.endsWith('/env')) return url.slice(0, -4);
if (url.endsWith('/env/')) return url.slice(0, -5);
return url;
}
const notifyUrl = process.env.BHPKG_NOTIFY_URL || 'http://localhost:8052/api/v1/notify/send';
const targets = ['/api/v1/notify/send/', '/api/v1/notify/send', '/api/v1/notify/', '/api/v1/notify'];
for (const target of targets) {
if (notifyUrl.includes(target)) {
return notifyUrl.replace(target, '/open2api/v1');
}
}
return 'http://localhost:8052/open2api/v1';
}
/**
* 获取全部任务列表
*/
async function getTasks() {
const url = `${getBaseUrl()}/tasks`;
const res = await request(url, 'GET');
return res.data || [];
}
/**
* 根据 ID 获取单个任务信息
*/
async function getTask(id) {
const url = `${getBaseUrl()}/tasks/${id}`;
const res = await request(url, 'GET');
return res.data;
}
/**
* 根据 ID 更新指定任务
*/
async function updateTask(id, name, command, remark, pin_type, trigger_type, schedule, timeout, work_dir, retry_count, retry_interval, random_range, enabled) {
const url = `${getBaseUrl()}/tasks/${id}`;
const payload = {};
if (name !== undefined) payload.name = name;
if (command !== undefined) payload.command = command;
if (remark !== undefined) payload.remark = remark;
if (pin_type !== undefined) payload.pin_type = pin_type;
if (trigger_type !== undefined) payload.trigger_type = trigger_type;
if (schedule !== undefined) payload.schedule = schedule;
if (timeout !== undefined) payload.timeout = timeout;
if (work_dir !== undefined) payload.work_dir = work_dir;
if (retry_count !== undefined) payload.retry_count = retry_count;
if (retry_interval !== undefined) payload.retry_interval = retry_interval;
if (random_range !== undefined) payload.random_range = random_range;
if (enabled !== undefined) payload.enabled = enabled;
const res = await request(url, 'PUT', payload);
return res.data;
}
/**
* 根据 ID 删除任务
*/
async function deleteTask(id) {
const url = `${getBaseUrl()}/tasks/${id}`;
await request(url, 'DELETE');
return true;
}
/**
* 触发运行指定任务
*/
async function executeTask(id) {
const url = `${getBaseUrl()}/execute/task/${id}`;
const res = await request(url, 'POST');
return res.data;
}
/**
* 根据日志 ID 停止正在运行的任务
*/
async function stopTask(logId) {
const url = `${getBaseUrl()}/tasks/stop/${logId}`;
const res = await request(url, 'POST');
return res.data;
}
/**
* 获取最近的任务执行结果列表
*/
async function getLastResults() {
const url = `${getBaseUrl()}/execute/results`;
const res = await request(url, 'GET');
return res.data || [];
}
module.exports = {
getTasks,
getTask,
updateTask,
deleteTask,
executeTask,
stopTask,
getLastResults
};
+10
View File
@@ -0,0 +1,10 @@
from setuptools import setup, find_packages
setup(
name='taskpool',
version='1.0.0',
description='TaskPool internal helper for Python',
packages=['taskpool'],
package_dir={'taskpool': 'taskpool'},
python_requires='>=3.6',
)
+56
View File
@@ -0,0 +1,56 @@
import os
from .notify import notify as _notify
from .env import (
get_envs,
get_env,
add_envs,
add_env,
update_env,
delete_envs,
delete_env
)
from .task import (
get_tasks,
get_task,
update_task,
delete_task,
execute_task,
stop_task,
get_last_results
)
def notify(title, text):
"""
发送内建通知。
会在调用时校验环境变量:BHPKG_NOTIFY_TOKEN, BHPKG_NOTIFY_CHANNEL
"""
_TOKEN = os.environ.get("BHPKG_NOTIFY_TOKEN")
_CHANNEL = os.environ.get("BHPKG_NOTIFY_CHANNEL")
if not _TOKEN or not _CHANNEL:
missing = []
if not _TOKEN: missing.append("BHPKG_NOTIFY_TOKEN")
if not _CHANNEL: missing.append("BHPKG_NOTIFY_CHANNEL")
error_msg = f"缺少必要的环境变量以使用 taskpool 模块: {', '.join(missing)}。请在任务池的任务设置中配置指定的 Key。"
raise RuntimeError(error_msg)
return _notify(title, text)
__all__ = [
'notify',
'get_envs',
'get_env',
'add_envs',
'add_env',
'update_env',
'delete_envs',
'delete_env',
'get_tasks',
'get_task',
'update_task',
'delete_task',
'execute_task',
'stop_task',
'get_last_results'
]
+133
View File
@@ -0,0 +1,133 @@
import os
import json
import urllib.request
import urllib.error
def _get_headers():
token = os.environ.get("BHPKG_OPENAPI_TOKEN") or os.environ.get("OPENAPI_TOKEN") or os.environ.get("BHPKG_NOTIFY_TOKEN")
if not token:
raise RuntimeError("没有正确配置或缺少 BHPKG_OPENAPI_TOKEN 环境变量以使用 env 函数。请在任务池的任务设置中配置这些 Key。")
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
def _get_envs_url():
url = os.environ.get("BHPKG_OPENAPI_URL") or os.environ.get("OPENAPI_URL")
if url:
return url
notify_url = os.environ.get("BHPKG_NOTIFY_URL", "http://localhost:8052/api/v1/notify/send")
for target in ["/api/v1/notify/send/", "/api/v1/notify/send", "/api/v1/notify/", "/api/v1/notify"]:
if target in notify_url:
return notify_url.replace(target, "/open2api/v1/env")
return "http://localhost:8052/open2api/v1/env"
def _request(url, method="GET", data=None):
headers = _get_headers()
payload = None
if data is not None:
payload = json.dumps(data).encode("utf-8")
req = urllib.request.Request(url, data=payload, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
body = resp.read().decode("utf-8")
if not body:
return {}
parsed = json.loads(body)
if isinstance(parsed, dict) and parsed.get("code") is not None and parsed.get("code") != 200:
msg = parsed.get("msg") or parsed.get("message") or "未知错误"
raise RuntimeError(f"请求失败 [{parsed.get('code')}]: {msg}")
return parsed
except urllib.error.HTTPError as e:
err_body = e.read().decode("utf-8")
try:
err_json = json.loads(err_body)
msg = err_json.get("msg") or err_json.get("message") or err_body
except Exception:
msg = err_body
raise RuntimeError(f"请求失败 [{e.code}]: {msg}")
except Exception as e:
raise RuntimeError(f"请求发生异常: {e}")
def get_envs():
"""
获取所有的环境变量列表。
"""
url = f"{_get_envs_url()}/all"
res = _request(url, "GET")
return res.get("data", [])
def get_env(name):
"""
根据变量名获取环境变量。如果不存在则返回 None。
"""
envs = get_envs()
for env in envs:
if env.get("name") == name:
return env
return None
def add_envs(envs_list):
"""
批量添加环境变量。
envs_list: 包含环境变量字典的列表,如 [{"name": "KEY", "value": "VAL", "remark": "备注"}]
"""
url = _get_envs_url()
added_envs = []
for env in envs_list:
if "name" not in env or "value" not in env:
raise ValueError("环境变量必须包含 'name''value'")
res = _request(url, "POST", env)
if "data" in res:
added_envs.append(res["data"])
return added_envs
def add_env(name, value, remark="", type="normal", hidden=True, enabled=True):
"""
添加单个环境变量。
"""
url = _get_envs_url()
payload = {
"name": name,
"value": value,
"remark": remark,
"type": type,
"hidden": hidden,
"enabled": enabled
}
res = _request(url, "POST", payload)
return res.get("data")
def update_env(id, name, value, remark=None, type=None, hidden=None, enabled=None):
"""
根据 ID 更新环境变量。
"""
url = f"{_get_envs_url()}/{id}"
payload = {}
if name is not None: payload["name"] = name
if value is not None: payload["value"] = value
if remark is not None: payload["remark"] = remark
if type is not None: payload["type"] = type
if hidden is not None: payload["hidden"] = hidden
if enabled is not None: payload["enabled"] = enabled
res = _request(url, "PUT", payload)
return res.get("data")
def delete_envs(ids):
"""
批量删除环境变量。
"""
for fid in ids:
delete_env(fid)
def delete_env(id):
"""
根据 ID 删除指定的环境变量。
"""
url = f"{_get_envs_url()}/{id}"
_request(url, "DELETE")
return True
+32
View File
@@ -0,0 +1,32 @@
import os
import json
import urllib.request
def notify(title, text, channel_id=None):
"""
发送内建通知。
"""
token = os.environ.get("BHPKG_NOTIFY_TOKEN")
url = os.environ.get("BHPKG_NOTIFY_URL", "http://localhost:8052/api/v1/notify/send")
default_channel = os.environ.get("BHPKG_NOTIFY_CHANNEL")
cid = channel_id or default_channel
if not url or not token or not cid:
return
payload = {
"channel_id": cid,
"title": title,
"text": text
}
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(url, data=data, method='POST')
req.add_header('Content-Type', 'application/json')
req.add_header('notify-token', token)
with urllib.request.urlopen(req) as resp:
return resp.read().decode('utf-8')
+128
View File
@@ -0,0 +1,128 @@
import os
import json
import urllib.request
import urllib.error
def _get_headers():
token = os.environ.get("BHPKG_OPENAPI_TOKEN") or os.environ.get("OPENAPI_TOKEN") or os.environ.get("BHPKG_NOTIFY_TOKEN")
if not token:
raise RuntimeError("没有正确配置或缺少 BHPKG_OPENAPI_TOKEN 环境变量以使用 task 函数。请在任务池的任务设置中配置这些 Key。")
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
def _get_base_url():
url = os.environ.get("BHPKG_OPENAPI_URL") or os.environ.get("OPENAPI_URL")
if url:
# If openapi_url ends with /env, replace it with nothing or use base
if url.endswith("/env"):
return url[:-4]
elif url.endswith("/env/"):
return url[:-5]
return url
notify_url = os.environ.get("BHPKG_NOTIFY_URL", "http://localhost:8052/api/v1/notify/send")
for target in ["/api/v1/notify/send/", "/api/v1/notify/send", "/api/v1/notify/", "/api/v1/notify"]:
if target in notify_url:
return notify_url.replace(target, "/open2api/v1")
return "http://localhost:8052/open2api/v1"
def _request(url, method="GET", data=None):
headers = _get_headers()
payload = None
if data is not None:
payload = json.dumps(data).encode("utf-8")
req = urllib.request.Request(url, data=payload, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
body = resp.read().decode("utf-8")
if not body:
return {}
parsed = json.loads(body)
if isinstance(parsed, dict) and parsed.get("code") is not None and parsed.get("code") != 200:
msg = parsed.get("msg") or parsed.get("message") or "未知错误"
raise RuntimeError(f"请求失败 [{parsed.get('code')}]: {msg}")
return parsed
except urllib.error.HTTPError as e:
err_body = e.read().decode("utf-8")
try:
err_json = json.loads(err_body)
msg = err_json.get("msg") or err_json.get("message") or err_body
except Exception:
msg = err_body
raise RuntimeError(f"请求失败 [{e.code}]: {msg}")
except Exception as e:
raise RuntimeError(f"请求发生异常: {e}")
def get_tasks():
"""
获取全部任务列表。
"""
url = f"{_get_base_url()}/tasks"
res = _request(url, "GET")
return res.get("data", [])
def get_task(id):
"""
根据 ID 获取单个任务的详细信息。
"""
url = f"{_get_base_url()}/tasks/{id}"
res = _request(url, "GET")
return res.get("data")
def update_task(id, name=None, command=None, remark=None, pin_type=None, trigger_type=None, schedule=None, timeout=None, work_dir=None, retry_count=None, retry_interval=None, random_range=None, enabled=None):
"""
根据 ID 更新任务。
"""
url = f"{_get_base_url()}/tasks/{id}"
payload = {}
if name is not None: payload["name"] = name
if command is not None: payload["command"] = command
if remark is not None: payload["remark"] = remark
if pin_type is not None: payload["pin_type"] = pin_type
if trigger_type is not None: payload["trigger_type"] = trigger_type
if schedule is not None: payload["schedule"] = schedule
if timeout is not None: payload["timeout"] = timeout
if work_dir is not None: payload["work_dir"] = work_dir
if retry_count is not None: payload["retry_count"] = retry_count
if retry_interval is not None: payload["retry_interval"] = retry_interval
if random_range is not None: payload["random_range"] = random_range
if enabled is not None: payload["enabled"] = enabled
res = _request(url, "PUT", payload)
return res.get("data")
def delete_task(id):
"""
根据 ID 删除指定任务。
"""
url = f"{_get_base_url()}/tasks/{id}"
_request(url, "DELETE")
return True
def execute_task(id):
"""
触发执行特定任务。
"""
url = f"{_get_base_url()}/execute/task/{id}"
res = _request(url, "POST")
return res.get("data")
def stop_task(log_id):
"""
根据日志 ID 停止正在运行的任务。
"""
url = f"{_get_base_url()}/tasks/stop/{log_id}"
res = _request(url, "POST")
return res.get("data")
def get_last_results():
"""
获取最近的执行结果列表。
"""
url = f"{_get_base_url()}/execute/results"
res = _request(url, "GET")
return res.get("data", [])
+104
View File
@@ -0,0 +1,104 @@
package builtininstall
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/engigu/taskpool/cmd/clibase"
"github.com/engigu/taskpool/internal/utils"
)
func printHelp() {
clibase.PrintSubCommandUsage("任务池内建依赖安装工具", "taskpool builtininstall", "", nil)
}
// Run 执行内建包安装逻辑
func Run(args []string) {
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
printHelp()
return
}
fs := flag.NewFlagSet("builtininstall", flag.ExitOnError)
fs.Usage = printHelp
if err := fs.Parse(args); err != nil {
return
}
fmt.Println(">> [Builtin] 开始为 mise 环境安装内建包...")
// 1. 确定内建包路径
// 优先使用 /www/builtin (Docker 环境),否则尝试相对于二进制文件的当前目录
builtinPath := "/www/builtin"
if _, err := os.Stat(builtinPath); os.IsNotExist(err) {
// 回退到当前目录下的 builtin
pwd, _ := os.Getwd()
builtinPath = filepath.Join(pwd, "builtin")
}
if _, err := os.Stat(builtinPath); os.IsNotExist(err) {
fmt.Printf(">> [Builtin] 错误: 找不到内建包目录: %s\n", builtinPath)
return
}
// 2. 安装 Node.js 包
installForLanguage("node", filepath.Join(builtinPath, "nodejs"))
// 3. 安装 Python 包
installForLanguage("python", filepath.Join(builtinPath, "python"))
fmt.Println(">> [Builtin] 内建包安装流程完成")
}
func installForLanguage(lang, pkgPath string) {
if _, err := os.Stat(pkgPath); os.IsNotExist(err) {
fmt.Printf(">> [Builtin] 警告: %s 的内建包目录不存在: %s\n", lang, pkgPath)
return
}
versions, err := utils.ListMiseInstalledVersions(lang)
if err != nil {
fmt.Printf(">> [Builtin] 错误: 获取 %s 的 mise 版本列表失败: %v\n", lang, err)
return
}
if len(versions) == 0 {
fmt.Printf(">> [Builtin] 未发现已安装的 %s 版本,跳过\n", lang)
return
}
for _, v := range versions {
fmt.Printf(">> [Builtin] 正在为 %s@%s 安装内建包...\n", lang, v)
var subCmdArgs []string
if lang == "node" {
// 使用 npm i -g 进行全局安装
subCmdArgs = []string{"npm", "i", "-g", pkgPath}
} else {
// python 改为标准安装 (非 -e),避免 Docker 内软链接可能导致的路径丢失问题
subCmdArgs = []string{"pip", "install", "--force-reinstall", pkgPath}
}
// 构建参数列表: [mise, exec, lang@v, --, cmd...]
fullArgs := utils.BuildMiseCommandArgsSimple(subCmdArgs, lang, v)
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", append([]string{"/c"}, fullArgs...)...)
} else {
cmd = exec.Command(fullArgs[0], fullArgs[1:]...)
}
out, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf(">> [Builtin] 错误: 为 %s@%s 安装失败: %v\n输出: %s\n", lang, v, err, string(out))
} else {
fmt.Printf(">> [Builtin] 为 %s@%s 安装成功\n", lang, v)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package clibase
import (
"flag"
"fmt"
"os"
"strings"
"github.com/engigu/taskpool/internal/bootstrap"
"github.com/engigu/taskpool/internal/services"
)
// InitContext 统一封装命令行所需的初始化上下文逻辑
func InitContext(requireSettings bool) error {
bootstrap.InitBasicForCmd()
if requireSettings {
settingsService := services.NewSettingsService()
if err := settingsService.InitSettings(); err != nil {
return fmt.Errorf("初始化系统设置失败: %w", err)
}
}
return nil
}
// PrintDBConfigHint 打印标准化的连接或检索失败时的排查指引
func PrintDBConfigHint(commandExample string) {
fmt.Println(">> 提示: 程序当前可能连接到了默认的空 SQLite 数据库。")
fmt.Println(">> 若您的生产环境使用的是 MySQL 或指定路径配置,请在执行命令时携带配置文件路径环境变量,例如:")
fmt.Printf(">> BH_CONFIG_PATH=/app/data/config.ini taskpool %s\n", commandExample)
}
// PrintSubCommandUsage 打印一致风格的子程序帮助信息
func PrintSubCommandUsage(title, usageStr, exampleStr string, fs *flag.FlagSet) {
fmt.Fprintf(os.Stderr, "\n%s\n\n", title)
fmt.Fprintf(os.Stderr, "用法:\n")
fmt.Fprintf(os.Stderr, " %s\n\n", usageStr)
if fs != nil {
fmt.Fprintf(os.Stderr, "参数说明:\n")
fs.PrintDefaults()
fmt.Fprintf(os.Stderr, "\n")
}
if exampleStr != "" {
fmt.Fprintf(os.Stderr, "示例:\n")
fmt.Fprintf(os.Stderr, "%s\n\n", exampleStr)
}
}
// VisualFormat 根据字符的视觉显示列宽(中文字符/宽字符计为2列,ASCII计为1列),
// 将字符串进行精确等宽填充或安全截断追加 "..",确保混合字符输出下控制台表格严丝合缝强制对齐。
func VisualFormat(s string, targetVisualWidth int) string {
w := 0
var sb strings.Builder
runes := []rune(s)
// 先计算总视觉宽度
totalW := 0
for _, r := range runes {
if r > 127 {
totalW += 2
} else {
totalW += 1
}
}
if totalW <= targetVisualWidth {
return s + strings.Repeat(" ", targetVisualWidth-totalW)
}
// 如果总宽度超出,进行精准截断并追加 ".."
maxContentW := targetVisualWidth - 2
for _, r := range runes {
rw := 1
if r > 127 {
rw = 2
}
if w+rw > maxContentW {
break
}
sb.WriteRune(r)
w += rw
}
res := sb.String() + ".."
// 补齐末尾可能相差的1个空格列宽
if w+2 < targetVisualWidth {
res += strings.Repeat(" ", targetVisualWidth-(w+2))
}
return res
}
+40
View File
@@ -0,0 +1,40 @@
package clibase
import (
"encoding/json"
"fmt"
"strings"
"github.com/engigu/taskpool/internal/bootstrap"
)
// CallInternalAPI 封装底层进程间 HTTP 通信,统一处理网络连接错误及业务级异常提取
func CallInternalAPI(method, endpoint string, payload any) ([]byte, error) {
bodyBytes, statusCode, err := bootstrap.SendInternalRequest(method, endpoint, payload)
if err != nil {
return nil, fmt.Errorf("无法连接到主程序后台服务: %w", err)
}
if statusCode != 200 {
return bodyBytes, fmt.Errorf("后台服务响应异常 (状态码: %d): %s", statusCode, strings.TrimSpace(string(bodyBytes)))
}
// 尝试通用结构体嗅探,提取业务级逻辑拒绝原因
var res struct {
Data struct {
Success *bool `json:"success"`
Error string `json:"error"`
} `json:"data"`
}
if err := json.Unmarshal(bodyBytes, &res); err == nil {
if res.Data.Success != nil && !*res.Data.Success {
errReason := res.Data.Error
if errReason == "" {
errReason = strings.TrimSpace(string(bodyBytes))
}
return bodyBytes, fmt.Errorf("%s", errReason)
}
}
return bodyBytes, nil
}
+71
View File
@@ -0,0 +1,71 @@
package clibase
import (
"bytes"
"io"
"regexp"
"strings"
)
// AnsiRegex 匹配终端 ANSI 控制序列的通用正则表达式
var AnsiRegex = regexp.MustCompile("\x1b\\[[0-9;]*[a-zA-Z]")
// CleanWriter 过滤输出流中的终端回车符覆写及 ANSI 色彩代码
type CleanWriter struct {
out io.Writer
buf []byte
}
// NewCleanWriter 构造输出清洗器
func NewCleanWriter(out io.Writer) *CleanWriter {
return &CleanWriter{out: out}
}
func (c *CleanWriter) Write(p []byte) (n int, err error) {
c.buf = append(c.buf, p...)
for {
idx := bytes.IndexAny(c.buf, "\r\n")
if idx == -1 {
break
}
if c.buf[idx] == '\r' && idx == len(c.buf)-1 {
// 跨块截断的回车,等待下一块
break
}
char := c.buf[idx]
line := string(c.buf[:idx])
c.buf = c.buf[idx+1:]
if char == '\r' && len(c.buf) > 0 && c.buf[0] == '\n' {
c.buf = c.buf[1:]
char = '\n'
}
s := AnsiRegex.ReplaceAllString(line, "")
if char == '\r' {
continue // 忽略终端进度条的同行覆盖
}
if s != "" {
c.out.Write([]byte(s + "\n"))
}
}
return len(p), nil
}
// Flush 输出末尾缓冲
func (c *CleanWriter) Flush() {
if len(c.buf) > 0 {
s := string(c.buf)
s = strings.TrimSuffix(s, "\r")
s = AnsiRegex.ReplaceAllString(s, "")
if s != "" {
c.out.Write([]byte(s + "\n"))
}
c.buf = nil
}
}
+31
View File
@@ -0,0 +1,31 @@
package cmd
import (
"github.com/engigu/taskpool/cmd/builtininstall"
"github.com/engigu/taskpool/cmd/depinstall"
"github.com/engigu/taskpool/cmd/reposync"
"github.com/engigu/taskpool/cmd/resetpwd"
"github.com/engigu/taskpool/cmd/restore"
"github.com/engigu/taskpool/cmd/task"
"github.com/engigu/taskpool/cmd/version"
"github.com/engigu/taskpool/cmd/webui"
// "github.com/engigu/taskpool/cmd/migrate"
)
// CommandHandler 定义命令执行函数
type CommandHandler func(args []string)
// Handlers 维护了除了 server 之外的命令的执行入口
var Handlers = map[string]CommandHandler{
"reposync": reposync.Run,
"resetpwd": resetpwd.Run,
"restore": restore.Run,
"builtininstall": builtininstall.Run,
"task": task.Run,
"webui": webui.Run,
"version": version.Run,
"-v": version.Run,
"-V": version.Run,
"depinstall": depinstall.Run,
// "migrate": migrate.Run,
}
+184
View File
@@ -0,0 +1,184 @@
package depinstall
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/engigu/taskpool/cmd/clibase"
"github.com/engigu/taskpool/internal/database"
"github.com/engigu/taskpool/internal/models"
"github.com/engigu/taskpool/internal/services"
"github.com/engigu/taskpool/internal/services/deps"
"github.com/engigu/taskpool/internal/utils"
)
// Run 依赖自动补全命令入口
func Run(args []string) {
if len(args) == 0 {
fmt.Println("用法: taskpool depinstall <log_id>")
return
}
logID := args[0]
fmt.Println(">> 提示: 依赖自动补全功能目前仅支持 Python 和 Node.js 环境,如有其他环境需求请及时反馈。")
// 初始化基础环境和数据库连接
if err := clibase.InitContext(true); err != nil {
fmt.Printf(">> 初始化环境失败: %v\n", err)
return
}
var log models.TaskLog
if err := database.DB.Where("id = ?", logID).First(&log).Error; err != nil {
fmt.Printf(">> 未找到指定的任务日志 (ID: %s): %v\n", logID, err)
return
}
var task models.Task
if err := database.DB.Where("id = ?", log.TaskID).First(&task).Error; err != nil {
fmt.Printf(">> 未找到对应的任务 (TaskID: %s): %v\n", log.TaskID, err)
return
}
logOutput, err := utils.DecompressFromBase64(string(log.Output))
if err != nil {
fmt.Printf(">> 解压日志失败: %v\n", err)
return
}
// 找出任务配置的语言
taskLangs := task.GetLanguages()
if len(taskLangs) == 0 {
fmt.Println(">> 提示: 当前任务未配置具体语言环境,请手动指定语言类型(例如 python3, node 等):")
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
fmt.Println(">> 已取消补全。")
return
}
taskLangs = append(taskLangs, map[string]string{
"name": input,
"version": "",
})
}
var allDetected []string
langToPkgMap := make(map[string][]string)
for _, langMap := range taskLangs {
langName := langMap["name"]
if langName == "" {
continue
}
detected, found := deps.DetectMissingDependencies(langName, logOutput)
if found {
langToPkgMap[langName] = detected
allDetected = append(allDetected, detected...)
}
}
reader := bufio.NewReader(os.Stdin)
// 如果没有检测到任何缺失的包,允许用户手动输入
if len(allDetected) == 0 {
fmt.Println(">> 分析完毕: 未从最近一次的任务运行日志中检测到缺失依赖模式。")
fmt.Println(">> 您可以手动输入想要安装的依赖包名称(多个包用空格分隔,若不安装请直接回车退出):")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
fmt.Println(">> 已退出依赖补全。")
return
}
// 默认分配到任务的第一个语言环境
defaultLang := taskLangs[0]["name"]
langToPkgMap[defaultLang] = strings.Fields(input)
allDetected = append(allDetected, langToPkgMap[defaultLang]...)
} else {
fmt.Println(">> 分析结果: 从运行日志中检测到以下缺失依赖包:")
for langName, pkgs := range langToPkgMap {
fmt.Printf(" [%s]: %s\n", langName, strings.Join(pkgs, ", "))
}
fmt.Println(">> 是否确认自动安装上述依赖包?(y/N):")
confirm, _ := reader.ReadString('\n')
confirm = strings.TrimSpace(strings.ToLower(confirm))
if confirm != "y" && confirm != "yes" {
fmt.Println(">> 用户已取消安装操作。")
return
}
}
fmt.Println("==================================================================")
fmt.Println(">> 开始执行依赖安装,请稍候...")
fmt.Println("==================================================================")
var failedPkgs []string
depService := services.NewDependencyService()
for langName, pkgs := range langToPkgMap {
var langVersion string
for _, lm := range taskLangs {
if lm["name"] == langName {
langVersion = lm["version"]
break
}
}
m := deps.GetManager(langName)
if m == nil {
fmt.Printf(">> 错误: 不支持的语言类型: %s\n", langName)
failedPkgs = append(failedPkgs, pkgs...)
continue
}
for _, pkg := range pkgs {
dep := &models.Dependency{
Name: pkg,
Language: langName,
LangVersion: langVersion,
}
cmdStr, err := m.GetInstallCommand(dep)
if err != nil {
fmt.Printf(">> 无法生成 %s 包 [%s] 的安装命令: %v\n", langName, pkg, err)
failedPkgs = append(failedPkgs, pkg)
continue
}
// 去除命令末尾的 success/failed echo 重定向,因为我们需要捕获退出状态并在控制台展示原始流程
if idx := strings.Index(cmdStr, " && echo"); idx != -1 {
cmdStr = cmdStr[:idx]
}
fmt.Printf(">> 正在安装 [%s] -> 执行指令: %s\n", pkg, cmdStr)
execCmd := utils.NewShellCommandCmd(cmdStr)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
execCmd.Stdin = os.Stdin
runErr := execCmd.Run()
if runErr != nil {
fmt.Printf(">> 【失败】依赖包 [%s] 安装出错。\n\n", pkg)
failedPkgs = append(failedPkgs, pkg)
} else {
fmt.Printf(">> 【成功】依赖包 [%s] 安装成功!\n\n", pkg)
// 成功后记录到依赖表
_ = depService.Create(dep)
}
}
}
fmt.Println("==================================================================")
if len(failedPkgs) > 0 {
fmt.Printf(">> 依赖补全已结束。其中以下依赖包安装失败,请用户自行判断/手动处理:\n")
for _, fp := range failedPkgs {
fmt.Printf(" - %s\n", fp)
}
} else {
fmt.Println(">> 恭喜!所有依赖包安装成功!")
}
fmt.Println("==================================================================")
}
+29
View File
@@ -0,0 +1,29 @@
package migrate
import (
"fmt"
"github.com/engigu/taskpool/internal/bootstrap"
"github.com/engigu/taskpool/internal/services"
)
func Run(args []string) {
fmt.Println("Starting Migration V3...")
// 初始化基础环境(配置和数据库,但不运行常规 Migrate,因为我们想手动控)
// 不过 bootstrap.New() 会调用 Migrate().
// 我们可以调用 InitBasic()
app := bootstrap.InitBasicForCmd()
if app == nil {
fmt.Println("Failed to initialize app")
return
}
// 此时数据库已经连接,Migrate() 已经运行过了(因为 bootstrap.InitBasic 调用了 app.initDatabase
// 由于我们在 Migrate() 中集成了 RunMigrationV3(),所以其实已经跑过了。
// 如果用户想重复跑,或者单独跑:
err := services.RunMigrationV3()
if err != nil {
fmt.Printf("Migration failed: %v\n", err)
return
}
fmt.Println("Migration V3 completed successfully.")
}
+775
View File
@@ -0,0 +1,775 @@
package reposync
import (
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/engigu/taskpool/cmd/clibase"
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/services/repo"
"github.com/engigu/taskpool/internal/utils"
)
type Config struct {
SourceType string
SourceURL string
TargetPath string
Branch string
Path string
SingleFile bool
Proxy string
ProxyURL string
AuthToken string
HttpProxy string
WhitelistPaths string // Comma or vertical line separated paths to preserve or filter (whitelist)
Blacklist string // Script filter blacklist keywords, vertical line separated
Dependence string // Script dependence file keywords, vertical line separated
Extensions string // Script file extensions, vertical line separated
TaskID string
RepoTaskID string
TaskLanguages string
TaskTimeout int
CommentToTask string
PreCommand string
PostCommand string
RepoName string
}
func Run(args []string) {
fs := flag.NewFlagSet("reposync", flag.ExitOnError)
var cfg Config
fs.StringVar(&cfg.SourceType, "source-type", "git", "Source type: git or url")
fs.StringVar(&cfg.SourceURL, "source-url", "", "Source url")
fs.StringVar(&cfg.TargetPath, "target-path", "", "Target path")
fs.StringVar(&cfg.Branch, "branch", "", "Branch")
fs.StringVar(&cfg.Path, "path", "", "Path for sparse checkout")
fs.BoolVar(&cfg.SingleFile, "single-file", false, "Single file mode")
fs.StringVar(&cfg.Proxy, "proxy", "none", "Proxy type")
fs.StringVar(&cfg.ProxyURL, "proxy-url", "", "Custom proxy url")
fs.StringVar(&cfg.AuthToken, "auth-token", "", "Auth token")
fs.StringVar(&cfg.HttpProxy, "http-proxy", "", "Http proxy")
fs.StringVar(&cfg.WhitelistPaths, "whitelist-paths", "", "Separated paths to preserve or filter (whitelist)")
fs.StringVar(&cfg.Blacklist, "blacklist", "", "Script filter blacklist keywords (| separated)")
fs.StringVar(&cfg.Dependence, "dependence", "", "Script dependence keywords (| separated)")
fs.StringVar(&cfg.Extensions, "extensions", "", "Script extensions (| separated)")
fs.StringVar(&cfg.TaskID, "task-id", "", "Task ID for metadata")
fs.StringVar(&cfg.TaskLanguages, "task-langs", "", "Configured languages (JSON)")
fs.StringVar(&cfg.RepoTaskID, "repo-task-id", "", "Original Task ID")
fs.IntVar(&cfg.TaskTimeout, "task-timeout", 30, "Task timeout (minutes)")
fs.StringVar(&cfg.CommentToTask, "commenttotask", "false", "Compatible with QL format script comment parsing (true/false)")
fs.StringVar(&cfg.PreCommand, "pre-command", "", "Default pre-command for discovered tasks")
fs.StringVar(&cfg.PostCommand, "post-command", "", "Default post-command for discovered tasks")
fs.StringVar(&cfg.RepoName, "repo-name", "", "Custom repository directory name")
printHelp := func() {
fmt.Fprintf(os.Stderr, "\n任务池仓库同步工具 (Reposync)\n\n")
fmt.Fprintf(os.Stderr, "用法:\n")
fmt.Fprintf(os.Stderr, " taskpool reposync [参数]\n\n")
fmt.Fprintf(os.Stderr, "参数详情:\n")
fs.PrintDefaults()
fmt.Fprintf(os.Stderr, "\n示例:\n")
fmt.Fprintf(os.Stderr, " taskpool reposync --source-url https://github.com/xxx/repo.git --target-path $SCRIPTS_DIR$/repo1\n\n")
}
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
printHelp()
return
}
fs.Usage = printHelp
if err := fs.Parse(args); err != nil {
return
}
if cfg.SourceURL == "" {
fmt.Fprintf(os.Stderr, "错误: 必须提供 --source-url 参数\n")
fs.Usage()
return
}
// 处理 $SCRIPTS_DIR$ 代号替换
if strings.Contains(cfg.TargetPath, constant.ScriptsDirPlaceholder) {
scriptsDir := os.Getenv("BH_SCRIPTS_DIR")
if scriptsDir != "" {
cfg.TargetPath = filepath.Clean(strings.ReplaceAll(cfg.TargetPath, constant.ScriptsDirPlaceholder, scriptsDir))
}
}
fmt.Println("========================================")
fmt.Println(" 仓库同步任务开始 ")
fmt.Println("========================================")
fmt.Printf("[1/3] 解析同步参数: %s\n", strings.Join(args, " "))
if cfg.SourceType == "git" {
fmt.Printf("[2/3] 正在通过 Git 同步内容...\n")
syncGit(cfg)
} else {
fmt.Printf("[2/3] 正在通过 URL 下载内容...\n")
syncURL(cfg)
}
// 执行前置指令
if cfg.PreCommand != "" {
fmt.Printf("[准备] 执行同步前指令: %s\n", cfg.PreCommand)
// 计算当前仓库真实的物理路径
repoDir := getActualRepoDir(cfg)
fmt.Printf("[准备] 工作目录: %s\n", repoDir)
fmt.Printf("[准备] 注入环境变量: CURR_REPO_DIR=%s\n", repoDir)
shell, shellArgs := utils.GetShellCommand(cfg.PreCommand)
envs := append(os.Environ(), "CURR_REPO_DIR="+repoDir)
runCmd(append([]string{shell}, shellArgs...), repoDir, envs)
}
// 执行脚本过滤(仅限 git 模式,url 加载通常为单文件,暂不处理过滤)
if cfg.SourceType == "git" {
fmt.Printf("[3/3] 正在执行脚本过滤与文件清理...\n")
filterFiles(cfg)
if cfg.TaskID != "" {
upsertedIDs, deletedIDs := repo.ParseRepoScriptsAndAddCron(cfg.TaskID, os.Stdout, cfg.CommentToTask == "true")
if len(upsertedIDs) > 0 || len(deletedIDs) > 0 {
notifyMainServerToSyncRepoTasks(cfg.TaskID, upsertedIDs, deletedIDs)
}
}
}
// 执行后置指令
if cfg.PostCommand != "" {
fmt.Printf("[收尾] 执行同步后指令: %s\n", cfg.PostCommand)
// 计算当前仓库真实的物理路径
repoDir := getActualRepoDir(cfg)
fmt.Printf("[收尾] 工作目录: %s\n", repoDir)
fmt.Printf("[收尾] 注入环境变量: CURR_REPO_DIR=%s\n", repoDir)
shell, shellArgs := utils.GetShellCommand(cfg.PostCommand)
envs := append(os.Environ(), "CURR_REPO_DIR="+repoDir)
runCmd(append([]string{shell}, shellArgs...), repoDir, envs)
}
fmt.Println("\n========================================")
fmt.Println(" 仓库同步任务完成 ")
fmt.Println("========================================")
}
func getActualRepoDir(cfg Config) string {
if cfg.SourceType == "git" {
repoName := cfg.RepoName
if repoName == "" {
repoName = utils.GetRepoIdentifier(cfg.SourceURL, cfg.Branch)
}
if repoName == "." {
return cfg.TargetPath
}
return filepath.Join(cfg.TargetPath, repoName)
}
return cfg.TargetPath
}
func notifyMainServerToSyncRepoTasks(repoID string, upsertedIDs []string, deletedIDs []string) {
_, err := clibase.CallInternalAPI("POST", "/internal/tasks/sync-repo-status", map[string]interface{}{
"repo_id": repoID,
"upserted_ids": upsertedIDs,
"deleted_ids": deletedIDs,
})
if err != nil {
fmt.Printf(">> [通知] 调度器同步失败: %v\n", err)
return
}
fmt.Println(">> [通知] 已成功将变动任务增量同步至主程序调度器")
}
func syncGit(cfg Config) {
env := os.Environ()
if isRawFileURL(cfg.SourceURL) {
fmt.Println("检测到 raw 文件 URL,自动切换到 URL 下载模式")
syncURL(cfg)
return
}
if cfg.HttpProxy != "" {
env = append(env, "http_proxy="+cfg.HttpProxy, "https_proxy="+cfg.HttpProxy)
}
repoURL := buildProxyURL(cfg.SourceURL, cfg.Proxy, cfg.ProxyURL)
if cfg.AuthToken != "" && strings.HasPrefix(repoURL, "https://") {
repoURL = strings.Replace(repoURL, "https://", "https://"+cfg.AuthToken+"@", 1)
}
dest := cfg.TargetPath
if cfg.Path != "" && cfg.SingleFile {
syncGitFile(cfg, repoURL, env)
return
}
gitDir := filepath.Join(dest, ".git")
if isDir(dest) && !pathExists(gitDir) {
repoName := cfg.RepoName
if repoName == "" {
repoName = utils.GetRepoIdentifier(cfg.SourceURL, cfg.Branch)
}
if repoName != "." {
dest = filepath.Join(dest, repoName)
fmt.Printf("目标路径自动追加仓库名: %s\n", dest)
gitDir = filepath.Join(dest, ".git")
} else {
fmt.Printf("目标路径使用当前目录 (不追加仓库名): %s\n", dest)
}
}
restore := preserve(dest, cfg.WhitelistPaths)
defer restore()
if pathExists(gitDir) {
fmt.Println("检测到已存在仓库,正在更新...")
runCmd([]string{"git", "fetch", "--all"}, dest, env)
targetBranch := cfg.Branch
if targetBranch != "" {
// 如果切换了分支,或者当前分支偏离,强制切换并对齐远程
runCmd([]string{"git", "checkout", "-B", targetBranch, "origin/" + targetBranch}, dest, env)
} else {
targetBranch = getCurrentBranch(dest, env)
}
if targetBranch != "" {
fmt.Printf("执行强制同步 (reset --hard origin/%s)\n", targetBranch)
runCmd([]string{"git", "reset", "--hard", "origin/" + targetBranch}, dest, env)
} else {
runCmd([]string{"git", "pull", "--rebase"}, dest, env)
}
} else {
fmt.Println("执行 git clone")
parentDir := filepath.Dir(dest)
if parentDir != "" {
os.MkdirAll(parentDir, 0755)
}
if pathExists(dest) && !isDirEmpty(dest) {
// If we still have files after preservation, warn but maybe continue if it's just leftovers that git can handle?
// Actually git clone requires an empty dir.
fmt.Printf("警告: 目标目录 '%s' 不为空,尝试清理非保护文件...\n", dest)
// Optional: delete everything else? User might not want that.
// For now, keep the error but it's less likely to occur if preservation moved things out.
fmt.Println("提示: 请清空目标目录或指定一个新目录")
os.Exit(1)
}
// If dest exists but is empty now, git clone might still complain if the directory itself exists?
// No, git clone works if dir is empty.
cloneCmd := []string{"git", "clone", "--depth", "1"}
if cfg.Branch != "" {
cloneCmd = append(cloneCmd, "-b", cfg.Branch)
}
if cfg.Path != "" {
cloneCmd = append(cloneCmd, "--filter=blob:none", "--no-checkout", repoURL, dest)
runCmd(cloneCmd, "", env)
runCmd([]string{"git", "sparse-checkout", "init", "--cone"}, dest, env)
runCmd([]string{"git", "sparse-checkout", "set", cfg.Path}, dest, env)
runCmd([]string{"git", "checkout"}, dest, env)
} else {
cloneCmd = append(cloneCmd, repoURL, dest)
runCmd(cloneCmd, "", env)
}
}
fmt.Println("同步完成")
}
func syncURL(cfg Config) {
downloadURL := buildProxyURL(cfg.SourceURL, cfg.Proxy, cfg.ProxyURL)
fmt.Printf("下载地址: %s\n", downloadURL)
dest := cfg.TargetPath
if isDir(dest) || strings.HasSuffix(dest, string(os.PathSeparator)) || strings.HasSuffix(dest, "/") {
urlPath := strings.Split(cfg.SourceURL, "?")[0]
filename := filepath.Base(urlPath)
if filename == "" {
filename = "downloaded_file"
}
dest = filepath.Join(dest, filename)
fmt.Printf("目标文件: %s\n", dest)
}
restore := preserve(cfg.TargetPath, cfg.WhitelistPaths)
defer restore()
downloadFile(downloadURL, dest, cfg.AuthToken)
}
func syncGitFile(cfg Config, repoURL string, env []string) {
sourceURL := cfg.SourceURL
filePath := cfg.Path
dest := cfg.TargetPath
if isDir(dest) || strings.HasSuffix(dest, string(os.PathSeparator)) || strings.HasSuffix(dest, "/") {
filename := filepath.Base(filePath)
dest = filepath.Join(dest, filename)
fmt.Printf("检测到目标路径为目录 '%s',自动修正为: '%s'\n", cfg.TargetPath, dest)
}
branch := cfg.Branch
if branch == "" {
branch = getRemoteDefaultBranch(repoURL, env)
}
cleanURL := strings.TrimSuffix(cfg.SourceURL, ".git")
rawURL := ""
if strings.Contains(sourceURL, "github.com") {
base := strings.Replace(strings.TrimSuffix(cfg.SourceURL, ".git"), "github.com", "raw.githubusercontent.com", 1)
rawURL = fmt.Sprintf("%s/%s/%s", base, branch, filePath)
} else if strings.Contains(sourceURL, "gitlab.com") {
rawURL = fmt.Sprintf("%s/-/raw/%s/%s", cleanURL, branch, filePath)
} else if strings.Contains(sourceURL, "gitee.com") {
rawURL = fmt.Sprintf("%s/raw/%s/%s", cleanURL, branch, filePath)
} else {
rawURL = fmt.Sprintf("%s/raw/%s/%s", cleanURL, branch, filePath)
}
rawURL = buildProxyURL(rawURL, cfg.Proxy, cfg.ProxyURL)
downloadFile(rawURL, dest, cfg.AuthToken)
}
func getRemoteDefaultBranch(repoURL string, env []string) string {
fmt.Printf("正在检测远程仓库默认分支: %s\n", repoURL)
cmd := exec.Command("git", "ls-remote", "--symref", repoURL, "HEAD")
cmd.Env = env
out, err := cmd.Output()
if err == nil {
lines := strings.Split(string(out), "\n")
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) >= 2 && parts[0] == "ref:" && strings.Contains(parts[1], "refs/heads/") {
branch := strings.TrimPrefix(parts[1], "refs/heads/")
fmt.Printf("检测到默认分支: %s\n", branch)
return branch
}
}
}
fmt.Println("无法检测到默认分支,回退使用 'main'")
return "main"
}
func buildProxyURL(url string, proxyType string, proxyURL string) string {
if proxyType == "" || proxyType == "none" {
return url
}
// 如果 URL 已经包含明显的代理前缀 (如用户手动填写的 http://ghproxy.com/...)
// 则跳过内置代理逻辑
if strings.Contains(url, "googo.win") || (proxyType == "custom" && strings.HasPrefix(url, proxyURL)) {
return url
}
base := ""
if proxyType == "ghproxy" {
base = "https://gh-proxy.com/"
} else if proxyType == "mirror" {
base = "https://mirror.ghproxy.com/"
} else if proxyType == "custom" && proxyURL != "" {
base = strings.TrimSuffix(proxyURL, "/") + "/"
}
if base != "" && strings.HasPrefix(url, "http") && !strings.HasPrefix(url, base) {
return base + url
}
return url
}
func downloadFile(url, dest, authToken string) {
fmt.Printf("下载地址: %s\n", url)
fmt.Printf("目标路径: %s\n", dest)
parentDir := filepath.Dir(dest)
if parentDir != "" {
os.MkdirAll(parentDir, 0755)
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Printf("下载准备失败: %v\n", err)
os.Exit(1)
}
if authToken != "" {
req.Header.Set("Authorization", "token "+authToken)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; reposync)")
client := &http.Client{Timeout: 300 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("下载请求失败: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fmt.Printf("下载失败, HTTP 状态码: %d\n", resp.StatusCode)
os.Exit(1)
}
out, err := os.Create(dest)
if err != nil {
fmt.Printf("创建文件失败: %v\n", err)
os.Exit(1)
}
defer out.Close()
n, err := io.Copy(out, resp.Body)
if err != nil {
fmt.Printf("写入数据失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("文件大小: %d 字节\n", n)
fmt.Println("下载完成")
}
func isRawFileURL(url string) bool {
rawPatterns := []string{
"raw.githubusercontent.com",
"/raw/",
"/-/raw/",
"/blob/",
}
for _, p := range rawPatterns {
if strings.Contains(url, p) {
return true
}
}
return false
}
func runCmd(args []string, dir string, env []string) {
fmt.Printf(">> %s\n", strings.Join(args, " "))
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = dir
cmd.Env = env
cw := clibase.NewCleanWriter(os.Stdout)
cmd.Stdout = cw
cmd.Stderr = cw
if err := cmd.Run(); err != nil {
cw.Flush()
fmt.Printf("命令执行失败: %v\n", err)
os.Exit(1)
}
cw.Flush()
}
func isDir(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil || !os.IsNotExist(err)
}
func isDirEmpty(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
_, err = f.Readdirnames(1)
return err == io.EOF
}
func getCurrentBranch(dir string, env []string) string {
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = dir
cmd.Env = env
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// preserve moves specified paths to a temporary location and returns a function to restore them
func preserve(baseDir string, paths string) func() {
if paths == "" || !pathExists(baseDir) {
return func() {}
}
preservedList := strings.Split(paths, ",")
// 优化:将临时目录创建在 baseDir 同一级或内部,确保在同一个文件系统,使得 Rename 是 O(1) 瞬时完成的
tmpParent, err := os.MkdirTemp(baseDir, ".taskpool_sync_preserve_*")
if err != nil {
fmt.Printf("警告: 无法在目标目录创建临时目录用于保留文件: %v\n", err)
return func() {}
}
type preservedItem struct {
relPath string
tmpPath string
}
var items []preservedItem
processed := make(map[string]bool)
for _, p := range preservedList {
p = strings.TrimSpace(p)
if p == "" {
continue
}
// Support glob matching
pattern := filepath.Join(baseDir, p)
matches, err := filepath.Glob(pattern)
if err != nil {
fmt.Printf("警告: 路径模式无效 %s: %v\n", p, err)
continue
}
// If literal path exists but Glob didn't find it (common for direct dir reference), add it manually
if len(matches) == 0 && pathExists(pattern) {
matches = []string{pattern}
}
for _, fullPath := range matches {
relPath, err := filepath.Rel(baseDir, fullPath)
// 同时要排除掉临时目录本身以及上级路径
if err != nil || strings.HasPrefix(relPath, "..") || relPath == "." || strings.HasPrefix(relPath, ".taskpool_sync_preserve") {
continue
}
if processed[relPath] {
continue
}
processed[relPath] = true
tmpPath := filepath.Join(tmpParent, relPath)
os.MkdirAll(filepath.Dir(tmpPath), 0755)
fmt.Printf("正在保护路径: %s\n", relPath)
if err := os.Rename(fullPath, tmpPath); err == nil {
items = append(items, preservedItem{relPath: relPath, tmpPath: tmpPath})
} else {
// Rename might fail across filesystems, try copy
if err := utils.CopyPath(fullPath, tmpPath); err == nil {
os.RemoveAll(fullPath)
items = append(items, preservedItem{relPath: relPath, tmpPath: tmpPath})
} else {
fmt.Printf("警告: 无法保护路径 %s: %v\n", relPath, err)
}
}
}
}
return func() {
// Restore in reverse order to handle nested structures correctly if they were picked up separately
for i := len(items) - 1; i >= 0; i-- {
item := items[i]
destPath := filepath.Join(baseDir, item.relPath)
os.MkdirAll(filepath.Dir(destPath), 0755)
if pathExists(destPath) {
fmt.Printf("目标已存在,覆盖恢复保护路径: %s\n", item.relPath)
os.RemoveAll(destPath)
} else {
fmt.Printf("正在恢复保护路径: %s\n", item.relPath)
}
if err := os.Rename(item.tmpPath, destPath); err != nil {
// Fallback to copy
utils.CopyPath(item.tmpPath, destPath)
}
}
os.RemoveAll(tmpParent)
}
}
// filterFiles performs script filtering based on whitelist, blacklist, dependence and extensions.
func filterFiles(cfg Config) {
// If no filtering is specified, do nothing.
if cfg.WhitelistPaths == "" && cfg.Blacklist == "" && cfg.Dependence == "" && cfg.Extensions == "" {
return
}
dest := getActualRepoDir(cfg)
fmt.Printf("开始执行脚本过滤: %s\n", dest)
whitelist := splitKeywords(cfg.WhitelistPaths)
blacklist := splitKeywords(cfg.Blacklist)
dependence := splitKeywords(cfg.Dependence)
extensions := splitKeywords(cfg.Extensions)
// We'll collect files to delete to avoid modifying while walking if possible.
// But os.RemoveAll is fine.
count := 0
filepath.Walk(dest, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if info.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(dest, path)
rel = filepath.ToSlash(rel)
filename := info.Name()
// 1. Check dependence: always keep
if matchesAny(rel, filename, dependence) {
return nil
}
// 2. Check extensions: delete if not matched and extensions is specified
if len(extensions) > 0 {
ext := strings.TrimPrefix(filepath.Ext(filename), ".")
matchedExt := false
for _, e := range extensions {
if strings.EqualFold(ext, strings.TrimPrefix(e, ".")) {
matchedExt = true
break
}
}
if !matchedExt {
fmt.Printf("过滤文件 (后缀不符): %s\n", rel)
os.Remove(path)
count++
return nil
}
}
// 3. Check blacklist: delete if matched
if matchesAny(rel, filename, blacklist) {
fmt.Printf("过滤文件 (黑名单): %s\n", rel)
os.Remove(path)
count++
return nil
}
// 4. Check whitelist: delete if NOT matched and whitelist is specified
if len(whitelist) > 0 {
if !matchesAny(rel, filename, whitelist) {
fmt.Printf("过滤文件 (不在白名单): %s\n", rel)
os.Remove(path)
count++
return nil
}
}
return nil
})
if count > 0 {
fmt.Printf("过滤完成,共删除 %d 个不符合要求的文件\n", count)
// Try to clean up empty directories
cleanEmptyDirs(dest)
}
}
func splitKeywords(s string) []string {
if s == "" {
return nil
}
// Try to split by common separators for compatibility
var parts []string
if strings.Contains(s, "|") {
parts = strings.Split(s, "|")
} else if strings.Contains(s, ",") {
parts = strings.Split(s, ",")
} else {
parts = []string{s}
}
var res []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
res = append(res, p)
}
}
return res
}
func matchesAny(rel, filename string, keywords []string) bool {
if len(keywords) == 0 {
return false
}
for _, k := range keywords {
// 1. 尝试作为正则整体进行匹配,默认不区分大小写 (?i)
// 如果关键字不包含正则元字符,则补齐 (?i) 开启忽略大小写
pattern := k
if !strings.HasPrefix(pattern, "(?i)") {
pattern = "(?i)" + pattern
}
reg, err := regexp.Compile(pattern)
if err == nil {
// 优先匹配文件名(解决 ^jd[^_] 这种锚点在相对路径下失效的问题)
if reg.MatchString(filename) || reg.MatchString(rel) {
return true
}
} else {
// 回退逻辑:全小写包含判断
kLower := strings.ToLower(k)
if strings.Contains(strings.ToLower(rel), kLower) || strings.Contains(strings.ToLower(filename), kLower) {
return true
}
}
}
return false
}
func cleanEmptyDirs(root string) {
// Post-order traversal to clean up empty dirs
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
return nil
}
if path == root {
return nil
}
if info.Name() == ".git" {
return filepath.SkipDir
}
return nil
})
// Actually we need to do this recursively or multiple times.
// A simpler way:
entries, _ := os.ReadDir(root)
for _, entry := range entries {
if entry.IsDir() {
if entry.Name() == ".git" {
continue
}
dirPath := filepath.Join(root, entry.Name())
cleanEmptyDirs(dirPath)
// Check if now empty
if isDirEmpty(dirPath) {
os.Remove(dirPath)
}
}
}
}
+82
View File
@@ -0,0 +1,82 @@
package resetpwd
import (
"bufio"
"flag"
"fmt"
"os"
"strings"
"github.com/engigu/taskpool/cmd/clibase"
"github.com/engigu/taskpool/internal/services"
"github.com/engigu/taskpool/internal/utils"
)
func printHelp() {
clibase.PrintSubCommandUsage("任务池用户密码重置工具", "taskpool resetpwd [用户名]", " taskpool resetpwd admin", nil)
}
func Run(args []string) {
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
printHelp()
return
}
fs := flag.NewFlagSet("resetpwd", flag.ExitOnError)
fs.Usage = printHelp
if err := fs.Parse(args); err != nil {
return
}
if err := clibase.InitContext(true); err != nil {
fmt.Println(err)
return
}
userService := services.NewUserService()
var username string
parsedArgs := fs.Args()
if len(parsedArgs) >= 1 {
username = parsedArgs[0]
} else {
username = "admin"
}
fmt.Printf("此操作将重置用户 [%s] 的密码,是否继续? (y/N): ", username)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("操作已取消。")
return
}
fmt.Printf("请输入用户 [%s] 的新密码 (留空则自动随机生成): ", username)
inputPwd, _ := reader.ReadString('\n')
newPassword := strings.TrimSpace(inputPwd)
if newPassword == "" {
newPassword = utils.RandomString(12)
fmt.Println("未输入密码,系统已自动生成。")
}
user := userService.GetUserByUsername(username)
if user == nil {
fmt.Printf("找不到用户 [%s]\n", username)
clibase.PrintDBConfigHint("resetpwd " + username)
return
}
err := userService.UpdatePassword(user.ID, newPassword)
if err != nil {
fmt.Printf("重置密码失败: %v\n", err)
return
}
fmt.Println("--------------------------------------------------")
fmt.Printf("用户 [%s] 密码已重置成功:\n", username)
fmt.Printf("新密码: %s\n", newPassword)
fmt.Println("请妥善保管您的新密码,并登录后及时修改。")
fmt.Println("--------------------------------------------------")
}
+64
View File
@@ -0,0 +1,64 @@
package restore
import (
"flag"
"fmt"
"os"
"path/filepath"
"github.com/engigu/taskpool/cmd/clibase"
"github.com/engigu/taskpool/internal/services"
)
func printHelp() {
clibase.PrintSubCommandUsage("任务池系统数据恢复工具", "taskpool restore <备份文件.zip>", " taskpool restore backup_20231027.zip", nil)
}
func Run(args []string) {
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
printHelp()
return
}
fs := flag.NewFlagSet("restore", flag.ExitOnError)
fs.Usage = printHelp
if err := fs.Parse(args); err != nil {
return
}
parsedArgs := fs.Args()
if len(parsedArgs) < 1 {
fmt.Fprintf(os.Stderr, "错误: 必须提供备份文件路径\n")
fs.Usage()
return
}
backupFile := parsedArgs[0]
absPath, err := filepath.Abs(backupFile)
if err != nil {
fmt.Printf("文件路径解析失败: %v\n", err)
os.Exit(1)
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
fmt.Printf("错误: 备份文件 '%s' 不存在\n", absPath)
os.Exit(1)
}
// 必须初始化环境与数据库才能恢复数据
clibase.InitContext(false)
backupService := services.NewBackupService()
fmt.Printf("正在从 '%s' 恢复系统数据,请勿强制中断...\n", absPath)
err = backupService.Restore(absPath)
if err != nil {
fmt.Printf("恢复备份失败: %v\n", err)
os.Exit(1)
}
fmt.Println("--------------------------------------------------")
fmt.Println("系统备份恢复成功!")
fmt.Println("注意:部分设定可能需要重启后台服务后才能完全生效。")
fmt.Println("--------------------------------------------------")
}
+398
View File
@@ -0,0 +1,398 @@
package task
import (
"flag"
"fmt"
"os"
"strings"
"github.com/engigu/taskpool/cmd/clibase"
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/database"
"github.com/engigu/taskpool/internal/models"
"github.com/engigu/taskpool/internal/utils"
)
// 打印主帮助
func printMainHelp() {
fmt.Fprintf(os.Stderr, "\n任务池任务命令行管理工具 (Task CLI)\n\n")
fmt.Fprintf(os.Stderr, "说明:\n")
fmt.Fprintf(os.Stderr, " 本工具原生兼容管理普通任务 (task) 与仓库同步任务 (repo)。\n")
fmt.Fprintf(os.Stderr, " 操作目标支持传入精确任务ID、任务名称模糊/精准查找,或使用快捷字面量 'repo' 一键操作主力仓库。\n\n")
fmt.Fprintf(os.Stderr, "用法:\n")
fmt.Fprintf(os.Stderr, " taskpool task <子命令> [参数]\n\n")
fmt.Fprintf(os.Stderr, "可用子命令:\n")
fmt.Fprintf(os.Stderr, " list 查询并输出任务列表\n")
fmt.Fprintf(os.Stderr, " run 手动立即触发执行指定的任务或仓库\n")
fmt.Fprintf(os.Stderr, " enable 启用指定的任务或仓库(同步加入后台调度队列)\n")
fmt.Fprintf(os.Stderr, " disable 禁用指定的任务或仓库(同步从后台调度队列摘除)\n")
fmt.Fprintf(os.Stderr, " status 查看指定任务或仓库最近一次执行的完整输出与状态\n")
fmt.Fprintf(os.Stderr, " history 查看指定任务或仓库近期的多次执行流水记录\n\n")
fmt.Fprintf(os.Stderr, "使用 'taskpool task <子命令> --help' 查看具体子命令的参数说明和示例。\n\n")
}
// resolveTaskID 智能解析目标任务ID:支持直接传入真实ID、任务名称,或传入 "repo" 快捷操作系统中唯一的仓库同步任务
func resolveTaskID(input string) string {
var t models.Task
// 1. 尝试按精确 ID 匹配
if res := database.DB.Where("id = ?", input).Limit(1).Find(&t); res.Error == nil && res.RowsAffected > 0 {
return t.ID
}
// 2. 如果输入字面量为 "repo",尝试匹配 type = 'repo' 的记录
if strings.ToLower(input) == "repo" {
var repos []models.Task
if res := database.DB.Where("type = ?", constant.TaskTypeRepo).Find(&repos); res.Error == nil {
if len(repos) == 1 {
fmt.Printf(">> 智能匹配到唯一的仓库任务: [%s] (ID: %s)\n", repos[0].Name, repos[0].ID)
return repos[0].ID
} else if len(repos) > 1 {
fmt.Fprintf(os.Stderr, ">> 提示: 系统中存在多个 repo 类型的仓库任务,请指定具体的仓库名称或ID进行精确操作。\n")
return input
}
}
}
// 3. 尝试按名称精准或模糊匹配
var namedTasks []models.Task
if res := database.DB.Where("name = ?", input).Find(&namedTasks); res.Error == nil && len(namedTasks) > 0 {
if len(namedTasks) == 1 {
fmt.Printf(">> 智能匹配到目标任务: [%s] (ID: %s)\n", namedTasks[0].Name, namedTasks[0].ID)
return namedTasks[0].ID
}
fmt.Fprintf(os.Stderr, ">> 提示: 存在多个同名任务 [%s],请使用精确的任务ID进行操作。\n", input)
return input
}
// 尝试名称模糊匹配 (LIKE)
if res := database.DB.Where("name LIKE ?", "%"+input+"%").Find(&namedTasks); res.Error == nil && len(namedTasks) == 1 {
fmt.Printf(">> 模糊匹配到唯一的任务: [%s] (ID: %s)\n", namedTasks[0].Name, namedTasks[0].ID)
return namedTasks[0].ID
}
// 默认原样返回
return input
}
// Run 任务命令行入口
func Run(args []string) {
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" {
printMainHelp()
return
}
subCommand := args[0]
subArgs := args[1:]
switch subCommand {
case "list":
runList(subArgs)
case "run":
runExecute(subArgs)
case "enable", "disable":
runToggle(subCommand, subArgs)
case "status":
runStatus(subArgs)
case "history":
runHistory(subArgs)
default:
fmt.Fprintf(os.Stderr, "未知子命令: %s\n", subCommand)
printMainHelp()
}
}
func runList(args []string) {
fs := flag.NewFlagSet("list", flag.ExitOnError)
namePtr := fs.String("name", "", "按任务名称或备注进行模糊筛选")
typePtr := fs.String("type", "", "按任务类型筛选 (例如: task, repo)")
pagePtr := fs.Int("page", 1, "查询页码")
sizePtr := fs.Int("size", 20, "每页展示条数")
fs.Usage = func() {
clibase.PrintSubCommandUsage("任务池任务列表查询工具", "taskpool task list [参数]", " taskpool task list\n taskpool task list -page 2 -size 10\n taskpool task list -name \"签到\"", fs)
}
if err := fs.Parse(args); err != nil {
return
}
clibase.InitContext(false)
var total int64
query := database.DB.Model(&models.Task{})
if *namePtr != "" {
query = query.Where("name LIKE ? OR remark LIKE ?", "%"+*namePtr+"%", "%"+*namePtr+"%")
}
if *typePtr != "" {
query = query.Where("type = ?", *typePtr)
}
query.Count(&total)
offset := (*pagePtr - 1) * *sizePtr
if offset < 0 {
offset = 0
}
var tasks []models.Task
query.Order("created_at DESC").Limit(*sizePtr).Offset(offset).Find(&tasks)
fmt.Println(strings.Repeat("=", 90))
fmt.Printf("%s | %s | %s | %s | %s\n",
clibase.VisualFormat("任务ID", 20),
clibase.VisualFormat("任务名称", 28),
clibase.VisualFormat("Cron规则", 18),
clibase.VisualFormat("类型", 6),
clibase.VisualFormat("状态", 6),
)
fmt.Println(strings.Repeat("-", 90))
for _, t := range tasks {
cron := t.Schedule
if cron == "" {
cron = "-"
}
status := "启用"
if !utils.DerefBool(t.Enabled, true) {
status = "禁用"
}
fmt.Printf("%s | %s | %s | %s | %s\n",
clibase.VisualFormat(t.ID, 20),
clibase.VisualFormat(t.Name, 28),
clibase.VisualFormat(cron, 18),
clibase.VisualFormat(t.Type, 6),
clibase.VisualFormat(status, 6),
)
}
fmt.Println(strings.Repeat("=", 90))
totalPages := (total + int64(*sizePtr) - 1) / int64(*sizePtr)
if totalPages == 0 {
totalPages = 1
}
fmt.Printf("共查询到 %d 个任务记录,当前展示第 %d/%d 页 (每页 %d 条)。\n", total, *pagePtr, totalPages, *sizePtr)
fmt.Printf("提示: 追加参数 (例如 '-page 2 -size 50') 即可灵活查看指定页码或调整展示数量。\n")
}
func runExecute(args []string) {
fs := flag.NewFlagSet("run", flag.ExitOnError)
fs.Usage = func() {
clibase.PrintSubCommandUsage("任务池手动任务触发工具", "taskpool task run <任务ID/名称/repo>", " taskpool task run a1b2c3d4\n taskpool task run \"自动签到\"\n taskpool task run repo", nil)
}
if err := fs.Parse(args); err != nil {
return
}
parsedArgs := fs.Args()
if len(parsedArgs) < 1 {
fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n")
fs.Usage()
return
}
taskID := parsedArgs[0]
clibase.InitContext(false)
taskID = resolveTaskID(taskID)
_, err := clibase.CallInternalAPI("POST", "/internal/tasks/execute/"+taskID, map[string]interface{}{})
if err != nil {
fmt.Printf(">> 任务触发失败: %v\n", err)
return
}
fmt.Printf(">> 任务 [%s] 触发指令下发成功!已进入后台调度队列排队或执行。\n", taskID)
fmt.Printf(">> 提示: 可以使用 'taskpool task status %s' 查看近期执行输出。\n", taskID)
}
func runToggle(action string, args []string) {
fs := flag.NewFlagSet(action, flag.ExitOnError)
actionName := "启用"
targetEnabled := true
if action == "disable" {
actionName = "禁用"
targetEnabled = false
}
fs.Usage = func() {
clibase.PrintSubCommandUsage(fmt.Sprintf("任务池任务%s工具", actionName), fmt.Sprintf("taskpool task %s <任务ID/名称/repo>", action), fmt.Sprintf(" taskpool task %s a1b2c3d4\n taskpool task %s repo", action, action), nil)
}
if err := fs.Parse(args); err != nil {
return
}
parsedArgs := fs.Args()
if len(parsedArgs) < 1 {
fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n")
fs.Usage()
return
}
taskID := parsedArgs[0]
clibase.InitContext(false)
taskID = resolveTaskID(taskID)
_, err := clibase.CallInternalAPI("POST", "/internal/tasks/toggle/"+taskID, map[string]interface{}{
"enabled": targetEnabled,
})
if err != nil {
fmt.Printf(">> 切换状态操作失败: %v\n", err)
return
}
fmt.Printf(">> 任务 [%s] 已成功%s\n", taskID, actionName)
}
func runStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError)
fs.Usage = func() {
clibase.PrintSubCommandUsage("任务池任务执行状态与日志查看工具", "taskpool task status <任务ID/名称/repo> [日志ID]", " taskpool task status a1b2c3d4\n taskpool task status repo\n taskpool task status \"自动签到\"", nil)
}
if err := fs.Parse(args); err != nil {
return
}
parsedArgs := fs.Args()
if len(parsedArgs) < 1 {
fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n")
fs.Usage()
return
}
taskID := parsedArgs[0]
var specificLogID string
if len(parsedArgs) > 1 {
specificLogID = parsedArgs[1]
}
clibase.InitContext(false)
taskID = resolveTaskID(taskID)
var taskLog models.TaskLog
query := database.DB.Where("task_id = ?", taskID)
if specificLogID != "" {
query = query.Where("id = ?", specificLogID)
}
res := query.Order("created_at DESC").Limit(1).Find(&taskLog)
if res.Error != nil || res.RowsAffected == 0 {
if specificLogID != "" {
fmt.Printf("找不到任务 [%s] 指定日志ID [%s] 的记录。\n", taskID, specificLogID)
} else {
fmt.Printf("找不到任务 [%s] 的任何执行记录。\n", taskID)
}
return
}
var task models.Task
database.DB.Where("id = ?", taskID).Limit(1).Find(&task)
taskName := taskID
if task.Name != "" {
taskName = task.Name
}
statusText := "运行中"
switch taskLog.Status {
case constant.TaskStatusSuccess:
statusText = "成功"
case constant.TaskStatusFailed:
statusText = "失败"
case constant.TaskStatusTimeout:
statusText = "超时"
case constant.TaskStatusCancelled:
statusText = "已取消"
}
fmt.Println("====================================================================================================")
fmt.Printf("任务名称: %s (ID: %s)\n", taskName, taskID)
fmt.Printf("日志记录: %s\n", taskLog.ID)
fmt.Printf("执行命令: %s\n", string(taskLog.Command))
fmt.Printf("最终状态: %s (耗时: %d 毫秒, 退出码: %d)\n", statusText, taskLog.Duration, taskLog.ExitCode)
if taskLog.StartTime != nil {
fmt.Printf("开始时间: %s\n", taskLog.StartTime.Time().Format("2006-01-02 15:04:05"))
}
fmt.Println("----------------------------------------------------------------------------------------------------")
fmt.Println("[日志输出内容]")
// 解压
decompressed, err := utils.DecompressFromBase64(string(taskLog.Output))
if err != nil {
fmt.Printf("[无法解压日志输出: %v]\n", err)
} else {
// 清理多余回车和终端 ANSI 转义字符
cleanText := strings.ReplaceAll(decompressed, "\r\n", "\n")
cleanText = clibase.AnsiRegex.ReplaceAllString(cleanText, "")
fmt.Println(strings.TrimSpace(cleanText))
}
if string(taskLog.Error) != "" {
fmt.Println("\n[系统捕获异常]")
fmt.Println(string(taskLog.Error))
}
fmt.Println("====================================================================================================")
}
func runHistory(args []string) {
fs := flag.NewFlagSet("history", flag.ExitOnError)
limitPtr := fs.Int("limit", 10, "展示的最近历史记录条数")
fs.Usage = func() {
clibase.PrintSubCommandUsage("任务池任务执行历史查看工具", "taskpool task history <任务ID/名称/repo> [参数]", " taskpool task history a1b2c3d4\n taskpool task history repo\n taskpool task history repo -limit 20", fs)
}
if err := fs.Parse(args); err != nil {
return
}
parsedArgs := fs.Args()
if len(parsedArgs) < 1 {
fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n")
fs.Usage()
return
}
taskID := parsedArgs[0]
clibase.InitContext(false)
taskID = resolveTaskID(taskID)
var task models.Task
database.DB.Where("id = ?", taskID).Limit(1).Find(&task)
taskName := taskID
if task.Name != "" {
taskName = task.Name
}
var logs []models.TaskLog
database.DB.Where("task_id = ?", taskID).Order("created_at DESC").Limit(*limitPtr).Find(&logs)
fmt.Println("====================================================================================================")
fmt.Printf("任务流水: %s (ID: %s) 的近期执行记录 (最多展示 %d 条)\n", taskName, taskID, *limitPtr)
fmt.Println("----------------------------------------------------------------------------------------------------")
fmt.Printf("%-20s | %-8s | %-6s | %-12s | %-20s\n", "日志ID", "状态", "退出码", "耗时", "开始时间")
fmt.Println("----------------------------------------------------------------------------------------------------")
if len(logs) == 0 {
fmt.Println("未查询到任何历史执行记录。")
} else {
for _, l := range logs {
statusText := "运行中"
switch l.Status {
case constant.TaskStatusSuccess:
statusText = "成功"
case constant.TaskStatusFailed:
statusText = "失败"
case constant.TaskStatusTimeout:
statusText = "超时"
case constant.TaskStatusCancelled:
statusText = "已取消"
}
startStr := "-"
if l.StartTime != nil {
startStr = l.StartTime.Time().Format("2006-01-02 15:04:05")
}
durationStr := fmt.Sprintf("%d ms", l.Duration)
fmt.Printf("%-20s | %-8s | %-6d | %-12s | %-20s\n", l.ID, statusText, l.ExitCode, durationStr, startStr)
}
}
fmt.Println("====================================================================================================")
fmt.Printf("提示: 结合命令 'taskpool task status %s <日志ID>' 查看特定历史日志内容。\n", taskID)
}
+11
View File
@@ -0,0 +1,11 @@
package version
import (
"fmt"
"github.com/engigu/taskpool/internal/constant"
)
func Run(args []string) {
fmt.Printf("taskpool %s (Build time: %s)\n", constant.Version, constant.BuildTime)
}
+132
View File
@@ -0,0 +1,132 @@
package webui
import (
"fmt"
"os"
"strings"
"github.com/engigu/taskpool/cmd/clibase"
"github.com/engigu/taskpool/internal/services"
)
func printMainHelp() {
fmt.Fprintf(os.Stderr, "\n任务池 WebUI 命令行管理工具\n\n")
fmt.Fprintf(os.Stderr, "用法:\n")
fmt.Fprintf(os.Stderr, " taskpool webui <子命令> [参数]\n\n")
fmt.Fprintf(os.Stderr, "可用子命令:\n")
fmt.Fprintf(os.Stderr, " list 列出当前安装的所有前端资源包\n")
fmt.Fprintf(os.Stderr, " set 设置激活指定的 WebUI\n")
fmt.Fprintf(os.Stderr, " reset 一键回退到系统默认的内置 WebUI\n")
fmt.Fprintf(os.Stderr, " delete 删除指定的 WebUI 资源包\n\n")
}
func Run(args []string) {
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" {
printMainHelp()
return
}
subCommand := args[0]
subArgs := args[1:]
switch subCommand {
case "list":
runList(subArgs)
case "set":
runSet(subArgs)
case "reset":
runReset(subArgs)
case "delete":
runDelete(subArgs)
default:
fmt.Fprintf(os.Stderr, "未知子命令: %s\n", subCommand)
printMainHelp()
}
}
func initServices() *services.WebUIService {
clibase.InitContext(false)
settingsService := services.NewSettingsService()
return services.NewWebUIService(settingsService)
}
func runList(args []string) {
svc := initServices()
list, err := svc.GetWebUIs()
if err != nil {
fmt.Printf(">> 获取WebUI列表失败: %v\n", err)
return
}
settingsService := services.NewSettingsService()
activeWebUI := settingsService.Get("site", "active_webui")
if activeWebUI == "" {
activeWebUI = "default"
}
fmt.Println(strings.Repeat("=", 100))
fmt.Printf("%s | %s | %s | %s | %s\n",
clibase.VisualFormat("名称", 20),
clibase.VisualFormat("版本", 12),
clibase.VisualFormat("作者", 15),
clibase.VisualFormat("状态", 10),
clibase.VisualFormat("描述", 30),
)
fmt.Println(strings.Repeat("-", 100))
for _, w := range list {
status := "-"
if w.Name == activeWebUI {
status = "使用中"
}
fmt.Printf("%s | %s | %s | %s | %s\n",
clibase.VisualFormat(w.Name, 20),
clibase.VisualFormat(w.Version, 12),
clibase.VisualFormat(w.Author, 15),
clibase.VisualFormat(status, 10),
clibase.VisualFormat(w.Description, 30),
)
}
fmt.Println(strings.Repeat("=", 100))
}
func runSet(args []string) {
if len(args) < 1 {
fmt.Fprintf(os.Stderr, "错误: 缺少目标 WebUI 名称。\n用法: taskpool webui set <name>\n")
return
}
name := args[0]
svc := initServices()
err := svc.SetActiveWebUI(name)
if err != nil {
fmt.Printf(">> 设置激活WebUI失败: %v\n", err)
return
}
fmt.Printf(">> 成功激活 WebUI: %s\n", name)
}
func runReset(args []string) {
svc := initServices()
err := svc.SetActiveWebUI("default")
if err != nil {
fmt.Printf(">> 回退默认WebUI失败: %v\n", err)
return
}
fmt.Println(">> 成功回退到内置默认 WebUI")
}
func runDelete(args []string) {
if len(args) < 1 {
fmt.Fprintf(os.Stderr, "错误: 缺少目标 WebUI 名称。\n用法: taskpool webui delete <name>\n")
return
}
name := args[0]
svc := initServices()
err := svc.DeleteWebUI(name)
if err != nil {
fmt.Printf(">> 删除WebUI失败: %v\n", err)
return
}
fmt.Printf(">> 成功删除 WebUI: %s\n", name)
}
+41
View File
@@ -0,0 +1,41 @@
[server]
# 服务监听端口
port = 8052
# 服务监听地址
host = 0.0.0.0
# URL前缀,例如 /taskpool,留空则无前缀
# 配置后:前端路径为 /taskpool/*,后端API路径为 /taskpool/api/v1/*
url_prefix =
# 全局会话 Cookie 名称
cookie_name = BHToken
[database]
# 数据库类型: sqlite, mysql, postgres
type = sqlite
# 数据库连接地址 (mysql/postgres)
host = localhost
# 数据库连接端口 (mysql: 3306, postgres: 5432)
port = 3306
# 数据库用户名
user = root
# 数据库密码
password =
# 数据库名称
dbname = taskpool
# 数据库文件路径 (仅 sqlite)
path = data/taskpool.db
# 数据库 DSN (仅 mysql/postgres, 如果设置则优先使用。注意:需同时将 type 设置为 mysql 或 postgres)
# 例如 (MySQL): user:password@unix(/var/run/mysqld/mysqld.sock)/dbname?charset=utf8mb4&parseTime=True&loc=Local
# 例如 (Postgres): postgres://user:password@localhost:5432/dbname?sslmode=disable
dsn =
# 表前缀
table_prefix = taskpool_
# SSL 模式 (仅 mysql/postgres): postgres 支持 disable/require/verify-ca/verify-full; mysql 支持 true/skip-verify
# ssl_mode = disable
[security]
# JWT 密钥。留空则在首次启动时自动生成并保存到数据库设置中。
# 如果你手动设置了此项,它将覆盖数据库中的设置。
secret =
+21
View File
@@ -0,0 +1,21 @@
[server]
port = 8055
host = 0.0.0.0
; url_prefix = /taskpool
message_server = http://message-nest-demo-site.qwapi.eu.org/
pprof_enabled=true
[database]
; type = sqlite
type = mysql
host = app.engigu.cn
port = 33066
user = root
password = Gq19940507+****+
dbname = taskpool-test2
table_prefix = taskpool_
; debug = true
[security]
secret = taskpool_secret_key_change_me
+32
View File
@@ -0,0 +1,32 @@
services:
taskpool-dev:
build:
context: .
dockerfile: docker/Dockerfile.dev
container_name: taskpool-dev
ports:
- "8052:8052"
- "5173:5173"
volumes:
- .:/app:Z
- taskpool-envs:/app/envs
- taskpool-node-modules:/app/web/node_modules
- go-path:/go
- npm-cache:/var/cache/npm
environment:
- TZ=Asia/Shanghai
- DEV_UID=${DEV_UID:-1000}
- DEV_GID=${DEV_GID:-1000}
- MISE_YES=1 # Auto-confirm mise prompts
stdin_open: true
tty: true
volumes:
taskpool-envs:
name: taskpool_dev_envs
taskpool-node-modules:
name: taskpool_dev_node_modules
go-path:
name: taskpool_dev_go_path
npm-cache:
name: taskpool_dev_npm_cache
+26
View File
@@ -0,0 +1,26 @@
services:
taskpool:
image: ghcr.io/engigu/taskpool:latest
container_name: taskpool
ports:
- "8052:8052"
volumes:
- ./data:/app/data
- ./configs:/app/configs
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
# 以下环境变量可覆盖配置文件(可选)
# - BH_SERVER_PORT=8052
# - BH_SERVER_HOST=0.0.0.0
# - BH_DB_TYPE=mysql
# - BH_DB_HOST=localhost
# - BH_DB_PORT=3306
# - BH_DB_USER=root
# - BH_DB_PASSWORD=password
# - BH_DB_NAME=taskpool
# - BH_DB_TABLE_PREFIX=taskpool_
# - BH_SECRET=your_secret_key
restart: unless-stopped
+41
View File
@@ -0,0 +1,41 @@
services:
# Frontend - React Web UI
frontend:
image: git.viaeon.com/admin/taskpool-react:latest
ports:
- "3000:80"
depends_on:
- backend
restart: unless-stopped
# Backend - Go API Server
backend:
image: git.viaeon.com/admin/taskpool:latest
ports:
- "8052:8052"
volumes:
- ./data:/app/data
- ./configs:/app/configs
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
- BH_SERVER_PORT=8052
- BH_SERVER_HOST=0.0.0.0
- BH_DB_TYPE=sqlite
- BH_DB_PATH=/app/data/taskpool.db
restart: unless-stopped
# Redis (可选 - 用于缓存和队列)
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
restart: unless-stopped
profiles:
- redis # 使用 --profile redis 启用
volumes:
redis_data:
+180
View File
@@ -0,0 +1,180 @@
ARG BASE_TAG=base
# ================================
# Stage 1: Build frontend
# ================================
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
WORKDIR /app/web
# Copy package files
COPY web/package*.json ./
# Install dependencies using cache mount for npm
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Copy frontend source
COPY web/ ./
# Build frontend
RUN npm run build
# ================================
# Stage 0: Generate build info
# ================================
FROM alpine:3.19 AS build-info
ARG VERSION
ARG BUILD_TIME
# 生成版本信息文件,确保主服务和 Agent 使用相同的值(使用东八区时间)
RUN VERSION_VAL="${VERSION:-dev-$(TZ=Asia/Shanghai date '+%Y%m%d%H%M%S')}" && \
BUILD_TIME_VAL="${BUILD_TIME:-$(TZ=Asia/Shanghai date '+%Y-%m-%d %H:%M:%S')}" && \
mkdir -p /build-info && \
echo "${VERSION_VAL}" > /build-info/version.txt && \
echo "${BUILD_TIME_VAL}" > /build-info/build_time.txt
# ================================
# Stage 2: Build backend
# ================================
FROM --platform=$BUILDPLATFORM golang:1.26 AS backend-builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
# Copy build info
COPY --from=build-info /build-info /build-info
# Go mod files
COPY go.mod go.sum ./
RUN go env -w GOPROXY=https://goproxy.cn,direct
# Using Go build cache mount can significantly speed up consecutive builds
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy backend source
COPY . .
# Copy frontend dist (needed for embedding)
COPY --from=frontend-builder /app/web/dist ./internal/static/dist
# Generate Swagger (Optional: better to commit docs and only copy, but if needed, install once)
# If openapi_docs exists, just use it, otherwise generate.
# Here we avoid go run @latest by copying it if you have it locally.
# If you don't, we install it once to the image.
RUN go install github.com/swaggo/swag/cmd/swag@latest
RUN swag init -g main.go -o ./openapi_docs
# Build Go binary using cache mounts for faster builds
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
VERSION_VAL=$(cat /build-info/version.txt) && \
BUILD_TIME_VAL=$(cat /build-info/build_time.txt) && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -ldflags="-s -w -X github.com/engigu/taskpool/internal/constant.Version=${VERSION_VAL} -X 'github.com/engigu/taskpool/internal/constant.BuildTime=${BUILD_TIME_VAL}'" \
-o taskpool .
# ================================
# Stage 3: Build Agent (all platforms)
# ================================
FROM --platform=$BUILDPLATFORM golang:1.26 AS agent-builder
WORKDIR /app
# Copy build info
COPY --from=build-info /build-info /build-info
# Using Go build cache mount
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy source needed for agent
COPY internal/ ./internal/
COPY agent/ ./agent/
# Build agent for all platforms and package as tar.gz
WORKDIR /app/agent
# Build agent with parallel-aware logic or just cache mount
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
VERSION_VAL=$(cat /build-info/version.txt) && \
BUILD_TIME_VAL=$(cat /build-info/build_time.txt) && \
LDFLAGS="-s -w -X 'main.Version=${VERSION_VAL}' -X 'main.BuildTime=${BUILD_TIME_VAL}'" && \
mkdir -p /opt/agent && \
echo "${VERSION_VAL}" > /opt/agent/version.txt && \
# Helper to build and compress
build_agent() { \
local os=$1; local arch=$2; local suffix=$3; \
local tmpdir="build-$os-$arch"; \
mkdir -p $tmpdir && cp config.example.ini $tmpdir/ && \
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -ldflags="${LDFLAGS}" -o $tmpdir/taskpool-agent$suffix . && \
(cd $tmpdir && tar -czvf /opt/agent/taskpool-agent-$os-$arch.tar.gz taskpool-agent$suffix config.example.ini) && \
rm -rf $tmpdir; \
} ; \
build_agent linux amd64 "" & pid1=$!; \
build_agent linux arm64 "" & pid2=$!; \
build_agent android arm64 "" & pid3=$!; \
build_agent darwin amd64 "" & pid4=$!; \
build_agent darwin arm64 "" & pid5=$!; \
wait $pid1 && wait $pid2 && wait $pid3 && wait $pid4 && wait $pid5 && \
echo "Agent build completed for all platforms"
# ================================
# Stage 4: Final image
# ================================
ARG BASE_TAG
FROM ghcr.io/engigu/taskpool:${BASE_TAG}
ENV TZ=Asia/Shanghai
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
ENV MISE_DATA_DIR=/app/envs/mise
ENV MISE_CONFIG_DIR=/app/envs/mise
ENV PATH="/app/envs/mise/shims:/app/envs/mise/bin:$PATH"
# # 安装必要系统工具 + Node + Python
# RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
# && sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
# && echo "${TZ}" > /etc/timezone \
# && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \
# && apt-get update \
# && apt-get install -y --no-install-recommends \
# tzdata git gcc curl wget vim nodejs htop npm python3 python3-venv python3-pip \
# && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy Go binary
COPY --from=backend-builder /app/taskpool .
# Copy frontend assets to /www/taskpool (No embed in Docker)
COPY --from=frontend-builder /app/web/dist /www/taskpool
# Copy configs and entrypoint
COPY --from=backend-builder /app/configs ./configs
COPY --from=backend-builder /app/example ./example
COPY builtin/ /www/builtin
COPY docker/docker-entrypoint.sh .
# Copy agent binaries to /opt/agent
COPY --from=agent-builder /opt/agent /opt/agent
COPY docker/mise-hook.sh /etc/profile.d/mise-hook.sh
RUN chmod +x docker-entrypoint.sh \
&& cat /etc/profile.d/mise-hook.sh >> /etc/bash.bashrc \
&& echo "set encoding=utf-8" >> /etc/vim/vimrc \
&& ln -sf /app/taskpool /usr/local/bin/taskpool
ENV MISE_TRUSTED_CONFIG_PATHS=/app/data/scripts
EXPOSE 8052
ENTRYPOINT ["./docker-entrypoint.sh"]
+26
View File
@@ -0,0 +1,26 @@
FROM alpine:3.19
ENV TZ=Asia/Shanghai
ENV NODE_VERSION=23.11.1
ENV PYTHON_VERSION=3.13.12
ENV MISE_DATA_DIR=/opt/mise-base
ENV MISE_CONFIG_DIR=/opt/mise-base
ENV PATH="/opt/mise-base/shims:/opt/mise-base/bin:$PATH"
# 安装必要系统工具
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories \
&& apk add --no-cache \
tzdata git bash curl wget vim htop ca-certificates rsync openssh-client \
&& BUILD_DEPS="build-base libffi-dev openssl-dev bzip2-dev zlib-dev readline-dev sqlite-dev ncurses-dev xz-dev" \
&& apk add --no-cache $BUILD_DEPS \
&& curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \
&& MISE_DATA_DIR=/opt/mise-base MISE_CONFIG_DIR=/opt/mise-base MISE_HTTP_TIMEOUT=300 \
PYTHON_BUILD_MIRROR_URL=https://pyenv.pages.dev/api/mirror/binaries \
mise use -g node@${NODE_VERSION} python@${PYTHON_VERSION} \
&& mise prune -- -f \
&& apk del $BUILD_DEPS \
&& ln -sf /usr/bin/python3 /usr/bin/python \
&& cp /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo "${TZ}" > /etc/timezone \
&& rm -rf /var/cache/apk/* \
&& rm -rf /root/.cache/mise /root/.cache/pip /opt/mise-base/cache/*
+29
View File
@@ -0,0 +1,29 @@
FROM debian:bookworm-slim
ENV TZ=Asia/Shanghai
ENV NODE_VERSION=23.11.1
ENV PYTHON_VERSION=3.13.12
ENV MISE_DATA_DIR=/opt/mise-base
ENV MISE_CONFIG_DIR=/opt/mise-base
ENV PATH="/opt/mise-base/shims:/opt/mise-base/bin:$PATH"
# 安装必要系统工具
RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& echo "${TZ}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
tzdata git curl wget vim htop ca-certificates rsync openssh-client \
&& BUILD_DEPS="gcc build-essential libssl-dev zlib1g-dev libbz2-dev \
libreadline-dev libsqlite3-dev libncursesw5-dev \
xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev" \
&& apt-get install -y --no-install-recommends $BUILD_DEPS \
&& curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \
&& MISE_DATA_DIR=/opt/mise-base MISE_CONFIG_DIR=/opt/mise-base MISE_HTTP_TIMEOUT=300 \
PYTHON_BUILD_MIRROR_URL=https://pyenv.pages.dev/api/mirror/binaries \
mise use -g node@${NODE_VERSION} python@${PYTHON_VERSION} \
&& mise prune -- -f \
&& apt-get purge -y --auto-remove $BUILD_DEPS \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /root/.cache/mise /root/.cache/pip /opt/mise-base/cache/*
+30
View File
@@ -0,0 +1,30 @@
FROM debian:trixie-slim
ENV TZ=Asia/Shanghai
ENV NODE_VERSION=23.11.1
ENV PYTHON_VERSION=3.13.12
ENV MISE_DATA_DIR=/opt/mise-base
ENV MISE_CONFIG_DIR=/opt/mise-base
ENV PATH="/opt/mise-base/shims:/opt/mise-base/bin:$PATH"
# 安装必要系统工具
RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& echo "${TZ}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
tzdata git curl wget vim htop ca-certificates rsync openssh-client \
&& BUILD_DEPS="gcc build-essential libssl-dev zlib1g-dev libbz2-dev \
libreadline-dev libsqlite3-dev libncursesw5-dev \
xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev" \
&& apt-get install -y --no-install-recommends $BUILD_DEPS \
&& curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \
&& MISE_DATA_DIR=/opt/mise-base MISE_CONFIG_DIR=/opt/mise-base MISE_HTTP_TIMEOUT=300 \
PYTHON_BUILD_MIRROR_URL=https://pyenv.pages.dev/api/mirror/binaries \
mise use -g node@${NODE_VERSION} python@${PYTHON_VERSION} \
&& mise prune -- -f \
&& apt-get purge -y --auto-remove $BUILD_DEPS \
&& ln -sf /usr/bin/python3 /usr/bin/python \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /root/.cache/mise /root/.cache/pip /opt/mise-base/cache/*
+19
View File
@@ -0,0 +1,19 @@
FROM debian:trixie-slim
ENV TZ=Asia/Shanghai
ENV MISE_DATA_DIR=/opt/mise-base
ENV MISE_CONFIG_DIR=/opt/mise-base
ENV PATH="/opt/mise-base/shims:/opt/mise-base/bin:$PATH"
# 安装必要系统工具并仅预装 mise
RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& echo "${TZ}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
tzdata git curl wget vim htop ca-certificates rsync openssh-client \
&& curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \
&& mkdir -p /opt/mise-base \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /root/.cache/mise /opt/mise-base/cache/*
+53
View File
@@ -0,0 +1,53 @@
FROM golang:1.26-trixie
ENV TZ=Asia/Shanghai
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
ENV NODE_VERSION=23.11.1
ENV PYTHON_VERSION=3.13.12
RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& echo "${TZ}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
sudo gosu tzdata git curl wget vim htop ca-certificates rsync openssh-client \
&& BUILD_DEPS="gcc build-essential libssl-dev zlib1g-dev libbz2-dev \
libreadline-dev libsqlite3-dev libncursesw5-dev \
xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev" \
&& apt-get install -y --no-install-recommends $BUILD_DEPS \
&& rm -rf /var/lib/apt/lists/*
COPY docker/mise-hook.sh /etc/profile.d/mise-hook.sh
RUN cat /etc/profile.d/mise-hook.sh >> /etc/bash.bashrc
RUN go env -w GOPROXY=https://goproxy.cn,direct
ENV MISE_GLOBAL_DIR=/opt/mise-dev
ENV PATH="/opt/mise-dev/shims:/opt/mise-dev/bin:$PATH"
RUN curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \
&& MISE_DATA_DIR=/opt/mise-dev MISE_CONFIG_DIR=/opt/mise-dev MISE_HTTP_TIMEOUT=300 \
PYTHON_BUILD_MIRROR_URL=https://pyenv.pages.dev/api/mirror/binaries \
mise use -g node@${NODE_VERSION} python@${PYTHON_VERSION} \
&& mise prune -- -f
ENV MISE_DATA_DIR=/app/envs/mise
ENV MISE_CONFIG_DIR=/app/envs/mise
ENV PATH="/app/envs/mise/shims:/app/envs/mise/bin:$PATH"
ENV NPM_CONFIG_CACHE=/var/cache/npm
COPY docker/docker-entrypoint-dev.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint-dev.sh
WORKDIR /app
ENV MISE_TRUSTED_CONFIG_PATHS=/app/data/scripts
EXPOSE 8052 5173
ENTRYPOINT ["docker-entrypoint-dev.sh"]
CMD ["make", "dev"]
+147
View File
@@ -0,0 +1,147 @@
ARG BASE_TAG=base-minimal
# ================================
# Stage 1: Build frontend
# ================================
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
WORKDIR /app/web
# Copy package files
COPY web/package*.json ./
# Install dependencies
RUN npm ci
# Copy frontend source
COPY web/ ./
# Build frontend
RUN npm run build
# ================================
# Stage 0: Generate build info
# ================================
FROM alpine:3.19 AS build-info
ARG VERSION
ARG BUILD_TIME
# 生成版本信息文件
RUN VERSION_VAL="${VERSION:-dev-$(TZ=Asia/Shanghai date '+%Y%m%d%H%M%S')}" && \
BUILD_TIME_VAL="${BUILD_TIME:-$(TZ=Asia/Shanghai date '+%Y-%m-%d %H:%M:%S')}" && \
mkdir -p /build-info && \
echo "${VERSION_VAL}" > /build-info/version.txt && \
echo "${BUILD_TIME_VAL}" > /build-info/build_time.txt
# ================================
# Stage 2: Build backend
# ================================
FROM --platform=$BUILDPLATFORM golang:1.26 AS backend-builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
# Copy build info
COPY --from=build-info /build-info /build-info
# Go mod files
COPY go.mod go.sum ./
RUN go env -w GOPROXY=https://goproxy.cn,direct && go mod download
# Copy backend source
COPY . .
# Copy frontend dist (needed for embedding)
COPY --from=frontend-builder /app/web/dist ./internal/static/dist
# Generate Swagger
RUN go run github.com/swaggo/swag/cmd/swag@latest init -g main.go -o ./openapi_docs
# Build Go binary
RUN VERSION_VAL=$(cat /build-info/version.txt) && \
BUILD_TIME_VAL=$(cat /build-info/build_time.txt) && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -ldflags="-s -w -X github.com/engigu/taskpool/internal/constant.Version=${VERSION_VAL} -X 'github.com/engigu/taskpool/internal/constant.BuildTime=${BUILD_TIME_VAL}'" \
-o taskpool .
# ================================
# Stage 3: Build Agent (all platforms)
# ================================
FROM --platform=$BUILDPLATFORM golang:1.26 AS agent-builder
WORKDIR /app
# Copy build info
COPY --from=build-info /build-info /build-info
# Go mod files
COPY go.mod go.sum ./
RUN go env -w GOPROXY=https://goproxy.cn,direct && go mod download
# Copy source needed for agent
COPY internal/ ./internal/
COPY agent/ ./agent/
# Build agent for all platforms and package as tar.gz
WORKDIR /app/agent
RUN VERSION_VAL=$(cat /build-info/version.txt) && \
BUILD_TIME_VAL=$(cat /build-info/build_time.txt) && \
LDFLAGS="-s -w -X 'main.Version=${VERSION_VAL}' -X 'main.BuildTime=${BUILD_TIME_VAL}'" && \
mkdir -p /opt/agent && \
echo "${VERSION_VAL}" > /opt/agent/version.txt && \
build_agent() { \
local os=$1; local arch=$2; local suffix=$3; \
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -ldflags="${LDFLAGS}" -o taskpool-agent$suffix . && \
tar -czvf /opt/agent/taskpool-agent-$os-$arch.tar.gz taskpool-agent$suffix config.example.ini && \
rm taskpool-agent$suffix; \
} && \
build_agent linux amd64 "" && \
build_agent linux arm64 "" && \
build_agent android arm64 "" && \
build_agent darwin amd64 "" && \
build_agent darwin arm64 ""
# ================================
# Stage 4: Final image
# ================================
FROM ghcr.io/engigu/taskpool:${BASE_TAG}
ENV TZ=Asia/Shanghai
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
ENV MISE_DATA_DIR=/app/envs/mise
ENV MISE_CONFIG_DIR=/app/envs/mise
ENV PATH="/app/envs/mise/shims:/app/envs/mise/bin:$PATH"
WORKDIR /app
# Copy Go binary
COPY --from=backend-builder /app/taskpool .
# Copy frontend assets
COPY --from=frontend-builder /app/web/dist /www/taskpool
# Copy configs and entrypoint
COPY --from=backend-builder /app/configs ./configs
COPY --from=backend-builder /app/example ./example
COPY builtin/ /www/builtin
COPY docker/docker-entrypoint.minimal.sh ./docker-entrypoint.sh
# Copy agent binaries
COPY --from=agent-builder /opt/agent /opt/agent
COPY docker/mise-hook.sh /etc/profile.d/mise-hook.sh
RUN chmod +x docker-entrypoint.sh \
&& cat /etc/profile.d/mise-hook.sh >> /etc/bash.bashrc \
&& echo "set encoding=utf-8" >> /etc/vim/vimrc \
&& ln -sf /app/taskpool /usr/local/bin/taskpool
ENV MISE_TRUSTED_CONFIG_PATHS=/app/data/scripts
EXPOSE 8052
ENTRYPOINT ["./docker-entrypoint.sh"]
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
set -e
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
export MISE_HIDE_UPDATE_WARNING=1
COLOR_PREFIX="\033[1;36m[Entrypoint]\033[0m"
log() {
printf "${COLOR_PREFIX} %s\n" "$1"
}
ensure_cache_ownership() {
local dir="$1"
local target_uid="$2"
local target_gid="$3"
if [ ! -d "$dir" ]; then
mkdir -p "$dir"
chown "$target_uid:$target_gid" "$dir"
return
fi
local current_uid=$(stat -c '%u' "$dir")
local dirty_file=$(find "$dir" -mindepth 1 ! -uid "$target_uid" -print -quit 2>/dev/null)
if [ "$current_uid" == "$target_uid" ] && [ -z "$dirty_file" ]; then
log "Cache hit: $dir owned by $target_uid. Reusing."
else
if [ -n "$dirty_file" ]; then
log "Dirty cache detected in $dir (Found root-owned files like $dirty_file)."
else
log "User changed ($current_uid -> $target_uid). Resetting cache in $dir..."
fi
log "Nuking directory to ensure clean state..."
find "$dir" -mindepth 1 -delete 2>/dev/null || rm -rf "$dir"/*
chown "$target_uid:$target_gid" "$dir"
log "Cache reset complete. Ownership transferred to $target_uid."
fi
}
log "Initializing Development Environment..."
DEV_UID=${DEV_UID:-1000}
DEV_GID=${DEV_GID:-1000}
EXISTING_USER=$(getent passwd "$DEV_UID" | cut -d: -f1 | head -n 1)
if [ -n "$EXISTING_USER" ]; then
TARGET_USER="$EXISTING_USER"
log "UID $DEV_UID already exists as user '$TARGET_USER'. Using existing user."
else
TARGET_USER="devuser"
log "Creating user '$TARGET_USER' (UID: $DEV_UID, GID: $DEV_GID)..."
groupadd -o -g "$DEV_GID" devgroup
useradd -o -u "$DEV_UID" -g "$DEV_GID" -m -s /bin/bash "$TARGET_USER"
fi
if [ "$TARGET_USER" != "root" ]; then
echo "$TARGET_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$TARGET_USER
chmod 0440 /etc/sudoers.d/$TARGET_USER
fi
export npm_config_cache=/var/cache/npm
mkdir -p /var/cache/npm /app/web/node_modules
chown "$DEV_UID:$DEV_GID" /var/cache/npm /app/web/node_modules
if [ "$DEV_UID" != "0" ]; then
ensure_cache_ownership "/app/envs" "$DEV_UID" "$DEV_GID"
ensure_cache_ownership "/app/web/node_modules" "$DEV_UID" "$DEV_GID"
ensure_cache_ownership "/var/cache/npm" "$DEV_UID" "$DEV_GID"
ensure_cache_ownership "/go" "$DEV_UID" "$DEV_GID"
fi
MISE_DIR="/app/envs/mise"
mkdir -p "$MISE_DIR"
log "Syncing mise environment from base..."
rsync -a --chown="$DEV_UID:$DEV_GID" --ignore-existing /opt/mise-dev/ "$MISE_DIR" || true
log "Mise environment synced"
export MISE_DATA_DIR="$MISE_DIR"
export MISE_CONFIG_DIR="$MISE_DIR"
export PATH="$MISE_DIR/shims:$MISE_DIR/bin:$PATH"
export PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.org/simple}
log "mise version: $(mise --version 2>/dev/null | head -n 1)"
log "python: $(python --version 2>&1 | head -n 1) at $(which python)"
log "node: $(node --version 2>&1 | head -n 1) at $(which node)"
log "npm: $(npm --version 2>&1 | head -n 1) at $(which npm)"
log "Environment ready! Starting command as user '$TARGET_USER' (UID: $DEV_UID)..."
exec gosu "$DEV_UID:$DEV_GID" "$@"
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
set -e
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
export MISE_HIDE_UPDATE_WARNING=1
# 日志输出格式
COLOR_PREFIX="\033[1;36m[Entrypoint]\033[0m"
log() {
printf "${COLOR_PREFIX} %s\n" "$1"
}
MISE_DIR="/app/envs/mise"
log "Starting environment initialization (Minimal Mode)..."
# ============================
# 创建基础目录
# ============================
mkdir -p \
/app/data \
/app/data/scripts \
/app/configs \
/app/envs
if [ -d "/app/example" ]; then
mkdir -p /app/data/scripts/example
rsync -a --ignore-existing /app/example/ /app/data/scripts/example/ || true
log "Example scripts synced to /app/data/scripts/example"
else
log "No example directory found, skipping example sync"
fi
# ============================
# Mise 环境初始化
# ============================
mkdir -p "$MISE_DIR"
if [ -d "/opt/mise-base" ]; then
log "Syncing mise environment from base..."
rsync -a --ignore-existing /opt/mise-base/ "$MISE_DIR/" || true
log "Mise environment synced"
else
log "No base mise environment found, skipping sync"
fi
# ============================
# 环境变量注入
# ============================
export MISE_DATA_DIR="$MISE_DIR"
export MISE_CONFIG_DIR="$MISE_DIR"
export PATH="$MISE_DIR/shims:$MISE_DIR/bin:$PATH"
# 默认启用 Python 镜像源
export PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.org/simple}
# Node 内存限制
export NODE_OPTIONS="--max-old-space-size=256"
export PYTHONPATH=/app/data/scripts:$PYTHONPATH
# ============================
# 打印确认
# ============================
log "mise version: $(mise --version 2>/dev/null | head -n 1)"
[ -x "$(command -v python)" ] && log "python: $(python --version 2>&1 | head -n 1) at $(which python)" || log "python: not installed"
[ -x "$(command -v node)" ] && log "node: $(node --version 2>&1 | head -n 1) at $(which node)" || log "node: not installed"
[ -x "$(command -v npm)" ] && log "npm: $(npm --version 2>&1 | head -n 1) at $(which npm)" || log "npm: not installed"
# ============================
# 将 taskpool 注册到全局命令
# ============================
ln -sf /app/taskpool /usr/local/bin/taskpool
# ============================
# 启动应用
# ============================
printf "\n\033[1;32m>>> Environment setup complete. Starting TaskPool Server...\033[0m\n\n"
cd /app
exec taskpool server
+91
View File
@@ -0,0 +1,91 @@
#!/bin/sh
set -e
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
export MISE_HIDE_UPDATE_WARNING=1
# 日志输出格式
COLOR_PREFIX="\033[1;36m[Entrypoint]\033[0m"
log() {
printf "${COLOR_PREFIX} %s\n" "$1"
}
MISE_DIR="/app/envs/mise"
log "Starting environment initialization..."
# ============================
# 创建基础目录
# ============================
mkdir -p \
/app/data \
/app/data/scripts \
/app/configs \
/app/envs
if [ -d "/app/example" ]; then
mkdir -p /app/data/scripts/example
rsync -a --ignore-existing /app/example/ /app/data/scripts/example/ || true
log "Example scripts synced to /app/data/scripts/example"
else
log "No example directory found, skipping example sync"
fi
# ============================
# Mise 环境初始化
# ============================
# 始终尝试同步基础环境(以补充用户挂载卷中可能缺失的文件,如 config.toml
mkdir -p "$MISE_DIR"
if [ -d "/opt/mise-base" ]; then
log "Syncing mise environment from base..."
# 使用 rsync 同步: -a 归档模式, --ignore-existing 不覆盖已存在文件
rsync -a --ignore-existing /opt/mise-base/ "$MISE_DIR/" || true
log "Mise environment synced"
else
log "No base mise environment found, skipping sync"
fi
# ============================
# 环境变量注入
# ============================
export MISE_DATA_DIR="$MISE_DIR"
export MISE_CONFIG_DIR="$MISE_DIR"
export PATH="$MISE_DIR/shims:$MISE_DIR/bin:$PATH"
log "Mise PATH configured, verifying runtimes..."
# 默认启用 Python 镜像源
export PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.org/simple}
# Node 内存限制
export NODE_OPTIONS="--max-old-space-size=256"
export PYTHONPATH=/app/data/scripts:$PYTHONPATH
# ============================
# 打印确认 (增加超时防护,防止这里卡死)
# ============================
log "Checking mise..."
log " - mise: $(mise --version 2>/dev/null | head -n 1 || echo "not found")"
log "Checking python..."
log " - python: $(python --version 2>&1 | head -n 1 || echo "not found")"
log "Checking node..."
log " - node: $(node --version 2>&1 | head -n 1 || echo "not found")"
log "Checking npm..."
log " - npm: $(npm --version 2>&1 | head -n 1 || echo "not found")"
# ============================
# 将 taskpool 注册到全局命令
# ============================
ln -sf /app/taskpool /usr/local/bin/taskpool
# ============================
# 启动应用
# ============================
printf "\n\033[1;32m>>> Environment setup complete. Starting TaskPool Server...\033[0m\n\n"
cd /app
exec taskpool server
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# 面向终端的交互式 Hook 拦截器
# 目的是为了在终端执行 node 相关任务时具有与 Go 系统底层相同的 NODE_PATH 环境
#
# 支持以下各种执行场景:
# 1. mise exec node@23.11.1 -- node index.js (指明具体版本)
# 2. mise exec python@3 node@23 -- node index.js (混合语言注入)
# 3. mise exec node -- node index.js (显式指定环境但使用默认版本)
# 4. mise exec -- node index.js (完全隐式环境,由项目配置决定)
mise() {
if [[ "$1" == "exec" || "$1" == "x" ]]; then
local node_spec=""
# 优先从命令行参数中提取指定的 node 版本(如 node@23...
for arg in "$@"; do
if [[ "$arg" == "--" ]]; then
break
elif [[ "$arg" == "node" || "$arg" == node@* ]]; then
node_spec="$arg"
break
fi
done
# 如果命令行参数中没写,则尝试检测该环境下是否有已激活的默认 node
if [[ -z "$node_spec" ]]; then
if command mise which node >/dev/null 2>&1; then
node_spec="node"
fi
fi
if [[ -n "$node_spec" ]]; then
local node_dir=$(command mise where "$node_spec" 2>/dev/null)
if [[ -n "$node_dir" ]]; then
NODE_PATH="$node_dir/lib/node_modules" command mise "$@"
return $?
fi
fi
fi
# 非 Node 场景或拦截失败,正常向下执行
command mise "$@"
}
# 导出此函数,供所有子环境继承使用
export -f mise
+93
View File
@@ -0,0 +1,93 @@
import { defineConfig } from 'vitepress'
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: 'taskpool',
description: '轻量易用的定时任务面板,支持多语言脚本、依赖管理与日志查看',
base: '/taskpool/',
lang: 'zh-CN',
head: [
['link', { rel: 'stylesheet', href: 'https://fonts.loli.net/css2?family=Noto+Sans+SC:wght@400;500;700&family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap' }],
['script', {}, `if (navigator.userAgent.indexOf('Windows') !== -1) document.documentElement.classList.add('is-windows');`]
],
themeConfig: {
logo: '/logo.svg',
nav: [
{ text: '快速开始', link: '/guide/introduction' },
{ text: '部署指南', link: '/guide/deployment' },
{ text: 'API 文档', link: '/guide/api' }
],
sidebar: [
{
text: '基础指南',
items: [
{ text: '项目介绍', link: '/guide/introduction' },
{ text: '部署说明', link: '/guide/deployment' },
{ text: '开始使用', link: '/guide/getting-started' },
{ text: 'API 文档', link: '/guide/api' }
]
},
{
text: '功能指南',
items: [
{ text: '数据仪表', link: '/guide/dashboard' },
{ text: '定时任务', link: '/guide/tasks' },
{ text: '远程执行', link: '/guide/agents' },
{ text: '面板互联', link: '/guide/interconnect' },
{ text: '脚本管理', link: '/guide/scripts' },
{ text: '执行历史', link: '/guide/history' },
{ text: '变量机密', link: '/guide/environments' },
{ text: '语言依赖', link: '/guide/languages' },
{ text: '终端命令', link: '/guide/terminal' },
{ text: '消息中心', link: '/guide/notify' },
{ text: '仓库同步', link: '/guide/sync' },
{ text: '命令行(CLI)', link: '/guide/cli' },
{
text: '脚本示例',
link: '/guide/examples/',
items: [
{ text: '浏览器示例', link: '/guide/examples/browser' },
{ text: '内置库示例', link: '/guide/examples/builtin' },
{ text: 'Linux 环境依赖', link: '/guide/examples/linux-deps' }
]
}
]
},
{
text: '部署配置',
items: [
{ text: '系统配置', link: '/guide/configuration' },
{ text: '前端定制(WebUI)', link: '/guide/webui' },
{ text: '反向代理', link: '/guide/nginx' }
]
},
{
text: '其他',
items: [
{ text: '镜像下载量', link: '/guide/package-stats' },
{ text: '更新日志', link: '/guide/changelog' },
{ text: '免责声明', link: '/guide/disclaimer' }
]
}
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/engigu/taskpool' }
],
footer: {
message: 'Released under the MIT License.',
copyright: 'Copyright © 2026-present engigu'
},
search: {
provider: 'local'
}
},
vite: {
ssr: {
noExternal: ['@scalar/api-reference']
}
}
})
+11
View File
@@ -0,0 +1,11 @@
:root {
--vp-font-family-mono: "Ubuntu Mono", "Noto Sans SC", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New", monospace;
--vp-code-font-size: 14px;
}
/* 仅在 Windows 下优先使用 Noto Sans SC (思源黑体),其他系统保持系统默认字体或 Inter */
.is-windows {
--vp-font-family-base: "Inter", "Noto Sans SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
+4
View File
@@ -0,0 +1,4 @@
import DefaultTheme from 'vitepress/theme'
import './custom.css'
export default DefaultTheme
+88
View File
@@ -0,0 +1,88 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
async function scrapeAll() {
const allResults = [];
const seenTags = new Set();
let page = 1;
let reachedEnd = false;
while (!reachedEnd) {
console.log(`Fetching page ${page}...`);
const url = `https://github.com/engigu/taskpool/pkgs/container/taskpool/versions?page=${page}`;
let html;
let success = false;
for (let retry = 1; retry <= 3; retry++) {
try {
html = execSync(`curl -sL "${url}"`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 10 });
success = true;
break;
} catch (e) {
console.error(`Failed to fetch page ${page} (attempt ${retry}/3):`, e.message);
if (retry < 3) {
console.log(`Waiting 5s before retrying...`);
await new Promise(r => setTimeout(r, 5000));
}
}
}
if (!success) {
console.error(`Giving up on page ${page}.`);
break;
}
const boxRows = html.split('class="Box-row"');
if (boxRows.length <= 1) {
console.log(`No versions found on page ${page}. Stopping.`);
break;
}
let parsedCount = 0;
for (let i = 1; i < boxRows.length; i++) {
const row = boxRows[i];
const tagMatch = row.match(/\?tag=([^"]+)"[^>]*>([^<]+)<\/a>/);
if (!tagMatch) continue;
const tag = tagMatch[1];
if (seenTags.has(tag)) {
console.log(`Duplicate tag "${tag}" detected. Reached end of registry pages.`);
reachedEnd = true;
break;
}
seenTags.add(tag);
// 只保留形如 1.1.15、1.1.15-minimal、1.1.15-debian13 以及 latest 等主版本及其不同架构/后缀版本
if (!/^(latest|\d+\.\d+\.\d+)/.test(tag)) {
continue;
}
const downloadsMatch = row.match(/([\d,]+)\s*<span class="sr-only">Version downloads<\/span>/);
const downloads = downloadsMatch ? parseInt(downloadsMatch[1].replace(/,/g, ''), 10) : 0;
allResults.push({ tag, downloads });
parsedCount++;
}
if (reachedEnd) break;
console.log(`Parsed ${parsedCount} versions from page ${page}.`);
// 每页保存一次,防止后面的页面超时或出错导致前面的数据丢失
const destDir = path.join(__dirname, './data');
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const destPath = path.join(destDir, 'pull-stats.json');
const outputData = {
updatedAt: new Date().toISOString(),
stats: allResults
};
fs.writeFileSync(destPath, JSON.stringify(outputData, null, 2));
console.log(`Saved ${allResults.length} versions (up to page ${page}) to ${destPath}`);
page++;
// Sleep 1s to avoid hitting rate limits
await new Promise(r => setTimeout(r, 1000));
}
}
scrapeAll();
+23
View File
@@ -0,0 +1,23 @@
# 远程执行 (Agents)
远程执行模块(Agents)是实现分布式、多节点任务管理的关键,允许您在一台主控制面板上协同调度部署在不同区域、不同操作系统的任务。
## Agents 架构
- **Agent 节点**:独立运行的小型客户端程序,监听主面板的任务分配。
- **通信协议**:基于高效且稳定的消息队列与 WebSocket 协议,确保持久化双向实时通信。
- **异构环境**Agent 完全支持 Linux、Windows 及 macOS。您可以将 Agent 安装在轻量级树莓派、本地闲置电脑甚至异地数据中心。
## 节点管理
- **节点注册**:通过唯一的指纹认证,确保存储与数据传输的安全性。
- **多状态监控**
- `在线`:节点准备就绪,可以接受任务。
- `离线`:失去连接,所有指派的任务将自动进入等待或失败逻辑(取决于具体任务配置)。
- `异常`:连接握手失败或版本由于过低暂不支持。
## 指派任务
1.`定时任务` 编辑页面,将执行器选项从 `本地执行` 切换为特定的 Agent 节点。
2. 任务执行完成后,所有的控制台日志将通过加密隧道回传至主面板进行统一存储与检索。
+51
View File
@@ -0,0 +1,51 @@
---
layout: false
---
<script setup>
import { ApiReference } from '@scalar/api-reference'
import '@scalar/api-reference/style.css'
</script>
<div class="scalar-container">
<ClientOnly>
<ApiReference
:configuration="{
spec: {
url: '/taskpool/swagger.json'
},
theme: 'alternate',
showSidebar: true,
servers: [
{
url: '{protocol}://{host}:{port}/open2api/v1',
description: '可编辑的服务器地址',
variables: {
protocol: { default: 'http', enum: ['http', 'https'] },
host: { default: 'localhost' },
port: { default: '8052' }
}
}
]
}"
/>
</ClientOnly>
</div>
<style>
:root, body, #app {
margin: 0;
padding: 0;
height: 100%;
}
.scalar-container {
height: 100vh;
width: 100vw;
}
/* 覆盖 VitePress 可能存在的样式干扰 */
.scalar-container :deep(.scalar-api-reference) {
min-height: 100vh;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
# 更新日志 ☕
本页面记录了taskpool的主要版本更新历史。
## 最近更新概览
### 2026.07.13 - 日志 ZSTD 压缩、依赖补全与计划任务排序 (v1.1.20)
- **日志 ZSTD 压缩升级 (New)**:日志流式压缩机制由 zlib 全面升级至高压缩比的 ZSTD,显著降低磁盘开销与传输带宽;前端集成 `fzstd` 无缝支持新格式解码,并实现了对旧版 zlib 日志的向后兼容;针对小于 128 字节的短日志自动绕过压缩,避免无意义的算力浪费。
- **依赖自动补全与交互终端 (New) (#147)**:全新上线依赖分析与自动补全安装 CLI,并提供终端安装向导提示;在定时任务日志界面右下角,现可通过“补全依赖”按钮一键调出内嵌终端进行交互式依赖安装。
- **定时任务排序功能 (#148)**:任务列表(大屏与中屏)现已支持点击表头“名称”、“执行时间”(下次执行时间)和“状态”进行排序;同时小屏/移动端顶栏新增了“排序规则”下拉菜单,规则无缝统一。
- **全局 ESC 关闭弹窗**:在通用组件 `DialogContent` 内部集成非侵入式 Escape 按键全局捕获,按 ESC 优先退出最顶层弹窗,避免输入框/Monaco等组件焦点占用导致退出失效。
- **视图管理与任务优化**:任务列表自定义视图现已可联动保存当前的排序状态并自动还原;为新建任务的日志清理配置默认设置为保留最近 30 条记录,防止磁盘占满;日志详情弹窗增加了最大高度及滚动条优化。
- **样式与体验优化**:将“状态”列宽度由 `w-8` 扩大至 `w-14` 消除因加入排序图标导致的文字折行与表头挤压;去除了 Dialog 自动聚焦时产生的窗口边缘白色高亮聚焦线。
### 2026.06.28 - 全新节点互联功能发布! (v1.1.17)
- **节点互联体系 (New)**:新增开放连接(OpenConnect)协议支持,轻松打通并管理多个任务池实例;全新上线同步管理面板,可实时总览所有连接节点的资源开销与各项负载指标;支持了跨节点间的环境变量全量无缝同步(完美保留原始结构与关联 ID);底层路由机制迎来全面升级,完美支持基于穿透隧道的前端互联访问代理。
- **终端体验优化**:针对移动端深度优化终端(xterm)交互,禁用移动端点击自动弹出软键盘,支持选中内容后 Ctrl+C 快捷复制,并大幅提升了小屏幕下的滑动流畅性。
- **调度器安全**:为 Worker 数量和队列大小添加了边界验证及安全限制,从底层防止高并发场景下出现 OOM 问题。
- **UI 体验优化**:修复了大屏模式下环境变量表格删除按钮丢失的问题;MasterView 按钮在小屏幕下支持自适应填满;增加了演示模式下互联角色的操作限制。
### 2026.06.22 - 体验优化与 Bug 修复 (v1.1.16)
- **执行历史自适应 (New)**:修复了执行历史列表及日志卡片高度被固定限制在 `520px` 的 Bug,改为通过 `calc(100vh - 190px)` 动态铺满视口,大幅提升大屏及竖屏利用率(#134)。
- **通知日志截断 Bug 修复**:修复了任务通知中执行日志在全局被提前硬编码截断导致个性化字数限制失效的 Bug(#133)。
- **去颜色性能优化**:将清除 ANSI 控制字符的正则操作移到循环外部只运行一次,降低了多通道投递时的 CPU 占用(#133)。
- **机密使用指引**:在机密管理 UI 中增加了醒目的使用指引说明,支持手动关闭并可本地记忆关闭状态防止打扰(#135)。
- **数据恢复兼容性**:在导入/恢复备份包时,支持自动检测并迁移旧版本 task 中的环境变量及 tags 数据(#135)。
- **其它优化与修复**:支持自定义仓库同步文件夹名称(#132);支持输出当前版本的 `version` 命令;修复了终端 UTF-8 截断引发的乱码缺陷;CI 新增 arm64 构建支持。
### 2026.06.12 - 调度器与面板资源实时监控
- **资源监控大盘 (New)**:新增对面板底层运行资源、调度器并发池(Worker)以及内存堆栈状态的实时高频监控展示。
### 2026.05.29 - 前端定制与 WebUI 插件化
- **自定义 WebUI 支持 (New)**:新增了前端自定义打包与热切换功能。面板系统彻底解耦前后端静态资源,用户可以在“系统设置 - 前端定制”中上传并管理自定义前端资源包,实现深度的主题替换与定制。
- **打包工具链整合**:提供了一键构建前端定制包的 `make pack-webui` 快捷命令与规范(自动生成 `uimanifest.json`)。
- **动态资源托管**:Go 后端引入动态静态资源拦截机制,可无缝接管系统入口与单页应用渲染,同时向下兼容内置面板。
### 2026.04.16 - 内建脚本助手库 (Built-in SDK)
- **内建助手库 (Built-in SDK) (New)**:新增 Python 与 Node.js 的轻量级助手库 `taskpool`。通过环境自动注入机制实现“零配置”通知投递,开发者无需在脚本中显式配置 TOKEN 或 URL。
- **环境自动初始化**:新增 `taskpool builtininstall` 命令行工具,支持一键为 `mise` 管理的所有多语言版本同步安装/刷新内建包依赖。
- **体验与文档升级**:重构了「脚本调用」UI 指引,简化了集成步骤说明,并统一了全局 UI 字体规范。
### 2026.04.14 - PWA 支持与推送渠道扩展
- **PWA 动态配置 (New)**:支持 Progressive Web App 动态 manifest 配置,应用名称和图标可由后端站点设置实时动态注入。
- **VoceChat 推送支持**:新增对 VoceChat 私有化部署推信渠道的支持(基于 Bot API)。
- **Bark 增强**:Bark 推送渠道新增“自定义服务器”支持,适配 Bark 私有化部署场景与加密推送。
### 2026.03.27 - 安全机密管理 (GitHub Secrets 风格)
- **安全机密功能 (New)**:引入类似 GitHub Actions 的机密管理机制。支持使用 **AES-GCM** 加密存储敏感变量,数据库不存明文,保障配置安全。
- **秘钥内存常驻销毁 (Safe-Unset)**:系统启动从环境变量读取秘钥后会立即执行 `Unset` 操作,确保秘钥仅保留在内存中,不暴露在进程环境内。
- **日志自动脱敏**:实时流水日志自动扫描并掩码(Mask)脱敏显示机密内容(`********`),防止执行时通过任务输出泄露机密。
- **严格隔离机制**:机密**仅在定时调度任务**时生效,终端命令执行、手动测试、调试等入口物理隔离机密。
### 2026.03.19 - 仓库同步功能增强
- **青龙指令深度兼容**:支持直接粘贴青龙格式的仓库同步指令,自动解析并创建任务。
### 2026.03.05 - API 文档重构
- **OpenAPI 认证体系**:支持站点级 Token 配置与 Basic Auth 保护。
- **自定义 UI**:新增设计感十足的全局 **404 页面**
### 2026.03.04 - 消息推送系统重构
- **原生内置**:全新原生支持企业微信、钉钉、飞书、Telegram、Bark、邮件等十余种主流渠道。
- **事件捕获**:接入系统级事件通知自动捕获,告别原有必配外部推送服务的繁琐历史。
### 2026.02.13 - 任务执行引擎重构
- **深度集成 Mise**:支持 Python, Node.js, Go, Rust, PHP 等几乎所有主流语言的动态安装与多版本切换。
- **依赖管理**:同步上线跨语言统一依赖管理系统。
### 2026.02.11 - 安全性增强
- **随机密码策略**:首次启动使用随机密码并打印在日志中。
- **暴力破解防护**:登录接口增加防暴力破解。
- **路径遍历防护**:文件系统操作增加路径穿越锁定。
### 2026.02.10 - 任务调度重构
- **调度性能**:重写了并发控制逻辑,完善了任务队列。
- **体验优化**:优化文件树交互体验,支持任务执行实时日志流。
### 2026.02.06 - 镜像扩展
- **Debian 13 支持**:增加对 Debian 13 (Trixie) 镜像支持,整理 Docker 目录结构。
+166
View File
@@ -0,0 +1,166 @@
# 命令行工具 (CLI)
taskpool在环境内内置了同名的 `taskpool` 命令行工具。如果您在终端内需要执行系统级别的操作,可以使用这些内置命令。
## 常用核心指令
| 命令 | 描述 |
| :--- | :--- |
| `taskpool server` | 面板启动指令,运行服务端后台进程。 |
| `taskpool reposync` | 供定时任务调用,将远程 Git 仓库的高级特性同步到本地目录中。 |
| `taskpool resetpwd` | 交互式重置系统 admin 账号密码(密码丢失时可通过进入终端重置)。 |
| `taskpool restore <file>` | 使用本地的 .zip 备份压缩包文件,一条命令直接全量恢复系统数据。 |
| `taskpool task` | 极速只读与控制台常驻任务管理(支持查询列表、手动触发、查看状态及开关控制)。 |
---
## 使用场景示例
### 1. 密码重置
您可以进入 Docker 容器或通过 ssh 连入宿主机控制台:
```bash
docker exec -it taskpool taskpool resetpwd
```
然后根据提示,输入新的管理员密码即可重置成功。
### 2. 手动启动
如果是通过手动部署二进制文件,可以使用 `taskpool server` 启动:
```bash
nohup ./taskpool server > /dev/null 2>&1 &
```
### 3. 数据恢复
上传备份后的 ZIP 文件至容器目录:
```bash
docker exec -it taskpool taskpool restore /app/data/backup-2026xxxx.zip
```
该操作会全量覆盖现有数据库和脚本文件,请谨慎操作。
---
## `reposync` 参数详解
`taskpool reposync` 是面板核心的同步命令,除了在任务中自动调用外,您也可以通过命令行手动执行。
### 参数列表
| 参数名 | 默认值 | 描述 |
| :--- | :--- | :--- |
| `--source-type` | `git` | 同步源类型,可选 `git`Git 仓库)或 `url`(文件直链下载)。 |
| `--source-url` | | 同步源地址,Git 仓库地址或下载 URL。 |
| `--target-path` | | 目标保存路径。支持变量替换(如 `$SCRIPTS_DIR$`)。 |
| `--branch` | | Git 分支名。留空时将自动检测远程默认分支(如 `main``master`)。 |
| `--path` | | 稀疏检出(Sparse checkout)的指定路径,或在单文件模式下的相对路径。 |
| `--single-file` | `false` | 是否开启单文件模式,仅从 Git 提取指定单个文件。 |
| `--proxy` | `none` | Github 加速代理类型,可选 `none``ghproxy``mirror``custom`。 |
| `--proxy-url` | | 自定义代理地址,仅在 `--proxy=custom` 时生效。 |
| `--auth-token` | | 私有仓库或 API 访问使用的鉴权 Token。 |
| `--http-proxy` | | HTTP/HTTPS 代理地址,例如 `http://127.0.0.1:7890`。 |
| `--whitelist-paths`| | 白名单路径(逗号或竖线分隔),同步时受保护不被清理的路径。 |
| `--blacklist` | | 黑名单关键字(竖线 `\|` 分隔),包含该关键字的文件将会被过滤删除。 |
| `--dependence` | | 依赖文件关键字(竖线 `\|` 分隔),这些文件将强制保留。 |
| `--extensions` | | 允许的脚本扩展名(竖线 `\|` 分隔,如 `.js\|.py`),后缀不符的文件将被删除。 |
| `--task-id` | | 内部任务 ID,用于在同步完成后通知调度器刷新增量任务。 |
| `--task-langs` | | 任务配置的语言(JSON格式),用于标记和解析。 |
| `--repo-task-id` | | 原始任务 ID。 |
| `--task-timeout` | `30` | 同步任务的超时时间,单位为分钟。 |
| `--commenttotask` | `false` | 是否启用青龙 (QL) 格式的脚本注释解析(`true`/`false`)。 |
### 使用示例
#### 1. 基础 Git 仓库同步
将指定仓库克隆或拉取到特定目录:
```bash
taskpool reposync --source-url https://github.com/example/repo.git --target-path /app/data/scripts/example_repo
```
#### 2. 启用代理的同步
针对 Github 仓库使用加速代理,并限定只保留 `.js``.py` 脚本:
```bash
taskpool reposync --source-url https://github.com/example/repo.git \
--target-path /app/data/scripts/example_repo \
--proxy ghproxy \
--extensions ".js|.py"
```
#### 3. 稀疏检出 (Sparse Checkout)
当仓库庞大时,仅同步特定的子目录或文件:
```bash
taskpool reposync --source-url https://github.com/example/repo.git \
--target-path /app/data/scripts/example_repo \
--path "scripts/daily"
```
#### 4. 单文件下载模式
如果只需要仓库中的某一个脚本文件:
```bash
taskpool reposync --source-url https://github.com/example/repo.git \
--target-path /app/data/scripts/ \
--single-file true \
--path "main_script.py"
```
#### 5. 高级过滤与青龙注释解析
使用黑名单排除特定脚本,并开启青龙格式注释解析以自动生成定时任务:
```bash
taskpool reposync --source-url https://github.com/example/repo.git \
--target-path /app/data/scripts/example_repo \
--blacklist "test|mock" \
--dependence "package.json|requirements.txt" \
--commenttotask "true"
```
---
## `taskpool task` 任务管理指令集
`taskpool task` 是一组专为纯终端操作与自动化脚本调度打造的轻量级任务管理子命令集。它能够绕过繁重的界面操作,直接提供闪电般的本地查询与安全指令下发控制。
### 支持子命令
#### 1. 任务列表查询 (`list`)
查询并分页展示系统内配置的所有任务概览。
```bash
# 默认展示前 20 条
taskpool task list
# 指定关键词过滤,并查看第 2 页 (每页展示 10 条)
taskpool task list -q "签到" -page 2 -size 10
```
#### 2. 手动立即触发 (`run`)
手动向常驻后台服务下发指令,立即异步运行指定的任务。
```bash
taskpool task run a1b2c3d4
```
#### 3. 任务状态切换 (`enable` / `disable`)
快速启用或禁用系统任务。
```bash
taskpool task enable a1b2c3d4
taskpool task disable a1b2c3d4
```
#### 4. 实时执行状态追踪 (`status`)
查看指定任务最新一次执行的详细输出日志和最终退出码。
```bash
# 查看最近一条日志
taskpool task status a1b2c3d4
# 查看指定历史日志条目的完整输出
taskpool task status a1b2c3d4 log_123456
```
#### 5. 近期执行历史流水 (`history`)
列出某任务最近的多次运行记录(包含耗时、执行时间及状态结果)。
```bash
taskpool task history a1b2c3d4
```
> [!TIP]
> 所有的 `taskpool task` 子命令均原生支持单独传入 `--help` 参数获取具体的示例和选项清单。例如:`taskpool task list --help`。
---
## 其他帮助
终端内直接执行 `taskpool` 即可在控制台直接打印内置支持详细说明和命令列表参数。
+90
View File
@@ -0,0 +1,90 @@
# 系统配置手册
taskpool支持通过环境变量和配置文件两种核心方式进行系统参数微调。
## 环境变量配置 (优先级最高)
环境变量在容器内自动注入,非常适合 CI/CD 和 Docker 混合编排场景。
### 核心配置项列表
| 环境变量 | 对应配置 | 说明 | 默认值 |
| :--- | :--- | :--- | :--- |
| `BH_SERVER_PORT` | server.port | 服务监听端口 | 8052 |
| `BH_SERVER_HOST` | server.host | 监听地址 | 0.0.0.0 |
| `BH_SERVER_URL_PREFIX` | server.url_prefix | URL 前缀,用于反向代理子路径部署 | - |
| `BH_COOKIE_NAME` | server.cookie_name | 全局会话 Cookie 名称 | BHToken |
| `BH_DB_TYPE` | database.type | 数据库类型 (sqlite/mysql) | sqlite |
| `BH_DB_HOST` | database.host | 数据库实例地址 | localhost |
| `BH_DB_PORT` | database.port | 数据库端口 | 3306 |
| `BH_DB_USER` | database.user | 数据库用户名 | root |
| `BH_DB_PASSWORD` | database.password | 数据库密码 | - |
| `BH_DB_NAME` | database.dbname | 数据库库名 | taskpool |
| `BH_DB_PATH` | database.path | SQLite 物理文件存储路径 | ./data/taskpool.db |
| `BH_DB_DSN` | database.dsn | 数据库 DSN (仅 mysql/postgres, 优先级高。**需对应设置 type**) | - |
| `BH_DB_TABLE_PREFIX` | database.table_prefix | 数据库表前缀 | taskpool_ |
| `BH_DB_SSL_MODE` | database.ssl_mode | SSL 模式: postgres 支持 disable/require/verify-ca/verify-full; mysql 支持 true/skip-verify | - |
| `TASKPOOL_SECRET_KEY` | - | 系统加密秘钥,用于机密变量功能(**注:仅支持环境变量设置,不支持配置文件**) | - |
---
## 配置文件挂载 (config.ini)
如果您希望对系统参数有更细致的控制(而非通过外部注入),可以使用配置文件。
### 挂载点
```yaml
volumes:
- ./configs:/app/configs
```
### 配置文件示例 (`configs/config.ini`)
```ini
[server]
port = 8052
host = 0.0.0.0
# 配置 URL 前缀用于反向代理,例如 /taskpool/
url_prefix = /taskpool
# 全局会话 Cookie 名称
cookie_name = BHToken
[database]
type = sqlite
path = /app/data/taskpool.db
# 数据库连接示例 (Unix Socket / DSN):
# 注意:使用 dsn 时,type 必须设为 mysql 或 postgres
# dsn = user:password@unix(/var/run/mysqld/mysqld.sock)/dbname?charset=utf8mb4&parseTime=True&loc=Local
# dsn = postgres://user:password@localhost:5432/dbname?sslmode=disable
table_prefix = taskpool_
```
---
## 调度设置说明
系统采用异步任务队列 + Worker Pool 架构,可在「系统设置 > 调度设置」页面进行配置:
- **Worker 数量** (默认 4):同时在后端并发运行的任务进程数。
- **队列大小** (默认 100):待处理任务队列的最大容量。
- **速率间隔** (默认 200 ms):控制两个任务启动之间的最小等待时长。
---
## 机密管理 (Secret Management)
taskpool提供了一套基于 **AES-GCM** 工业级标准的安全机密管理系统,其设计理念参考了 GitHub Actions Secrets。
### 核心特性
1. **强加密存储**:所有标记为“机密”的变量在数据库中均以加密密文形式存储。
2. **秘钥安全**:通过环境变量 `TASKPOOL_SECRET_KEY` 注入加密秘钥。系统读取秘钥后会立即将其从进程环境变量中销毁(Unset),确保秘钥仅驻留在内存中。
3. **日志自动脱敏**:系统会自动扫描任务执行生成的实时日志流。一旦发现机密明文,将自动替换为 `********`,防止敏感信息通过日志泄露。
4. **严格权限隔离**
- 机密内容**仅在计划任务由调度器定时执行时**才会注入到环境。
- 通过**终端命令**、**测试运行**或**调试运行**调起的临时进程无法获取机密内容,保障核心资产安全。
### 配置建议
- 建议在 Docker/Compose 启动项中设置 `TASKPOOL_SECRET_KEY` 为一个复杂的随机字符串。
- 不要将该秘钥写入 `config.ini` 或提交到版本控制系统。
+16
View File
@@ -0,0 +1,16 @@
# 数据仪表
数据仪表提供了taskpool的全局运行状态、任务执行统计及关键指标的可视化展示。
## 主要功能
- **总体概览**:实时统计当前面板的总任务数、正在运行的任务数、总脚本数以及 Agent 节点的状态。
- **执行状况统计**:通过饼图展示过去 24 小时或 7 天内任务执行的成功、失败及因超时被自动强制终止的比例。
- **并发趋势监测**:折线图实时呈现任务并发执行的高峰与低谷,辅助管理员评估物理硬件或云服务器的负载情况。
- **资源监控**:实时获取主机 CPU、内存在线占用状态,确保面板在资源充裕的环境下高效运转。
## 面板布局
1. **状态卡片**:位于顶部,快速掌握系统规模。
2. **执行热力图**:展示不同时段的任务运行频次。
3. **系统性能仪表**:直观展示核心硬件利用率。
+181
View File
@@ -0,0 +1,181 @@
# 快速部署
项目提供多种基础镜像,默认版本基于 Debian 12,集成了 Python 3.13 与 Node.js 23。
## 基础镜像选择
| 标签 (Tag) | 基础镜像 | 说明 |
| :--- | :--- | :--- |
| `latest` | Debian 12 | **默认推荐**:集成 Python 3.13 与 Node.js 23,开箱即用 |
| `latest-debian13` | Debian 13 | 尝鲜版本,基于 Debian Trixie |
| `latest-minimal` | Debian 13 | **最小化版**:不预置任何语言环境,仅内置 Mise,适合追求极致纯净的用户 |
> **提示**:目前默认使用 `latest` 标签。如需切换环境,只需将镜像名后的 `latest` 替换为 `latest-minimal`(极致纯净)或 `latest-debian13` 即可。
## 环境版本重构说明 (2026.02.13+)
> **警告**:架构升级破坏性变更
>
> 本版本(2026.02.13+)对底层运行时环境进行了彻底重构,弃用了原有的静态 Python/Node 环境,转为使用 **Mise** 进行动态版本管理。
>
> 1. **不再提供 Alpine 镜像**:由于 glibc 兼容性问题,Mise 无法在 Alpine 上完美运行,因此暂时取消 Alpine 镜像支持。
> 2. **环境数据不兼容**:如果您是从旧版本升级上来,原有的 Python/Node 环境数据将无法迁移。您需要清空挂载的 `envs/` 目录并让其由新容器自动初始化。
---
## 方式一:Docker 运行 (环境变量配置)
通过环境变量指定配置,简单灵活,适合一般部署。
### SQLite (默认)
```bash
docker run -d \
--name taskpool \
-p 8052:8052 \
-v $(pwd)/data:/app/data \
-v $(pwd)/envs:/app/envs \
-e TZ=Asia/Shanghai \
-e BH_SERVER_PORT=8052 \
-e BH_SERVER_HOST=0.0.0.0 \
-e BH_DB_TYPE=sqlite \
-e BH_DB_PATH=/app/data/taskpool.db \
-e BH_DB_TABLE_PREFIX=taskpool_ \
--restart unless-stopped \
ghcr.io/engigu/taskpool:latest
```
### MySQL
```bash
docker run -d \
--name taskpool \
-p 8052:8052 \
-v $(pwd)/data:/app/data \
-v $(pwd)/envs:/app/envs \
-e TZ=Asia/Shanghai \
-e BH_SERVER_PORT=8052 \
-e BH_SERVER_HOST=0.0.0.0 \
-e BH_DB_TYPE=mysql \
-e BH_DB_HOST=mysql-server \
-e BH_DB_PORT=3306 \
-e BH_DB_USER=root \
-e BH_DB_PASSWORD=your_password \
-e BH_DB_NAME=taskpool \
-e BH_DB_TABLE_PREFIX=taskpool_ \
--restart unless-stopped \
ghcr.io/engigu/taskpool:latest
```
---
## 方式二:Docker Compose 部署
推荐的生产环境部署方式。
### 核心部署模板
```yaml
services:
taskpool:
image: ghcr.io/engigu/taskpool:latest
container_name: taskpool
ports:
- "8052:8052"
volumes:
- ./data:/app/data
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
- BH_SERVER_PORT=8052
- BH_SERVER_HOST=0.0.0.0
- BH_DB_TYPE=sqlite
- BH_DB_PATH=/app/data/taskpool.db
- BH_DB_TABLE_PREFIX=taskpool_
# - BH_SERVER_URL_PREFIX=/taskpool # 可选:配置 URL 前缀用于反向代理
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped
```
---
## 方式三:配置文件挂载模式
通过挂载 `/app/configs/config.ini` 来管理详细配置。
### 配置文件挂载示例
```yaml
volumes:
- ./data:/app/data
- ./configs:/app/configs
- ./envs:/app/envs
```
---
## Docker 启动流程
容器启动时 `docker-entrypoint.sh` 会自动执行以下关键步骤:
1. **环境自检**:检查 `/app/data``/app/configs``/app/envs` 挂载点并创建必要子目录。
2. **Mise 同步**:自动将镜像内置的 Mise 核心运行时激活文件同步到持久化挂载目录中,确保容器重启后环境依然可用。
3. **运行时激活**:动态注入环境变量,将 `mise shims` 路径加入系统 `PATH`
4. **包管理预设**:自动为 Python 配置清华源 (PIP) 镜像,配置 Node.js 内存限制。
5. **主进程启动**:运行 `taskpool server` 开启面板。
> **提示**:通过持久化挂载 `./envs` 目录,您安装的所有运行时版本和第三方依赖库均会永久保留。
---
## 自动更新 Docker 镜像
如果您希望taskpool能够自动拉取最新镜像并无感更新,推荐使用 **Watchtower**。Watchtower 会定期检查被监控容器的基础镜像,当发现有新版本推送时,它会自动拉取新镜像、使用与原容器完全相同的配置重启容器。
由于taskpool采用持久化挂载(数据和环境都在外部),因此自动更新不会造成任何数据丢失。
### 方式一:独立一行命令运行 Watchtower(推荐)
执行以下命令,Watchtower 将会自动在每天凌晨 3 点自动检查并更新名为 `taskpool` 的容器:
```bash
docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
-e TZ=Asia/Shanghai \
-e WATCHTOWER_SCHEDULE="0 0 3 * * *" \
-e WATCHTOWER_CLEANUP=true \
--restart unless-stopped \
containrrr/watchtower \
taskpool
```
> **参数说明**
> - 结尾处的 `taskpool` 为指定仅监控更新名为 `taskpool` 的容器。若不加此参数,则会自动更新宿主机上所有的 Docker 容器。
> - `WATCHTOWER_CLEANUP=true`:更新成功后自动删除旧版本的废弃镜像,防止存储空间被占满。
> - `WATCHTOWER_SCHEDULE`:设置定时检查的 Cron 表达式(秒 分 时 日 月 周)。
### 方式二:集成到 Docker Compose 中
您可以直接将 Watchtower 作为附加服务加入现有的 `docker-compose.yml` 中:
```yaml
services:
taskpool:
image: ghcr.io/engigu/taskpool:latest
container_name: taskpool
# ... 省略端口、挂载等其他配置 ...
watchtower:
image: containrrr/watchtower
container_name: watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- TZ=Asia/Shanghai
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_SCHEDULE="0 0 3 * * *"
command: taskpool
restart: unless-stopped
```
修改完成后,执行 `docker compose up -d` 生效即可。
+24
View File
@@ -0,0 +1,24 @@
# 免责声明
taskpoolTaskPool)及其开发者在提供本项目的同时,默认用户已完全知悉并同意以下条款:
## 1. 免责保证
- **无业务逻辑**:本项目仅作为一个轻量级的任务托管与调度平台,不提供、不内置任何具有实际业务逻辑的第三方脚本。
- **脚本审核**:用户自行添加或配置的脚本来源、逻辑及潜在的系统影响均由用户自行负责。请勿执行来源不明的恶意脚本,并在执行前仔细阅读并审核其源代码,确保安全性。
## 2. 软件责任
- **按“原样”提供**:本项目属于业余开源开发作品,采用 Apache License 2.0 协议发布(需遵守 NOTICE 署名要求)。开发者不保证软件不存在任何 Bug、系统漏洞或逻辑缺陷。
- **损失赔偿**:因运行用户自行脚本或使用本系统带来的一切数据泄露、系统损坏、财产损失(如服务器被封、云服务欠费)及相关法律责任,均由使用者本人承担。
## 3. 授权与使用
- **合理镜像申请**:由于项目涉及到网络请求和资产调度,请遵循相关的开源协议进行公平、合理的使用。
- **禁止非法用途**:严禁将taskpool用于任何违反中华人民共和国法律法规及相关组织政策的行为。
---
## 4. 联系我们
如果您在使用过程中发现任何技术问题,欢迎通过 GitHub [Issues](https://github.com/engigu/taskpool/issues) 反馈。
+19
View File
@@ -0,0 +1,19 @@
# 变量机密
变量机密提供了统一的环境变量管理功能,旨在保护敏感数据并提高配置的灵活性,包含环境变量和机密。
## 环境变量 (Environment Variables)
- **全局作用域**:一旦在变量机密页面配置成功,所有的 `定时任务``命令行交互` 在运行期间均会自动注入这些环境变量。
- **变量命名规范**:建议使用大写字母加下划线的形式,例如 `DB_PASSWORD``AUTH_TOKEN`
## 机密 (Secrets)
- **字段脱敏**:对于标记为 `Secret` 的变量,面板在浏览列表中将以星号 `*******` 显示,避免在协作或投屏场景下泄露机密信息。
- **加密存储**:数据库中的敏感字段均由系统后端进行深度加密,确保存储层的物理安全。
- **编辑权限**:某些机密字段可能在编辑后不可见其原始值,仅支持通过覆盖更新的方式进行修改。
## 注入机制
- **任务运行时动态挂载**:在启动任务对应的进程前,主进程或 Agent 会将配置好的键值对同步至子进程的 `Environment` 参数中,确保脚本可以直接通过 `os.environ``process.env` 获取到该配置。
+191
View File
@@ -0,0 +1,191 @@
# 浏览器示例
`example/playwright` 提供了一组远程浏览器脚本示例,演示如何连接 **Browserless**
> [!NOTE]
> 本示例以 Browserless 为主要演示对象。以此类推,您也可以使用其他的浏览器镜像(如原生的 `headless-shell` 或其他的浏览器集群服务)进行部署,只要它们支持 CDP 协议。
---
## 部署方式对比
taskpool强烈建议采用 **单独部署浏览器服务(如 Browserless** 的方案,而不是在任务池镜像内部安装浏览器。
### 本地部署 (在任务池容器内安装) 的缺点
- **资源争抢**:浏览器是极度的“内存/CPU 杀手”,在同一容器内运行多任务极易导致任务池主进程因 OOM (内存溢出) 而崩溃。
- **镜像臃肿**:安装 Chromium 后,原本精简的 Docker 镜像体积会暴增数倍(增加 500MB+),导致拉取和更新缓慢。
- **依赖环境复杂**:在精简镜像中安装浏览器常会遇到各种缺失 `.so` 库文件的底层错误,排查极其困难。
- **不利于扩展**:无法实现多节点负载均衡,一个容器内的资源始终是有限的。
### 远程部署 (Browserless) 的优势
- **性能隔离**:浏览器的负载波动不会影响taskpool的稳定性。
- **开箱即用**:专业的浏览器镜像是针对性优化的,包含所有底层依赖和沙箱安全配置。
- **可视化调试**:大多数服务(如 Browserless)支持通过 VNC 同步查看浏览器画面,方便排查脚本逻辑。
- **弹性伸缩**:支持多会话、多实例模式,可以应对高并发爬虫需求。
---
## 准备工作
在taskpool中运行浏览器自动化脚本前,需要先配置好对应的 **语言环境****第三方依赖包**
### 1. Node.js 环境 (JavaScript)
如果您使用 `playwright.js` 脚本:
- **依赖安装**:前往「语言依赖」->「Node.js」,安装 `puppeteer-core`
- **说明**:该脚本使用 `puppeteer-core` 通过 CDP 协议连接远程浏览器,无需安装完整的 puppeteer 及其内置浏览器。
### 2. Python 环境
如果您使用 `playwright.py` 脚本:
- **版本推荐**:建议在「语言环境」中安装并使用 **Python 3.11**
> [!TIP]
> 建议避开更高版本的 Python(如 3.12+),因为目前部分 Playwright 依赖在极新版本的 Python 环境下可能会遭遇编译或安装失败。
- **依赖安装**:前往「语言依赖」->「Python」,安装 `playwright`
---
配置完成后,即可创建定时任务并关联对应的脚本文件。
## Browserless 连接要点
如果你当前是通过 Browserless 连接远程浏览器:
- 不要执行 `playwright install`
- 不需要额外下载 Chromium / Firefox / WebKit
- 直接使用 `connect_over_cdp` 连接远程浏览器即可
## 适用场景
- 验证任务池到 Browserless 的网络连通性
- 验证远程浏览器地址和 Token 是否配置正确
- 快速测试浏览器自动化脚本是否能正常运行
- 快速确认 Node.js / Python 语言环境是否已经配置完成
- 作为后续网页自动化脚本的基础模板
## 推荐部署方式
建议配合 `browserless/chromium` 一起使用,再由任务池中的脚本连接远程浏览器服务。
参考 `docker-compose.yml`
```yaml
version: "3.8"
services:
browser:
image: ghcr.io/browserless/chromium:latest
container_name: browser
restart: unless-stopped
environment:
MAX_CONCURRENT_SESSIONS: 5
MAX_QUEUE_LENGTH: 20
CONNECTION_TIMEOUT: 300000
DEFAULT_LAUNCH_ARGS: '["--no-sandbox","--disable-setuid-sandbox","--disable-dev-shm-usage"]'
TOKEN: your-secret-token
ENABLE_DEBUGGER: "false"
shm_size: "1gb"
mem_limit: 2g
cpus: 2
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/healthz"]
interval: 30s
timeout: 10s
retries: 3
taskpool:
image: ghcr.io/engigu/taskpool:latest
container_name: taskpool
restart: unless-stopped
ports:
- "8052:8052"
volumes:
- ./data:/app/data
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
- BH_SERVER_PORT=8052
- BH_SERVER_HOST=0.0.0.0
- BH_DB_TYPE=sqlite
- BH_DB_PATH=/app/data/taskpool.db
- BH_DB_TABLE_PREFIX=taskpool_
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
depends_on:
- browser
```
## 使用步骤
1. 启动 `browser``taskpool` 服务。
2. 在任务池的“语言依赖”中安装对应包。
3. 按实际环境修改脚本中的 Browserless 地址和 Token。
4. 在任务池中创建任务并运行对应脚本。
Python 示例的核心写法如下:
```python
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(
"http://browser:3000?token=your-secret-token"
)
page = browser.new_page()
page.goto("https://www.baidu.com")
page.screenshot(path="baidu.png")
browser.close()
```
## 运行前检查
- Browserless 服务已经正常启动
- 任务池可以访问 Browserless 地址
- `TOKEN` 与 Browserless 配置保持一致
- 脚本中的地址、端口和协议填写正确
## 常见问题
### 1. 报错提示找不到模块
通常是还没有在“语言依赖”中安装对应包:
- Node.js 示例需要 `puppeteer-core`
- Python 示例需要 `playwright`
### 2. 为什么没有执行 `playwright install`
这是预期行为。
如果你使用的是 Browserless 这类远程浏览器服务,Playwright 只是作为客户端发起连接,不需要在任务池容器里再下载本地浏览器,所以通常不要执行 `playwright install`
### 3. 连接不上 Browserless
请优先检查:
- Browserless 服务是否正常启动
- Token 是否正确
- 任务池与 Browserless 是否在同一网络中
- 地址是否写成了当前运行环境可访问的地址
### 4. 页面打开超时
可以尝试:
- 换一个更稳定的目标站点
- 调大超时时间
- 增加 Browserless 容器的 `shm_size`
- 检查容器 CPU / 内存是否不足
### 5. 没有看到截图文件
请确认:
- 脚本已经执行成功
- 截图保存路径是否正确
- 任务工作目录是否符合预期
## 说明
这组示例主要用于快速验证任务池与远程浏览器服务之间的连通性,以及对应语言环境是否已经配置完成。
+296
View File
@@ -0,0 +1,296 @@
# 内置库示例
taskpool提供了一个名为 `taskpool` 的内建包(Built-in SDK),支持 Python 和 Node.js。通过该内置库,您可以在脚本中实现**消息推送**、**环境变量管理**以及**任务执行控制**等高级功能。
---
## 准备工作
在运行内置库脚本之前,请确保完成了以下步骤:
### 1. 安装内置包
在taskpool的「终端」页面中,或者通过创建临时任务执行以下命令,为面板管理的所有语言环境安装 `taskpool` 包:
```bash
taskpool builtininstall
```
### 2. 配置环境变量
根据您需要调用的功能,在定时任务的“环境变量”或“机密”中配置以下对应 Key:
#### 消息推送所需环境变量
- **`BHPKG_NOTIFY_TOKEN`**:进入「消息推送」->「脚本调用说明」页面即可找到。
- **`BHPKG_NOTIFY_CHANNEL`**:进入「消息推送」->「渠道列表」页面,查看对应渠道的 **ID**
- **`BHPKG_NOTIFY_URL`** (可选):默认为 `http://localhost:8052/api/v1/notify/send`。如果修改了主服务端口,需要同步修改。
#### 环境变量管理与定时任务控制所需环境变量
- **`BHPKG_OPENAPI_TOKEN`** (或 `OPENAPI_TOKEN`):用于 OpenAPI 接口鉴权,进入「系统设置」->「OpenAPI」页面,生成并复制 Token。
- **`BHPKG_OPENAPI_URL`** (或 `OPENAPI_URL`,可选):默认为本地面板 API 地址。若在非标准环境下运行,可手动指定(例如 `http://localhost:8052`)。
---
## 消息通知示例
只需要一行代码即可触发零配置推送。
::: code-group
```python [Python]
import taskpool
def main():
print("正在尝试发送 Python 内建通知...")
try:
# 调用内置 notify 函数
# 内部会自动使用环境变量进行鉴权和投递
response = taskpool.notify(
title="Python 任务提醒",
text="这是一条来自 Python 示例脚本的通知消息。调用非常简单!"
)
print("发送请求已处理。")
if response:
print(f"服务器响应: {response}")
except Exception as e:
print(f"发送过程发生异常: {e}")
if __name__ == "__main__":
main()
```
```javascript [Node.js]
const taskpool = require('taskpool');
console.log("正在尝试发送 Node.js 内建通知...");
try {
// 简单的一行代码即可完成推送,内置包采用异步非阻塞发送
taskpool.notify(
"Node.js 任务提醒",
"这是一条来自 Node.js 示例脚本的通知消息。无需配置 API 地址或 Token。"
);
console.log("发送请求已提交。");
} catch (e) {
console.error(`通知失败: ${e.message}`);
}
```
:::
---
## 环境变量管理
内置库支持对面板的环境变量进行增删改查。
### 支持方法
* **Python**:
- `get_envs()`: 获取所有环境变量列表。
- `get_env(name)`: 根据变量名称获取详情。
- `add_env(name, value, remark)`: 添加新的环境变量。
- `update_env(id, name, value, remark)`: 更新指定 ID 的环境变量值。
- `delete_env(id)`: 根据 ID 删除环境变量。
* **Node.js**:
- `getEnvs()`: 获取所有环境变量列表。
- `getEnv(name)`: 根据变量名称获取详情。
- `addEnv(name, value, remark)`: 添加新的环境变量。
- `updateEnv(id, name, value, remark)`: 更新指定 ID 的环境变量值。
- `deleteEnv(id)`: 根据 ID 删除环境变量。
### 代码示例
::: code-group
```python [Python]
import taskpool
def main():
print("====== 开始运行 Python 环境变量管理示例 ======")
try:
# 1. 获取全部环境变量
envs = taskpool.get_envs()
print(f"当前共有 {len(envs)} 个环境变量")
# 2. 新增一个临时环境变量
new_env_name = "BHPKG_TEST_KEY"
new_env_val = "HelloTaskPool"
print(f"正在创建环境变量: {new_env_name}...")
created_env = taskpool.add_env(
name=new_env_name,
value=new_env_val,
remark="Python SDK 测试自动创建"
)
print(f"创建成功: ID={created_env.get('id')}, Name={created_env.get('name')}")
# 3. 查询刚才创建的环境变量详情
checked_env = taskpool.get_env(new_env_name)
if checked_env:
print(f"成功查询到变量: {checked_env.get('name')} = {checked_env.get('value')}")
# 4. 修改该环境变量的值
updated_val = "HelloTaskPool_Updated"
print(f"正在修改环境变量的值为: {updated_val}...")
updated_env = taskpool.update_env(
id=checked_env.get("id"),
name=new_env_name,
value=updated_val,
remark="Python SDK 测试自动更新"
)
print(f"更新成功: Value={updated_env.get('value')}")
# 5. 删除该临时环境变量
print(f"正在删除临时环境变量: ID={checked_env.get('id')}...")
taskpool.delete_env(checked_env.get("id"))
print("删除成功!")
except Exception as e:
print(f"环境变量操作失败: {e}")
print("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。")
if __name__ == "__main__":
main()
```
```javascript [Node.js]
const taskpool = require('taskpool');
async function main() {
console.log("====== 开始运行 Node.js 环境变量管理示例 ======");
try {
// 1. 获取全部环境变量
const envs = await taskpool.getEnvs();
console.log(`当前共有 ${envs.length} 个环境变量`);
// 2. 新增一个临时环境变量
const newEnvName = "BHPKG_TEST_KEY_JS";
const newEnvVal = "HelloTaskPoolJS";
console.log(`正在创建环境变量: ${newEnvName}...`);
const createdEnv = await taskpool.addEnv(
newEnvName,
newEnvVal,
"Node.js SDK 测试自动创建"
);
console.log(`创建成功: ID={createdEnv.id}, Name={createdEnv.name}`);
// 3. 查询该环境变量
const checkedEnv = await taskpool.getEnv(newEnvName);
if (checkedEnv) {
console.log(`成功查询到变量: ${checkedEnv.name} = ${checkedEnv.value}`);
// 4. 修改该环境变量的值
const updatedVal = "HelloTaskPoolJS_Updated";
console.log(`正在修改环境变量的值为: ${updatedVal}...`);
const updatedEnv = await taskpool.updateEnv(
checkedEnv.id,
newEnvName,
updatedVal,
"Node.js SDK 测试自动更新"
);
console.log(`更新成功: Value=${updatedEnv.value}`);
// 5. 删除该临时环境变量
console.log(`正在删除临时环境变量: ID={checkedEnv.id}...`);
await taskpool.deleteEnv(checkedEnv.id);
console.log("删除成功!");
}
} catch (e) {
console.error(`环境变量操作失败: ${e.message}`);
console.log("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。");
}
}
main();
```
:::
---
## 定时任务管理与控制
内置库支持查询面板的任务列表、最近的执行结果以及手动触发特定任务的运行。
### 支持方法
* **Python**:
- `get_tasks()`: 获取所有定时任务列表。
- `execute_task(id)`: 立即触发指定 ID 任务的运行。
- `get_last_results()`: 获取最近任务的执行记录。
* **Node.js**:
- `getTasks()`: 获取所有定时任务列表。
- `executeTask(id)`: 立即触发指定 ID 任务的运行。
- `getLastResults()`: 获取最近任务的执行记录。
### 代码示例
::: code-group
```python [Python]
import taskpool
def main():
print("====== 开始运行 Python 任务管理与执行控制示例 ======")
try:
# 1. 获取所有任务列表
tasks = taskpool.get_tasks()
print(f"成功获取到 {len(tasks)} 个定时任务:")
for task in tasks[:5]: # 仅打印前5个
print(f" - [{task.get('id')}] {task.get('name')} (表达式: {task.get('schedule')}, 备注: {task.get('remark')})")
# 2. 尝试触发第一个任务的运行
if tasks:
target_task = tasks[0]
print(f"\n尝试手动触发任务运行: [{target_task.get('id')}] {target_task.get('name')}...")
taskpool.execute_task(target_task.get("id"))
print("执行指令发送成功。")
# 3. 获取最近的执行结果列表
results = taskpool.get_last_results()
print(f"\n最近共有 {len(results)} 条任务执行记录。")
except Exception as e:
print(f"任务操作失败: {e}")
print("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。")
if __name__ == "__main__":
main()
```
```javascript [Node.js]
const taskpool = require('taskpool');
async function main() {
console.log("====== 开始运行 Node.js 任务管理与执行控制示例 ======");
try {
// 1. 获取所有任务列表
const tasks = await taskpool.getTasks();
console.log(`成功获取到 ${tasks.length} 个定时任务:`);
tasks.slice(0, 5).forEach(task => { // 仅展示前5项
console.log(` - [${task.id}] ${task.name} (表达式: ${task.schedule || ''}, 备注: ${task.remark || ''})`);
});
// 2. 尝试触发第一个任务的运行
if (tasks.length > 0) {
const targetTask = tasks[0];
console.log(`\n尝试手动触发任务运行: [${targetTask.id}] ${targetTask.name}...`);
await taskpool.executeTask(targetTask.id);
console.log("执行指令发送成功。");
}
// 3. 获取最近的执行结果列表
const results = await taskpool.getLastResults();
console.log(`\n最近共有 ${results.length} 条任务执行记录。`);
} catch (e) {
console.error(`任务操作失败: ${e.message}`);
console.log("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。");
}
}
main();
```
:::
+27
View File
@@ -0,0 +1,27 @@
# 脚本示例
任务池内置了一批可直接参考的脚本示例,适合用来验证运行环境、演示常见用法,或者作为你自己脚本的起点。
如果你使用 Docker 镜像启动任务池,容器启动后会自动将仓库中的 `example` 目录同步到脚本目录下。通常你可以在脚本目录中看到:
```text
example/
```
使用脚本示例前,建议先完成下面几步:
1. 确认示例文件已经同步到脚本目录。
2. 根据脚本语言,到“语言依赖”页面安装对应依赖包。
3. 按实际环境修改脚本中的地址、Token、账号或其他配置。
4. 在任务管理中选择对应脚本并运行。
> [!TIP]
> 如果示例脚本依赖第三方包,但你还没有在“语言依赖”中安装,对应任务通常会直接报缺少模块或包。
## 当前示例
目前文档已经整理出的示例类型:
- [浏览器示例](./browser.md)
- [内置库示例](./builtin.md)
- [Linux 环境依赖示例](./linux-deps.md)
+111
View File
@@ -0,0 +1,111 @@
# Linux 系统依赖处理
在使用taskpool时,您可能会在运行某些脚本时遇到缺少底层 Linux 系统级依赖(例如 `apt``apk` 包)的情况。这篇指南将详细讲解如何优雅、持久地解决这些依赖问题。
## 背景与痛点
taskpool通常以 Docker 容器的形式运行。Docker 的文件系统具有以下特性:
- **挂载目录(持久化)**:像 `data/` 这样的目录被映射到了宿主机,其中的数据(如脚本、日志、配置文件)在重启或升级镜像时会保留。
- **容器层(非持久化)**:容器自身的系统目录(如 `/usr/bin`, `/lib`, `/etc`)是临时层。如果您直接在终端里手动执行 `apt-get install xxx`,虽然当下可以立即使用,**但在容器被销毁重建或更新镜像后,这些刚安装的系统包就会全部丢失。**(注意:仅仅是普通的 `docker restart` 重启容器并不会丢失,只有重建容器时才会重置)
## 核心解决思路
为了解决依赖丢失的问题,taskpool提供了一种自动化的解决方案:**利用 `taskpool_startup`(开机触发)类型的定时任务,在面板每次启动时自动执行一段依赖安装脚本。**
这样,无论您如何更新镜像或重启容器,系统依赖都能在面板核心服务就绪前自动被补充安装,并且对后续的普通脚本任务透明。
---
## 具体操作步骤
### 第一步:编写依赖安装脚本
首先,在您的脚本目录(通常为 `data/scripts` 下,或者您可以单独建一个 `data/scripts/deps` 目录)创建一个 Shell 脚本,例如 `install_my_deps.sh`
由于taskpool的镜像目前均基于 Debian 系统,您可以直接在脚本中使用 `apt``apt-get` 命令来管理系统依赖。
**示例 1:安装 Puppeteer (无头浏览器) 的依赖动态库**
```bash
#!/bin/bash
# 遇到错误即停止执行
set -e
echo "正在检测并安装 Puppeteer 依赖..."
# 提前 update 索引是非常重要的一步
apt-get update
apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2
echo "Puppeteer 依赖安装完成!"
```
**示例 2:安装 Python/C++ 编译所需的基础工具链**
```bash
#!/bin/bash
set -e
apt-get update
# 安装 gcc, g++, make 以及 python3 相关的头文件
apt-get install -y build-essential python3-dev
```
**示例 3:带 Hash 检查的高阶依赖安装脚本(推荐)**
此脚本利用 `/tmp` 目录和脚本自身内容的哈希值,完美匹配 Docker 容器的生命周期,避免在普通重启时无意义地检测。
```bash
#!/bin/bash
set -e
# 生成当前脚本的哈希标识
SCRIPT_HASH=$(md5sum "$0" | awk '{print $1}')
FLAG_FILE="/tmp/deps_installed_${SCRIPT_HASH}"
# 如果标识文件存在,说明在此容器生命周期内已安装过,且脚本未被修改,直接退出
if [ -f "$FLAG_FILE" ]; then
echo "系统依赖已就绪,跳过安装。"
exit 0
fi
echo "开始安装系统环境依赖..."
apt-get update
# 假设您需要安装 ffmpeg 和 imagemagick
apt-get install -y ffmpeg imagemagick
# 安装成功后写入标识文件
touch "$FLAG_FILE"
echo "系统依赖安装完成!"
```
### 第二步:配置开机触发任务
脚本编写并保存到面板后,接下来只需将其配置为开机任务:
1. 进入面板的 **「定时任务」** 页面,点击 **「新建任务」**。
2. **任务名称**: 填写容易辨识的名称,例如 “安装系统底层依赖”。
3. **执行命令**: 输入执行该脚本的命令,例如 `bash deps/install_my_deps.sh` (假设您将脚本放在了 `deps` 文件夹下)。
4. **触发类型**: 在下拉菜单中选择 **`taskpool_startup` (开机触发)**。
5. **保存** 任务。
现在,你可以尝试在终端中执行一下该任务验证脚本是否无误。一旦无误,未来每次容器重启,面板都会自动在后台静默执行这个任务,确保环境完备。
---
## 官方预设示例:PHP 编译依赖
为了方便用户参考,我们在项目源码中内置了一个更完善的依赖安装脚本示例。
通过 `mise` 安装某些 PHP 版本时,系统会尝试从源码编译,这就需要用到 `autoconf`, `bison`, `pkg-config` 等工具。
如果您在安装 PHP 时遇到 `autoconf not found``buildconf failed`,可以直接使用项目根目录下的预设示例脚本:
- **路径位置**: `example/deps/install_php_env_deps.sh`
- **使用方法**: 新建 `taskpool_startup` 触发类型的任务,执行命令填写 `bash example/deps/install_php_env_deps.sh` 即可。
此示例脚本中还包含了“检测是否已安装再决定是否执行 apt install”的逻辑,您可以查阅其源码作为编写自己依赖脚本的最佳实践参考。
---
## 注意事项与进阶建议
1. **幂等性 (Idempotency)**:开机脚本在每次重启时都会在后台异步执行。像 `apt-get install -y` 这种命令天然是幂等的(如果已安装就不会重新下载),虽然它不会阻塞面板的启动速度,但每次无意义地检查和刷新软件源仍会白白占用开机初期的系统资源。建议您在脚本中先用 `dpkg -l <包名>``command -v <命令>` 判断依赖是否存在,不存在时再执行安装。
- **进阶技巧**:您也可以在依赖安装完成后,向 `/tmp` 目录下写入一个带有当前脚本内容 Hash 值的标识文件(例如 `touch /tmp/deps_installed_$(md5sum "$0" | awk '{print $1}')`)。在脚本开头判断该文件是否存在,若存在则直接退出。由于容器被销毁重建时 `/tmp` 目录和您安装的系统依赖会一并丢失,而在普通的重启中它们又会一并保留,这种方式完美契合了容器的临时层生命周期,能避免反复执行依赖检测逻辑,进一步加速开机任务。
2. **网络环境** Docker 镜像已经**默认将 APT 源替换为了清华源 (TUNA)**,因此在国内网络下执行 `apt-get` 也能获得很快的下载速度,您通常不需要在脚本中再次手动替换源。
3. **避免冲突**:请仅安装您脚本运行强依赖的底层库,尽量不要通过 `apt` 安装 Node.js 或 Python 的运行环境,这些高级语言环境应交由面板的 **「编程语言」** (Mise) 模块统一管理。
+43
View File
@@ -0,0 +1,43 @@
# 访问面板
部署成功并启动容器后,您只需通过浏览器即可访问taskpool。
## 默认账号
- **访问地址**`http://localhost:8052` (或您配置的宿主机端口)
- **用户名**`admin`
- **密码**:首次启动成功后,系统会为管理员账号生成 **12 位随机初始密码** 并打印在容器启动日志中。
> **如何查找初始密码**
> 运行容器后,在命令行执行:
> ```bash
> docker logs taskpool | grep "管理员账号创建成功"
> ```
> 找到包含密码的内容后登录,登录后建议首选操作:**修改管理员密码**。
---
## 登录后的首要配置
### 1. 修改密码
在右上角用户头像下拉菜单选择「个人设置」进行账号安全修改。
### 2. 系统调度设置
在「系统设置」>「调度设置」中,可以根据服务器资源微调任务队列的并发数(默认 4)和最大队列大小(默认 100)。
### 3. 环境与依赖
如果您需要执行特定语言或脚本包,请先进入「编程语言」页面确认所需的环境已安装(如已安装 Python3.x 或 Node.js.x)。
---
## 面板功能一览
| 模块 | 说明 |
| :--- | :--- |
| **仪表盘 (Dashboard)** | 实时监控任务执行动态、容器状态和资源占用频率情况。 |
| **定时任务 (Tasks)** | 管理和调度各种 Cron 脚本。 |
| **脚本管理 (Scripts)** | 在线编辑、上传项目源代码。 |
| **在线终端 (Terminal)** | 直接操作容器环境进行运维和调试。 |
| **消息推送 (Notify)** | 配置各类通知渠道。 |
| **环境变量 (Environments)** | 管理脚本所需的各种隐私信息、持久配置。 |
| **个人设置 (Settings)** | 调整站点 UI 和账号安全信息。 |
+24
View File
@@ -0,0 +1,24 @@
# 执行历史
执行历史详细记录了面板中所有任务的运行状态、实时日志及耗时统计。
## 日志详情
- **实时日志推流**:即使任务仍在运行,也可以在历史日志详情页实时看到程序的控制台输出(stdout/stderr)。
- **历史归档**:系统默认保留最近一段时间的任务运行快照。
- **状态统计**
- `SUCCESS`:任务按计划成功运行并返回正常退出代码。
- `FAILURE`:脚本运行时报错或程序异常终止。
- `TIMEOUT`:任务执行超出了设定的最大运行时间,由系统强制中止并标记为超时。
## 日志管理
- **搜索与过滤**:支持通过 `任务名称``脚本文件名``状态` 快速检索历史。
- **自动清理策略**
- **最大保留份数**:支持在系统设置中配置每个任务保留的历史日志最大数量。
- **日志滚动更新**:当产生新日志且超过最大份数时,最旧的记录将被自动清除,确保存储空间的动态平衡。
## 执行耗时
- **精准计时**:精确统计每次任务执行从启动到退出的全周期耗时。
- **性能分析**:通过历史耗时数据对比,可辅助用户排查脚本是否出现了性能退化。
+62
View File
@@ -0,0 +1,62 @@
# 面板互联 (Interconnect)
面板互联功能允许您将多个面板(taskpool)连接在一起,形成主从(Master-Child)架构的集群。这使得您可以在一个中心化的主面板上集中监控和管理所有的子节点,极大地简化了多面板环境下的运维工作。
## 核心特性
- **集中管理**:在主节点上统一查看所有子节点的状态。
- **无缝穿越**:主节点可以直接“穿越”到子节点的控制台进行管理,无需反复登录。
- **内网穿透**:即使子节点部署在没有公网 IP 的深层内网(如家庭宽带、企业内网),只要主节点具有公网访问能力,子节点也能主动与主节点建立安全的反向穿透隧道,实现主节点对内网子节点的直连管理。
- **极致性能**:深度优化了底层连接与通信逻辑,严格控制并优化了协程(Goroutine)数目,确保在海量节点高并发连接下依然保持极低的资源占用和极高的系统稳定性。
- **角色互斥**:每个面板只能扮演一种角色(主节点或子节点),避免循环嵌套。
## 架构说明
### 主节点 (Master)
- **功能**:集中监控其他面板的状态,并可无缝穿越到子节点进行管理。
- **适用场景**:部署在具有公网 IP 的云服务器上,作为整个集群的控制中心。
- **配置操作**:选择作为主节点后,您可以生成专属的连接密钥,并将此密钥提供给子节点用于连接。在主节点的界面上可以添加并管理多个子节点。
### 子节点 (Child)
- **功能**:向主节点报告自身的运行状态,并允许主节点穿越到本面板进行管理。
- **适用场景**:部署在各种边缘环境,如家庭宽带、企业内网等可能没有公网 IP 的环境中。
- **配置操作**:选择作为子节点后,需要填入主节点的地址和由主节点生成的密钥。子节点会主动发起连接,与主节点建立安全的反向穿透隧道。
## 使用步骤
1. **确定角色**:首先在您的面板集群中规划好哪台机器作为主节点,哪些机器作为子节点。
2. **配置主节点**
- 登录主节点的面板。
- 导航至左侧菜单的 **面板互联**
- 选择 **我是主节点 (Master)** 角色。
- 复制生成的连接信息或密钥。
3. **配置子节点**
- 登录子节点的面板。
- 导航至左侧菜单的 **面板互联**
- 选择 **我是子节点 (Child)** 角色。
- 填入主节点的地址和刚才复制的密钥。
- 保存并连接。
4. **统一管理**
- 回到主节点,您将看到刚刚连接上来的子节点列表及其在线状态。
- 点击子节点列表中的对应操作按钮,即可实现无缝穿越,直接管理该子节点的资源和任务。
## 无缝穿越功能 (Seamless Travel)
无缝穿越是面板互联中最强大的功能之一,它允许您在不离开主节点浏览器界面的情况下,直接接管并操作任何连接的子节点。
### 穿越特点
- **免密直连**:只要子节点已经连接到主节点,即可一键穿越,无需再次输入子节点的管理员账号和密码。
- **全功能接管**:穿越后,您看到的所有数据(如定时任务、脚本、执行历史、系统状态等)和进行的所有操作(如新建任务、执行脚本)都是针对**该子节点**的。相当于您直接在子节点本地登录。
- **内网穿透能力**:得益于底层的反向隧道技术,即使子节点位于无法直接访问的深层内网,穿越依然能够流畅进行,所有的 API 请求都将通过隧道安全转发。
### 如何退出穿越
当您处于穿越状态时(即正在管理某个子节点),界面左下方会出现一个醒目的悬浮控制条(**“返回主节点”**)。
- 随时点击该按钮即可**退出穿越**。
- 退出后,您的视图和操作权限将立即恢复为主节点的本地状态。
> **注意**
> 请根据实际集群架构分配角色,一旦设定角色,除非重置配置,否则该面板将一直保持此角色。在演示模式下,可能无法修改互联角色。
+23
View File
@@ -0,0 +1,23 @@
# 项目介绍
taskpool (TaskPool) 是一款极致轻量、高性能的自动化任务调度平台。采用 Go + Vue3 架构,专注于高性能与低系统开销。
## 核心亮点
- **极致性能**:采用 Go 语言开发,在同样的任务执行下,资源占用极低。
- **运行时解耦**:深度集成 **Mise** 运行时管理,原生支持 Python、Node.js、Go、Rust、PHP 等所有主流语言环境的动态安装(几乎所有的版本)与统一依赖管理。
- **一键部署**:支持 Docker/Docker-Compose 一键部署,开箱即用。
- **现代 UI**:基于 Vue3 + TailwindCSS + Shadcn/ui,提供响应式设计与深色/浅色主题。
## 主要特色
- **轻量级:** docker/compose部署,无需复杂配置,开箱即用
- **任务调度:** 支持标准 Cron 表达式,常用时间规则快捷选择。日志不落文件,没有磁盘频繁io的问题
- **脚本管理:** 在线代码编辑器,支持文件上传、压缩包解压
- **在线终端:** WebSocket 实时终端,命令执行结果实时输出
- **消息推送:** 内置强大消息推送与通知引擎,无缝兼容主流渠道,支持系统级事件告警
- **环境变量:** 安全存储敏感配置,任务执行时自动注入
- **移动端:** 适配移动小屏样式
- **远程执行:** 支持远程agent执行任务,展示执行结果
- **多语言支持:** 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理
+74
View File
@@ -0,0 +1,74 @@
# 语言依赖
taskpool深度集成了 **Mise** 运行时管理器,这使得它具备多版本语言环境的高灵活性和隔离性。
## 脚本运行环境
taskpool原生支持以下脚本的定时执行:
- **Python3**, **Node.js**, **Bash** (标准版镜像内置环境)
- 通过 **Mise** 扩展:支持几乎所有主流编程语言的动态安装与切换。
> [!TIP]
> **Minimal 镜像注意**:如果您使用的是 `minimal` 标签的镜像,系统初始不包含 Python 和 Node.js。您需要进入「编程语言」页面手动点击安装您所需的运行时。
## 依赖管理支持
系统内置了高度集成的跨语言依赖管理器,支持自动化安装和管理以下语言的依赖项,并确保在容器内全局可用:
| 语言 | 包管理器 | 功能说明 |
| :--- | :--- | :--- |
| **Python** | pip | 自动使用内置虚拟环境,支持清华源 |
| **Node.js** | npm | 全局安装模式,自动配置 npmmirror 镜像 |
| **Go** | go install | 通过 `go install` 安装二进制工具 |
| **Rust** | cargo | 通过 `cargo install` 安装 Rust 依赖 |
| **Ruby** | gem | 支持 `gem install` 本地安装 |
| **Bun** | bun | 支持 `bun add -g` 全局模式 |
| **PHP** | composer | 支持 `composer global require` |
| **Deno** | deno | 支持 `deno install -g` |
| **.NET** | dotnet | 支持 `dotnet tool install -g` |
| **Elixir/Erlang** | mix | 支持 `mix archive.install` |
| **Lua** | luarocks | 通过 `luarocks` 管理 Lua 包 |
| **Nim** | nimble | 支持 `nimble install` |
| **Dart/Flutter** | pub | 支持 `pub global activate` |
| **Perl** | cpanm | 简单的 `cpanm` 安装支持 |
| **Crystal** | shards | `shards` 项目级别或工具安装 |
## 使用方法
### 1. 安装环境
进入「编程语言」页面,使用 `mise` 一键安装所需的语言及版本。
### 2. 依赖管理
在已安装列表点击「依赖管理」,输入名称(可选版本)即可自动在对应环境内完成安装。
### 3. 多版本切换
对于复杂的项目,您可以通过面板配置不同的任务版本镜像,系统基于 `mise exec` 实现了完善的环境隔离,不同版本的依赖包互不冲突。
## 常用工具安装
如果您需要在面板环境中使用 Ansible 或其他通过 pipx 管理的工具,可以使用以下命令进行快速安装:
### 安装 Ansible
taskpool推荐通过 `mise` 结合 `pipx` 安装 Ansible,以保持环境隔离且全局可用:
```bash
# 首先安装 pipx
mise use -g pipx@latest
# 使用 pipx 安装 ansible
mise use -g ansible@latest
```
安装完成后,您可以在「脚本管理」或「定时任务」中直接调用 `ansible``ansible-playbook` 命令。
---
## 隔离机制说明
- taskpool通过动态注入 `PATH` 环境和 `mise shims` 将语言环境暴露给系统。
- 每个任务在执行前都会根据任务配置自动加载对应的运行时环境变量。
- **运行时激活**:自动将 `MISE_DATA_DIR` 等环境变量指向宿主机的持久化挂载目录,确保护持久化可用。
+86
View File
@@ -0,0 +1,86 @@
# Nginx 反向代理配置
如果您需要通过域名和 HTTPS 访问taskpool,推荐使用 Nginx 作为反向代理并配置 WebSocket 负载均衡。
## Nginx 反向代理配置示例
### 1. 配置映射
首先,在 `nginx.conf``http` 块中添加 WebSocket 升级映射:
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
---
### 2. 服务器配置
`example.com` 替换为您的域名,并指定宿主机监听端口:
```nginx
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
access_log /var/log/nginx/example.access.log;
error_log /var/log/nginx/example.error.log warn;
location / {
proxy_pass http://127.0.0.1:8052; # 指定taskpool宿主机 IP 和端口
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持(在线控制台必需)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 60s;
}
}
# 自动 HTTP 跳转 HTTPS (可选)
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
```
---
### 3. 子路径部署场景
如果您是通过 `BH_SERVER_URL_PREFIX=/taskpool` 进行子路径托管,请修改 `location` 参数:
```nginx
location /taskpool/ {
proxy_pass http://127.0.0.1:8052/taskpool/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
---
## 验证与发布
在保存配置文件后,请执行以下命令确保 Nginx 配置正确并重启:
```bash
# 检查语法
nginx -t
# 重启服务
nginx -s reload
```
+132
View File
@@ -0,0 +1,132 @@
# 消息中心
消息中心集成了一整套灵活且现代的消息分发引擎,支持多场景自动推送到外部 IM 工具。
## 消息通道
- **企业 IM**:支持集成 **企业微信** (WeCom)、**钉钉** (DingTalk)、**飞书** (Lark)。
- **个人推送到位**:支持 **Telegram** Bot、**Bark** (支持自建)、**VoceChat** (支持自建) 以及基于 **Wpush** 的推送服务。
- **公共渠道**:标准的 **SMTP 邮件****Webhook** 回调。
## 事件通知规则
- **多事件配置**:您可以灵活定义在哪些场景下触发通知,包括但不限于:
- **任务失败**:定时任务在 Cron 触发后运行报错。
- **任务超时**:任务由于运行过长被系统中止。
- **登录安全**:检测到异地登录或多次密码错误。
- **服务下线**:Agent 节点掉线提醒。
## 推送使用路径
taskpool提供了两种不同层面的通知推送方式,满足从“自动报警”到“程序内自定义推送”的全场景需求。
---
### 路径一:任务绑定通知(零代码自动化)
这是最常用的方式,用于在定时任务执行完成后,根据结果自动发送通知。
1. **入口**:在 **「定时任务」** 页面,点击任务右侧的 **「编辑」**。
2. **配置**:在弹窗底部的 **「通知配置」** 栏目中:
- **选择渠道**:指定发送消息的 IM 渠道。
- **触发时机**:勾选 `成功时``失败时``超时时`(建议至少勾选失败和超时)。
- **附带日志**:开启后可在消息中直接预览报错日志,支持设置截取长度。
3. **生效**:保存后,该任务每次运行结束都会按设定的逻辑自动推信。
---
### 路径二:脚本手动调用 (内置助手库 - 推荐)
taskpool提供了一套**零配置**的内建助手库(Built-in SDK),支持 Python 和 Node.js。除了支持极简的消息通知投递外,它还支持管理面板的**环境变量**与**定时任务控制**。
#### 1. 如何获取配置 Key
在使用助手库前,请确保您已经在任务设置的“环境变量”或“机密”中配置了以下对应 Key:
- **消息推送相关**
- `BHPKG_NOTIFY_TOKEN`:进入「消息推送」->「脚本调用说明」标签,可以直接复制此处的 Token。
- `BHPKG_NOTIFY_CHANNEL`:进入「消息推送」->「渠道列表」标签,可以查看每个渠道对应的 **ID**
- `BHPKG_NOTIFY_URL` (可选):内置通知 API 的地址。默认为 `http://localhost:8052/api/v1/notify/send`
- **环境与任务管理相关**
- `BHPKG_OPENAPI_TOKEN` (或 `OPENAPI_TOKEN`)OpenAPI 鉴权 Token,在「系统设置」->「OpenAPI」中生成。
- `BHPKG_OPENAPI_URL` (可选):默认为本地面板 API 地址。
#### 2. 环境初始化
在开始编写脚本前,您需要在终端执行以下命令,为面板管理的所有语言环境安装 `taskpool` 包:
```bash
taskpool builtininstall
```
*该操作会将助手库安装到 mise 管理的所有版本中,确保 import 成功。*
#### 3. 代码示例
##### Python (同步调用)
```python
import taskpool
# 消息通知
taskpool.notify("任务标题", "通知正文内容")
# 环境变量与任务管理(详细用法见内置库示例)
envs = taskpool.get_envs()
tasks = taskpool.get_tasks()
```
##### Node.js (异步调用)
```javascript
const taskpool = require('taskpool');
// 消息通知
taskpool.notify("任务标题", "通知正文内容");
// 环境变量与任务管理(详细用法见内置库示例)
(async () => {
const envs = await taskpool.getEnvs();
const tasks = await taskpool.getTasks();
})();
```
> [!TIP]
> 关于环境变量增删改查以及任务触发控制的完整 API 列表与更详尽的代码,请参考 [内置库示例](./examples/builtin.md)。
---
### 路径三:其他语言/高级调用 (原始 API)
> [!IMPORTANT]
> 以下示例中的端口均默认为 `8052`。如果您更改了容器内部的服务端口(通过 `BH_SERVER_PORT` 环境配置),请务必在调用时将 `8052` 替换为您的实际端口。
如果您使用 Shell 或其他尚未提供助手库的语言,可以通过标准 HTTP POST 请求调用。
#### 1. 快速获取代码
进入 **「消息推送」** -> **「脚本调用说明」** 标签,页面会根据您的配置自动生成包含 **通知 Token****默认渠道 ID** 的完整代码。
#### 2. 代码参考示例
##### Shell (Curl)
> **注意**:如果更改了容器内部的服务端口,请将 `8052` 替换为实际端口,或直接使用环境变量 `BHPKG_NOTIFY_URL`。
```bash
curl -X POST "http://localhost:8052/api/v1/notify/send" \
-H "notify-token: 您的_NOTIFY_TOKEN" \
-d '{"channel_id":"渠道ID", "title":"标题", "text":"内容"}'
```
##### 基础 Python (requests)
```python
import requests
def send_notify(title, content):
url = "http://localhost:8052/api/v1/notify/send"
headers = { "notify-token": "您的_NOTIFY_TOKEN" }
data = {"channel_id": "您的_渠道_ID", "title": title, "text": content}
requests.post(url, headers=headers, json=data)
```
---
## 消息中心管理
除了配置发送路径,您还可以在消息中心进行以下操作:
- **发送记录 (审计)**:实时记录每一条通过taskpool发送至外部的消息,方便追溯。
- **回执查询**:在 **「消息日志」** 页面查看到每条推送的详细状态,如果发送失败,会提供原始的错误响应代码以供排查。
+388
View File
@@ -0,0 +1,388 @@
<script setup>
import { ref, computed } from 'vue'
import pullStatsData from '../data/pull-stats.json'
const searchQuery = ref('')
const activePoint = ref(null)
const pullStatsList = computed(() => pullStatsData.stats || [])
// 格式化时间显示 (北京时间)
const formattedUpdateTime = computed(() => {
if (!pullStatsData.updatedAt) return '-'
const date = new Date(pullStatsData.updatedAt)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
const ss = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
})
// 辅助函数:解析 SemVer 版本号
const parseVersion = (tag) => {
const match = tag.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/)
if (!match) return { major: 0, minor: 0, patch: 0, suffix: tag }
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
suffix: match[4] || ''
}
}
// 提取最近发布的主语义版本(包含 latest,过滤掉架构),用于折线图趋势展示
const chartPoints = computed(() => {
const list = [...pullStatsList.value]
.filter(item => (/^\d+\.\d+\.\d+$/.test(item.tag) || item.tag === 'latest') && item.downloads !== 0)
.sort((a, b) => {
if (a.tag === 'latest') return 1
if (b.tag === 'latest') return -1
const va = parseVersion(a.tag)
const vb = parseVersion(b.tag)
if (va.major !== vb.major) return va.major - vb.major
if (va.minor !== vb.minor) return va.minor - vb.minor
return va.patch - vb.patch
})
.slice(-20) // 展示最近的 20 个正式版本
if (list.length === 0) return []
const maxVal = Math.max(...list.map(d => d.downloads)) || 1
const width = 600
const height = 240
const paddingLeft = 45
const paddingRight = 25
const paddingTop = 20
const paddingBottom = 40
const chartWidth = width - paddingLeft - paddingRight
const chartHeight = height - paddingTop - paddingBottom
return list.map((item, idx) => {
const x = paddingLeft + (idx / (list.length - 1)) * chartWidth
const y = paddingTop + (1 - item.downloads / maxVal) * chartHeight
return {
x,
y,
tag: item.tag,
downloads: item.downloads,
maxVal,
chartHeight,
paddingTop,
transform: "rotate(15 " + Math.round(x) + " 222)",
tooltipLeft: (x - 55) + "px",
tooltipTop: (y - 48) + "px"
}
})
})
const gridLines = computed(() => {
if (chartPoints.value.length === 0) return []
const maxVal = chartPoints.value[0].maxVal
const height = 240
const paddingTop = 20
const paddingBottom = 40
const chartHeight = height - paddingTop - paddingBottom
const steps = 4
const lines = []
for (let i = 0; i !== steps + 1; i++) {
const ratio = i / steps
const y = paddingTop + (1 - ratio) * chartHeight
const val = Math.round(ratio * maxVal)
lines.push({
y,
label: Math.max(val, 1000) === val ? (val / 1000).toFixed(1) + 'k' : val.toString()
})
}
return lines
})
const linePath = computed(() => {
const pts = chartPoints.value
if (pts.length === 0) return ''
return pts.reduce((path, pt, idx) => {
return path + (idx === 0 ? `M ${pt.x} ${pt.y}` : ` L ${pt.x} ${pt.y}`)
}, '')
})
const areaPath = computed(() => {
const pts = chartPoints.value
if (pts.length === 0) return ''
const startX = pts[0].x
const endX = pts[pts.length - 1].x
const baselineY = 200 // height - paddingBottom
return linePath.value + ` L ${endX} ${baselineY} L ${startX} ${baselineY} Z`
})
// 过滤搜索并排序的所有版本(列表显示)
const filteredStats = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
let list = [...pullStatsList.value]
if (query) {
list = list.filter(item => item.tag.toLowerCase().includes(query))
}
return list.sort((a, b) => {
if (a.tag === 'latest') return -1
if (b.tag === 'latest') return 1
const va = parseVersion(a.tag)
const vb = parseVersion(b.tag)
if (va.major !== vb.major) return vb.major - va.major
if (va.minor !== vb.minor) return vb.minor - va.minor
if (va.patch !== vb.patch) return vb.patch - va.patch
if (!va.suffix && vb.suffix) return -1
if (va.suffix && !vb.suffix) return 1
return vb.suffix.localeCompare(va.suffix)
})
})
</script>
# 镜像下载量统计
本页面展示 GitHub Container Registry 上任务池(`ghcr.io/engigu/taskpool`)各版本镜像的 Pull(下载)数量统计。数据在文档部署时自动更新。
<div class="update-time-box">
<span>数据更新时间:</span>
<strong>{{ formattedUpdateTime }}</strong>
</div>
<div class="stats-container">
<div class="chart-sectioncard">
<h3>主版本下载量趋势折线图</h3>
<div class="line-chart-wrapper">
<svg viewBox="0 0 600 240" class="trend-svg">
<defs>
<linearGradient id="chart-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--vp-c-brand-1)" stop-opacity="0.25"></stop>
<stop offset="100%" stop-color="var(--vp-c-brand-1)" stop-opacity="0.0"></stop>
</linearGradient>
</defs>
<g stroke="var(--vp-c-divider)" stroke-dasharray="3,3" stroke-width="1">
<line v-for="grid in gridLines" :key="grid.y" x1="45" :y1="grid.y" x2="575" :y2="grid.y"></line>
</g>
<g fill="var(--vp-c-text-3)" font-size="11" font-family="var(--vp-font-family-base)" text-anchor="end">
<text v-for="grid in gridLines" :key="grid.y" x="38" :y="grid.y + 4">{{ grid.label }}</text>
</g>
<path :d="areaPath" fill="url(#chart-grad)"></path>
<path :d="linePath" fill="none" stroke="var(--vp-c-brand-1)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path>
<g>
<circle
v-for="(pt, idx) in chartPoints"
:key="idx"
:cx="pt.x"
:cy="pt.y"
r="5"
fill="var(--vp-c-bg)"
stroke="var(--vp-c-brand-1)"
stroke-width="2"
class="chart-dot"
@mouseenter="activePoint = pt"
@mouseleave="activePoint = null"
></circle>
</g>
<g fill="var(--vp-c-text-2)" font-size="11" font-family="var(--vp-font-family-base)" text-anchor="middle">
<text
v-for="(pt, idx) in chartPoints"
:key="idx"
:x="pt.x"
y="222"
:transform="pt.transform"
>
{{ pt.tag }}
</text>
</g>
</svg>
<div v-if="activePoint" class="chart-tooltip" :style="{ left: activePoint.tooltipLeft, top: activePoint.tooltipTop }">
<span class="tooltip-tag">{{ activePoint.tag }}</span>
<span class="tooltip-val">{{ activePoint.downloads.toLocaleString() }} Pulls</span>
</div>
</div>
</div>
<div class="table-sectioncard">
<div class="table-header-control">
<h3>所有版本下载数据</h3>
<input type="text" v-model="searchQuery" placeholder="搜索版本标签..." class="search-input" />
</div>
<div class="table-wrapper">
<table class="stats-table">
<thead>
<tr>
<th>序号</th>
<th>版本标签 (Tag)</th>
<th style="text-align: right;">下载量 (Pulls)</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in filteredStats" :key="item.tag">
<td>{{ idx + 1 }}</td>
<td class="tag-name"><code>{{ item.tag }}</code></td>
<td style="text-align: right; font-weight: 500;">{{ item.downloads.toLocaleString() }}</td>
</tr>
<tr v-if="filteredStats.length === 0">
<td colspan="3" style="text-align: center; color: var(--vp-c-text-3); padding: 2rem 0;">没有找到匹配的版本</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<style scoped>
.stats-container {
display: flex;
flex-direction: column;
gap: 2rem;
margin-top: 1.5rem;
}
.update-time-box {
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-top: -0.5rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.update-time-box strong {
color: var(--vp-c-brand-1);
font-family: var(--vp-font-family-mono);
}
.chart-sectioncard, .table-sectioncard {
background-color: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-border);
border-radius: 8px;
padding: 1.5rem;
position: relative;
}
.chart-sectioncard h3, .table-sectioncard h3 {
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
.line-chart-wrapper {
position: relative;
width: 100%;
}
.trend-svg {
width: 100%;
height: auto;
overflow: visible;
}
.chart-dot {
cursor: pointer;
transition: r 0.2s, stroke-width 0.2s;
}
.chart-dot:hover {
r: 7;
stroke-width: 3px;
}
.chart-tooltip {
position: absolute;
background-color: var(--vp-c-bg-elv);
border: 1px solid var(--vp-c-brand-1);
border-radius: 4px;
padding: 0.35rem 0.6rem;
font-size: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
box-shadow: var(--vp-shadow-3);
pointer-events: none;
z-index: 10;
min-width: 110px;
text-align: center;
}
.tooltip-tag {
font-weight: 600;
font-family: var(--vp-font-family-mono);
color: var(--vp-c-text-1);
}
.tooltip-val {
color: var(--vp-c-brand-1);
font-weight: 500;
}
.table-header-control {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
}
.table-header-control h3 {
margin-bottom: 0;
}
.search-input {
background-color: var(--vp-c-bg);
border: 1px solid var(--vp-c-border);
border-radius: 6px;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
color: var(--vp-c-text-1);
outline: none;
min-width: 200px;
transition: border-color 0.25s;
}
.search-input:focus {
border-color: var(--vp-c-brand-1);
}
.table-wrapper {
max-height: 500px;
overflow-y: auto;
border: 1px solid var(--vp-c-border);
border-radius: 6px;
}
.stats-table {
width: 100%;
border-collapse: collapse;
margin: 0 !important;
}
.stats-table th, .stats-table td {
padding: 0.6rem 0.8rem;
font-size: 0.85rem;
text-align: left;
border-bottom: 1px solid var(--vp-c-border);
}
.stats-table th {
background-color: var(--vp-c-bg-mute);
position: sticky;
top: 0;
z-index: 1;
font-weight: 600;
color: var(--vp-c-text-2);
}
.stats-table tr:last-child td {
border-bottom: none;
}
.tag-name code {
font-size: 0.8rem;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
# 脚本管理
脚本管理提供了taskpool的在线文件资源浏览器和编辑器。
## 资源管理器
- **目录树视图**:清晰层级化展示位于 `scripts` 目录下的所有子文件夹及文件。
- **动态预览**:支持常见的文本文件预览。
- **文件操作**
- **创建/重命名**:在浏览器中直接对脚本文件进行管理和修订。
- **极速上传**:支持直接在 Web 端上传单文件或进行多选拖拽上传。
- **在线解包**:支持一键解压缩 `.zip` 包,大大优化了脚本部署流程。
## 在线编辑器
- **语法高亮**:集成了现代代码编辑器,完美支持 JavaScript、Python、Shell、Go、TypeScript 等主流开发语言。
- **编辑器增强**:支持常见的代码查找与替换、自动缩进和括号匹配。
- **一键保存**:编辑后的内容将实时写入服务器端物理存储,配合 `定时任务` 可快速生效。
## 权限控制
- **安全防御**:默认只能在指定的 `scripts` 根路径内进行相关文件操作,防止跨目录读取系统敏感文件。
- **文件保护**:系统核心配置文件不可在脚本管理器中直接修改。
+25
View File
@@ -0,0 +1,25 @@
# 仓库同步 (Repo)
仓库同步允许taskpool直接以 Git 仓库的形式管理和更新脚本库,极大地方便了脚本的大规模分发与自动化部署。
## 同步源管理
- **青龙 (QL) 指令解析**:如果您曾经是青龙面板的用户,您可以直接粘贴类似的 `ql repo <url> <whitelist> <blacklist> <dependence> <branch>` 指令,系统将自动提取各项参数。
> [!IMPORTANT]
> **依赖管理说明**:由于该面板采用基于 Mise 的多版本语言管理系统,与青龙的全局环境不同,系统 **无法通过 `dependence` 字段自动安装依赖**。用户需要手动前往「语言依赖」页面,或者在终端中自己执行依赖,在对应的运行中安装脚本所需的依赖包。
- **Git 源管理**:支持从 **GitHub**, **GitLab**, **Gitee** 等主流代码托管平台同步脚本。
- **SSH/Token 访问**:支持私有仓库的访问,可以在环境变量中配置对应的 Git 鉴权秘钥。
## 扫描与注册规则
- **自动解析配置**:在同步代码至本地物理磁盘后,面板将深度扫描每个 `.js``.py` 文件。
- **配置探测**
- `new Env('任务名称')`:解析 JavaScript 脚本定义的展示名。
- `cron "0 0 * * *"`:自动提取文件头部的 Cron 注释规则。
- **白名单/黑名单**:通过正则表达式(Regex)过滤哪些子目录或特定命名的文件需要被注册为定时任务。
## 增量同步
- **Git 离线拉取**:支持增量更新,仅下载变更部分,降低带宽压力。
- **分支切换**:支持指定任意分支进行同步,方便用户在生产与测试环境间切换脚本源。
- **稀疏检出 (Sparse Checkout)**:如果仓库过于庞大,您可以配置仅同步特定的子文件夹以节省存储空间。
+36
View File
@@ -0,0 +1,36 @@
# 定时任务
定时任务是taskpool的核心模块,支持对各类多语言脚本、命令进行精细化执行管理。
## 任务属性
- **任务名称**:给任务起一个直观的名称,例如 `每日签到任务`
- **Cron 表达式**:支持标准 cron 规则(分、时、日、月、周)。
- **脚本路径**:关联到 `scripts` 目录下的具体脚本文件或直接输入 Shell 命令。
- **执行终端**:允许选择运行在 `本机` 或是指定的 `远程 Agent` 节点。
- **任务超时**:设定单次运行的最大时长,防止僵尸进程占用资源。
## 管理操作
- **启动/停止**:手动控制任务的状态,支持一键切换自动调度与临时暂停。
- **立即执行**:不等待 Cron 触发,即刻拉起脚本运行。
- **查看日志**:直接跳转到与该任务关联的最新执行历史详情。
- **批量管理**:支持对选中的多个任务执行批量禁用、启用或删除动作。
## 交互设计
- **预设 Cron 规则**:在编辑任务时,提供常用的 `每分钟执行``每小时整点` 等预设样式。
- **下次触发预测**:实时计算并展示任务下一次执行的北京时间,帮助验证调度逻辑是否符合预期。
## 特殊任务类型
除了标准的 Cron 定时触发,taskpool还支持以下特殊触发场景:
### 开机启动任务 (`taskpool_startup`)
当您在定时规则(Schedule)中填写 `taskpool_startup` 时,该任务将被标记为**系统启动任务**。
- **触发时机**: 面板主进程启动或重启完成后立即执行。
- **应用场景**:
- 自动挂载磁盘或网络共享。
- **环境预热**: 例如安装 PHP 编译依赖(参考 [PHP 编译依赖说明](languages.md#php-环境特别说明))。建议命令: `bash example/deps/install_php_env_deps.sh`
- 启动自定义的后台常驻服务。
+15
View File
@@ -0,0 +1,15 @@
# 终端命令
终端命令模块(Terminal)允许用户在 Web 端直接与服务器的 Shell 环境进行交互。
## 实时交互
- **WebSocket 双工连接**:不仅是单向的命令发送,您可以获得一个可以输入交互(如 `yes/no` 確認、`npm init` 交互等)的伪终端(PTY)。
- **实时输出回传**:秒级显示命令的执行结果,支持 ANSI 转义序列以正确渲染终端样式与彩色文本。
- **自定义工作目录**:可以选择在哪一个文件夹(如 `scripts` 或系统根目录)下启动终端。
## 常用指令
- `ls -la`:查看当前目录详细文件列表。
- `git status`:在 `scripts` 目录下查看仓库 Git 定位状态。
+62
View File
@@ -0,0 +1,62 @@
# 功能特性
taskpool不仅提供基础的脚本执行功能,还集成了众多的实用工具和管理模块。
## 数据仪表
- **运行状态概览**:实时展示系统运行状态、任务执行统计及资源消耗。
- **动态图表**:通过直观的图表展示任务成功率、并发趋势及系统负载。
## 定时任务管理
- **标准 Cron 表达式**:支持高度灵活的调度配置。
- **控制台快捷键**:常用规则一键选择。
- **手动触发执行**:支持临时执行任务。
- **任务超时控制**:通过配置 `timeout` 参数,系统会自动隔离并中止长时间运行的任务。
## 远程分布式执行 (Agents)
- **子节点管理**:支持注册多个远程 Agent 节点,实现分布式任务分发。
- **跨平台支持**Agent 可部署在 Linux、Windows、macOS 等不同系统,覆盖异构执行环境。
## 脚本文件管理
- **在线代码编辑器**:集成了现代代码编辑器,支持语法高亮和编辑。
- **文件树形结构**:直观展示项目内所有文件。
- **文件上传与解压**:支持单文件、多文件上传和对 ZIP 压缩包的在线解压。
- **文件管理**:支持在线进行创建、重命名、移动和删除操作。
## 在线终端
- **WebSocket 实时终端**:支持常用的 Shell 命令。
- **命令输出实时推流**:实时查看脚本运行的物理设备输出。
## 执行日志
- **任务执行历史**:记录每次运行的状态(成功/失败/超时)。
- **执行耗时统计**:自动统计任务耗时,辅助性能优化。
- **日志压缩存储**:通过对旧日志进行自动清理和压缩,规避存储空间占用问题。
## 环境变量管理 (Secret)
- **机密性管理**:对敏感字段(如脚本 Key、DB 密码)进行脱敏显示和加密存储。
- **全局环境隔离**:在不同脚本运行期间动态注入,确保持久化和隔离。
## 消息推送与系统通知
- **原生内置分发**:集成了企业微信、钉钉、飞书、Telegram、Bark、邮件等十余种主流渠道。
- **多事件灵活通知**:您可以配置「任务失败」、「服务下线」、「登录安全报警」等事件通知条件。
- **API 示例**:系统自动生成各种编程语言的一键集成代码片段,方便用户脚本集成。
- **消息日志**:详细记录每条推送消息的状态、接收人和尝试发送的日志,方便故障排查。
## 仓库任务同步
- **青龙指令兼容**:支持直接粘贴 `ql repo` 指令快速创建同步任务。
- **脚本自动注册**:自动扫描同步目录下的脚本文件,解析其中的 `new Env()` 名称和 `cron` 表达式。
- **灵活筛选规则**:支持通过正则表达式配置白名单、黑名单,精确控制哪些脚本需要转化为面板任务。
- **版本控制集成**:基于 Git 进行增量同步,支持分支切换和稀疏检出(Sparse Checkout)。
## 系统设置
- **数据备份与恢复**:支持全量数据的本地导出和一键导入恢复。
- **页面设置**:自定义站点标题、标语和分页显示逻辑。
+134
View File
@@ -0,0 +1,134 @@
# 前端定制 (WebUI)
taskpool支持完全接管和替换默认系统面板界面。你可以开发自己专属的前端主题,甚至添加自定义的前端交互功能,并打包为独立的 WebUI 资源包上传至系统应用。
> [!IMPORTANT]
> **安全与一致性维护声明**
> 更换前端包后,系统无法自动保障自定义前端的安全性,亦无法确保其与后续更新的后端 API 接口始终保持一致。**自定义前端包的更新、向后兼容维护与漏洞修复需完全由该前端资源提供者(或开发者)负责**。
---
## 快速使用
### 1. 网页端上传与切换
1. 进入系统后,点击导航栏的 **系统设置**
2. 切换到 **前端定制** 面板。
3. 点击右上角的 **上传前端资源包**,选择你打包好的 `.zip``.tar.gz``.tgz` 格式的前端资源包。
4. 上传成功后,列表会显示该包的信息(名称、版本、作者、状态等)。
5. 点击操作栏中的 **启用** 按钮,系统将自动重载并切换至你的自定义前端包。
> [!WARNING]
> 自定义前端包若存在 Bug 或打包不完整可能导致界面白屏。如果不慎应用了错误或不兼容的包,请使用下方命令行工具恢复。
### 2. 命令行 (CLI) 运维
当界面因异常白屏无法访问时,可以进入taskpool容器/服务器终端,使用 `taskpool webui` 命令一键管理或恢复:
- **一键恢复默认内置界面**
```bash
taskpool webui reset
```
- **查看已安装的资源包列表**
```bash
taskpool webui list
```
- **手动切换/启用前端包**
```bash
taskpool webui set <包名>
```
- **删除指定的前端包**
```bash
taskpool webui delete <包名>
```
---
## 开发自定义前端
taskpool的前端架构是完全解耦的。你可以选择以下两种方式之一来定制专属前端:
### 方式一:基于现有代码二次开发(推荐)
如果你只是想修改部分样式、布局,或者在原有功能基础上增加新特性,最简单的方式是 **Fork `taskpool` 项目**。
1. Fork 本项目并克隆代码到本地。
2. 直接在项目的 `web/` 目录下,对现有的 Vue3 源码进行修改与定制。
3. 修改完成后,利用项目自带的 Makefile 打包命令(见下方说明)一键将你的修改编译为独立的 WebUI 资源包。
### 方式二:从零开始全新开发
如果你想用自己熟悉的技术栈(如 React, Angular,或者是纯静态的 HTML/JS)完全重写整个面板,这也是完全支持的!你只需要按照下方的核心规范进行开发和打包即可。
### 1. 核心校验规则
taskpool后端提取并启用前端资源时,会执行以下强校验:
1. **压缩包根目录下必须包含 `index.html`**:作为单页应用 (SPA) 的静态入口文件。
2. **压缩包根目录下必须包含 `uimanifest.json`**:声明该前端包的元数据信息。
### 2. 配置文件 `uimanifest.json` 规范
在前端打包产物的根目录下(与 `index.html` 同级),必须创建一个 `uimanifest.json` 文件。格式示例如下:
```json
{
"name": "custom-neon-theme",
"version": "1.0.2",
"author": "YourName",
"description": "taskpool霓虹暗黑风定制前端主题",
"min_panel_version": "1.0.0"
}
```
*注:`name` 字段不能设置为 `"default"`(default 被保留作为内置前端的系统标识)。*
### 3. API 请求地址与开发环境代理
在独立开发自定义前端时,需要配置请求与后端的通信地址及代理:
- **后端默认服务地址与端口**
taskpool后端服务默认运行在端口 `8052` 上,本地调试 API 的基础 URL 通常为:
`http://127.0.0.1:8052/api/v1`
- **本地开发环境代理配置(以 Vite 为例)**:
为了避免跨域问题(CORS),推荐在前端开发服务器中设置代理。在 `vite.config.ts` 中配置示例如下:
```typescript
export default defineConfig({
server: {
proxy: {
'/api/v1': {
target: 'http://127.0.0.1:8052', // 本地运行的taskpool后端地址
changeOrigin: true
}
}
}
})
```
- **生产环境线上适配(相对路径)**:
前端包部署生效后,与后端处于同端口同域名下。后端会在返回的 `index.html` 的 `<head>` 中自动注入以下配置变量:
```html
<script>
window.__BASE_URL__ = ""; // 部署子路径前缀 (根据实际反代配置)
window.__API_VERSION__ = "/api/v1"; // API 接口版本前缀
</script>
```
建议在封装 Axios 或 Fetch 时,直接通过浏览器环境变量拼接相对路径作为 API 地址:
```typescript
const baseURL = `${window.location.origin}${window.__BASE_URL__ || ''}${window.__API_VERSION__ || '/api/v1'}`;
```
- **接口定义与类型参考**
默认系统中已经定义好了所有的后端 API 接口签名、传参格式以及 TS 类型声明。你在二次开发或自定义前端时,可以直接参考项目源码中的前端接口定义文件: `web/src/api/index.ts`。
---
## 打包前端资源包(现成)
你可以利用taskpool项目自带的 `Makefile` 脚本,在现在的前端页面进行修改,将开发好的前端项目快速编译打包成标准的 `.tar.gz` 前端资源包, 自己使用或者分享使用。
### 使用 Makefile 打包
在项目根目录下,运行以下指令(参数必须填写完整):
```bash
make pack-webui NAME=neon-theme VERSION=1.0.2 AUTHOR=MyName DESC="霓虹定制主题包"
```
该指令会自动执行以下步骤:
1. 进入 `web/` 目录并安装依赖;
2. 编译构建前端静态资源(默认输出到 `web/dist`);
3. 在 `web/dist` 中自动按参数生成校验所需的 `uimanifest.json`
4. 将该目录下所有文件使用 `tar` 命令进行 gzip 压缩打包;
5. 输出归档文件在项目根目录的 `bin/webui-neon-theme-1.0.2.tar.gz`,此包即可直接在面板中上传安装。
+39
View File
@@ -0,0 +1,39 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: "taskpool"
text: "极致轻量、高性能的自动化任务调度平台"
tagline: "采用 Go + Vue3 架构,专注于高性能与低系统开销。"
image:
src: /logo.svg
alt: TaskPool Logo
actions:
- theme: brand
text: 快速开始
link: /guide/introduction
- theme: alt
text: 查看源码
link: https://github.com/engigu/taskpool
features:
- title: 极致轻量
details: Docker/Compose 一键部署,无需复杂配置,开箱即用,资源分配合理。
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>'
- title: 任务调度
details: 支持标准 Cron 表达式,日志不落文件,规避频繁磁盘 IO 问题。
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'
- title: 多语言支持
details: 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理。
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>'
- title: 在线管理
details: 现代响应式 UI,集成在线编辑器、实时终端与 WebSocket 日志流。
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>'
- title: 消息推送
details: 内置主流推送渠道(微信、钉钉、飞书、Telegram 等),支持系统级事件通知。
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>'
- title: 安全稳健
details: 安全存储敏感配置,任务自动注入,登录防暴力破解,精细权限定制。
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2-1 4-2 7-2 2.5 0 4.5 1 6.5 2a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>'
---

Some files were not shown because too many files have changed in this diff Show More