mirror of
https://github.com/whyour/qinglong.git
synced 2026-08-12 03:10:48 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bb41ebe96 | ||
|
|
d67f33df39 | ||
|
|
24d539fc05 | ||
|
|
214e23a63d | ||
|
|
ddb3ab7ac3 | ||
|
|
da6dac8bf1 | ||
|
|
9b17a41987 | ||
|
|
cdfa6c3c3c | ||
|
|
1953022b99 | ||
|
|
38d84f9f31 | ||
|
|
e92ddb0aed | ||
|
|
a0d57c78c5 | ||
|
|
a942a5686f | ||
|
|
a1ccd9947d | ||
|
|
327c3a317b | ||
|
|
d3016431ce | ||
|
|
5fbff0e1c8 | ||
|
|
3044f63f03 | ||
|
|
369dd13212 | ||
|
|
b5a5fb3be6 | ||
|
|
0ee0b83207 | ||
|
|
5f2d5bb24a | ||
|
|
949d956aef | ||
|
|
96b4c90398 | ||
|
|
7d8feadc78 | ||
|
|
d1dfde3ca9 | ||
|
|
6796068523 | ||
|
|
05f8fd3805 | ||
|
|
946731ac8d | ||
|
|
617cf7e5b4 | ||
|
|
662adc0f9c | ||
|
|
23b41893a3 | ||
|
|
34d4526413 | ||
|
|
ad92e0c2c0 | ||
|
|
c14390cf41 | ||
|
|
fd6b4e4cde | ||
|
|
865e3035b9 | ||
|
|
d8d30c3619 | ||
|
|
4e803df51e | ||
|
|
e8ac195c96 | ||
|
|
c0b7527148 | ||
|
|
ca347c5854 | ||
|
|
4bee3fbdf4 | ||
|
|
b6537e27ef | ||
|
|
0e5b8bef4b | ||
|
|
e06c3571a8 | ||
|
|
c2eac8b8fd | ||
|
|
3aab1233bb | ||
|
|
2fe9470ff0 | ||
|
|
abad29cbf9 | ||
|
|
84d730d510 | ||
|
|
57d58c871e | ||
|
|
bb6d436c19 | ||
|
|
8bc0906949 | ||
|
|
7a8917f8e4 | ||
|
|
66f9457be8 | ||
|
|
0c5e3b5d04 | ||
|
|
400e4770de | ||
|
|
40d4de9017 | ||
|
|
3464c4da61 | ||
|
|
1315578878 | ||
|
|
a1ae08da58 | ||
|
|
66700ebe1a | ||
|
|
07bf0c705b | ||
|
|
fd516977e3 | ||
|
|
c39f4ef846 | ||
|
|
275d8af4e2 | ||
|
|
544c432f49 | ||
|
|
6bec52dca1 | ||
|
|
ce599d306f | ||
|
|
d53437d169 | ||
|
|
d526602d19 | ||
|
|
91b44914f6 | ||
|
|
4f6c93cc1c | ||
|
|
e326d89571 | ||
|
|
5f0dafa010 | ||
|
|
dc0b3f2eb2 | ||
|
|
3db716763d | ||
|
|
fae226745e | ||
|
|
9330650163 | ||
|
|
073de76a4a | ||
|
|
c61d1aa828 | ||
|
|
33fa3aca99 | ||
|
|
c772fc9527 | ||
|
|
c5d2aa3aba | ||
|
|
02a05f06bd | ||
|
|
3b0f55caf4 | ||
|
|
6a3dd4f83c | ||
|
|
177cd3de81 | ||
|
|
d473c3ae88 | ||
|
|
ee2fbe5335 | ||
|
|
48abf44ceb | ||
|
|
03c7031a3c | ||
|
|
0e5de4a824 | ||
|
|
af96bd98ac | ||
|
|
08ef509e27 | ||
|
|
fbeb4f4a6c | ||
|
|
eb09a417a1 | ||
|
|
e84ddb6cfc | ||
|
|
d01ec3b310 | ||
|
|
45fac29804 | ||
|
|
72d4c8bfd4 | ||
|
|
8f23c61fee | ||
|
|
e41eed81ae | ||
|
|
58f5fe763f | ||
|
|
214241797d | ||
|
|
aedd48c9c4 | ||
|
|
90ddf0fb57 | ||
|
|
c9fc9b4b45 | ||
|
|
8fdc69421c | ||
|
|
1deb264913 | ||
|
|
1f2fd8ac02 | ||
|
|
06aa07329f | ||
|
|
4cb9f57479 | ||
|
|
c369514741 | ||
|
|
0e28e1b6c4 | ||
|
|
73f8f3c5fa | ||
|
|
494ccb3c08 | ||
|
|
399728b433 | ||
|
|
52a1de5063 | ||
|
|
18f27a9a69 | ||
|
|
07951964a1 | ||
|
|
a1f888af59 | ||
|
|
f7472b6e74 | ||
|
|
a7baeba755 | ||
|
|
e4f733320d | ||
|
|
55c92dc320 | ||
|
|
50769c43dd | ||
|
|
0587644a6b | ||
|
|
87b934aafe | ||
|
|
7a92e7c6ab | ||
|
|
1d8403c0ec | ||
|
|
ef9e38f167 | ||
|
|
c9bd053fbd | ||
|
|
57939391b9 | ||
|
|
394e96bbf8 | ||
|
|
47c194c1f4 | ||
|
|
7d65d96ebd | ||
|
|
224000b63b | ||
|
|
1c18668bad | ||
|
|
f94582b68d | ||
|
|
eb1c00984c | ||
|
|
1a185f5682 | ||
|
|
b6ea8565ec | ||
|
|
2e94d58758 | ||
|
|
472a3088df | ||
|
|
ec3d61a713 | ||
|
|
95459c33ed | ||
|
|
5459b72f63 | ||
|
|
05db2b1df8 | ||
|
|
c3072e7712 | ||
|
|
3fafe4d24d | ||
|
|
32bccb3f3e | ||
|
|
ac04478d1d | ||
|
|
8a18baa921 | ||
|
|
9a399f8de8 | ||
|
|
da639a1f8f | ||
|
|
425e49675a | ||
|
|
8174762c18 | ||
|
|
710a107e73 | ||
|
|
d871585eee | ||
|
|
729b405b0f | ||
|
|
71a7c1b9d3 | ||
|
|
40a831f3a2 | ||
|
|
1befa1bb8c | ||
|
|
8009634b44 | ||
|
|
03a23f6f31 | ||
|
|
124e01e93a | ||
|
|
2370923a75 | ||
|
|
256e4e7a83 | ||
|
|
c0ec063333 | ||
|
|
576408de01 | ||
|
|
cf94ecfb11 |
+13
-11
@@ -1,14 +1,16 @@
|
|||||||
UPDATE_PORT=5300
|
GRPC_PORT=5500
|
||||||
PUBLIC_PORT=5400
|
BACK_PORT=5700
|
||||||
CRON_PORT=5500
|
|
||||||
BACK_PORT=5600
|
|
||||||
PORT=5700
|
|
||||||
|
|
||||||
LOG_LEVEL='debug'
|
# 服务绑定地址,默认 ::(IPv6 通配,双栈系统同时支持 IPv4/IPv6)
|
||||||
|
# 纯 IPv4 环境自动 fallback 到 0.0.0.0,也可手动指定
|
||||||
|
# BIND_HOST=0.0.0.0
|
||||||
|
# BIND_HOST_GRPC=0.0.0.0
|
||||||
|
|
||||||
SECRET='whyour'
|
LOG_LEVEL='info'
|
||||||
|
|
||||||
QINIU_AK=''
|
JWT_SECRET=
|
||||||
QINIU_SK=''
|
JWT_EXPIRES_IN=
|
||||||
QINIU_SCOPE=''
|
|
||||||
TEMP=''
|
QINIU_AK=
|
||||||
|
QINIU_SK=
|
||||||
|
QINIU_SCOPE=
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
name: Bug Fixer
|
||||||
|
description: Fix this issue following our error handling pattern.
|
||||||
|
---
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
name: Build And Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- "*.md"
|
||||||
|
branches:
|
||||||
|
- "master"
|
||||||
|
- "develop"
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
code_gitlab:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Push to GitLab
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
git remote add gitlab git@gitlab.com:whyour/qinglong.git 2>/dev/null
|
||||||
|
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed"
|
||||||
|
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
|
||||||
|
|
||||||
|
code_gitee:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Push to Gitee
|
||||||
|
env:
|
||||||
|
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null
|
||||||
|
|
||||||
|
if git push --force --all gitee 2>&1; then
|
||||||
|
echo "::notice::Gitee push --all succeeded"
|
||||||
|
else
|
||||||
|
echo "::warning::Push failed, trying to create repo via API..."
|
||||||
|
curl -sS --connect-timeout 30 --max-time 60 \
|
||||||
|
-X POST "https://gitee.com/api/v5/user/repos" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"qinglong","private":"false"}' \
|
||||||
|
"?access_token=$GITEE_TOKEN" 2>/dev/null
|
||||||
|
git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry"
|
||||||
|
fi
|
||||||
|
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
|
||||||
|
|
||||||
|
build-static:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: "8.3.1"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: build front and back
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build:front
|
||||||
|
pnpm build:back
|
||||||
|
|
||||||
|
- name: copy to static repo
|
||||||
|
env:
|
||||||
|
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||||
|
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
mkdir -p tmp
|
||||||
|
cd ./tmp
|
||||||
|
cp -rf ../static/* ./
|
||||||
|
git init -b ${GITHUB_BRANCH} && git add .
|
||||||
|
git config --local user.name 'github-actions[bot]'
|
||||||
|
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
|
||||||
|
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
|
||||||
|
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
||||||
|
|
||||||
|
static_gitlab:
|
||||||
|
needs: build-static
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Push qinglong-static to GitLab
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
|
||||||
|
cd static-mirror
|
||||||
|
git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null
|
||||||
|
git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed"
|
||||||
|
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed"
|
||||||
|
|
||||||
|
static_gitee:
|
||||||
|
needs: build-static
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Push qinglong-static to Gitee
|
||||||
|
env:
|
||||||
|
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
|
||||||
|
cd static-mirror
|
||||||
|
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
|
||||||
|
|
||||||
|
if git push --force --all gitee 2>&1; then
|
||||||
|
echo "::notice::Gitee static push succeeded"
|
||||||
|
else
|
||||||
|
echo "::warning::Push failed, trying to create repo via API..."
|
||||||
|
curl -sS --connect-timeout 30 --max-time 60 \
|
||||||
|
-X POST "https://gitee.com/api/v5/user/repos" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"qinglong-static","private":"false"}' \
|
||||||
|
"?access_token=$GITEE_TOKEN" 2>/dev/null
|
||||||
|
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
|
||||||
|
fi
|
||||||
|
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
|
||||||
|
|
||||||
|
build-alpine:
|
||||||
|
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
needs: build-static
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: "8.3.1"
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Read version from version.yaml
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Setup timezone
|
||||||
|
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v6
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ github.repository }}
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
flavor: |
|
||||||
|
latest=false
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
|
||||||
|
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||||
|
type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
cache-image: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Build and push (Alpine)
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
build-args: |
|
||||||
|
MAINTAINER=${{ github.repository_owner }}
|
||||||
|
QL_BRANCH=${{ github.ref_name }}
|
||||||
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
|
network: host
|
||||||
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
|
||||||
|
cache-to: type=registry,ref=whyour/qinglong:cache-alpine,mode=max
|
||||||
|
|
||||||
|
build-debian:
|
||||||
|
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
needs: build-static
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: "8.3.1"
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Read version from version.yaml
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Setup timezone
|
||||||
|
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v6
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ github.repository }}
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
flavor: |
|
||||||
|
latest=false
|
||||||
|
tags: |
|
||||||
|
type=raw,value=debian-dev,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
|
||||||
|
type=raw,value=debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||||
|
type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
cache-image: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Build and push (Debian)
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
build-args: |
|
||||||
|
MAINTAINER=${{ github.repository_owner }}
|
||||||
|
QL_BRANCH=${{ github.ref_name }}
|
||||||
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
|
network: host
|
||||||
|
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile.debian
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
||||||
|
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
|
||||||
|
|
||||||
|
build-alpine310:
|
||||||
|
if: ${{ github.ref_name == 'master' }}
|
||||||
|
needs: build-static
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: "8.3.1"
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Read version from version.yaml
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Setup timezone
|
||||||
|
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
cache-image: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Build and push (Alpine Python 3.10)
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
build-args: |
|
||||||
|
MAINTAINER=${{ github.repository_owner }}
|
||||||
|
QL_BRANCH=${{ github.ref_name }}
|
||||||
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
|
network: host
|
||||||
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile.310
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
whyour/qinglong:python3.10
|
||||||
|
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
|
||||||
|
cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
|
||||||
|
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
|
||||||
|
|
||||||
|
build-debian310:
|
||||||
|
if: ${{ github.ref_name == 'master' }}
|
||||||
|
needs: build-static
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: "8.3.1"
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Read version from version.yaml
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Setup timezone
|
||||||
|
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
cache-image: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Build and push (Debian Python 3.10)
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
build-args: |
|
||||||
|
MAINTAINER=${{ github.repository_owner }}
|
||||||
|
QL_BRANCH=${{ github.ref_name }}
|
||||||
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
|
network: host
|
||||||
|
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile.debian310
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
whyour/qinglong:debian-python3.10
|
||||||
|
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
|
||||||
|
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
|
||||||
|
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
|
||||||
|
|
||||||
|
publish:
|
||||||
|
if: ${{ github.ref_name == 'master' }}
|
||||||
|
needs: [build-alpine, build-debian]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: "8.3.1"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: build front and back
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build:front
|
||||||
|
pnpm build:back
|
||||||
|
|
||||||
|
- name: publish npm package
|
||||||
|
run: |
|
||||||
|
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
|
||||||
|
npm publish
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
name: Build And Push Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths-ignore:
|
|
||||||
- "*.md"
|
|
||||||
branches:
|
|
||||||
- "master"
|
|
||||||
- "develop"
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
schedule:
|
|
||||||
- cron: "00 20 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
to_gitlab:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: wearerequired/git-mirror-action@v1
|
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
|
||||||
with:
|
|
||||||
source-repo: https://github.com/whyour/qinglong.git
|
|
||||||
destination-repo: git@gitlab.com:whyour/qinglong.git
|
|
||||||
|
|
||||||
to_gitee:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: wearerequired/git-mirror-action@v1
|
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
|
||||||
with:
|
|
||||||
source-repo: https://github.com/whyour/qinglong.git
|
|
||||||
destination-repo: git@gitee.com:whyour/qinglong.git
|
|
||||||
|
|
||||||
build-static:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: "8.3.1"
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: build front and back
|
|
||||||
run: |
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm build:front
|
|
||||||
pnpm build:back
|
|
||||||
|
|
||||||
- name: copy to static repo
|
|
||||||
env:
|
|
||||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
|
||||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
|
||||||
REPO_GITEE: git@gitee.com:whyour/qinglong-static.git
|
|
||||||
REPO_GITLAB: git@gitlab.com:whyour/qinglong-static.git
|
|
||||||
PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
|
||||||
run: |
|
|
||||||
mkdir -p tmp
|
|
||||||
cd ./tmp
|
|
||||||
cp -rf ../static/* ./
|
|
||||||
git init -b ${GITHUB_BRANCH} && git add .
|
|
||||||
git config --local user.name 'github-actions[bot]'
|
|
||||||
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
|
|
||||||
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
|
|
||||||
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
|
||||||
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${PRIVATE_KEY}" > ~/.ssh/id_rsa
|
|
||||||
chmod 600 ~/.ssh/id_rsa
|
|
||||||
|
|
||||||
export GIT_SSH_COMMAND="ssh -v -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -l git"
|
|
||||||
git remote add gitee "${REPO_GITEE}"
|
|
||||||
git remote add gitlab "${REPO_GITLAB}"
|
|
||||||
git gc
|
|
||||||
git push --force --quiet gitee ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
|
||||||
git push --force --quiet gitlab ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: build-static
|
|
||||||
|
|
||||||
# 由于 ubuntu-latest 中使用 linux6.x 内核 linux/s390x npm 无法使用
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
# runs-on: self-hosted
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: "8.3.1"
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: Setup timezone
|
|
||||||
uses: szenius/set-timezone@v2.0
|
|
||||||
with:
|
|
||||||
timezoneLinux: Asia/Shanghai
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ github.repository }}
|
|
||||||
ghcr.io/${{ github.repository }}
|
|
||||||
# generate Docker tags based on the following events/attributes
|
|
||||||
# nightly, master, pr-2, 1.2.3, 1.2, 1
|
|
||||||
flavor: |
|
|
||||||
latest=false
|
|
||||||
tags: |
|
|
||||||
type=schedule,pattern=nightly
|
|
||||||
type=edge
|
|
||||||
type=ref,event=pr
|
|
||||||
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
|
|
||||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
id: docker_build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
build-args: |
|
|
||||||
MAINTAINER=${{ github.repository_owner }}
|
|
||||||
QL_BRANCH=${{ github.ref_name }}
|
|
||||||
SOURCE_COMMIT=${{ github.sha }}
|
|
||||||
network: host
|
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
|
||||||
# platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
|
||||||
context: .
|
|
||||||
file: ./docker/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=registry,ref=whyour/qinglong:cache
|
|
||||||
cache-to: type=registry,ref=whyour/qinglong:cache,mode=max
|
|
||||||
|
|
||||||
- name: Image digest
|
|
||||||
run: |
|
|
||||||
echo ${{ steps.docker_build.outputs.digest }}
|
|
||||||
|
|
||||||
build310:
|
|
||||||
if: ${{ github.ref_name == 'master' }}
|
|
||||||
needs: build-static
|
|
||||||
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: "8.3.1"
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: Setup timezone
|
|
||||||
uses: szenius/set-timezone@v2.0
|
|
||||||
with:
|
|
||||||
timezoneLinux: Asia/Shanghai
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build and push python3.10
|
|
||||||
id: docker_build_310
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
build-args: |
|
|
||||||
MAINTAINER=${{ github.repository_owner }}
|
|
||||||
QL_BRANCH=${{ github.ref_name }}
|
|
||||||
SOURCE_COMMIT=${{ github.sha }}
|
|
||||||
network: host
|
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
|
||||||
context: .
|
|
||||||
file: ./docker/310.Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: whyour/qinglong:python3.10
|
|
||||||
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10
|
|
||||||
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max
|
|
||||||
|
|
||||||
- name: Image digest
|
|
||||||
run: |
|
|
||||||
echo ${{ steps.docker_build_310.outputs.digest }}
|
|
||||||
@@ -28,3 +28,10 @@ __pycache__
|
|||||||
/shell/preload/notify.*
|
/shell/preload/notify.*
|
||||||
/shell/preload/*-notify.json
|
/shell/preload/*-notify.json
|
||||||
/shell/preload/__ql_notify__.*
|
/shell/preload/__ql_notify__.*
|
||||||
|
/shell/preload/lang_env.sh
|
||||||
|
|
||||||
|
.deepseek/
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# local Kubernetes overlays
|
||||||
|
/deploy/kubernetes/overlays/local/
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
/.tmp/
|
||||||
|
/.github/
|
||||||
|
/.vscode/
|
||||||
|
/.history/
|
||||||
|
/back/**/*.ts
|
||||||
|
/back/**/*.json
|
||||||
|
/cli/
|
||||||
|
/data/
|
||||||
|
/src/
|
||||||
|
/static/**/*.js.map
|
||||||
|
/static/**/*.gz
|
||||||
|
/.editorconfig
|
||||||
|
/.gitignore
|
||||||
|
/.prettierignore
|
||||||
|
/.prettierrc
|
||||||
|
/.umirc.ts
|
||||||
|
/nodemon.json
|
||||||
|
/pnpm-lock.yaml
|
||||||
|
/tsconfig.back.json
|
||||||
|
/tsconfig.json
|
||||||
|
/typings.d.ts
|
||||||
|
/.env
|
||||||
@@ -16,18 +16,8 @@ export default defineConfig({
|
|||||||
favicons: [`https://qn.whyour.cn/favicon.svg`],
|
favicons: [`https://qn.whyour.cn/favicon.svg`],
|
||||||
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
|
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
|
||||||
proxy: {
|
proxy: {
|
||||||
[`${baseUrl}api/update`]: {
|
|
||||||
target: 'http://127.0.0.1:5300/',
|
|
||||||
changeOrigin: true,
|
|
||||||
pathRewrite: { [`^${baseUrl}api/update`]: '/api' },
|
|
||||||
},
|
|
||||||
[`${baseUrl}api/public`]: {
|
|
||||||
target: 'http://127.0.0.1:5400/',
|
|
||||||
changeOrigin: true,
|
|
||||||
pathRewrite: { [`^${baseUrl}api/public`]: '/api' },
|
|
||||||
},
|
|
||||||
[`${baseUrl}api`]: {
|
[`${baseUrl}api`]: {
|
||||||
target: 'http://127.0.0.1:5600/',
|
target: 'http://127.0.0.1:5700/',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
pathRewrite: { [`^${baseUrl}api`]: '/api' },
|
pathRewrite: { [`^${baseUrl}api`]: '/api' },
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **qinglong** (2778 symbols, 6698 relationships, 233 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
|
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "develop"})`.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/qinglong/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/qinglong/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/qinglong/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/qinglong/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
+15
-2
@@ -18,9 +18,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
|||||||
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
|
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
|
||||||
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
|
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
|
||||||
|
|
||||||
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
|
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
|
||||||
|
|
||||||
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
|
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|

|
||||||
@@ -41,11 +41,24 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
|||||||
|
|
||||||
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
|
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
|
||||||
|
|
||||||
|
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull whyour/qinglong:latest
|
docker pull whyour/qinglong:latest
|
||||||
docker pull whyour/qinglong:debian
|
docker pull whyour/qinglong:debian
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When running the `debian` image as a non-root user, specify `--user qinglong`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-v /path/to/ql/data:/ql/data \
|
||||||
|
-p 5700:5700 \
|
||||||
|
--user qinglong \
|
||||||
|
--name qinglong \
|
||||||
|
whyour/qinglong:debian
|
||||||
|
```
|
||||||
|
|
||||||
### npm
|
### npm
|
||||||
|
|
||||||
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
|
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
|||||||
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
|
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
|
||||||
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
|
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
|
||||||
|
|
||||||
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
|
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
|
||||||
|
|
||||||
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
|
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|

|
||||||
@@ -43,11 +43,24 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
|||||||
|
|
||||||
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
|
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
|
||||||
|
|
||||||
|
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull whyour/qinglong:latest
|
docker pull whyour/qinglong:latest
|
||||||
docker pull whyour/qinglong:debian
|
docker pull whyour/qinglong:debian
|
||||||
```
|
```
|
||||||
|
|
||||||
|
使用 `debian` 镜像以非 root 用户运行时,需指定 `--user qinglong`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-v /path/to/ql/data:/ql/data \
|
||||||
|
-p 5700:5700 \
|
||||||
|
--user qinglong \
|
||||||
|
--name qinglong \
|
||||||
|
whyour/qinglong:debian
|
||||||
|
```
|
||||||
|
|
||||||
### npm
|
### npm
|
||||||
|
|
||||||
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
|
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import 'express';
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace Express {
|
|
||||||
interface Request {
|
|
||||||
platform: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-9
@@ -4,8 +4,9 @@ import { Logger } from 'winston';
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
import { join } from 'path';
|
import { join, basename } from 'path';
|
||||||
import { SAMPLE_FILES } from '../config/const';
|
import { SAMPLE_FILES } from '../config/const';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
import ConfigService from '../services/config';
|
import ConfigService from '../services/config';
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
@@ -14,7 +15,7 @@ export default (app: Router) => {
|
|||||||
app.use('/configs', route);
|
app.use('/configs', route);
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/sample',
|
'/samples',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
res.send({
|
res.send({
|
||||||
@@ -71,15 +72,24 @@ export default (app: Router) => {
|
|||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
const { name, content } = req.body;
|
const { name, content } = req.body;
|
||||||
if (config.blackFileList.includes(name)) {
|
// Resolve path first to prevent traversal attacks
|
||||||
res.send({ code: 403, message: '文件无法访问' });
|
let basePath = config.configPath;
|
||||||
}
|
|
||||||
let path = join(config.configPath, name);
|
|
||||||
if (name.startsWith('data/scripts/')) {
|
if (name.startsWith('data/scripts/')) {
|
||||||
path = join(config.rootPath, name);
|
basePath = join(config.rootPath, 'data/scripts');
|
||||||
}
|
}
|
||||||
await writeFileWithLock(path, content);
|
const cleanName = name.replace(/^data\/scripts\//, '');
|
||||||
res.send({ code: 200, message: '保存成功' });
|
const resolvedPath = join(basePath, cleanName);
|
||||||
|
const normalized = join(resolvedPath);
|
||||||
|
// Verify the resolved path stays within allowed directory
|
||||||
|
if (!normalized.startsWith(basePath)) {
|
||||||
|
return res.send({ code: 403, message: t('文件路径无效') });
|
||||||
|
}
|
||||||
|
// Check blacklist on actual filename (not user input)
|
||||||
|
if (config.blackFileList.includes(basename(normalized))) {
|
||||||
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
|
}
|
||||||
|
await writeFileWithLock(normalized, content);
|
||||||
|
res.send({ code: 200, message: t('保存成功') });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-3
@@ -5,6 +5,11 @@ import CronService from '../services/cron';
|
|||||||
import CronViewService from '../services/cronView';
|
import CronViewService from '../services/cronView';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
import { commonCronSchema } from '../validation/schedule';
|
import { commonCronSchema } from '../validation/schedule';
|
||||||
|
import {
|
||||||
|
RunningInstanceModel,
|
||||||
|
InstanceStatus,
|
||||||
|
} from '../data/runningInstance';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
@@ -60,7 +65,7 @@ export default (app: Router) => {
|
|||||||
try {
|
try {
|
||||||
const cronViewService = Container.get(CronViewService);
|
const cronViewService = Container.get(CronViewService);
|
||||||
if (req.body.type === 1) {
|
if (req.body.type === 1) {
|
||||||
return res.send({ code: 400, message: '参数错误' });
|
return res.send({ code: 400, message: t('参数错误') });
|
||||||
} else {
|
} else {
|
||||||
const data = await cronViewService.update(req.body);
|
const data = await cronViewService.update(req.body);
|
||||||
return res.send({ code: 200, data });
|
return res.send({ code: 200, data });
|
||||||
@@ -307,8 +312,8 @@ export default (app: Router) => {
|
|||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
const cronService = Container.get(CronService);
|
const cronService = Container.get(CronService);
|
||||||
const data = await cronService.log(req.params.id);
|
const result = await cronService.log(req.params.id);
|
||||||
return res.send({ code: 200, data });
|
return res.send({ code: 200, data: result.content, logStatus: result.status });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
@@ -429,6 +434,7 @@ export default (app: Router) => {
|
|||||||
log_path: Joi.string().optional().allow(null),
|
log_path: Joi.string().optional().allow(null),
|
||||||
last_running_time: Joi.number().optional().allow(null),
|
last_running_time: Joi.number().optional().allow(null),
|
||||||
last_execution_time: Joi.number().optional().allow(null),
|
last_execution_time: Joi.number().optional().allow(null),
|
||||||
|
exit_code: Joi.number().optional().allow(null),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -446,6 +452,48 @@ export default (app: Router) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/:id/instances',
|
||||||
|
celebrate({
|
||||||
|
params: Joi.object({
|
||||||
|
id: Joi.number().required(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const instances = await RunningInstanceModel.findAll({
|
||||||
|
where: {
|
||||||
|
cron_id: req.params.id,
|
||||||
|
},
|
||||||
|
order: [['started_at', 'DESC']],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
return res.send({ code: 200, data: instances });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.post(
|
||||||
|
'/:id/instances/:instanceId/stop',
|
||||||
|
celebrate({
|
||||||
|
params: Joi.object({
|
||||||
|
id: Joi.number().required(),
|
||||||
|
instanceId: Joi.number().required(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request<{ id: number; instanceId: number }>, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const cronService = Container.get(CronService);
|
||||||
|
const data = await cronService.stopInstance(req.params.instanceId);
|
||||||
|
return res.send(data);
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/:id/logs',
|
'/:id/logs',
|
||||||
celebrate({
|
celebrate({
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
import { fn, col, where, Op } from 'sequelize';
|
||||||
|
import { CrontabModel } from '../data/cron';
|
||||||
|
import { CrontabStatModel } from '../data/cronStats';
|
||||||
|
import {
|
||||||
|
RunningInstanceModel,
|
||||||
|
InstanceStatus,
|
||||||
|
} from '../data/runningInstance';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import os from 'os';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
|
import { t, tf } from '../shared/i18n';
|
||||||
|
|
||||||
|
const route = Router();
|
||||||
|
|
||||||
|
export default (app: Router) => {
|
||||||
|
app.use('/dashboard', route);
|
||||||
|
|
||||||
|
route.post('/record', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { ref_id, code, elapsed } = req.body;
|
||||||
|
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
|
||||||
|
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
const isSuccess = code === 0 ? 1 : 0;
|
||||||
|
const isFail = code !== 0 ? 1 : 0;
|
||||||
|
const elapsedMs = (Number(elapsed) || 0) * 1000;
|
||||||
|
|
||||||
|
const existing = await CrontabStatModel.findOne({
|
||||||
|
where: { ref_id: Number(ref_id), date: today },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await CrontabStatModel.update(
|
||||||
|
{
|
||||||
|
run_count: (existing.run_count || 0) + 1,
|
||||||
|
success_count: (existing.success_count || 0) + isSuccess,
|
||||||
|
fail_count: (existing.fail_count || 0) + isFail,
|
||||||
|
total_time: (existing.total_time || 0) + elapsedMs,
|
||||||
|
max_time: Math.max(existing.max_time || 0, elapsedMs),
|
||||||
|
},
|
||||||
|
{ where: { id: existing.id } },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await CrontabStatModel.create({
|
||||||
|
ref_id: Number(ref_id),
|
||||||
|
date: today,
|
||||||
|
run_count: 1,
|
||||||
|
success_count: isSuccess,
|
||||||
|
fail_count: isFail,
|
||||||
|
total_time: elapsedMs,
|
||||||
|
max_time: elapsedMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send({ code: 200 });
|
||||||
|
} catch (e) {
|
||||||
|
res.send({ code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/overview',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const [total, enabled, disabled, stats] = await Promise.all([
|
||||||
|
CrontabModel.count(),
|
||||||
|
CrontabModel.count({ where: { isDisabled: 0 } }),
|
||||||
|
CrontabModel.count({ where: { isDisabled: 1 } }),
|
||||||
|
CrontabStatModel.findOne({
|
||||||
|
attributes: [
|
||||||
|
[fn('SUM', col('run_count')), 'total_runs'],
|
||||||
|
[fn('SUM', col('success_count')), 'total_success'],
|
||||||
|
[fn('SUM', col('fail_count')), 'total_fail'],
|
||||||
|
[fn('SUM', col('total_time')), 'total_time'],
|
||||||
|
],
|
||||||
|
where: { date: today },
|
||||||
|
raw: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const row = stats as any;
|
||||||
|
const totalRuns = Number(row?.total_runs) || 0;
|
||||||
|
const totalSuccess = Number(row?.total_success) || 0;
|
||||||
|
const totalFail = Number(row?.total_fail) || 0;
|
||||||
|
const totalTime = Number(row?.total_time) || 0;
|
||||||
|
|
||||||
|
res.send({
|
||||||
|
code: 200,
|
||||||
|
data: {
|
||||||
|
total,
|
||||||
|
enabled,
|
||||||
|
disabled,
|
||||||
|
todayRuns: totalRuns,
|
||||||
|
todaySuccess: totalSuccess,
|
||||||
|
todayFail: totalFail,
|
||||||
|
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0',
|
||||||
|
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/trend',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const days = parseInt(req.query.days as string) || 7;
|
||||||
|
const dates: string[] = [];
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (await CrontabStatModel.findAll({
|
||||||
|
attributes: [
|
||||||
|
'date',
|
||||||
|
[fn('SUM', col('run_count')), 'total_runs'],
|
||||||
|
[fn('SUM', col('success_count')), 'total_success'],
|
||||||
|
[fn('SUM', col('fail_count')), 'total_fail'],
|
||||||
|
],
|
||||||
|
where: {
|
||||||
|
date: { [Op.in]: dates },
|
||||||
|
},
|
||||||
|
group: ['date'],
|
||||||
|
order: [['date', 'ASC']],
|
||||||
|
raw: true,
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
const dataMap: Record<string, any> = {};
|
||||||
|
rows.forEach((r: any) => {
|
||||||
|
dataMap[r.date] = {
|
||||||
|
total: Number(r.total_runs) || 0,
|
||||||
|
success: Number(r.total_success) || 0,
|
||||||
|
fail: Number(r.total_fail) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = dates.map((d) => ({
|
||||||
|
date: dayjs(d).format('MM-DD'),
|
||||||
|
...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/top-time',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const rows = (await CrontabStatModel.findAll({
|
||||||
|
attributes: [
|
||||||
|
'ref_id',
|
||||||
|
[fn('SUM', col('total_time')), 'total_time'],
|
||||||
|
[fn('SUM', col('run_count')), 'run_count'],
|
||||||
|
[fn('MAX', col('max_time')), 'max_time'],
|
||||||
|
],
|
||||||
|
where: { date: today, run_count: { [Op.gt]: 0 } },
|
||||||
|
group: ['ref_id'],
|
||||||
|
order: [[fn('SUM', col('total_time')), 'DESC']],
|
||||||
|
limit: 5,
|
||||||
|
raw: true,
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
const ids = rows.map((r) => Number(r.ref_id));
|
||||||
|
const crons = await CrontabModel.findAll({
|
||||||
|
where: { id: { [Op.in]: ids } },
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
const nameMap: Record<number, string> = {};
|
||||||
|
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
|
||||||
|
|
||||||
|
const data = rows.map((r: any, i) => ({
|
||||||
|
rank: i + 1,
|
||||||
|
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
|
||||||
|
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
|
||||||
|
maxTime: Number(r.max_time),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/top-count',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const rows = (await CrontabStatModel.findAll({
|
||||||
|
attributes: [
|
||||||
|
'ref_id',
|
||||||
|
[fn('SUM', col('run_count')), 'run_count'],
|
||||||
|
[fn('SUM', col('total_time')), 'total_time'],
|
||||||
|
[fn('SUM', col('success_count')), 'success_count'],
|
||||||
|
],
|
||||||
|
where: { date: today, run_count: { [Op.gt]: 0 } },
|
||||||
|
group: ['ref_id'],
|
||||||
|
order: [[fn('SUM', col('run_count')), 'DESC']],
|
||||||
|
limit: 5,
|
||||||
|
raw: true,
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
const ids = rows.map((r) => Number(r.ref_id));
|
||||||
|
const crons = await CrontabModel.findAll({
|
||||||
|
where: { id: { [Op.in]: ids } },
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
const nameMap: Record<number, string> = {};
|
||||||
|
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
|
||||||
|
|
||||||
|
const data = rows.map((r: any, i) => ({
|
||||||
|
rank: i + 1,
|
||||||
|
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
|
||||||
|
runCount: Number(r.run_count),
|
||||||
|
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
|
||||||
|
successRate:
|
||||||
|
Number(r.run_count) > 0
|
||||||
|
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1)
|
||||||
|
: '0',
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/runtime',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const runningInstances = await RunningInstanceModel.findAll({
|
||||||
|
where: {
|
||||||
|
status: InstanceStatus.running,
|
||||||
|
},
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedCrons = await CrontabModel.findAll({
|
||||||
|
where: {
|
||||||
|
status: 3, // queued
|
||||||
|
},
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch cron names for running instances
|
||||||
|
const cronIds = [
|
||||||
|
...new Set(runningInstances.map((i: any) => i.cron_id)),
|
||||||
|
];
|
||||||
|
const crons =
|
||||||
|
cronIds.length > 0
|
||||||
|
? await CrontabModel.findAll({
|
||||||
|
where: { id: cronIds },
|
||||||
|
raw: true,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
|
||||||
|
|
||||||
|
const now = dayjs().unix();
|
||||||
|
const running = runningInstances.map((inst: any) => {
|
||||||
|
const cron = cronMap.get(inst.cron_id);
|
||||||
|
return {
|
||||||
|
instanceId: inst.id,
|
||||||
|
id: inst.cron_id,
|
||||||
|
name: cron?.name || cron?.command || tf('任务#%s', inst.cron_id),
|
||||||
|
pid: inst.pid,
|
||||||
|
elapsed: inst.started_at ? now - inst.started_at : 0,
|
||||||
|
logPath: inst.log_path,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayAgo = dayjs().subtract(24, 'hour').unix();
|
||||||
|
const idleTasks = await CrontabModel.findAll({
|
||||||
|
where: {
|
||||||
|
isDisabled: 0,
|
||||||
|
status: 1,
|
||||||
|
last_execution_time: { [Op.lt]: dayAgo },
|
||||||
|
},
|
||||||
|
order: [['last_execution_time', 'ASC']],
|
||||||
|
limit: 5,
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send({
|
||||||
|
code: 200,
|
||||||
|
data: {
|
||||||
|
runningCount: running.length,
|
||||||
|
queuedCount: queuedCrons.length,
|
||||||
|
running,
|
||||||
|
idleTasks: idleTasks.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name || c.command || tf('任务#%s', c.id),
|
||||||
|
lastRun: c.last_execution_time
|
||||||
|
? dayjs.unix(c.last_execution_time).format('MM-DD HH:mm')
|
||||||
|
: '-',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/labels',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
const [crons, stats] = (await Promise.all([
|
||||||
|
CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }),
|
||||||
|
CrontabStatModel.findAll({ where: { date: today }, raw: true }),
|
||||||
|
]));
|
||||||
|
|
||||||
|
const statMap: Record<number, any> = {};
|
||||||
|
stats.forEach((s: any) => { statMap[s.ref_id] = s; });
|
||||||
|
|
||||||
|
const labelMap: Record<string, { count: number; runs: number; success: number; totalTime: number }> = {};
|
||||||
|
crons.forEach((c) => {
|
||||||
|
let rawLabels = c.labels;
|
||||||
|
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
|
||||||
|
const labels: string[] = Array.isArray(rawLabels)
|
||||||
|
? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))]
|
||||||
|
: [];
|
||||||
|
if (labels.length === 0) {
|
||||||
|
labels.push(t('未分类'));
|
||||||
|
}
|
||||||
|
const st = statMap[c.id!];
|
||||||
|
labels.forEach((label: string) => {
|
||||||
|
if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
|
||||||
|
labelMap[label].count += 1;
|
||||||
|
if (st) {
|
||||||
|
labelMap[label].runs += Number(st.run_count) || 0;
|
||||||
|
labelMap[label].success += Number(st.success_count) || 0;
|
||||||
|
labelMap[label].totalTime += Number(st.total_time) || 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = Object.entries(labelMap)
|
||||||
|
.map(([label, v]) => ({
|
||||||
|
label,
|
||||||
|
count: v.count,
|
||||||
|
todayRuns: v.runs,
|
||||||
|
successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
|
||||||
|
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.todayRuns - a.todayRuns);
|
||||||
|
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/system',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const memUsage = process.memoryUsage();
|
||||||
|
res.send({
|
||||||
|
code: 200,
|
||||||
|
data: {
|
||||||
|
platform: os.platform(),
|
||||||
|
uptime: Math.floor(process.uptime()),
|
||||||
|
memTotal: os.totalmem(),
|
||||||
|
memFree: os.freemem(),
|
||||||
|
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1),
|
||||||
|
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||||
|
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||||
|
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
|
||||||
|
cpus: os.cpus().length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
+22
-17
@@ -8,17 +8,28 @@ const route = Router();
|
|||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
app.use('/dependencies', route);
|
app.use('/dependencies', route);
|
||||||
|
|
||||||
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
route.get(
|
||||||
const logger: Logger = Container.get('logger');
|
'/',
|
||||||
try {
|
celebrate({
|
||||||
const dependenceService = Container.get(DependenceService);
|
query:
|
||||||
const data = await dependenceService.dependencies(req.query as any);
|
Joi.object({
|
||||||
return res.send({ code: 200, data });
|
searchValue: Joi.string().optional().allow(''),
|
||||||
} catch (e) {
|
type: Joi.string().optional().allow(''),
|
||||||
logger.error('🔥 error: %o', e);
|
status: Joi.string().optional().allow(''),
|
||||||
return next(e);
|
}).unknown(true),
|
||||||
}
|
}),
|
||||||
});
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const logger: Logger = Container.get('logger');
|
||||||
|
try {
|
||||||
|
const dependenceService = Container.get(DependenceService);
|
||||||
|
const data = await dependenceService.dependencies(req.query as any);
|
||||||
|
return res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('🔥 error: %o', e);
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
route.post(
|
route.post(
|
||||||
'/',
|
'/',
|
||||||
@@ -32,7 +43,6 @@ export default (app: Router) => {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
const dependenceService = Container.get(DependenceService);
|
const dependenceService = Container.get(DependenceService);
|
||||||
const data = await dependenceService.create(req.body);
|
const data = await dependenceService.create(req.body);
|
||||||
@@ -54,7 +64,6 @@ export default (app: Router) => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
const dependenceService = Container.get(DependenceService);
|
const dependenceService = Container.get(DependenceService);
|
||||||
const data = await dependenceService.update(req.body);
|
const data = await dependenceService.update(req.body);
|
||||||
@@ -71,7 +80,6 @@ export default (app: Router) => {
|
|||||||
body: Joi.array().items(Joi.number().required()),
|
body: Joi.array().items(Joi.number().required()),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
const dependenceService = Container.get(DependenceService);
|
const dependenceService = Container.get(DependenceService);
|
||||||
const data = await dependenceService.remove(req.body);
|
const data = await dependenceService.remove(req.body);
|
||||||
@@ -88,7 +96,6 @@ export default (app: Router) => {
|
|||||||
body: Joi.array().items(Joi.number().required()),
|
body: Joi.array().items(Joi.number().required()),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
const dependenceService = Container.get(DependenceService);
|
const dependenceService = Container.get(DependenceService);
|
||||||
const data = await dependenceService.remove(req.body, true);
|
const data = await dependenceService.remove(req.body, true);
|
||||||
@@ -107,7 +114,6 @@ export default (app: Router) => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
const dependenceService = Container.get(DependenceService);
|
const dependenceService = Container.get(DependenceService);
|
||||||
const data = await dependenceService.getDb({ id: req.params.id });
|
const data = await dependenceService.getDb({ id: req.params.id });
|
||||||
@@ -124,7 +130,6 @@ export default (app: Router) => {
|
|||||||
body: Joi.array().items(Joi.number().required()),
|
body: Joi.array().items(Joi.number().required()),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
const dependenceService = Container.get(DependenceService);
|
const dependenceService = Container.get(DependenceService);
|
||||||
const data = await dependenceService.reInstall(req.body);
|
const data = await dependenceService.reInstall(req.body);
|
||||||
|
|||||||
+89
-9
@@ -1,12 +1,13 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Joi, celebrate } from 'celebrate';
|
||||||
import { Container } from 'typedi';
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
import EnvService from '../services/env';
|
|
||||||
import { Logger } from 'winston';
|
|
||||||
import { celebrate, Joi } from 'celebrate';
|
|
||||||
import multer from 'multer';
|
|
||||||
import config from '../config';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
import config from '../config';
|
||||||
import { safeJSONParse } from '../config/util';
|
import { safeJSONParse } from '../config/util';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
import EnvService from '../services/env';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
@@ -18,6 +19,10 @@ const storage = multer.diskStorage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const upload = multer({ storage: storage });
|
const upload = multer({ storage: storage });
|
||||||
|
const labelSchema = Joi.array()
|
||||||
|
.items(Joi.string().trim().required())
|
||||||
|
.min(1)
|
||||||
|
.required();
|
||||||
|
|
||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
app.use('/envs', route);
|
app.use('/envs', route);
|
||||||
@@ -44,6 +49,7 @@ export default (app: Router) => {
|
|||||||
.required()
|
.required()
|
||||||
.pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/),
|
.pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/),
|
||||||
remarks: Joi.string().optional().allow(''),
|
remarks: Joi.string().optional().allow(''),
|
||||||
|
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -52,7 +58,7 @@ export default (app: Router) => {
|
|||||||
try {
|
try {
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
if (!req.body?.length) {
|
if (!req.body?.length) {
|
||||||
return res.send({ code: 400, message: '参数不正确' });
|
return res.send({ code: 400, message: t('参数不正确') });
|
||||||
}
|
}
|
||||||
const data = await envService.create(req.body);
|
const data = await envService.create(req.body);
|
||||||
return res.send({ code: 200, data });
|
return res.send({ code: 200, data });
|
||||||
@@ -70,6 +76,7 @@ export default (app: Router) => {
|
|||||||
name: Joi.string().required(),
|
name: Joi.string().required(),
|
||||||
remarks: Joi.string().optional().allow('').allow(null),
|
remarks: Joi.string().optional().allow('').allow(null),
|
||||||
id: Joi.number().required(),
|
id: Joi.number().required(),
|
||||||
|
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -196,6 +203,78 @@ export default (app: Router) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/pin',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.array().items(Joi.number().required()),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const logger: Logger = Container.get('logger');
|
||||||
|
try {
|
||||||
|
const envService = Container.get(EnvService);
|
||||||
|
const data = await envService.pin(req.body);
|
||||||
|
return res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/unpin',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.array().items(Joi.number().required()),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const logger: Logger = Container.get('logger');
|
||||||
|
try {
|
||||||
|
const envService = Container.get(EnvService);
|
||||||
|
const data = await envService.unPin(req.body);
|
||||||
|
return res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.post(
|
||||||
|
'/labels',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
ids: Joi.array().items(Joi.number().required()).min(1).required(),
|
||||||
|
labels: labelSchema,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const envService = Container.get(EnvService);
|
||||||
|
const data = await envService.addLabels(req.body.ids, req.body.labels);
|
||||||
|
return res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.delete(
|
||||||
|
'/labels',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
ids: Joi.array().items(Joi.number().required()).min(1).required(),
|
||||||
|
labels: labelSchema,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const envService = Container.get(EnvService);
|
||||||
|
const data = await envService.removeLabels(req.body.ids, req.body.labels);
|
||||||
|
return res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
route.post(
|
route.post(
|
||||||
'/upload',
|
'/upload',
|
||||||
upload.single('env'),
|
upload.single('env'),
|
||||||
@@ -214,13 +293,14 @@ export default (app: Router) => {
|
|||||||
name: x.name,
|
name: x.name,
|
||||||
value: x.value,
|
value: x.value,
|
||||||
remarks: x.remarks,
|
remarks: x.remarks,
|
||||||
|
labels: x.labels,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
return res.send({ code: 200, data: result });
|
return res.send({ code: 200, data: result });
|
||||||
} else {
|
} else {
|
||||||
return res.send({
|
return res.send({
|
||||||
code: 400,
|
code: 400,
|
||||||
message: '每条数据 name 或者 value 字段不能为空,参考导出文件格式',
|
message: t('每条数据 name 或者 value 字段不能为空,参考导出文件格式'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import Logger from '../loaders/logger';
|
||||||
|
import { HealthService } from '../services/health';
|
||||||
|
import Container from 'typedi';
|
||||||
|
const route = Router();
|
||||||
|
|
||||||
|
export default (app: Router) => {
|
||||||
|
app.use('/', route);
|
||||||
|
|
||||||
|
route.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const healthService = Container.get(HealthService);
|
||||||
|
const health = await healthService.check();
|
||||||
|
res.status(200).send({
|
||||||
|
code: 200,
|
||||||
|
data: health,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
Logger.error('Health check failed:', err);
|
||||||
|
res.status(500).send({
|
||||||
|
code: 500,
|
||||||
|
message: 'Health check failed',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -9,6 +9,9 @@ import open from './open';
|
|||||||
import dependence from './dependence';
|
import dependence from './dependence';
|
||||||
import system from './system';
|
import system from './system';
|
||||||
import subscription from './subscription';
|
import subscription from './subscription';
|
||||||
|
import update from './update';
|
||||||
|
import dashboard from './dashboard';
|
||||||
|
import health from './health';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const app = Router();
|
const app = Router();
|
||||||
@@ -22,6 +25,9 @@ export default () => {
|
|||||||
dependence(app);
|
dependence(app);
|
||||||
system(app);
|
system(app);
|
||||||
subscription(app);
|
subscription(app);
|
||||||
|
update(app);
|
||||||
|
dashboard(app);
|
||||||
|
health(app);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
};
|
};
|
||||||
|
|||||||
+78
-24
@@ -1,10 +1,17 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { getFileContentByName, readDirs, removeAnsi, rmPath } from '../config/util';
|
import { t } from '../shared/i18n';
|
||||||
import { join, resolve } from 'path';
|
import {
|
||||||
import { celebrate, Joi } from 'celebrate';
|
getFileContentByName,
|
||||||
|
readDirs,
|
||||||
|
removeAnsi,
|
||||||
|
rmPath,
|
||||||
|
} from '../config/util';
|
||||||
|
import LogService from '../services/log';
|
||||||
|
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
const blacklist = ['.tmp'];
|
const blacklist = ['.tmp'];
|
||||||
|
|
||||||
@@ -29,20 +36,28 @@ export default (app: Router) => {
|
|||||||
'/detail',
|
'/detail',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const finalPath = resolve(
|
const logService = Container.get(LogService);
|
||||||
config.logPath,
|
const finalPath = logService.checkFilePath(
|
||||||
(req.query.path as string) || '',
|
(req.query.path as string) || '',
|
||||||
(req.query.file as string) || '',
|
(req.query.file as string) || '',
|
||||||
);
|
);
|
||||||
|
if (!finalPath || blacklist.includes(req.query.path as string)) {
|
||||||
if (
|
return res.send({
|
||||||
blacklist.includes(req.query.path as string) ||
|
code: 403,
|
||||||
!finalPath.startsWith(config.logPath)
|
message: t('暂无权限'),
|
||||||
) {
|
});
|
||||||
return res.send({ code: 403, message: '暂无权限' });
|
|
||||||
}
|
}
|
||||||
|
const logPath = `${req.query.path as string}/${req.query.file as string}`;
|
||||||
|
const runningInstance = await RunningInstanceModel.findOne({
|
||||||
|
where: { log_path: logPath, status: InstanceStatus.running },
|
||||||
|
});
|
||||||
|
|
||||||
const content = await getFileContentByName(finalPath);
|
const content = await getFileContentByName(finalPath);
|
||||||
res.send({ code: 200, data: removeAnsi(content) });
|
res.send({
|
||||||
|
code: 200,
|
||||||
|
data: removeAnsi(content),
|
||||||
|
logStatus: runningInstance ? 'running' : undefined,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
@@ -53,16 +68,16 @@ export default (app: Router) => {
|
|||||||
'/:file',
|
'/:file',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const finalPath = resolve(
|
const logService = Container.get(LogService);
|
||||||
config.logPath,
|
const finalPath = logService.checkFilePath(
|
||||||
(req.query.path as string) || '',
|
(req.query.path as string) || '',
|
||||||
(req.params.file as string) || '',
|
(req.params.file as string) || '',
|
||||||
);
|
);
|
||||||
if (
|
if (!finalPath || blacklist.includes(req.query.path as string)) {
|
||||||
blacklist.includes(req.path) ||
|
return res.send({
|
||||||
!finalPath.startsWith(config.logPath)
|
code: 403,
|
||||||
) {
|
message: t('暂无权限'),
|
||||||
return res.send({ code: 403, message: '暂无权限' });
|
});
|
||||||
}
|
}
|
||||||
const content = await getFileContentByName(finalPath);
|
const content = await getFileContentByName(finalPath);
|
||||||
res.send({ code: 200, data: content });
|
res.send({ code: 200, data: content });
|
||||||
@@ -83,17 +98,56 @@ export default (app: Router) => {
|
|||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
let { filename, path, type } = req.body as {
|
let { filename, path } = req.body as {
|
||||||
filename: string;
|
filename: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: string;
|
|
||||||
};
|
};
|
||||||
const filePath = join(config.logPath, path, filename);
|
const logService = Container.get(LogService);
|
||||||
await rmPath(filePath);
|
const finalPath = logService.checkFilePath(path, filename);
|
||||||
|
if (!finalPath || blacklist.includes(path)) {
|
||||||
|
return res.send({
|
||||||
|
code: 403,
|
||||||
|
message: t('暂无权限'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await rmPath(finalPath);
|
||||||
res.send({ code: 200 });
|
res.send({ code: 200 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
route.post(
|
||||||
|
'/download',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
filename: Joi.string().required(),
|
||||||
|
path: Joi.string().allow(''),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
let { filename, path } = req.body as {
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
const logService = Container.get(LogService);
|
||||||
|
const filePath = logService.checkFilePath(path, filename);
|
||||||
|
if (!filePath) {
|
||||||
|
return res.send({
|
||||||
|
code: 403,
|
||||||
|
message: t('暂无权限'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.download(filePath, filename, (err) => {
|
||||||
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+155
-73
@@ -1,4 +1,4 @@
|
|||||||
import { fileExist, readDirs, readDir, rmPath } from '../config/util';
|
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
@@ -7,10 +7,16 @@ import * as fs from 'fs/promises';
|
|||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
import path, { join, parse } from 'path';
|
import path, { join, parse } from 'path';
|
||||||
import ScriptService from '../services/script';
|
import ScriptService from '../services/script';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
|
function isPathAllowed(targetPath: string): boolean {
|
||||||
|
const resolved = path.resolve(targetPath);
|
||||||
|
return config.writePathList.some((x) => resolved.startsWith(x));
|
||||||
|
}
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: function (req, file, cb) {
|
destination: function (req, file, cb) {
|
||||||
cb(null, config.scriptPath);
|
cb(null, config.scriptPath);
|
||||||
@@ -24,55 +30,68 @@ const upload = multer({ storage: storage });
|
|||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
app.use('/scripts', route);
|
app.use('/scripts', route);
|
||||||
|
|
||||||
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
route.get(
|
||||||
const logger: Logger = Container.get('logger');
|
'/',
|
||||||
try {
|
celebrate({
|
||||||
let result = [];
|
query: Joi.object({
|
||||||
const blacklist = [
|
path: Joi.string().optional().allow(''),
|
||||||
'node_modules',
|
}).unknown(true),
|
||||||
'.git',
|
}),
|
||||||
'.pnpm',
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
'pnpm-lock.yaml',
|
const logger: Logger = Container.get('logger');
|
||||||
'yarn.lock',
|
try {
|
||||||
'package-lock.json',
|
let result: IFile[] = [];
|
||||||
];
|
const blacklist = [
|
||||||
if (req.query.path) {
|
'node_modules',
|
||||||
const targetPath = path.join(
|
'.git',
|
||||||
config.scriptPath,
|
'.pnpm',
|
||||||
req.query.path as string,
|
'pnpm-lock.yaml',
|
||||||
);
|
'yarn.lock',
|
||||||
result = await readDir(targetPath, config.scriptPath, blacklist);
|
'package-lock.json',
|
||||||
} else {
|
];
|
||||||
result = await readDirs(
|
if (req.query.path) {
|
||||||
config.scriptPath,
|
result = await readDir(
|
||||||
config.scriptPath,
|
req.query.path as string,
|
||||||
blacklist,
|
config.scriptPath,
|
||||||
(a, b) => {
|
blacklist,
|
||||||
if (a.type === b.type) {
|
);
|
||||||
return a.title.localeCompare(b.title);
|
} else {
|
||||||
} else {
|
result = await readDirs(
|
||||||
return a.type === 'directory' ? -1 : 1;
|
config.scriptPath,
|
||||||
}
|
config.scriptPath,
|
||||||
},
|
blacklist,
|
||||||
);
|
(a, b) => {
|
||||||
|
if (a.type === b.type) {
|
||||||
|
return a.title.localeCompare(b.title);
|
||||||
|
} else {
|
||||||
|
return a.type === 'directory' ? -1 : 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.send({
|
||||||
|
code: 200,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('🔥 error: %o', e);
|
||||||
|
return next(e);
|
||||||
}
|
}
|
||||||
res.send({
|
});
|
||||||
code: 200,
|
|
||||||
data: result,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('🔥 error: %o', e);
|
|
||||||
return next(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/detail',
|
'/detail',
|
||||||
|
celebrate({
|
||||||
|
query: Joi.object({
|
||||||
|
path: Joi.string().optional().allow(''),
|
||||||
|
file: Joi.string().required(),
|
||||||
|
}).unknown(true),
|
||||||
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const scriptService = Container.get(ScriptService);
|
const scriptService = Container.get(ScriptService);
|
||||||
const content = await scriptService.getFile(
|
const content = await scriptService.getFile(
|
||||||
req.query.path as string,
|
req.query?.path as string || '',
|
||||||
req.query.file as string,
|
req.query.file as string,
|
||||||
);
|
);
|
||||||
res.send({ code: 200, data: content });
|
res.send({ code: 200, data: content });
|
||||||
@@ -84,11 +103,19 @@ export default (app: Router) => {
|
|||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/:file',
|
'/:file',
|
||||||
|
celebrate({
|
||||||
|
params: Joi.object({
|
||||||
|
file: Joi.string().required(),
|
||||||
|
}),
|
||||||
|
query: Joi.object({
|
||||||
|
path: Joi.string().optional().allow(''),
|
||||||
|
}).unknown(true),
|
||||||
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const scriptService = Container.get(ScriptService);
|
const scriptService = Container.get(ScriptService);
|
||||||
const content = await scriptService.getFile(
|
const content = await scriptService.getFile(
|
||||||
req.query.path as string,
|
req.query?.path as string || '',
|
||||||
req.params.file,
|
req.params.file,
|
||||||
);
|
);
|
||||||
res.send({ code: 200, data: content });
|
res.send({ code: 200, data: content });
|
||||||
@@ -101,8 +128,17 @@ export default (app: Router) => {
|
|||||||
route.post(
|
route.post(
|
||||||
'/',
|
'/',
|
||||||
upload.single('file'),
|
upload.single('file'),
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
filename: Joi.string().required(),
|
||||||
|
path: Joi.string().optional().allow(''),
|
||||||
|
content: Joi.string().optional().allow(''),
|
||||||
|
originFilename: Joi.string().optional().allow(''),
|
||||||
|
directory: Joi.string().optional().allow(''),
|
||||||
|
file: Joi.string().optional().allow(''),
|
||||||
|
}).unknown(true),
|
||||||
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
let { filename, path, content, originFilename, directory } =
|
let { filename, path, content, originFilename, directory } =
|
||||||
req.body as {
|
req.body as {
|
||||||
@@ -124,29 +160,38 @@ export default (app: Router) => {
|
|||||||
}
|
}
|
||||||
if (config.writePathList.every((x) => !path.startsWith(x))) {
|
if (config.writePathList.every((x) => !path.startsWith(x))) {
|
||||||
return res.send({
|
return res.send({
|
||||||
code: 430,
|
code: 403,
|
||||||
message: '文件路径禁止访问',
|
message: t('暂无权限'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
await fs.rename(req.file.path, join(path, filename));
|
const uploadPath = join(path, filename);
|
||||||
|
if (!isPathAllowed(uploadPath)) {
|
||||||
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
|
}
|
||||||
|
await fs.rename(req.file.path, uploadPath);
|
||||||
return res.send({ code: 200 });
|
return res.send({ code: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (directory) {
|
if (directory) {
|
||||||
await fs.mkdir(join(path, directory), { recursive: true });
|
const dirPath = join(path, directory);
|
||||||
|
if (!isPathAllowed(dirPath)) {
|
||||||
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
|
}
|
||||||
|
await fs.mkdir(dirPath, { recursive: true });
|
||||||
return res.send({ code: 200 });
|
return res.send({ code: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!originFilename) {
|
if (!originFilename) {
|
||||||
originFilename = filename;
|
originFilename = filename;
|
||||||
}
|
}
|
||||||
const originFilePath = join(
|
const originFilePath = join(path, originFilename);
|
||||||
path,
|
const filePath = join(path, filename);
|
||||||
`${originFilename.replace(/\//g, '')}`,
|
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
|
||||||
);
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
const filePath = join(path, `${filename.replace(/\//g, '')}`);
|
}
|
||||||
|
await fs.mkdir(path, { recursive: true });
|
||||||
const fileExists = await fileExist(filePath);
|
const fileExists = await fileExist(filePath);
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
await fs.copyFile(
|
await fs.copyFile(
|
||||||
@@ -175,14 +220,20 @@ export default (app: Router) => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
let { filename, content, path } = req.body as {
|
let { filename, content, path } = req.body as {
|
||||||
filename: string;
|
filename: string;
|
||||||
content: string;
|
content: string;
|
||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
const filePath = join(config.scriptPath, path, filename);
|
const scriptService = Container.get(ScriptService);
|
||||||
|
const filePath = scriptService.checkFilePath(path, filename);
|
||||||
|
if (!filePath) {
|
||||||
|
return res.send({
|
||||||
|
code: 403,
|
||||||
|
message: t('暂无权限'),
|
||||||
|
});
|
||||||
|
}
|
||||||
await writeFileWithLock(filePath, content);
|
await writeFileWithLock(filePath, content);
|
||||||
return res.send({ code: 200 });
|
return res.send({ code: 200 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -196,19 +247,27 @@ export default (app: Router) => {
|
|||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
filename: Joi.string().required(),
|
filename: Joi.string().required(),
|
||||||
path: Joi.string().allow(''),
|
path: Joi.string().optional().allow(''),
|
||||||
type: Joi.string().optional(),
|
type: Joi.string().optional(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
let { filename, path, type } = req.body as {
|
let { filename, path } = req.body as {
|
||||||
filename: string;
|
filename: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: string;
|
|
||||||
};
|
};
|
||||||
const filePath = join(config.scriptPath, path, filename);
|
if (!path) {
|
||||||
|
path = '';
|
||||||
|
}
|
||||||
|
const scriptService = Container.get(ScriptService);
|
||||||
|
const filePath = scriptService.checkFilePath(path, filename);
|
||||||
|
if (!filePath) {
|
||||||
|
return res.send({
|
||||||
|
code: 403,
|
||||||
|
message: t('暂无权限'),
|
||||||
|
});
|
||||||
|
}
|
||||||
await rmPath(filePath);
|
await rmPath(filePath);
|
||||||
res.send({ code: 200 });
|
res.send({ code: 200 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -222,24 +281,30 @@ export default (app: Router) => {
|
|||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
filename: Joi.string().required(),
|
filename: Joi.string().required(),
|
||||||
|
path: Joi.string().optional().allow(''),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
let { filename } = req.body as {
|
let { filename, path } = req.body as {
|
||||||
filename: string;
|
filename: string;
|
||||||
|
path: string;
|
||||||
};
|
};
|
||||||
const filePath = join(config.scriptPath, filename);
|
if (!path) {
|
||||||
// const stats = fs.statSync(filePath);
|
path = '';
|
||||||
// res.set({
|
}
|
||||||
// 'Content-Type': 'application/octet-stream', //告诉浏览器这是一个二进制文件
|
const scriptService = Container.get(ScriptService);
|
||||||
// 'Content-Disposition': 'attachment; filename=' + filename, //告诉浏览器这是一个需要下载的文件
|
const filePath = scriptService.checkFilePath(path, filename);
|
||||||
// 'Content-Length': stats.size //文件大小
|
if (!filePath) {
|
||||||
// });
|
return res.send({
|
||||||
// fs.createReadStream(filePath).pipe(res);
|
code: 403,
|
||||||
|
message: t('暂无权限'),
|
||||||
|
});
|
||||||
|
}
|
||||||
return res.download(filePath, filename, (err) => {
|
return res.download(filePath, filename, (err) => {
|
||||||
return next(err);
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
@@ -260,8 +325,14 @@ export default (app: Router) => {
|
|||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
let { filename, content, path } = req.body;
|
let { filename, content, path } = req.body;
|
||||||
|
if (!path) {
|
||||||
|
path = '';
|
||||||
|
}
|
||||||
const { name, ext } = parse(filename);
|
const { name, ext } = parse(filename);
|
||||||
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
|
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
|
||||||
|
if (!isPathAllowed(filePath)) {
|
||||||
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
|
}
|
||||||
await writeFileWithLock(filePath, content || '');
|
await writeFileWithLock(filePath, content || '');
|
||||||
|
|
||||||
const scriptService = Container.get(ScriptService);
|
const scriptService = Container.get(ScriptService);
|
||||||
@@ -285,8 +356,14 @@ export default (app: Router) => {
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
let { filename, path, pid } = req.body;
|
let { filename, path, pid } = req.body;
|
||||||
|
if (!path) {
|
||||||
|
path = '';
|
||||||
|
}
|
||||||
const { name, ext } = parse(filename);
|
const { name, ext } = parse(filename);
|
||||||
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
|
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
|
||||||
|
if (!isPathAllowed(filePath)) {
|
||||||
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
|
}
|
||||||
const logPath = join(config.logPath, path, `${name}.swap`);
|
const logPath = join(config.logPath, path, `${name}.swap`);
|
||||||
|
|
||||||
const scriptService = Container.get(ScriptService);
|
const scriptService = Container.get(ScriptService);
|
||||||
@@ -312,14 +389,19 @@ export default (app: Router) => {
|
|||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
let { filename, path, type, newFilename } = req.body as {
|
let { filename, path, newFilename } = req.body as {
|
||||||
filename: string;
|
filename: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: string;
|
|
||||||
newFilename: string;
|
newFilename: string;
|
||||||
};
|
};
|
||||||
|
if (!path) {
|
||||||
|
path = '';
|
||||||
|
}
|
||||||
const filePath = join(config.scriptPath, path, filename);
|
const filePath = join(config.scriptPath, path, filename);
|
||||||
const newPath = join(config.scriptPath, path, newFilename);
|
const newPath = join(config.scriptPath, path, newFilename);
|
||||||
|
if (!isPathAllowed(filePath) || !isPathAllowed(newPath)) {
|
||||||
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
|
}
|
||||||
await fs.rename(filePath, newPath);
|
await fs.rename(filePath, newPath);
|
||||||
res.send({ code: 200 });
|
res.send({ code: 200 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
|
|||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import SubscriptionService from '../services/subscription';
|
import SubscriptionService from '../services/subscription';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
import cron_parser from 'cron-parser';
|
import CronExpressionParser from 'cron-parser';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
@@ -60,7 +60,7 @@ export default (app: Router) => {
|
|||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
!req.body.schedule ||
|
!req.body.schedule ||
|
||||||
cron_parser.parseExpression(req.body.schedule).hasNext()
|
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||||
) {
|
) {
|
||||||
const subscriptionService = Container.get(SubscriptionService);
|
const subscriptionService = Container.get(SubscriptionService);
|
||||||
const data = await subscriptionService.create(req.body);
|
const data = await subscriptionService.create(req.body);
|
||||||
@@ -193,7 +193,7 @@ export default (app: Router) => {
|
|||||||
if (
|
if (
|
||||||
!req.body.schedule ||
|
!req.body.schedule ||
|
||||||
typeof req.body.schedule === 'object' ||
|
typeof req.body.schedule === 'object' ||
|
||||||
cron_parser.parseExpression(req.body.schedule).hasNext()
|
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||||
) {
|
) {
|
||||||
const subscriptionService = Container.get(SubscriptionService);
|
const subscriptionService = Container.get(SubscriptionService);
|
||||||
const data = await subscriptionService.update(req.body);
|
const data = await subscriptionService.update(req.body);
|
||||||
|
|||||||
+95
-15
@@ -6,6 +6,8 @@ import config from '../config';
|
|||||||
import SystemService from '../services/system';
|
import SystemService from '../services/system';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
import UserService from '../services/user';
|
import UserService from '../services/user';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
import { isDefaultAuthInfo } from '../shared/auth';
|
||||||
import {
|
import {
|
||||||
getUniqPath,
|
getUniqPath,
|
||||||
handleLogPath,
|
handleLogPath,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
} from '../config/util';
|
} from '../config/util';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import { logStreamManager } from '../shared/logStreamManager';
|
||||||
|
|
||||||
const route = Router();
|
const route = Router();
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
@@ -37,14 +40,7 @@ export default (app: Router) => {
|
|||||||
const { version, changeLog, changeLogLink, publishTime } =
|
const { version, changeLog, changeLogLink, publishTime } =
|
||||||
await parseVersion(config.versionFile);
|
await parseVersion(config.versionFile);
|
||||||
|
|
||||||
let isInitialized = true;
|
const isInitialized = !isDefaultAuthInfo(authInfo);
|
||||||
if (
|
|
||||||
Object.keys(authInfo).length === 2 &&
|
|
||||||
authInfo.username === 'admin' &&
|
|
||||||
authInfo.password === 'admin'
|
|
||||||
) {
|
|
||||||
isInitialized = false;
|
|
||||||
}
|
|
||||||
res.send({
|
res.send({
|
||||||
code: 200,
|
code: 200,
|
||||||
data: {
|
data: {
|
||||||
@@ -273,19 +269,22 @@ export default (app: Router) => {
|
|||||||
{
|
{
|
||||||
onStart: async (cp, startTime) => {
|
onStart: async (cp, startTime) => {
|
||||||
res.setHeader('QL-Task-Pid', `${cp.pid}`);
|
res.setHeader('QL-Task-Pid', `${cp.pid}`);
|
||||||
|
res.setHeader('QL-Task-Log', `${logPath}`);
|
||||||
},
|
},
|
||||||
onEnd: async (cp, endTime, diff) => {
|
onEnd: async (cp, endTime, diff) => {
|
||||||
|
// Close the stream after task completion
|
||||||
|
await logStreamManager.closeStream(await handleLogPath(logPath));
|
||||||
res.end();
|
res.end();
|
||||||
},
|
},
|
||||||
onError: async (message: string) => {
|
onError: async (message: string) => {
|
||||||
res.write(`\n${message}`);
|
res.write(message);
|
||||||
const absolutePath = await handleLogPath(logPath);
|
const absolutePath = await handleLogPath(logPath);
|
||||||
await fs.appendFile(absolutePath, `\n${message}`);
|
await logStreamManager.write(absolutePath, message);
|
||||||
},
|
},
|
||||||
onLog: async (message: string) => {
|
onLog: async (message: string) => {
|
||||||
res.write(`\n${message}`);
|
res.write(message);
|
||||||
const absolutePath = await handleLogPath(logPath);
|
const absolutePath = await handleLogPath(logPath);
|
||||||
await fs.appendFile(absolutePath, `\n${message}`);
|
await logStreamManager.write(absolutePath, message);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -316,10 +315,15 @@ export default (app: Router) => {
|
|||||||
|
|
||||||
route.put(
|
route.put(
|
||||||
'/data/export',
|
'/data/export',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
type: Joi.array().items(Joi.string()).optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const systemService = Container.get(SystemService);
|
const systemService = Container.get(SystemService);
|
||||||
await systemService.exportData(res);
|
await systemService.exportData(res, req.body.type);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
@@ -385,13 +389,17 @@ export default (app: Router) => {
|
|||||||
retries: Joi.number().optional(),
|
retries: Joi.number().optional(),
|
||||||
twoFactorActivated: Joi.boolean().optional(),
|
twoFactorActivated: Joi.boolean().optional(),
|
||||||
password: Joi.string().optional(),
|
password: Joi.string().optional(),
|
||||||
|
username: Joi.string().optional(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const userService = Container.get(UserService);
|
const userService = Container.get(UserService);
|
||||||
await userService.resetAuthInfo(req.body);
|
const result = await userService.resetAuthInfo(req.body);
|
||||||
res.send({ code: 200, message: '更新成功' });
|
if (result) {
|
||||||
|
return res.send(result);
|
||||||
|
}
|
||||||
|
res.send({ code: 200, message: t('更新成功') });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
@@ -415,4 +423,76 @@ export default (app: Router) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/config/lang',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
lang: Joi.string().allow('').allow(null),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const systemService = Container.get(SystemService);
|
||||||
|
const result = await systemService.updateLanguage(req.body);
|
||||||
|
res.send(result);
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/config/panel-title',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
panelTitle: Joi.string().max(100).allow('').allow(null),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const systemService = Container.get(SystemService);
|
||||||
|
const result = await systemService.updatePanelTitle(req.body);
|
||||||
|
res.send(result);
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/config/global-ssh-key',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
globalSshKey: Joi.string().allow('').allow(null),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const systemService = Container.get(SystemService);
|
||||||
|
const result = await systemService.updateGlobalSshKey(req.body);
|
||||||
|
res.send(result);
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/config/dependence-clean',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
type: Joi.string().allow(''),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const systemService = Container.get(SystemService);
|
||||||
|
const result = await systemService.cleanDependence(req.body.type);
|
||||||
|
res.send(result);
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
|
import Container from 'typedi';
|
||||||
|
import Logger from '../loaders/logger';
|
||||||
|
import SystemService from '../services/system';
|
||||||
|
const route = Router();
|
||||||
|
|
||||||
|
export default (app: Router) => {
|
||||||
|
app.use('/update', route);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/reload',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const systemService = Container.get(SystemService);
|
||||||
|
const result = await systemService.reloadSystem();
|
||||||
|
res.send(result);
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('🔥 error: %o', e);
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/system',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const systemService = Container.get(SystemService);
|
||||||
|
const result = await systemService.reloadSystem('system');
|
||||||
|
res.send(result);
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('🔥 error: %o', e);
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/data',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const systemService = Container.get(SystemService);
|
||||||
|
const result = await systemService.reloadSystem('data');
|
||||||
|
res.send(result);
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('🔥 error: %o', e);
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
+10
-8
@@ -8,6 +8,8 @@ import path from 'path';
|
|||||||
import { v4 as uuidV4 } from 'uuid';
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
import { isDemoEnv, getToken } from '../config/util';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
@@ -55,7 +57,8 @@ export default (app: Router) => {
|
|||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
const userService = Container.get(UserService);
|
const userService = Container.get(UserService);
|
||||||
await userService.logout(req.platform);
|
const token = getToken(req);
|
||||||
|
await userService.logout(req.platform, token);
|
||||||
res.send({ code: 200 });
|
res.send({ code: 200 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
@@ -72,14 +75,13 @@ export default (app: Router) => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
try {
|
||||||
if (process.env.DeployEnv === 'demo') {
|
if (isDemoEnv()) {
|
||||||
return res.send({ code: 450, message: '未知错误' });
|
return res.send({ code: 450, message: t('未知错误') });
|
||||||
}
|
}
|
||||||
const userService = Container.get(UserService);
|
const userService = Container.get(UserService);
|
||||||
await userService.updateUsernameAndPassword(req.body);
|
await userService.updateUsernameAndPassword(req.body);
|
||||||
res.send({ code: 200, message: '更新成功' });
|
res.send({ code: 200, message: t('更新成功') });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
@@ -139,12 +141,12 @@ export default (app: Router) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
route.put(
|
route.put(
|
||||||
'/two-factor/deactive',
|
'/two-factor/deactivate',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
const userService = Container.get(UserService);
|
const userService = Container.get(UserService);
|
||||||
const data = await userService.deactiveTwoFactor();
|
const data = await userService.deactivateTwoFactor();
|
||||||
res.send({ code: 200, data });
|
res.send({ code: 200, data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
@@ -228,7 +230,7 @@ export default (app: Router) => {
|
|||||||
try {
|
try {
|
||||||
const userService = Container.get(UserService);
|
const userService = Container.get(UserService);
|
||||||
await userService.updateUsernameAndPassword(req.body);
|
await userService.updateUsernameAndPassword(req.body);
|
||||||
res.send({ code: 200, message: '更新成功' });
|
res.send({ code: 200, message: t('更新成功') });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
|
|||||||
+303
-26
@@ -1,31 +1,308 @@
|
|||||||
import 'reflect-metadata'; // We need this in order to use @Decorators
|
import 'reflect-metadata';
|
||||||
import config from './config';
|
import cluster, { type Worker } from 'cluster';
|
||||||
|
import compression from 'compression';
|
||||||
|
import cors from 'cors';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
import config from './config';
|
||||||
import Logger from './loaders/logger';
|
import Logger from './loaders/logger';
|
||||||
|
import { monitoringMiddleware } from './middlewares/monitoring';
|
||||||
|
import { errStack } from './config/util';
|
||||||
|
import { type GrpcServerService } from './services/grpc';
|
||||||
|
import { type HttpServerService } from './services/http';
|
||||||
|
|
||||||
async function startServer() {
|
interface WorkerMetadata {
|
||||||
const app = express();
|
id: number;
|
||||||
|
pid: number;
|
||||||
await require('./loaders/db').default();
|
serviceType: string;
|
||||||
|
startTime: Date;
|
||||||
await require('./loaders/initFile').default();
|
|
||||||
|
|
||||||
await require('./loaders/app').default({ expressApp: app });
|
|
||||||
|
|
||||||
const server = app
|
|
||||||
.listen(config.port, '0.0.0.0', () => {
|
|
||||||
Logger.debug(`✌️ 后端服务启动成功!`);
|
|
||||||
console.debug(`✌️ 后端服务启动成功!`);
|
|
||||||
process.send?.('ready');
|
|
||||||
require('./loaders/bootAfter').default();
|
|
||||||
})
|
|
||||||
.on('error', (err) => {
|
|
||||||
Logger.error(err);
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
await require('./loaders/server').default({ server });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startServer();
|
class Application {
|
||||||
|
private app: express.Application;
|
||||||
|
private httpServerService?: HttpServerService;
|
||||||
|
private grpcServerService?: GrpcServerService;
|
||||||
|
private isShuttingDown = false;
|
||||||
|
private workerMetadataMap = new Map<number, WorkerMetadata>();
|
||||||
|
private httpWorker?: Worker;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.app = express();
|
||||||
|
// 创建一个全局中间件,删除查询参数中的t
|
||||||
|
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (req.query.t) {
|
||||||
|
delete req.query.t;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
try {
|
||||||
|
if (cluster.isPrimary) {
|
||||||
|
await this.initializeDatabase();
|
||||||
|
}
|
||||||
|
if (cluster.isPrimary) {
|
||||||
|
this.startMasterProcess();
|
||||||
|
} else {
|
||||||
|
await this.startWorkerProcess();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to start application:\n${errStack(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startMasterProcess() {
|
||||||
|
// Fork gRPC worker first and wait for it to be ready
|
||||||
|
const grpcWorker = this.forkWorker('grpc');
|
||||||
|
|
||||||
|
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
||||||
|
this.waitForWorkerReady(grpcWorker, 30000)
|
||||||
|
.then(() => {
|
||||||
|
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
|
||||||
|
this.httpWorker = this.forkWorker('http');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
Logger.error(`[boot] Failed to wait for gRPC worker:\n${errStack(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
cluster.on('exit', (worker, code, signal) => {
|
||||||
|
const metadata = this.workerMetadataMap.get(worker.id);
|
||||||
|
if (metadata) {
|
||||||
|
if (!this.isShuttingDown) {
|
||||||
|
Logger.error(
|
||||||
|
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
|
||||||
|
}). Restarting...`,
|
||||||
|
);
|
||||||
|
// If gRPC worker died, restart it and wait for it to be ready
|
||||||
|
if (metadata.serviceType === 'grpc') {
|
||||||
|
const newGrpcWorker = this.forkWorker('grpc');
|
||||||
|
this.waitForWorkerReady(newGrpcWorker, 30000)
|
||||||
|
.then(() => {
|
||||||
|
Logger.info('gRPC worker restarted and ready');
|
||||||
|
// Re-register cron jobs by notifying the HTTP worker
|
||||||
|
if (this.httpWorker) {
|
||||||
|
try {
|
||||||
|
this.httpWorker.send('reregister-crons');
|
||||||
|
Logger.info('Sent reregister-crons message to HTTP worker');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to send reregister-crons message:\n${errStack(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
Logger.error(`Failed to restart gRPC worker:\n${errStack(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For HTTP worker, just restart it
|
||||||
|
const newWorker = this.forkWorker(metadata.serviceType);
|
||||||
|
this.httpWorker = newWorker;
|
||||||
|
Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.workerMetadataMap.delete(worker.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupMasterShutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const messageHandler = (msg: any) => {
|
||||||
|
if (msg === 'ready') {
|
||||||
|
worker.removeListener('message', messageHandler);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
worker.on('message', messageHandler);
|
||||||
|
|
||||||
|
// Timeout after specified milliseconds
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
worker.removeListener('message', messageHandler);
|
||||||
|
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private forkWorker(serviceType: string): Worker {
|
||||||
|
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
|
||||||
|
|
||||||
|
this.workerMetadataMap.set(worker.id, {
|
||||||
|
id: worker.id,
|
||||||
|
pid: worker.process.pid!,
|
||||||
|
serviceType,
|
||||||
|
startTime: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeDatabase() {
|
||||||
|
const dbLoader = await import('./loaders/db');
|
||||||
|
await dbLoader.default();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupMiddlewares() {
|
||||||
|
this.app.use(helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
}));
|
||||||
|
this.app.use(cors(config.cors));
|
||||||
|
this.app.use(compression());
|
||||||
|
this.app.use(monitoringMiddleware);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupMasterShutdown() {
|
||||||
|
const shutdown = async () => {
|
||||||
|
if (this.isShuttingDown) return;
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
|
const workers = Object.values(cluster.workers || {});
|
||||||
|
const workerPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
workers.forEach((worker) => {
|
||||||
|
if (worker) {
|
||||||
|
const exitPromise = new Promise<void>((resolve) => {
|
||||||
|
worker.once('exit', () => {
|
||||||
|
Logger.info(`Worker ${worker.process.pid} exited`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
worker.send('shutdown');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(`Failed to send shutdown to worker ${worker.process.pid}:\n${errStack(error)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
workerPromises.push(exitPromise);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
Promise.all(workerPromises),
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
Logger.warn('Worker shutdown timeout reached');
|
||||||
|
resolve();
|
||||||
|
}, 10000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Error during worker shutdown:\n${errStack(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startWorkerProcess() {
|
||||||
|
const serviceType = process.env.SERVICE_TYPE;
|
||||||
|
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
|
||||||
|
Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (serviceType === 'http') {
|
||||||
|
await this.startHttpService();
|
||||||
|
} else {
|
||||||
|
await this.startGrpcService();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.send?.('ready');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startHttpService() {
|
||||||
|
// 在导入任何 gRPC 客户端模块之前初始化 mTLS 证书
|
||||||
|
const { initGrpcCerts } = await import('./config/grpcCerts');
|
||||||
|
await initGrpcCerts();
|
||||||
|
|
||||||
|
this.setupMiddlewares();
|
||||||
|
|
||||||
|
const { HttpServerService } = await import('./services/http');
|
||||||
|
this.httpServerService = Container.get(HttpServerService);
|
||||||
|
|
||||||
|
const appLoader = await import('./loaders/app');
|
||||||
|
await appLoader.default({ app: this.app });
|
||||||
|
|
||||||
|
const server = await this.httpServerService.initialize(
|
||||||
|
this.app,
|
||||||
|
config.port,
|
||||||
|
);
|
||||||
|
|
||||||
|
const serverLoader = await import('./loaders/server');
|
||||||
|
await (serverLoader.default as any)({ server });
|
||||||
|
this.setupWorkerShutdown('http');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startGrpcService() {
|
||||||
|
const { GrpcServerService } = await import('./services/grpc');
|
||||||
|
this.grpcServerService = Container.get(GrpcServerService);
|
||||||
|
|
||||||
|
await this.grpcServerService.initialize();
|
||||||
|
this.setupWorkerShutdown('grpc');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupWorkerShutdown(serviceType: string) {
|
||||||
|
process.on('message', async (msg) => {
|
||||||
|
if (msg === 'shutdown') {
|
||||||
|
this.gracefulShutdown(serviceType);
|
||||||
|
} else if (msg === 'reregister-crons' && serviceType === 'http') {
|
||||||
|
// Re-register cron jobs when gRPC worker restarts
|
||||||
|
try {
|
||||||
|
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
|
||||||
|
const CronService = (await import('./services/cron')).default;
|
||||||
|
const cronService = Container.get(CronService);
|
||||||
|
await cronService.autosave_crontab();
|
||||||
|
Logger.info('[boot] Cron jobs re-registered successfully');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = () => this.gracefulShutdown(serviceType);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async gracefulShutdown(serviceType: string) {
|
||||||
|
if (this.isShuttingDown) return;
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (serviceType === 'http') {
|
||||||
|
await this.httpServerService?.shutdown();
|
||||||
|
} else {
|
||||||
|
await this.grpcServerService?.shutdown();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[${serviceType}] Error during shutdown:\n${errStack(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Application();
|
||||||
|
app.start().catch((error) => {
|
||||||
|
Logger.error(`🙅♀️ Application failed to start:\n${errStack(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { maybeSudo } from './container';
|
||||||
|
|
||||||
export const LOG_END_SYMBOL = ' ';
|
export const LOG_END_SYMBOL = ' ';
|
||||||
|
|
||||||
export const TASK_COMMAND = 'task';
|
export const TASK_COMMAND = 'task';
|
||||||
@@ -23,3 +25,65 @@ export const SAMPLE_FILES = [
|
|||||||
target: 'data/scripts/notify.py',
|
target: 'data/scripts/notify.py',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
|
||||||
|
|
||||||
|
export const NotificationModeStringMap = {
|
||||||
|
0: 'gotify',
|
||||||
|
1: 'goCqHttpBot',
|
||||||
|
2: 'serverChan',
|
||||||
|
3: 'pushDeer',
|
||||||
|
4: 'bark',
|
||||||
|
5: 'chat',
|
||||||
|
6: 'telegramBot',
|
||||||
|
7: 'dingtalkBot',
|
||||||
|
8: 'weWorkBot',
|
||||||
|
9: 'weWorkApp',
|
||||||
|
10: 'aibotk',
|
||||||
|
11: 'iGot',
|
||||||
|
12: 'pushPlus',
|
||||||
|
13: 'wePlusBot',
|
||||||
|
14: 'email',
|
||||||
|
15: 'pushMe',
|
||||||
|
16: 'feishu',
|
||||||
|
17: 'webhook',
|
||||||
|
18: 'chronocat',
|
||||||
|
19: 'ntfy',
|
||||||
|
20: 'wxPusherBot',
|
||||||
|
21: 'wxPusherSpt',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const LINUX_DEPENDENCE_COMMAND: Record<
|
||||||
|
'Debian' | 'Ubuntu' | 'Alpine',
|
||||||
|
{
|
||||||
|
install: string;
|
||||||
|
uninstall: string;
|
||||||
|
info: string;
|
||||||
|
check(info: string): boolean;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
Debian: {
|
||||||
|
install: maybeSudo('apt-get install -y'),
|
||||||
|
uninstall: maybeSudo('apt-get remove -y'),
|
||||||
|
info: maybeSudo('dpkg-query -s'),
|
||||||
|
check(info: string) {
|
||||||
|
return info.includes('install ok installed');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Ubuntu: {
|
||||||
|
install: maybeSudo('apt-get install -y'),
|
||||||
|
uninstall: maybeSudo('apt-get remove -y'),
|
||||||
|
info: maybeSudo('dpkg-query -s'),
|
||||||
|
check(info: string) {
|
||||||
|
return info.includes('install ok installed');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Alpine: {
|
||||||
|
install: 'apk add --no-check-certificate',
|
||||||
|
uninstall: 'apk del',
|
||||||
|
info: 'apk info -es',
|
||||||
|
check(info: string) {
|
||||||
|
return info.includes('installed');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function isInContainer(): boolean {
|
||||||
|
return process.env.QL_CONTAINER === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maybeSudo(cmd: string): string {
|
||||||
|
return isInContainer() ? `sudo ${cmd}` : cmd;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import config from './index';
|
||||||
|
import { fileExist } from './util';
|
||||||
|
import Logger from '../loaders/logger';
|
||||||
|
|
||||||
|
export interface GrpcTlsConfig {
|
||||||
|
caCert: string;
|
||||||
|
serverCert: string;
|
||||||
|
serverKey: string;
|
||||||
|
clientCert: string;
|
||||||
|
clientKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const certDir = path.join(config.configPath, 'grpc');
|
||||||
|
const caKeyPath = path.join(certDir, 'ca.key');
|
||||||
|
const caCertPath = path.join(certDir, 'ca.crt');
|
||||||
|
const serverKeyPath = path.join(certDir, 'server.key');
|
||||||
|
const serverCertPath = path.join(certDir, 'server.crt');
|
||||||
|
const clientKeyPath = path.join(certDir, 'client.key');
|
||||||
|
const clientCertPath = path.join(certDir, 'client.crt');
|
||||||
|
|
||||||
|
let cachedConfig: GrpcTlsConfig | null = null;
|
||||||
|
|
||||||
|
function run(cmd: string, execOpts?: Record<string, unknown>): string {
|
||||||
|
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any;
|
||||||
|
return (execSync(cmd, opts) as string).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tmpFile(prefix: string): Promise<string> {
|
||||||
|
const dir = (await fileExist(certDir)) ? certDir : os.tmpdir();
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
||||||
|
Logger.info('[boot] Generating gRPC mTLS certificates...');
|
||||||
|
|
||||||
|
const caKeyTmp = await tmpFile('ca_key');
|
||||||
|
const caCertTmp = await tmpFile('ca_cert');
|
||||||
|
const serverKeyTmp = await tmpFile('server_key');
|
||||||
|
const serverCsrTmp = await tmpFile('server_csr');
|
||||||
|
const serverExtTmp = await tmpFile('server_ext');
|
||||||
|
const clientKeyTmp = await tmpFile('client_key');
|
||||||
|
const clientCsrTmp = await tmpFile('client_csr');
|
||||||
|
const clientExtTmp = await tmpFile('client_ext');
|
||||||
|
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl');
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
|
||||||
|
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
|
||||||
|
try { await fs.unlink(f); } catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用)
|
||||||
|
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`);
|
||||||
|
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`);
|
||||||
|
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
|
||||||
|
const caCert = await fs.readFile(caCertTmp, 'utf-8');
|
||||||
|
await fs.mkdir(certDir, { recursive: true });
|
||||||
|
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
// 2. 服务端
|
||||||
|
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`);
|
||||||
|
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`);
|
||||||
|
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n');
|
||||||
|
const serverCert = run(
|
||||||
|
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`,
|
||||||
|
);
|
||||||
|
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
|
||||||
|
|
||||||
|
// 3. 客户端
|
||||||
|
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`);
|
||||||
|
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`);
|
||||||
|
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
|
||||||
|
const clientCert = run(
|
||||||
|
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
|
||||||
|
);
|
||||||
|
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
Logger.info('[boot] gRPC mTLS certificates generated successfully');
|
||||||
|
|
||||||
|
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||||
|
} catch (e) {
|
||||||
|
await cleanup();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCerts(tlsConfig: GrpcTlsConfig): Promise<void> {
|
||||||
|
await fs.mkdir(certDir, { recursive: true });
|
||||||
|
|
||||||
|
await fs.writeFile(caCertPath, tlsConfig.caCert, { mode: 0o644 });
|
||||||
|
await fs.writeFile(serverCertPath, tlsConfig.serverCert, { mode: 0o644 });
|
||||||
|
await fs.writeFile(serverKeyPath, tlsConfig.serverKey, { mode: 0o600 });
|
||||||
|
await fs.writeFile(clientCertPath, tlsConfig.clientCert, { mode: 0o644 });
|
||||||
|
await fs.writeFile(clientKeyPath, tlsConfig.clientKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
Logger.info(`[boot] gRPC mTLS certificates saved to ${certDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExistingCerts(): Promise<GrpcTlsConfig | null> {
|
||||||
|
const exists = await Promise.all([
|
||||||
|
fileExist(caCertPath),
|
||||||
|
fileExist(serverCertPath),
|
||||||
|
fileExist(serverKeyPath),
|
||||||
|
fileExist(clientCertPath),
|
||||||
|
fileExist(clientKeyPath),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (exists.some((e) => !e)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [caCert, serverCert, serverKey, clientCert, clientKey] = await Promise.all([
|
||||||
|
fs.readFile(caCertPath, 'utf-8'),
|
||||||
|
fs.readFile(serverCertPath, 'utf-8'),
|
||||||
|
fs.readFile(serverKeyPath, 'utf-8'),
|
||||||
|
fs.readFile(clientCertPath, 'utf-8'),
|
||||||
|
fs.readFile(clientKeyPath, 'utf-8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Logger.info('[boot] Loaded existing gRPC mTLS certificates from disk');
|
||||||
|
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initGrpcCerts(): Promise<GrpcTlsConfig> {
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tlsConfig = await loadExistingCerts();
|
||||||
|
|
||||||
|
if (!tlsConfig) {
|
||||||
|
tlsConfig = await generateAllCerts();
|
||||||
|
await saveCerts(tlsConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedConfig = tlsConfig;
|
||||||
|
return tlsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGrpcCerts(): GrpcTlsConfig | null {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { request as undiciRequest, Dispatcher } from 'undici';
|
||||||
|
|
||||||
|
type RequestBaseOptions = {
|
||||||
|
dispatcher?: Dispatcher;
|
||||||
|
json?: Record<string, any>;
|
||||||
|
form?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
} & Omit<Dispatcher.RequestOptions<null>, 'origin' | 'path' | 'method'>;
|
||||||
|
|
||||||
|
type RequestOptionsWithOptions = RequestBaseOptions &
|
||||||
|
Partial<Pick<Dispatcher.RequestOptions, 'method'>>;
|
||||||
|
|
||||||
|
type ResponseTypeMap = {
|
||||||
|
json: Record<string, any>;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResponseTypeKey = keyof ResponseTypeMap;
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
url: string,
|
||||||
|
options?: RequestOptionsWithOptions,
|
||||||
|
): Promise<Dispatcher.ResponseData<null>> {
|
||||||
|
const { json, form, body, headers = {}, ...rest } = options || {};
|
||||||
|
const finalHeaders = { ...headers } as Record<string, string>;
|
||||||
|
let finalBody = body;
|
||||||
|
|
||||||
|
if (json) {
|
||||||
|
finalHeaders['content-type'] = 'application/json';
|
||||||
|
finalBody = JSON.stringify(json);
|
||||||
|
} else if (form) {
|
||||||
|
finalBody = form;
|
||||||
|
delete finalHeaders['content-type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await undiciRequest(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: finalHeaders,
|
||||||
|
body: finalBody,
|
||||||
|
...rest,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post<T extends ResponseTypeKey = 'json'>(
|
||||||
|
url: string,
|
||||||
|
options?: RequestBaseOptions & { responseType?: T },
|
||||||
|
): Promise<ResponseTypeMap[T]> {
|
||||||
|
const resp = await request(url, { ...options, method: 'POST' });
|
||||||
|
|
||||||
|
const rawText = await resp.body.text();
|
||||||
|
|
||||||
|
if (options?.responseType === 'text') {
|
||||||
|
return rawText as ResponseTypeMap[T];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawText) as ResponseTypeMap[T];
|
||||||
|
} catch {
|
||||||
|
return rawText as ResponseTypeMap[T];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpClient = {
|
||||||
|
post,
|
||||||
|
request,
|
||||||
|
};
|
||||||
+78
-18
@@ -1,13 +1,64 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { createRandomString } from './share';
|
|
||||||
|
dotenv.config({
|
||||||
|
path: path.join(__dirname, '../../.env'),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
port: number;
|
||||||
|
grpcPort: number;
|
||||||
|
bindHost: string;
|
||||||
|
bindHostGrpc: string;
|
||||||
|
nodeEnv: string;
|
||||||
|
isDevelopment: boolean;
|
||||||
|
isProduction: boolean;
|
||||||
|
jwt: {
|
||||||
|
secret: string;
|
||||||
|
expiresIn?: string;
|
||||||
|
};
|
||||||
|
cors: {
|
||||||
|
origin: string[];
|
||||||
|
methods: string[];
|
||||||
|
};
|
||||||
|
logs: {
|
||||||
|
level: string;
|
||||||
|
};
|
||||||
|
api: {
|
||||||
|
prefix: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
port: parseInt(process.env.BACK_PORT || '5700', 10),
|
||||||
|
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
|
||||||
|
bindHost: process.env.BIND_HOST || '::',
|
||||||
|
bindHostGrpc: process.env.BIND_HOST_GRPC || '::',
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
isDevelopment: process.env.NODE_ENV === 'development',
|
||||||
|
isProduction: process.env.NODE_ENV === 'production',
|
||||||
|
logs: {
|
||||||
|
level: process.env.LOG_LEVEL || 'silly',
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
prefix: '/api',
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.JWT_SECRET || 'whyour-secret',
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN,
|
||||||
|
},
|
||||||
|
cors: {
|
||||||
|
origin: process.env.CORS_ORIGIN
|
||||||
|
? process.env.CORS_ORIGIN.split(',')
|
||||||
|
: ['*'],
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
||||||
|
|
||||||
if (!process.env.QL_DIR) {
|
if (!process.env.QL_DIR) {
|
||||||
// 声明QL_DIR环境变量
|
|
||||||
let qlHomePath = path.join(__dirname, '../../');
|
let qlHomePath = path.join(__dirname, '../../');
|
||||||
// 生产环境
|
|
||||||
if (qlHomePath.endsWith('/static/')) {
|
if (qlHomePath.endsWith('/static/')) {
|
||||||
qlHomePath = path.join(qlHomePath, '../');
|
qlHomePath = path.join(qlHomePath, '../');
|
||||||
}
|
}
|
||||||
@@ -16,6 +67,19 @@ if (!process.env.QL_DIR) {
|
|||||||
|
|
||||||
const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
|
const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
|
||||||
|
|
||||||
|
// Get and normalize QlBaseUrl
|
||||||
|
let baseUrl = process.env.QlBaseUrl || '';
|
||||||
|
if (baseUrl) {
|
||||||
|
// Ensure it starts with /
|
||||||
|
if (!baseUrl.startsWith('/')) {
|
||||||
|
baseUrl = `/${baseUrl}`;
|
||||||
|
}
|
||||||
|
// Remove trailing slash for consistency in route definitions
|
||||||
|
if (baseUrl.endsWith('/')) {
|
||||||
|
baseUrl = baseUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rootPath = process.env.QL_DIR as string;
|
const rootPath = process.env.QL_DIR as string;
|
||||||
const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
|
const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
|
||||||
|
|
||||||
@@ -38,12 +102,14 @@ const dbPath = path.join(dataPath, 'db/');
|
|||||||
const uploadPath = path.join(dataPath, 'upload/');
|
const uploadPath = path.join(dataPath, 'upload/');
|
||||||
const sshdPath = path.join(dataPath, 'ssh.d/');
|
const sshdPath = path.join(dataPath, 'ssh.d/');
|
||||||
const systemLogPath = path.join(dataPath, 'syslog/');
|
const systemLogPath = path.join(dataPath, 'syslog/');
|
||||||
|
const dependenceCachePath = path.join(dataPath, 'dep_cache/');
|
||||||
|
|
||||||
const envFile = path.join(preloadPath, 'env.sh');
|
const envFile = path.join(preloadPath, 'env.sh');
|
||||||
const jsEnvFile = path.join(preloadPath, 'env.js');
|
const jsEnvFile = path.join(preloadPath, 'env.js');
|
||||||
const pyEnvFile = path.join(preloadPath, 'env.py');
|
const pyEnvFile = path.join(preloadPath, 'env.py');
|
||||||
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
|
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
|
||||||
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
|
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
|
||||||
|
const langEnvFile = path.join(preloadPath, 'lang_env.sh');
|
||||||
const confFile = path.join(configPath, 'config.sh');
|
const confFile = path.join(configPath, 'config.sh');
|
||||||
const crontabFile = path.join(configPath, 'crontab.list');
|
const crontabFile = path.join(configPath, 'crontab.list');
|
||||||
const authConfigFile = path.join(configPath, 'auth.json');
|
const authConfigFile = path.join(configPath, 'auth.json');
|
||||||
@@ -52,8 +118,6 @@ const confBakDir = path.join(dataPath, 'config/bak/');
|
|||||||
const sampleFile = path.join(samplePath, 'config.sample.sh');
|
const sampleFile = path.join(samplePath, 'config.sample.sh');
|
||||||
const sqliteFile = path.join(samplePath, 'database.sqlite');
|
const sqliteFile = path.join(samplePath, 'database.sqlite');
|
||||||
|
|
||||||
const authError = '错误的用户名密码,请重试';
|
|
||||||
const loginFaild = '请先登录!';
|
|
||||||
const configString = 'config sample crontab shareCode diy';
|
const configString = 'config sample crontab shareCode diy';
|
||||||
const versionFile = path.join(rootPath, 'version.yaml');
|
const versionFile = path.join(rootPath, 'version.yaml');
|
||||||
const dataTgzFile = path.join(tmpPath, 'data.tgz');
|
const dataTgzFile = path.join(tmpPath, 'data.tgz');
|
||||||
@@ -65,17 +129,9 @@ if (envFound.error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: parseInt(process.env.BACK_PORT as string, 10),
|
...config,
|
||||||
cronPort: parseInt(process.env.CRON_PORT as string, 10),
|
jwt: config.jwt,
|
||||||
publicPort: parseInt(process.env.PUBLIC_PORT as string, 10),
|
baseUrl,
|
||||||
updatePort: parseInt(process.env.UPDATE_PORT as string, 10),
|
|
||||||
secret: process.env.SECRET || createRandomString(16, 32),
|
|
||||||
logs: {
|
|
||||||
level: process.env.LOG_LEVEL || 'silly',
|
|
||||||
},
|
|
||||||
api: {
|
|
||||||
prefix: '/api',
|
|
||||||
},
|
|
||||||
rootPath,
|
rootPath,
|
||||||
tmpPath,
|
tmpPath,
|
||||||
dataPath,
|
dataPath,
|
||||||
@@ -83,8 +139,6 @@ export default {
|
|||||||
shareShellFile,
|
shareShellFile,
|
||||||
dependenceProxyFile,
|
dependenceProxyFile,
|
||||||
configString,
|
configString,
|
||||||
loginFaild,
|
|
||||||
authError,
|
|
||||||
logPath,
|
logPath,
|
||||||
extraFile,
|
extraFile,
|
||||||
authConfigFile,
|
authConfigFile,
|
||||||
@@ -97,6 +151,7 @@ export default {
|
|||||||
pyEnvFile,
|
pyEnvFile,
|
||||||
jsNotifyFile,
|
jsNotifyFile,
|
||||||
pyNotifyFile,
|
pyNotifyFile,
|
||||||
|
langEnvFile,
|
||||||
dbPath,
|
dbPath,
|
||||||
uploadPath,
|
uploadPath,
|
||||||
configPath,
|
configPath,
|
||||||
@@ -113,11 +168,14 @@ export default {
|
|||||||
'env.js',
|
'env.js',
|
||||||
'env.py',
|
'env.py',
|
||||||
'token.json',
|
'token.json',
|
||||||
|
'grpc',
|
||||||
|
'__pycache__',
|
||||||
],
|
],
|
||||||
writePathList: [configPath, scriptPath],
|
writePathList: [configPath, scriptPath],
|
||||||
bakPath,
|
bakPath,
|
||||||
apiWhiteList: [
|
apiWhiteList: [
|
||||||
'/api/user/login',
|
'/api/user/login',
|
||||||
|
'/api/health',
|
||||||
'/open/auth/token',
|
'/open/auth/token',
|
||||||
'/api/user/two-factor/login',
|
'/api/user/two-factor/login',
|
||||||
'/api/system',
|
'/api/system',
|
||||||
@@ -134,4 +192,6 @@ export default {
|
|||||||
sqliteFile,
|
sqliteFile,
|
||||||
sshdPath,
|
sshdPath,
|
||||||
systemLogPath,
|
systemLogPath,
|
||||||
|
dependenceCachePath,
|
||||||
|
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
|
||||||
};
|
};
|
||||||
|
|||||||
+325
-105
@@ -1,19 +1,48 @@
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import got from 'got';
|
import { exec, execSync } from 'child_process';
|
||||||
import iconv from 'iconv-lite';
|
import psTreeFun from 'ps-tree';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import FormData from 'form-data';
|
|
||||||
import psTreeFun from 'pstree.remy';
|
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { load } from 'js-yaml';
|
import { load } from 'js-yaml';
|
||||||
import config from './index';
|
import config from './index';
|
||||||
import { TASK_COMMAND } from './const';
|
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
|
import { DependenceTypes } from '../data/dependence';
|
||||||
|
import { FormData } from 'undici';
|
||||||
|
import os from 'os';
|
||||||
|
import { maybeSudo, isInContainer } from './container';
|
||||||
|
|
||||||
export * from './share';
|
export * from './share';
|
||||||
|
|
||||||
|
let osType: 'Debian' | 'Ubuntu' | 'Alpine' | undefined;
|
||||||
|
|
||||||
|
function getOsTypeSync(): 'Debian' | 'Ubuntu' | 'Alpine' | undefined {
|
||||||
|
// 1. 环境变量覆盖
|
||||||
|
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
|
||||||
|
if (envOs === 'alpine') return 'Alpine';
|
||||||
|
if (envOs === 'debian') return 'Debian';
|
||||||
|
if (envOs === 'ubuntu') return 'Ubuntu';
|
||||||
|
|
||||||
|
// 2. 模块缓存(由 detectOS 设置)
|
||||||
|
if (osType) return osType;
|
||||||
|
|
||||||
|
// 3. 能力检测:检查包管理器二进制
|
||||||
|
try {
|
||||||
|
execSync('which apt-get', { stdio: 'ignore' });
|
||||||
|
return 'Debian';
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
execSync('which apk', { stdio: 'ignore' });
|
||||||
|
return 'Alpine';
|
||||||
|
} catch {
|
||||||
|
// macOS / 未知系统
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getFileContentByName(fileName: string) {
|
export async function getFileContentByName(fileName: string) {
|
||||||
const _exsit = await fileExist(fileName);
|
const _exsit = await fileExist(fileName);
|
||||||
if (_exsit) {
|
if (_exsit) {
|
||||||
@@ -57,78 +86,6 @@ export function getToken(req: any) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNetIp(req: any) {
|
|
||||||
const ipArray = [
|
|
||||||
...new Set([
|
|
||||||
...(req.headers['x-real-ip'] || '').split(','),
|
|
||||||
...(req.headers['x-forwarded-for'] || '').split(','),
|
|
||||||
req.ip,
|
|
||||||
...req.ips,
|
|
||||||
req.socket.remoteAddress,
|
|
||||||
]),
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
let ip = ipArray[0];
|
|
||||||
|
|
||||||
if (ipArray.length > 1) {
|
|
||||||
for (let i = 0; i < ipArray.length; i++) {
|
|
||||||
const ipNumArray = ipArray[i].split('.');
|
|
||||||
const tmp = ipNumArray[0] + '.' + ipNumArray[1];
|
|
||||||
if (
|
|
||||||
tmp === '192.168' ||
|
|
||||||
(ipNumArray[0] === '172' &&
|
|
||||||
ipNumArray[1] >= 16 &&
|
|
||||||
ipNumArray[1] <= 32) ||
|
|
||||||
tmp === '10.7' ||
|
|
||||||
tmp === '127.0'
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ip = ipArray[i];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ip = ip.substr(ip.lastIndexOf(':') + 1, ip.length);
|
|
||||||
if (ip.includes('127.0') || ip.includes('192.168') || ip.includes('10.7')) {
|
|
||||||
ip = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ip) {
|
|
||||||
return { address: `获取失败`, ip };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const csdnApi = got
|
|
||||||
.get(`https://searchplugin.csdn.net/api/v1/ip/get?ip=${ip}`, {
|
|
||||||
timeout: 10000,
|
|
||||||
retry: 0,
|
|
||||||
})
|
|
||||||
.text();
|
|
||||||
const pconlineApi = got
|
|
||||||
.get(`https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true`, {
|
|
||||||
timeout: 10000,
|
|
||||||
retry: 0,
|
|
||||||
})
|
|
||||||
.buffer();
|
|
||||||
const [csdnBody, pconlineBody] = await await Promise.all<any>([
|
|
||||||
csdnApi,
|
|
||||||
pconlineApi,
|
|
||||||
]);
|
|
||||||
const csdnRes = JSON.parse(csdnBody);
|
|
||||||
const pconlineRes = JSON.parse(iconv.decode(pconlineBody, 'GBK'));
|
|
||||||
let address = '';
|
|
||||||
if (csdnBody && csdnRes.code == 200) {
|
|
||||||
address = csdnRes.data.address;
|
|
||||||
} else if (pconlineRes && pconlineRes.addr) {
|
|
||||||
address = pconlineRes.addr;
|
|
||||||
}
|
|
||||||
return { address, ip };
|
|
||||||
} catch (error) {
|
|
||||||
return { address: `获取失败`, ip };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
|
export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
|
||||||
const ua = userAgent.toLowerCase();
|
const ua = userAgent.toLowerCase();
|
||||||
const testUa = (regexp: RegExp) => regexp.test(ua);
|
const testUa = (regexp: RegExp) => regexp.test(ua);
|
||||||
@@ -237,7 +194,7 @@ enum FileType {
|
|||||||
'file',
|
'file',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IFile {
|
export interface IFile {
|
||||||
title: string;
|
title: string;
|
||||||
key: string;
|
key: string;
|
||||||
type: 'directory' | 'file';
|
type: 'directory' | 'file';
|
||||||
@@ -305,23 +262,54 @@ export async function readDir(
|
|||||||
dir: string,
|
dir: string,
|
||||||
baseDir: string = '',
|
baseDir: string = '',
|
||||||
blacklist: string[] = [],
|
blacklist: string[] = [],
|
||||||
) {
|
): Promise<IFile[]> {
|
||||||
const relativePath = path.relative(baseDir, dir);
|
const absoluteDir = path.resolve(baseDir, dir);
|
||||||
const files = await fs.readdir(dir);
|
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
|
||||||
const result: any = files
|
return [];
|
||||||
.filter((x) => !blacklist.includes(x))
|
}
|
||||||
.map(async (file: string) => {
|
const relativePath = path.relative(baseDir, absoluteDir);
|
||||||
const subPath = path.join(dir, file);
|
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(absoluteDir);
|
||||||
|
const result: IFile[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const subPath = path.join(absoluteDir, file);
|
||||||
const stats = await fs.lstat(subPath);
|
const stats = await fs.lstat(subPath);
|
||||||
const key = path.join(relativePath, file);
|
const key = path.join(relativePath, file);
|
||||||
return {
|
|
||||||
title: file,
|
if (blacklist.includes(file) || stats.isSymbolicLink()) {
|
||||||
type: stats.isDirectory() ? 'directory' : 'file',
|
continue;
|
||||||
key,
|
}
|
||||||
parent: relativePath,
|
|
||||||
};
|
if (stats.isDirectory()) {
|
||||||
});
|
result.push({
|
||||||
return result;
|
title: file,
|
||||||
|
type: 'directory',
|
||||||
|
key,
|
||||||
|
parent: relativePath,
|
||||||
|
createTime: stats.birthtime.getTime(),
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
title: file,
|
||||||
|
type: 'file',
|
||||||
|
key,
|
||||||
|
parent: relativePath,
|
||||||
|
size: stats.size,
|
||||||
|
createTime: stats.birthtime.getTime(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function promiseExec(command: string): Promise<string> {
|
export async function promiseExec(command: string): Promise<string> {
|
||||||
@@ -352,9 +340,9 @@ export function parseHeaders(headers: string) {
|
|||||||
if (!headers) return {};
|
if (!headers) return {};
|
||||||
|
|
||||||
const parsed: any = {};
|
const parsed: any = {};
|
||||||
let key;
|
let key: string;
|
||||||
let val;
|
let val: string;
|
||||||
let i;
|
let i: number;
|
||||||
|
|
||||||
headers &&
|
headers &&
|
||||||
headers.split('\n').forEach(function parser(line) {
|
headers.split('\n').forEach(function parser(line) {
|
||||||
@@ -433,11 +421,11 @@ export function parseBody(
|
|||||||
|
|
||||||
export function psTree(pid: number): Promise<number[]> {
|
export function psTree(pid: number): Promise<number[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
psTreeFun(pid, (err: any, pids: number[]) => {
|
psTreeFun(pid, (err: any, children) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
resolve(pids.filter((x) => !isNaN(x)));
|
resolve(children.map((x) => Number(x.PID)).filter((x) => !isNaN(x)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -450,7 +438,7 @@ export async function killTask(pid: number) {
|
|||||||
[pid, ...pids].reverse().forEach((x) => {
|
[pid, ...pids].reverse().forEach((x) => {
|
||||||
process.kill(x, 15);
|
process.kill(x, 15);
|
||||||
});
|
});
|
||||||
} catch (error) {}
|
} catch (error) { }
|
||||||
} else {
|
} else {
|
||||||
process.kill(pid, 2);
|
process.kill(pid, 2);
|
||||||
}
|
}
|
||||||
@@ -462,6 +450,27 @@ export async function getPid(cmd: string) {
|
|||||||
return pid ? Number(pid) : undefined;
|
return pid ? Number(pid) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllPids(cmd: string): Promise<number[]> {
|
||||||
|
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
|
||||||
|
const pidsStr = await promiseExec(taskCommand);
|
||||||
|
if (!pidsStr) return [];
|
||||||
|
return pidsStr
|
||||||
|
.split('\n')
|
||||||
|
.map((p) => Number(p.trim()))
|
||||||
|
.filter((p) => !isNaN(p) && p > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function killAllTasks(cmd: string): Promise<void> {
|
||||||
|
const pids = await getAllPids(cmd);
|
||||||
|
for (const pid of pids) {
|
||||||
|
try {
|
||||||
|
await killTask(pid);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors if process already terminated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface IVersion {
|
interface IVersion {
|
||||||
version: string;
|
version: string;
|
||||||
changeLogLink: string;
|
changeLogLink: string;
|
||||||
@@ -527,11 +536,17 @@ export function safeJSONParse(value?: string) {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(value);
|
return JSON.parse(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('[safeJSONParse失败]', error);
|
Logger.error('[safeJSONParse error]', error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function errStack(error: unknown): string {
|
||||||
|
return error instanceof Error && error.stack
|
||||||
|
? error.stack
|
||||||
|
: String(error);
|
||||||
|
}
|
||||||
|
|
||||||
export async function rmPath(path: string) {
|
export async function rmPath(path: string) {
|
||||||
try {
|
try {
|
||||||
const _exsit = await fileExist(path);
|
const _exsit = await fileExist(path);
|
||||||
@@ -539,7 +554,7 @@ export async function rmPath(path: string) {
|
|||||||
await fs.rm(path, { force: true, recursive: true, maxRetries: 5 });
|
await fs.rm(path, { force: true, recursive: true, maxRetries: 5 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('[rmPath失败]', error);
|
Logger.error('[rmPath error]', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,12 +564,217 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
|
|||||||
throw new Error('Invalid timezone');
|
throw new Error('Invalid timezone');
|
||||||
}
|
}
|
||||||
|
|
||||||
await promiseExec(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`);
|
await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`));
|
||||||
await promiseExec(`echo "${timezone}" > /etc/timezone`);
|
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('[setSystemTimezone失败]', error);
|
Logger.error('[setSystemTimezone error]', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGetCommand(type: DependenceTypes, name: string): string {
|
||||||
|
const baseCommands = {
|
||||||
|
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
|
||||||
|
[DependenceTypes.python3]: `
|
||||||
|
python3 -c "exec('''
|
||||||
|
name='${name}'
|
||||||
|
try:
|
||||||
|
from importlib.metadata import version
|
||||||
|
print(version(name))
|
||||||
|
except:
|
||||||
|
import importlib.util as u
|
||||||
|
import importlib.metadata as m
|
||||||
|
spec=u.find_spec(name)
|
||||||
|
print(name if spec else '')
|
||||||
|
''')"`,
|
||||||
|
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||||
|
? `apk info -es ${name}`
|
||||||
|
: maybeSudo(`dpkg-query -s ${name}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
return baseCommands[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstallCommand(type: DependenceTypes, name: string): string {
|
||||||
|
const baseCommands = {
|
||||||
|
[DependenceTypes.nodejs]: 'pnpm add -g',
|
||||||
|
[DependenceTypes.python3]:
|
||||||
|
'pip3 install --disable-pip-version-check --root-user-action=ignore',
|
||||||
|
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||||
|
? 'apk add --no-check-certificate'
|
||||||
|
: maybeSudo('apt-get install -y'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = baseCommands[type];
|
||||||
|
|
||||||
|
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
|
||||||
|
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${command} ${name.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUninstallCommand(
|
||||||
|
type: DependenceTypes,
|
||||||
|
name: string,
|
||||||
|
): string {
|
||||||
|
const baseCommands = {
|
||||||
|
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
||||||
|
[DependenceTypes.python3]:
|
||||||
|
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
|
||||||
|
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||||
|
? 'apk del'
|
||||||
|
: maybeSudo('apt-get remove -y'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${baseCommands[type]} ${name.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDemoEnv() {
|
||||||
|
return process.env.DeployEnv === 'demo';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOSReleaseInfo(): Promise<string> {
|
||||||
|
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
|
||||||
|
return osRelease;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDebian(osReleaseInfo: string): boolean {
|
||||||
|
return osReleaseInfo.includes('Debian');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUbuntu(osReleaseInfo: string): boolean {
|
||||||
|
return osReleaseInfo.includes('Ubuntu');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCentOS(osReleaseInfo: string): boolean {
|
||||||
|
return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlpine(osReleaseInfo: string): boolean {
|
||||||
|
return osReleaseInfo.includes('Alpine');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectOS(): Promise<
|
||||||
|
'Debian' | 'Ubuntu' | 'Alpine' | undefined
|
||||||
|
> {
|
||||||
|
if (osType) return osType;
|
||||||
|
|
||||||
|
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
|
||||||
|
if (envOs === 'alpine') {
|
||||||
|
osType = 'Alpine';
|
||||||
|
return osType;
|
||||||
|
}
|
||||||
|
if (envOs === 'debian') {
|
||||||
|
osType = 'Debian';
|
||||||
|
return osType;
|
||||||
|
}
|
||||||
|
if (envOs === 'ubuntu') {
|
||||||
|
osType = 'Ubuntu';
|
||||||
|
return osType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = os.platform();
|
||||||
|
|
||||||
|
if (platform === 'linux') {
|
||||||
|
const osReleaseInfo = await getOSReleaseInfo();
|
||||||
|
if (isDebian(osReleaseInfo)) {
|
||||||
|
osType = 'Debian';
|
||||||
|
} else if (isUbuntu(osReleaseInfo)) {
|
||||||
|
osType = 'Ubuntu';
|
||||||
|
} else if (isAlpine(osReleaseInfo)) {
|
||||||
|
osType = 'Alpine';
|
||||||
|
} else {
|
||||||
|
Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
|
||||||
|
console.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
|
||||||
|
}
|
||||||
|
} else if (platform === 'darwin') {
|
||||||
|
osType = undefined;
|
||||||
|
} else {
|
||||||
|
Logger.error(`Unsupported platform: ${platform}`);
|
||||||
|
console.error(`Unsupported platform: ${platform}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return osType;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentMirrorDomain(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const fileContent = await fs.readFile(filePath, 'utf8');
|
||||||
|
const lines = fileContent.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim().startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = line.match(/https?:\/\/[^\/]+/);
|
||||||
|
if (match) {
|
||||||
|
return match[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceDomainInFile(
|
||||||
|
filePath: string,
|
||||||
|
oldDomainWithScheme: string,
|
||||||
|
newDomainWithScheme: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let fileContent = await fs.readFile(filePath, 'utf8');
|
||||||
|
let updatedContent = fileContent.replace(
|
||||||
|
new RegExp(oldDomainWithScheme, 'g'),
|
||||||
|
newDomainWithScheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newDomainWithScheme.endsWith('/')) {
|
||||||
|
newDomainWithScheme += '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFileWithLock(filePath, updatedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _updateLinuxMirror(
|
||||||
|
osType: string,
|
||||||
|
mirrorDomainWithScheme: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const S = isInContainer() ? 'sudo ' : '';
|
||||||
|
let filePath: string, currentDomainWithScheme: string | null;
|
||||||
|
switch (osType) {
|
||||||
|
case 'Debian':
|
||||||
|
filePath = '/etc/apt/sources.list.d/debian.sources';
|
||||||
|
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||||
|
if (currentDomainWithScheme) {
|
||||||
|
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
||||||
|
} else {
|
||||||
|
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
||||||
|
}
|
||||||
|
case 'Ubuntu':
|
||||||
|
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
|
||||||
|
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||||
|
if (currentDomainWithScheme) {
|
||||||
|
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
||||||
|
} else {
|
||||||
|
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
||||||
|
}
|
||||||
|
case 'Alpine':
|
||||||
|
filePath = '/etc/apk/repositories';
|
||||||
|
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||||
|
if (currentDomainWithScheme) {
|
||||||
|
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
|
||||||
|
} else {
|
||||||
|
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw Error('Unsupported OS type for updating mirrors.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
|
||||||
|
const detectedOS = await detectOS();
|
||||||
|
if (!detectedOS) {
|
||||||
|
throw Error(`Unknown Linux Distribution`);
|
||||||
|
}
|
||||||
|
return await _updateLinuxMirror(detectedOS, mirror);
|
||||||
|
}
|
||||||
|
|||||||
+12
-3
@@ -21,6 +21,9 @@ export class Crontab {
|
|||||||
extra_schedules?: Array<{ schedule: string }>;
|
extra_schedules?: Array<{ schedule: string }>;
|
||||||
task_before?: string;
|
task_before?: string;
|
||||||
task_after?: string;
|
task_after?: string;
|
||||||
|
log_name?: string;
|
||||||
|
allow_multiple_instances?: 1 | 0;
|
||||||
|
work_dir?: string;
|
||||||
|
|
||||||
constructor(options: Crontab) {
|
constructor(options: Crontab) {
|
||||||
this.name = options.name;
|
this.name = options.name;
|
||||||
@@ -45,17 +48,20 @@ export class Crontab {
|
|||||||
this.extra_schedules = options.extra_schedules;
|
this.extra_schedules = options.extra_schedules;
|
||||||
this.task_before = options.task_before;
|
this.task_before = options.task_before;
|
||||||
this.task_after = options.task_after;
|
this.task_after = options.task_after;
|
||||||
|
this.log_name = options.log_name;
|
||||||
|
this.allow_multiple_instances = options.allow_multiple_instances || 0;
|
||||||
|
this.work_dir = options.work_dir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CrontabStatus {
|
export enum CrontabStatus {
|
||||||
'running' = 0,
|
'running' = 0,
|
||||||
'queued' = 0.5,
|
|
||||||
'idle' = 1,
|
'idle' = 1,
|
||||||
'disabled',
|
'disabled' = 2,
|
||||||
|
'queued' = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CronInstance extends Model<Crontab, Crontab>, Crontab { }
|
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
|
||||||
export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
||||||
name: {
|
name: {
|
||||||
unique: 'compositeIndex',
|
unique: 'compositeIndex',
|
||||||
@@ -84,4 +90,7 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
|||||||
extra_schedules: DataTypes.JSON,
|
extra_schedules: DataTypes.JSON,
|
||||||
task_before: DataTypes.STRING,
|
task_before: DataTypes.STRING,
|
||||||
task_after: DataTypes.STRING,
|
task_after: DataTypes.STRING,
|
||||||
|
log_name: DataTypes.STRING,
|
||||||
|
allow_multiple_instances: DataTypes.NUMBER,
|
||||||
|
work_dir: DataTypes.STRING,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { DataTypes, Model } from 'sequelize';
|
||||||
|
import { sequelize } from '.';
|
||||||
|
|
||||||
|
export class CrontabStat {
|
||||||
|
id?: number;
|
||||||
|
ref_id!: number;
|
||||||
|
date!: string;
|
||||||
|
run_count?: number;
|
||||||
|
success_count?: number;
|
||||||
|
fail_count?: number;
|
||||||
|
total_time?: number;
|
||||||
|
max_time?: number;
|
||||||
|
|
||||||
|
constructor(options: CrontabStat) {
|
||||||
|
this.id = options.id;
|
||||||
|
this.ref_id = options.ref_id;
|
||||||
|
this.date = options.date;
|
||||||
|
this.run_count = options.run_count || 0;
|
||||||
|
this.success_count = options.success_count || 0;
|
||||||
|
this.fail_count = options.fail_count || 0;
|
||||||
|
this.total_time = options.total_time || 0;
|
||||||
|
this.max_time = options.max_time || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrontabStatInstance extends Model<CrontabStat, CrontabStat>, CrontabStat {}
|
||||||
|
|
||||||
|
export const CrontabStatModel = sequelize.define<CrontabStatInstance>(
|
||||||
|
'CrontabStat',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
},
|
||||||
|
ref_id: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
run_count: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
success_count: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
fail_count: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
total_time: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
max_time: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
indexes: [
|
||||||
|
{ unique: true, fields: ['ref_id', 'date'] },
|
||||||
|
{ fields: ['date'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -41,30 +41,12 @@ export enum DependenceTypes {
|
|||||||
'linux',
|
'linux',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum InstallDependenceCommandTypes {
|
|
||||||
'pnpm add -g',
|
|
||||||
'pip3 install --disable-pip-version-check --root-user-action=ignore',
|
|
||||||
'apk add --no-check-certificate',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum GetDependenceCommandTypes {
|
|
||||||
'pnpm ls -g ',
|
|
||||||
'pip3 show --disable-pip-version-check',
|
|
||||||
'apk info -es',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum versionDependenceCommandTypes {
|
export enum versionDependenceCommandTypes {
|
||||||
'@',
|
'@',
|
||||||
'==',
|
'==',
|
||||||
'=',
|
'=',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum unInstallDependenceCommandTypes {
|
|
||||||
'pnpm remove -g',
|
|
||||||
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
|
|
||||||
'apk del',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DependenceInstance
|
export interface DependenceInstance
|
||||||
extends Model<Dependence, Dependence>,
|
extends Model<Dependence, Dependence>,
|
||||||
Dependence {}
|
Dependence {}
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,5 @@
|
|||||||
|
import { DataTypes, Model } from 'sequelize';
|
||||||
import { sequelize } from '.';
|
import { sequelize } from '.';
|
||||||
import { DataTypes, Model, ModelDefined } from 'sequelize';
|
|
||||||
|
|
||||||
export class Env {
|
export class Env {
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -9,6 +9,8 @@ export class Env {
|
|||||||
position?: number;
|
position?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
remarks?: string;
|
remarks?: string;
|
||||||
|
isPinned?: 1 | 0;
|
||||||
|
labels?: string[];
|
||||||
|
|
||||||
constructor(options: Env) {
|
constructor(options: Env) {
|
||||||
this.value = options.value;
|
this.value = options.value;
|
||||||
@@ -21,6 +23,8 @@ export class Env {
|
|||||||
this.position = options.position;
|
this.position = options.position;
|
||||||
this.name = options.name;
|
this.name = options.name;
|
||||||
this.remarks = options.remarks || '';
|
this.remarks = options.remarks || '';
|
||||||
|
this.isPinned = options.isPinned || 0;
|
||||||
|
this.labels = options.labels || [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,4 +46,6 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
|
|||||||
position: DataTypes.NUMBER,
|
position: DataTypes.NUMBER,
|
||||||
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
|
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
|
||||||
remarks: DataTypes.STRING,
|
remarks: DataTypes.STRING,
|
||||||
|
isPinned: DataTypes.NUMBER,
|
||||||
|
labels: DataTypes.JSON,
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-3
@@ -1,5 +1,3 @@
|
|||||||
import { IncomingHttpHeaders } from 'http';
|
|
||||||
|
|
||||||
export enum NotificationMode {
|
export enum NotificationMode {
|
||||||
'gotify' = 'gotify',
|
'gotify' = 'gotify',
|
||||||
'goCqHttpBot' = 'goCqHttpBot',
|
'goCqHttpBot' = 'goCqHttpBot',
|
||||||
@@ -22,6 +20,7 @@ export enum NotificationMode {
|
|||||||
'chronocat' = 'Chronocat',
|
'chronocat' = 'Chronocat',
|
||||||
'ntfy' = 'ntfy',
|
'ntfy' = 'ntfy',
|
||||||
'wxPusherBot' = 'wxPusherBot',
|
'wxPusherBot' = 'wxPusherBot',
|
||||||
|
'openiLink' = 'openiLink',
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class NotificationBaseInfo {
|
abstract class NotificationBaseInfo {
|
||||||
@@ -117,6 +116,7 @@ export class EmailNotification extends NotificationBaseInfo {
|
|||||||
public emailService: string = '';
|
public emailService: string = '';
|
||||||
public emailUser: string = '';
|
public emailUser: string = '';
|
||||||
public emailPass: string = '';
|
public emailPass: string = '';
|
||||||
|
public emailTo: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PushMeNotification extends NotificationBaseInfo {
|
export class PushMeNotification extends NotificationBaseInfo {
|
||||||
@@ -143,12 +143,17 @@ export class WebhookNotification extends NotificationBaseInfo {
|
|||||||
|
|
||||||
export class LarkNotification extends NotificationBaseInfo {
|
export class LarkNotification extends NotificationBaseInfo {
|
||||||
public larkKey = '';
|
public larkKey = '';
|
||||||
|
public larkSecret = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NtfyNotification extends NotificationBaseInfo {
|
export class NtfyNotification extends NotificationBaseInfo {
|
||||||
public ntfyUrl = '';
|
public ntfyUrl = '';
|
||||||
public ntfyTopic = '';
|
public ntfyTopic = '';
|
||||||
public ntfyPriority = '';
|
public ntfyPriority = '';
|
||||||
|
public ntfyToken = '';
|
||||||
|
public ntfyUsername = '';
|
||||||
|
public ntfyPassword = '';
|
||||||
|
public ntfyActions = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WxPusherBotNotification extends NotificationBaseInfo {
|
export class WxPusherBotNotification extends NotificationBaseInfo {
|
||||||
@@ -157,6 +162,16 @@ export class WxPusherBotNotification extends NotificationBaseInfo {
|
|||||||
public wxPusherBotUids = '';
|
public wxPusherBotUids = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class WxPusherSptNotification extends NotificationBaseInfo {
|
||||||
|
public wxPusherSptList = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpeniLinkNotification extends NotificationBaseInfo {
|
||||||
|
public openiLinkAppToken = '';
|
||||||
|
public openiLinkHubUrl = '';
|
||||||
|
public openiLinkContextToken = '';
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationInfo
|
export interface NotificationInfo
|
||||||
extends GoCqHttpBotNotification,
|
extends GoCqHttpBotNotification,
|
||||||
GotifyNotification,
|
GotifyNotification,
|
||||||
@@ -178,4 +193,6 @@ export interface NotificationInfo
|
|||||||
ChronocatNotification,
|
ChronocatNotification,
|
||||||
LarkNotification,
|
LarkNotification,
|
||||||
NtfyNotification,
|
NtfyNotification,
|
||||||
WxPusherBotNotification {}
|
WxPusherBotNotification,
|
||||||
|
WxPusherSptNotification,
|
||||||
|
OpeniLinkNotification {}
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ export interface AppToken {
|
|||||||
expiration: number;
|
expiration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system';
|
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
|
||||||
|
|
||||||
export interface AppInstance extends Model<App, App>, App {}
|
export interface AppInstance extends Model<App, App>, App {}
|
||||||
export const AppModel = sequelize.define<AppInstance>('App', {
|
export const AppModel = sequelize.define<AppInstance>('App', {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { sequelize } from '.';
|
||||||
|
import { DataTypes, Model } from 'sequelize';
|
||||||
|
|
||||||
|
export enum InstanceStatus {
|
||||||
|
'running' = 0,
|
||||||
|
'finished' = 1,
|
||||||
|
'stopped' = 2,
|
||||||
|
'error' = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunningInstanceAttributes {
|
||||||
|
id?: number;
|
||||||
|
cron_id: number;
|
||||||
|
pid?: number;
|
||||||
|
log_path?: string;
|
||||||
|
started_at: number;
|
||||||
|
finished_at?: number;
|
||||||
|
status: InstanceStatus;
|
||||||
|
exit_code?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RunningInstance {
|
||||||
|
id?: number;
|
||||||
|
cron_id!: number;
|
||||||
|
pid?: number;
|
||||||
|
log_path?: string;
|
||||||
|
started_at!: number;
|
||||||
|
finished_at?: number;
|
||||||
|
status!: InstanceStatus;
|
||||||
|
exit_code?: number;
|
||||||
|
|
||||||
|
constructor(options: RunningInstanceAttributes) {
|
||||||
|
this.id = options.id;
|
||||||
|
this.cron_id = options.cron_id;
|
||||||
|
this.pid = options.pid;
|
||||||
|
this.log_path = options.log_path;
|
||||||
|
this.started_at = options.started_at;
|
||||||
|
this.finished_at = options.finished_at;
|
||||||
|
this.status = options.status;
|
||||||
|
this.exit_code = options.exit_code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunningInstanceModel
|
||||||
|
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>,
|
||||||
|
RunningInstanceAttributes {}
|
||||||
|
|
||||||
|
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
|
||||||
|
'RunningInstance',
|
||||||
|
{
|
||||||
|
cron_id: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
pid: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
log_path: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
started_at: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
finished_at: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: InstanceStatus.running,
|
||||||
|
},
|
||||||
|
exit_code: {
|
||||||
|
type: DataTypes.NUMBER,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -2,11 +2,13 @@ export class SockMessage {
|
|||||||
message?: string;
|
message?: string;
|
||||||
type?: SockMessageType;
|
type?: SockMessageType;
|
||||||
references?: number[];
|
references?: number[];
|
||||||
|
status?: number | string;
|
||||||
|
|
||||||
constructor(options: SockMessage) {
|
constructor(options: SockMessage) {
|
||||||
this.type = options.type;
|
this.type = options.type;
|
||||||
this.message = options.message;
|
this.message = options.message;
|
||||||
this.references = options.references;
|
this.references = options.references;
|
||||||
|
this.status = options.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -31,6 +31,8 @@ export enum AuthDataType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemConfigInfo {
|
export interface SystemConfigInfo {
|
||||||
|
lang?: string;
|
||||||
|
panelTitle?: string;
|
||||||
logRemoveFrequency?: number;
|
logRemoveFrequency?: number;
|
||||||
cronConcurrency?: number;
|
cronConcurrency?: number;
|
||||||
dependenceProxy?: string;
|
dependenceProxy?: string;
|
||||||
@@ -38,6 +40,7 @@ export interface SystemConfigInfo {
|
|||||||
pythonMirror?: string;
|
pythonMirror?: string;
|
||||||
linuxMirror?: string;
|
linuxMirror?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
|
globalSshKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginLogInfo {
|
export interface LoginLogInfo {
|
||||||
@@ -48,6 +51,19 @@ export interface LoginLogInfo {
|
|||||||
status?: LoginStatus;
|
status?: LoginStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TokenInfo {
|
||||||
|
value: string;
|
||||||
|
timestamp: number;
|
||||||
|
ip: string;
|
||||||
|
address: string;
|
||||||
|
platform: string;
|
||||||
|
/**
|
||||||
|
* Token expiration time in seconds since Unix epoch.
|
||||||
|
* If undefined, the token uses JWT's built-in expiration.
|
||||||
|
*/
|
||||||
|
expiration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthInfo {
|
export interface AuthInfo {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -58,7 +74,7 @@ export interface AuthInfo {
|
|||||||
platform: string;
|
platform: string;
|
||||||
isTwoFactorChecking: boolean;
|
isTwoFactorChecking: boolean;
|
||||||
token: string;
|
token: string;
|
||||||
tokens: Record<string, string>;
|
tokens: Record<string, string | TokenInfo[]>;
|
||||||
twoFactorActivated: boolean;
|
twoFactorActivated: boolean;
|
||||||
twoFactorSecret: string;
|
twoFactorSecret: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
|||||||
Vendored
-7
@@ -1,7 +0,0 @@
|
|||||||
declare namespace Express {
|
|
||||||
interface Request {
|
|
||||||
platform: 'desktop' | 'mobile';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'pstree.remy';
|
|
||||||
+13
-14
@@ -5,25 +5,24 @@ import initData from './initData';
|
|||||||
import { Application } from 'express';
|
import { Application } from 'express';
|
||||||
import linkDeps from './deps';
|
import linkDeps from './deps';
|
||||||
import initTask from './initTask';
|
import initTask from './initTask';
|
||||||
|
import initFile from './initFile';
|
||||||
|
|
||||||
export default async ({ expressApp }: { expressApp: Application }) => {
|
export default async ({ app }: { app: Application }) => {
|
||||||
depInjectorLoader();
|
depInjectorLoader();
|
||||||
Logger.info('✌️ Dependency loaded');
|
Logger.info('[boot] Dependency loaded');
|
||||||
console.log('✌️ Dependency loaded');
|
|
||||||
|
|
||||||
await initData();
|
|
||||||
Logger.info('✌️ Init data loaded');
|
|
||||||
console.log('✌️ Init data loaded');
|
|
||||||
|
|
||||||
await linkDeps();
|
await linkDeps();
|
||||||
Logger.info('✌️ Link deps loaded');
|
Logger.info('[boot] Link deps loaded');
|
||||||
console.log('✌️ Link deps loaded');
|
|
||||||
|
await initFile();
|
||||||
|
Logger.info('[boot] Init file loaded');
|
||||||
|
|
||||||
|
await initData();
|
||||||
|
Logger.info('[boot] Init data loaded');
|
||||||
|
|
||||||
initTask();
|
initTask();
|
||||||
Logger.info('✌️ Init task loaded');
|
Logger.info('[boot] Init task loaded');
|
||||||
console.log('✌️ Init task loaded');
|
|
||||||
|
|
||||||
expressLoader({ app: expressApp });
|
expressLoader({ app });
|
||||||
Logger.info('✌️ Express loaded');
|
Logger.info('[boot] Express loaded');
|
||||||
console.log('✌️ Express loaded');
|
|
||||||
};
|
};
|
||||||
|
|||||||
+40
-41
@@ -6,6 +6,8 @@ import { AppModel } from '../data/open';
|
|||||||
import { SystemModel } from '../data/system';
|
import { SystemModel } from '../data/system';
|
||||||
import { SubscriptionModel } from '../data/subscription';
|
import { SubscriptionModel } from '../data/subscription';
|
||||||
import { CrontabViewModel } from '../data/cronView';
|
import { CrontabViewModel } from '../data/cronView';
|
||||||
|
import { CrontabStatModel } from '../data/cronStats';
|
||||||
|
import { RunningInstanceModel } from '../data/runningInstance';
|
||||||
import { sequelize } from '../data';
|
import { sequelize } from '../data';
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
@@ -17,50 +19,47 @@ export default async () => {
|
|||||||
await EnvModel.sync();
|
await EnvModel.sync();
|
||||||
await SubscriptionModel.sync();
|
await SubscriptionModel.sync();
|
||||||
await CrontabViewModel.sync();
|
await CrontabViewModel.sync();
|
||||||
|
await CrontabStatModel.sync();
|
||||||
|
await RunningInstanceModel.sync();
|
||||||
|
|
||||||
// 初始化新增字段
|
// 初始化新增字段
|
||||||
try {
|
const migrations = [
|
||||||
await sequelize.query(
|
{
|
||||||
'alter table CrontabViews add column filterRelation VARCHAR(255)',
|
table: 'CrontabViews',
|
||||||
);
|
column: 'filterRelation',
|
||||||
} catch (error) {}
|
type: 'VARCHAR(255)',
|
||||||
try {
|
},
|
||||||
await sequelize.query(
|
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
|
||||||
'alter table Subscriptions add column proxy VARCHAR(255)',
|
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
|
||||||
);
|
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
|
||||||
} catch (error) {}
|
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
|
||||||
try {
|
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
|
||||||
await sequelize.query('alter table CrontabViews add column type NUMBER');
|
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
|
||||||
} catch (error) {}
|
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
|
||||||
try {
|
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
|
||||||
await sequelize.query(
|
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
|
||||||
'alter table Subscriptions add column autoAddCron NUMBER',
|
{
|
||||||
);
|
table: 'Crontabs',
|
||||||
} catch (error) {}
|
column: 'allow_multiple_instances',
|
||||||
try {
|
type: 'NUMBER',
|
||||||
await sequelize.query(
|
},
|
||||||
'alter table Subscriptions add column autoDelCron NUMBER',
|
{ table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
|
||||||
);
|
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
|
||||||
} catch (error) {}
|
{ table: 'Envs', column: 'labels', type: 'JSON' },
|
||||||
try {
|
];
|
||||||
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
|
|
||||||
} catch (error) {}
|
|
||||||
try {
|
|
||||||
await sequelize.query(
|
|
||||||
'alter table Crontabs add column extra_schedules JSON',
|
|
||||||
);
|
|
||||||
} catch (error) {}
|
|
||||||
try {
|
|
||||||
await sequelize.query('alter table Crontabs add column task_before TEXT');
|
|
||||||
} catch (error) {}
|
|
||||||
try {
|
|
||||||
await sequelize.query('alter table Crontabs add column task_after TEXT');
|
|
||||||
} catch (error) {}
|
|
||||||
|
|
||||||
console.log('✌️ DB loaded');
|
for (const migration of migrations) {
|
||||||
Logger.info('✌️ DB loaded');
|
try {
|
||||||
|
await sequelize.query(
|
||||||
|
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Column already exists or other error, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info('[boot] DB loaded');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('✌️ DB load failed');
|
Logger.error('[boot] DB load failed', error);
|
||||||
Logger.error(error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+22
-28
@@ -1,24 +1,22 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import chokidar from 'chokidar';
|
import os from 'os';
|
||||||
import config from '../config/index';
|
import config from '../config/index';
|
||||||
import { fileExist, promiseExec, rmPath } from '../config/util';
|
import Logger from './logger';
|
||||||
|
|
||||||
async function linkToNodeModule(src: string, dst?: string) {
|
|
||||||
const target = path.join(config.rootPath, 'node_modules', dst || src);
|
|
||||||
const source = path.join(config.rootPath, src);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stats = await fs.lstat(target);
|
|
||||||
if (!stats) {
|
|
||||||
await fs.symlink(source, target, 'dir');
|
|
||||||
}
|
|
||||||
} catch (error) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function linkCommand() {
|
async function linkCommand() {
|
||||||
const commandPath = await promiseExec('which node');
|
const homeDir = os.homedir();
|
||||||
const commandDir = path.dirname(commandPath);
|
let userBinDir = path.join(homeDir, 'bin');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(userBinDir, { recursive: true });
|
||||||
|
await linkCommandToDir(userBinDir);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Linking command failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkCommandToDir(commandDir: string) {
|
||||||
const linkShell = [
|
const linkShell = [
|
||||||
{
|
{
|
||||||
src: 'update.sh',
|
src: 'update.sh',
|
||||||
@@ -36,22 +34,18 @@ async function linkCommand() {
|
|||||||
const source = path.join(config.rootPath, 'shell', link.src);
|
const source = path.join(config.rootPath, 'shell', link.src);
|
||||||
const target = path.join(commandDir, link.dest);
|
const target = path.join(commandDir, link.dest);
|
||||||
const tmpTarget = path.join(commandDir, link.tmp);
|
const tmpTarget = path.join(commandDir, link.tmp);
|
||||||
|
try {
|
||||||
|
const stats = await fs.lstat(tmpTarget);
|
||||||
|
if (stats) {
|
||||||
|
await fs.unlink(tmpTarget);
|
||||||
|
}
|
||||||
|
} catch (error) { }
|
||||||
|
|
||||||
await fs.symlink(source, tmpTarget);
|
await fs.symlink(source, tmpTarget);
|
||||||
await fs.rename(tmpTarget, target);
|
await fs.rename(tmpTarget, target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async (src: string = 'deps') => {
|
export default async () => {
|
||||||
await linkCommand();
|
await linkCommand();
|
||||||
await linkToNodeModule(src);
|
|
||||||
|
|
||||||
const source = path.join(config.rootPath, src);
|
|
||||||
const watcher = chokidar.watch(source, {
|
|
||||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
|
||||||
persistent: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
watcher
|
|
||||||
.on('add', (path) => linkToNodeModule(src))
|
|
||||||
.on('change', (path) => linkToNodeModule(src));
|
|
||||||
};
|
};
|
||||||
|
|||||||
+87
-47
@@ -7,36 +7,59 @@ import { UnauthorizedError, expressjwt } from 'express-jwt';
|
|||||||
import { getPlatform, getToken } from '../config/util';
|
import { getPlatform, getToken } from '../config/util';
|
||||||
import rewrite from 'express-urlrewrite';
|
import rewrite from 'express-urlrewrite';
|
||||||
import { errors } from 'celebrate';
|
import { errors } from 'celebrate';
|
||||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
|
||||||
import { serveEnv } from '../config/serverEnv';
|
import { serveEnv } from '../config/serverEnv';
|
||||||
import Logger from './logger';
|
import { shareStore } from '../shared/store';
|
||||||
import { IKeyvStore, shareStore } from '../shared/store';
|
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
||||||
|
import { AuthInfo } from '../data/system';
|
||||||
|
import path from 'path';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
import { AppScope } from '../data/open';
|
||||||
|
|
||||||
export default ({ app }: { app: Application }) => {
|
export default ({ app }: { app: Application }) => {
|
||||||
|
// Security: Enable strict routing to prevent case-insensitive path bypass
|
||||||
|
app.set('case sensitive routing', true);
|
||||||
|
app.set('strict routing', true);
|
||||||
app.set('trust proxy', 'loopback');
|
app.set('trust proxy', 'loopback');
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
||||||
|
// Security: Path normalization middleware to prevent case variation attacks
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const originalPath = req.path;
|
||||||
|
const normalizedPath = originalPath.toLowerCase();
|
||||||
|
|
||||||
|
// Block requests with case variations on protected paths
|
||||||
|
if (originalPath !== normalizedPath &&
|
||||||
|
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
|
||||||
|
return res.status(400).json({
|
||||||
|
code: 400,
|
||||||
|
message: 'Invalid path format'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rewrite URLs to strip baseUrl prefix if configured
|
||||||
|
// This allows the rest of the app to work without baseUrl awareness
|
||||||
|
if (config.baseUrl) {
|
||||||
|
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
|
||||||
|
}
|
||||||
|
|
||||||
app.get(`${config.api.prefix}/env.js`, serveEnv);
|
app.get(`${config.api.prefix}/env.js`, serveEnv);
|
||||||
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
|
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
|
||||||
|
|
||||||
app.use(
|
|
||||||
'/api/public',
|
|
||||||
createProxyMiddleware({
|
|
||||||
target: `http://0.0.0.0:${config.publicPort}/api`,
|
|
||||||
changeOrigin: true,
|
|
||||||
pathRewrite: { '/api/public': '' },
|
|
||||||
logger: Logger,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use(bodyParser.json({ limit: '50mb' }));
|
app.use(bodyParser.json({ limit: '50mb' }));
|
||||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
||||||
|
|
||||||
|
const frontendPath = path.join(config.rootPath, 'static/dist');
|
||||||
|
app.use(express.static(frontendPath));
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
expressjwt({
|
expressjwt({
|
||||||
secret: config.secret,
|
secret: config.jwt.secret,
|
||||||
algorithms: ['HS384'],
|
algorithms: ['HS384'],
|
||||||
}).unless({
|
}).unless({
|
||||||
path: [...config.apiWhiteList, /^\/open\//],
|
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,28 +73,42 @@ export default ({ app }: { app: Application }) => {
|
|||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req: Request, res, next) => {
|
||||||
|
const pathLower = req.path.toLowerCase();
|
||||||
|
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
const headerToken = getToken(req);
|
const headerToken = getToken(req);
|
||||||
if (req.path.startsWith('/open/')) {
|
if (pathLower.startsWith('/open/')) {
|
||||||
const apps = await shareStore.getApps();
|
const apps = await shareStore.getApps();
|
||||||
const doc = apps?.filter((x) =>
|
const doc = apps?.filter((x) =>
|
||||||
x.tokens?.find((y) => y.value === headerToken),
|
x.tokens?.find((y) => y.value === headerToken),
|
||||||
)?.[0];
|
)?.[0];
|
||||||
if (doc && doc.tokens && doc.tokens.length > 0) {
|
if (doc && doc.tokens && doc.tokens.length > 0) {
|
||||||
const currentToken = doc.tokens.find((x) => x.value === headerToken);
|
const currentToken = doc.tokens.find((x) => x.value === headerToken);
|
||||||
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
|
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
|
||||||
const key = keyMatch && keyMatch[1];
|
const key = keyMatch && keyMatch[1];
|
||||||
if (
|
|
||||||
doc.scopes.includes(key as any) &&
|
if (!doc.scopes.includes(key as AppScope)) {
|
||||||
currentToken &&
|
const err = new UnauthorizedError('credentials_bad_scheme', {
|
||||||
currentToken.expiration >= Math.round(Date.now() / 1000)
|
message: t('暂无权限'),
|
||||||
) {
|
});
|
||||||
return next();
|
return next(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) {
|
||||||
|
const err = new UnauthorizedError('invalid_token', {
|
||||||
|
message: t('Token 已失效'),
|
||||||
|
});
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const originPath = `${req.baseUrl}${req.path === '/' ? '' : req.path}`;
|
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
|
||||||
if (
|
if (
|
||||||
!headerToken &&
|
!headerToken &&
|
||||||
originPath &&
|
originPath &&
|
||||||
@@ -81,39 +118,37 @@ export default ({ app }: { app: Application }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authInfo = await shareStore.getAuthInfo();
|
const authInfo = await shareStore.getAuthInfo();
|
||||||
if (authInfo && headerToken) {
|
if (isValidToken(authInfo, headerToken, req.platform)) {
|
||||||
const { token = '', tokens = {} } = authInfo;
|
return next();
|
||||||
if (headerToken === token || tokens[req.platform] === headerToken) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
||||||
const errorMessage = headerToken
|
const errorMessage = headerToken
|
||||||
? 'jwt malformed'
|
? t('Token 已失效')
|
||||||
: 'No authorization token was found';
|
: t('请先登录');
|
||||||
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
||||||
next(err);
|
next(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
|
const pathLower = req.path.toLowerCase();
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
'/api/user/init',
|
||||||
|
'/api/user/notification/init',
|
||||||
|
'/open/user/init',
|
||||||
|
'/open/user/notification/init',
|
||||||
|
].includes(pathLower)
|
||||||
|
) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
const authInfo =
|
const authInfo =
|
||||||
(await shareStore.getAuthInfo()) || ({} as IKeyvStore['authInfo']);
|
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||||
|
|
||||||
let isInitialized = true;
|
let isInitialized = !isDefaultAuthInfo(authInfo);
|
||||||
if (
|
|
||||||
Object.keys(authInfo).length === 2 &&
|
|
||||||
authInfo.username === 'admin' &&
|
|
||||||
authInfo.password === 'admin'
|
|
||||||
) {
|
|
||||||
isInitialized = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
return res.send({ code: 450, message: '未知错误' });
|
return res.send({ code: 450, message: t('未知错误') });
|
||||||
} else {
|
} else {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
@@ -122,10 +157,15 @@ export default ({ app }: { app: Application }) => {
|
|||||||
app.use(rewrite('/open/*', '/api/$1'));
|
app.use(rewrite('/open/*', '/api/$1'));
|
||||||
app.use(config.api.prefix, routes());
|
app.use(config.api.prefix, routes());
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.get('*', (_, res, next) => {
|
||||||
const err: any = new Error('Not Found');
|
const indexPath = path.join(frontendPath, 'index.html');
|
||||||
err['status'] = 404;
|
res.sendFile(indexPath, (err) => {
|
||||||
next(err);
|
if (err) {
|
||||||
|
const err: any = new Error('Not Found');
|
||||||
|
err['status'] = 404;
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(errors());
|
app.use(errors());
|
||||||
|
|||||||
+48
-16
@@ -13,11 +13,13 @@ import { AuthDataType, SystemModel } from '../data/system';
|
|||||||
import SystemService from '../services/system';
|
import SystemService from '../services/system';
|
||||||
import UserService from '../services/user';
|
import UserService from '../services/user';
|
||||||
import { writeFile, readFile } from 'fs/promises';
|
import { writeFile, readFile } from 'fs/promises';
|
||||||
import { createRandomString, safeJSONParse } from '../config/util';
|
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
|
||||||
import OpenService from '../services/open';
|
import OpenService from '../services/open';
|
||||||
import { shareStore } from '../shared/store';
|
import { shareStore } from '../shared/store';
|
||||||
import Logger from './logger';
|
import Logger from './logger';
|
||||||
import { AppModel } from '../data/open';
|
import { AppModel } from '../data/open';
|
||||||
|
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||||
|
import { setLang, systemLang } from '../shared/i18n';
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
const cronService = Container.get(CronService);
|
const cronService = Container.get(CronService);
|
||||||
@@ -36,10 +38,15 @@ export default async () => {
|
|||||||
if (!systemApp) {
|
if (!systemApp) {
|
||||||
systemApp = await AppModel.create({
|
systemApp = await AppModel.create({
|
||||||
name: 'system',
|
name: 'system',
|
||||||
scopes: ['crons', 'system'],
|
scopes: ['crons', 'system', 'dashboard'],
|
||||||
client_id: createRandomString(12, 12),
|
client_id: createRandomString(12, 12),
|
||||||
client_secret: createRandomString(24, 24),
|
client_secret: createRandomString(24, 24),
|
||||||
});
|
});
|
||||||
|
} else if (!systemApp.scopes.includes('dashboard')) {
|
||||||
|
await AppModel.update(
|
||||||
|
{ scopes: [...systemApp.scopes, 'dashboard'] },
|
||||||
|
{ where: { name: 'system' } },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const [systemConfig] = await SystemModel.findOrCreate({
|
const [systemConfig] = await SystemModel.findOrCreate({
|
||||||
where: { type: AuthDataType.systemConfig },
|
where: { type: AuthDataType.systemConfig },
|
||||||
@@ -50,14 +57,17 @@ export default async () => {
|
|||||||
const [authConfig] = await SystemModel.findOrCreate({
|
const [authConfig] = await SystemModel.findOrCreate({
|
||||||
where: { type: AuthDataType.authConfig },
|
where: { type: AuthDataType.authConfig },
|
||||||
});
|
});
|
||||||
if (!authConfig?.info) {
|
if (!authConfig?.info || isDemoEnv()) {
|
||||||
let authInfo = {
|
let authInfo = {
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
password: 'admin',
|
password: 'admin',
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const content = await readFile(config.authConfigFile, 'utf8');
|
const authFileExist = await fileExist(config.authConfigFile);
|
||||||
authInfo = safeJSONParse(content);
|
if (authFileExist) {
|
||||||
|
const content = await readFile(config.authConfigFile, 'utf8');
|
||||||
|
authInfo = safeJSONParse(content);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.warn('Failed to read auth config file, using default credentials');
|
Logger.warn('Failed to read auth config file, using default credentials');
|
||||||
}
|
}
|
||||||
@@ -68,24 +78,27 @@ export default async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const installDependencies = () => {
|
const installDependencies = async () => {
|
||||||
// 初始化时安装所有处于安装中,安装成功,安装失败的依赖
|
const docs = await DependenceModel.findAll({
|
||||||
DependenceModel.findAll({
|
|
||||||
where: {},
|
where: {},
|
||||||
order: [
|
order: [
|
||||||
['type', 'DESC'],
|
['type', 'DESC'],
|
||||||
['createdAt', 'DESC'],
|
['createdAt', 'DESC'],
|
||||||
],
|
],
|
||||||
raw: true,
|
raw: true,
|
||||||
}).then(async (docs) => {
|
|
||||||
await DependenceModel.update(
|
|
||||||
{ status: DependenceStatus.queued, log: [] },
|
|
||||||
{ where: { id: docs.map((x) => x.id!) } },
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
|
||||||
dependenceService.installDependenceOneByOne(docs);
|
|
||||||
}, 5000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await DependenceModel.update(
|
||||||
|
{ status: DependenceStatus.queued, log: [] },
|
||||||
|
{ where: { id: docs.map((x) => x.id!) } },
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
await dependenceService.installDependenceOneByOne(docs);
|
||||||
|
|
||||||
|
const bootAfterLoader = await import('./bootAfter');
|
||||||
|
bootAfterLoader.default();
|
||||||
|
}, 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化更新 linux/python/nodejs 镜像源配置
|
// 初始化更新 linux/python/nodejs 镜像源配置
|
||||||
@@ -128,6 +141,12 @@ export default async () => {
|
|||||||
// 初始化更新所有任务状态为空闲
|
// 初始化更新所有任务状态为空闲
|
||||||
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
|
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
|
||||||
|
|
||||||
|
// 清空所有运行中的实例记录(服务重启后进程已不存在)
|
||||||
|
await RunningInstanceModel.update(
|
||||||
|
{ status: InstanceStatus.stopped },
|
||||||
|
{ where: { status: InstanceStatus.running } },
|
||||||
|
);
|
||||||
|
|
||||||
// 初始化时执行一次所有的 ql repo 任务
|
// 初始化时执行一次所有的 ql repo 任务
|
||||||
CrontabModel.findAll({
|
CrontabModel.findAll({
|
||||||
where: {
|
where: {
|
||||||
@@ -204,8 +223,21 @@ export default async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 初始化语言(必须在 autosave_crontab 之前)
|
||||||
|
const lang = systemConfig.info?.lang || systemLang();
|
||||||
|
setLang(lang);
|
||||||
|
|
||||||
|
// 确保 lang_env.sh 存在
|
||||||
|
try {
|
||||||
|
const langEnvExist = await fileExist(config.langEnvFile);
|
||||||
|
if (!langEnvExist) {
|
||||||
|
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
// 初始化保存一次ck和定时任务数据
|
// 初始化保存一次ck和定时任务数据
|
||||||
await cronService.autosave_crontab();
|
await cronService.autosave_crontab();
|
||||||
|
|
||||||
await envService.set_envs();
|
await envService.set_envs();
|
||||||
|
|
||||||
const authInfo = await userService.getAuthInfo();
|
const authInfo = await userService.getAuthInfo();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const uploadPath = path.join(dataPath, 'upload/');
|
|||||||
const bakPath = path.join(dataPath, 'bak/');
|
const bakPath = path.join(dataPath, 'bak/');
|
||||||
const samplePath = path.join(rootPath, 'sample/');
|
const samplePath = path.join(rootPath, 'sample/');
|
||||||
const tmpPath = path.join(logPath, '.tmp/');
|
const tmpPath = path.join(logPath, '.tmp/');
|
||||||
|
const rootTmpPath = path.join(rootPath, '.tmp/');
|
||||||
const confFile = path.join(configPath, 'config.sh');
|
const confFile = path.join(configPath, 'config.sh');
|
||||||
const sampleConfigFile = path.join(samplePath, 'config.sample.sh');
|
const sampleConfigFile = path.join(samplePath, 'config.sample.sh');
|
||||||
const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh');
|
const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh');
|
||||||
@@ -44,6 +45,7 @@ const directories = [
|
|||||||
preloadPath,
|
preloadPath,
|
||||||
logPath,
|
logPath,
|
||||||
tmpPath,
|
tmpPath,
|
||||||
|
rootTmpPath,
|
||||||
uploadPath,
|
uploadPath,
|
||||||
sshPath,
|
sshPath,
|
||||||
bakPath,
|
bakPath,
|
||||||
@@ -116,11 +118,12 @@ export default async () => {
|
|||||||
`Neither content nor source specified for ${item.target}`,
|
`Neither content nor source specified for ${item.target}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const content = item.content || (await fs.readFile(item.source!));
|
const content =
|
||||||
|
item.content ||
|
||||||
|
(await fs.readFile(item.source!, { encoding: 'utf-8' }));
|
||||||
await writeFileWithLock(item.target, content);
|
await writeFileWithLock(item.target, content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info('✌️ Init file down');
|
Logger.info('[boot] Init file down');
|
||||||
console.log('✌️ Init file down');
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import { Container } from 'typedi';
|
|||||||
import SystemService from '../services/system';
|
import SystemService from '../services/system';
|
||||||
import ScheduleService, { ScheduleTaskType } from '../services/schedule';
|
import ScheduleService, { ScheduleTaskType } from '../services/schedule';
|
||||||
import SubscriptionService from '../services/subscription';
|
import SubscriptionService from '../services/subscription';
|
||||||
|
import SshKeyService from '../services/sshKey';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { fileExist } from '../config/util';
|
import { fileExist } from '../config/util';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
const systemService = Container.get(SystemService);
|
const systemService = Container.get(SystemService);
|
||||||
const scheduleService = Container.get(ScheduleService);
|
const scheduleService = Container.get(ScheduleService);
|
||||||
const subscriptionService = Container.get(SubscriptionService);
|
const subscriptionService = Container.get(SubscriptionService);
|
||||||
|
const sshKeyService = Container.get(SshKeyService);
|
||||||
|
|
||||||
// 生成内置token
|
// 生成内置token
|
||||||
let tokenCommand = `ts-node-transpile-only ${join(
|
let tokenCommand = `ts-node-transpile-only ${join(
|
||||||
@@ -23,7 +26,7 @@ export default async () => {
|
|||||||
}
|
}
|
||||||
const cron = {
|
const cron = {
|
||||||
id: NaN,
|
id: NaN,
|
||||||
name: '生成token',
|
name: t('生成token'),
|
||||||
command: tokenCommand,
|
command: tokenCommand,
|
||||||
runOrigin: 'system',
|
runOrigin: 'system',
|
||||||
} as ScheduleTaskType;
|
} as ScheduleTaskType;
|
||||||
@@ -42,7 +45,7 @@ export default async () => {
|
|||||||
if (data.info.logRemoveFrequency) {
|
if (data.info.logRemoveFrequency) {
|
||||||
const rmlogCron = {
|
const rmlogCron = {
|
||||||
id: data.id as number,
|
id: data.id as number,
|
||||||
name: '删除日志',
|
name: t('删除日志'),
|
||||||
command: `ql rmlog ${data.info.logRemoveFrequency}`,
|
command: `ql rmlog ${data.info.logRemoveFrequency}`,
|
||||||
runOrigin: 'system' as const,
|
runOrigin: 'system' as const,
|
||||||
};
|
};
|
||||||
@@ -57,6 +60,11 @@ export default async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemService.updateTimezone(data.info);
|
systemService.updateTimezone(data.info);
|
||||||
|
|
||||||
|
// Apply global SSH key if configured
|
||||||
|
if (data.info.globalSshKey) {
|
||||||
|
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await subscriptionService.setSshConfig();
|
await subscriptionService.setSshConfig();
|
||||||
|
|||||||
+50
-25
@@ -4,35 +4,60 @@ import config from '../config';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const levelMap: Record<string, string> = {
|
const levelMap: Record<string, string> = {
|
||||||
info: '\ue6f5',
|
info: 'ℹ️', // info图标
|
||||||
warn: '\ue880',
|
warn: '⚠️', // 警告图标
|
||||||
error: '\ue602',
|
error: '❌', // 错误图标
|
||||||
debug: '\ue67f'
|
debug: '🐛', // debug调试图标
|
||||||
}
|
|
||||||
|
|
||||||
const customFormat = winston.format.combine(
|
|
||||||
winston.format.splat(),
|
|
||||||
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
|
||||||
winston.format.align(),
|
|
||||||
winston.format.printf((i) => `[${levelMap[i.level]}${i.level}] [${[i.timestamp]}]: ${i.message}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultOptions = {
|
|
||||||
format: customFormat,
|
|
||||||
datePattern: "YYYY-MM-DD",
|
|
||||||
maxSize: "20m",
|
|
||||||
maxFiles: "7d",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const baseFormat = [
|
||||||
|
winston.format.splat(),
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.align(),
|
||||||
|
];
|
||||||
|
|
||||||
|
const consoleFormat = winston.format.combine(
|
||||||
|
winston.format.colorize({ level: true }),
|
||||||
|
...baseFormat,
|
||||||
|
winston.format.printf((info) => {
|
||||||
|
return `[${info.level} ${info.timestamp}]:${info.message}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const plainFormat = winston.format.combine(
|
||||||
|
winston.format.uncolorize(),
|
||||||
|
...baseFormat,
|
||||||
|
winston.format.printf((info) => {
|
||||||
|
return `[${levelMap[info.level] || ''}${info.level} ${info.timestamp}]:${
|
||||||
|
info.message
|
||||||
|
}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const consoleTransport = new winston.transports.Console({
|
||||||
|
format: consoleFormat,
|
||||||
|
level: 'debug',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileTransport = new winston.transports.DailyRotateFile({
|
||||||
|
filename: path.join(config.systemLogPath, '%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '7d',
|
||||||
|
format: plainFormat,
|
||||||
|
level: config.logs.level || 'info',
|
||||||
|
});
|
||||||
|
|
||||||
const LoggerInstance = winston.createLogger({
|
const LoggerInstance = winston.createLogger({
|
||||||
level: config.logs.level,
|
level: 'debug',
|
||||||
levels: winston.config.npm.levels,
|
levels: winston.config.npm.levels,
|
||||||
transports: [
|
transports: [consoleTransport, fileTransport],
|
||||||
new winston.transports.DailyRotateFile({
|
exceptionHandlers: [consoleTransport, fileTransport],
|
||||||
filename: path.join(config.systemLogPath, '%DATE%.log'),
|
rejectionHandlers: [consoleTransport, fileTransport],
|
||||||
...defaultOptions,
|
});
|
||||||
})
|
|
||||||
],
|
LoggerInstance.on('error', (error) => {
|
||||||
|
console.error('Logger error:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default LoggerInstance;
|
export default LoggerInstance;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Sock from './sock';
|
|||||||
|
|
||||||
export default async ({ server }: { server: Server }) => {
|
export default async ({ server }: { server: Server }) => {
|
||||||
await Sock({ server });
|
await Sock({ server });
|
||||||
Logger.info('✌️ Sock loaded');
|
Logger.info('[boot] Sock loaded');
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
process.on('uncaughtException', (error) => {
|
||||||
Logger.error('Uncaught exception:', error);
|
Logger.error('Uncaught exception:', error);
|
||||||
|
|||||||
+13
-13
@@ -4,9 +4,11 @@ import { Container } from 'typedi';
|
|||||||
import SockService from '../services/sock';
|
import SockService from '../services/sock';
|
||||||
import { getPlatform } from '../config/util';
|
import { getPlatform } from '../config/util';
|
||||||
import { shareStore } from '../shared/store';
|
import { shareStore } from '../shared/store';
|
||||||
|
import { isValidToken } from '../shared/auth';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
export default async ({ server }: { server: Server }) => {
|
export default async ({ server }: { server: Server }) => {
|
||||||
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
|
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
|
||||||
const sockService = Container.get(SockService);
|
const sockService = Container.get(SockService);
|
||||||
|
|
||||||
echo.on('connection', async (conn) => {
|
echo.on('connection', async (conn) => {
|
||||||
@@ -17,21 +19,19 @@ export default async ({ server }: { server: Server }) => {
|
|||||||
const authInfo = await shareStore.getAuthInfo();
|
const authInfo = await shareStore.getAuthInfo();
|
||||||
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
||||||
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||||
if (authInfo) {
|
|
||||||
const { token = '', tokens = {} } = authInfo;
|
|
||||||
if (headerToken === token || tokens[platform] === headerToken) {
|
|
||||||
sockService.addClient(conn);
|
|
||||||
|
|
||||||
conn.on('data', (message) => {
|
if (isValidToken(authInfo, headerToken, platform)) {
|
||||||
conn.write(message);
|
sockService.addClient(conn);
|
||||||
});
|
|
||||||
|
|
||||||
conn.on('close', function () {
|
conn.on('data', (message) => {
|
||||||
sockService.removeClient(conn);
|
conn.write(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
conn.on('close', function () {
|
||||||
}
|
sockService.removeClient(conn);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.close('404');
|
conn.close('404');
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import bodyParser from 'body-parser';
|
|
||||||
import { errors } from 'celebrate';
|
|
||||||
import cors from 'cors';
|
|
||||||
import { Application, NextFunction, Request, Response } from 'express';
|
|
||||||
import { expressjwt } from 'express-jwt';
|
|
||||||
import Container from 'typedi';
|
|
||||||
import config from '../config';
|
|
||||||
import SystemService from '../services/system';
|
|
||||||
import Logger from './logger';
|
|
||||||
|
|
||||||
export default ({ app }: { app: Application }) => {
|
|
||||||
app.set('trust proxy', 'loopback');
|
|
||||||
app.use(cors());
|
|
||||||
|
|
||||||
app.use(bodyParser.json({ limit: '50mb' }));
|
|
||||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
expressjwt({
|
|
||||||
secret: config.secret,
|
|
||||||
algorithms: ['HS384'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.put(
|
|
||||||
'/api/reload',
|
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
try {
|
|
||||||
const systemService = Container.get(SystemService);
|
|
||||||
const result = await systemService.reloadSystem();
|
|
||||||
res.send(result);
|
|
||||||
} catch (e) {
|
|
||||||
Logger.error('🔥 error: %o', e);
|
|
||||||
return next(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.put(
|
|
||||||
'/api/system',
|
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
try {
|
|
||||||
const systemService = Container.get(SystemService);
|
|
||||||
const result = await systemService.reloadSystem('system');
|
|
||||||
res.send(result);
|
|
||||||
} catch (e) {
|
|
||||||
Logger.error('🔥 error: %o', e);
|
|
||||||
return next(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.put(
|
|
||||||
'/api/data',
|
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
try {
|
|
||||||
const systemService = Container.get(SystemService);
|
|
||||||
const result = await systemService.reloadSystem('data');
|
|
||||||
res.send(result);
|
|
||||||
} catch (e) {
|
|
||||||
Logger.error('🔥 error: %o', e);
|
|
||||||
return next(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
const err: any = new Error('Not Found');
|
|
||||||
err['status'] = 404;
|
|
||||||
next(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(errors());
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
(
|
|
||||||
err: Error & { status: number },
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
) => {
|
|
||||||
if (err.name === 'UnauthorizedError') {
|
|
||||||
return res
|
|
||||||
.status(err.status)
|
|
||||||
.send({ code: 401, message: err.message })
|
|
||||||
.end();
|
|
||||||
}
|
|
||||||
return next(err);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
(
|
|
||||||
err: Error & { status: number },
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
) => {
|
|
||||||
res.status(err.status || 500);
|
|
||||||
res.json({
|
|
||||||
code: err.status || 500,
|
|
||||||
message: err.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import Logger from '../loaders/logger';
|
||||||
|
import { performance } from 'perf_hooks';
|
||||||
|
import { metricsService } from '../services/metrics';
|
||||||
|
|
||||||
|
interface RequestMetrics {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
duration: number;
|
||||||
|
statusCode: number;
|
||||||
|
timestamp: number;
|
||||||
|
platform?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestMetrics: RequestMetrics[] = [];
|
||||||
|
|
||||||
|
export const monitoringMiddleware = (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
|
const start = performance.now();
|
||||||
|
const originalEnd = res.end;
|
||||||
|
|
||||||
|
res.end = function (chunk?: any, encoding?: any, cb?: any) {
|
||||||
|
const duration = performance.now() - start;
|
||||||
|
const metric: RequestMetrics = {
|
||||||
|
method: req.method,
|
||||||
|
path: req.path,
|
||||||
|
duration,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
platform: req.platform,
|
||||||
|
};
|
||||||
|
|
||||||
|
requestMetrics.push(metric);
|
||||||
|
metricsService.record('http_request', duration, {
|
||||||
|
method: req.method,
|
||||||
|
path: req.path,
|
||||||
|
statusCode: res.statusCode.toString(),
|
||||||
|
...(req.platform && { platform: req.platform }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestMetrics.length > 1000) {
|
||||||
|
requestMetrics.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration > 1000) {
|
||||||
|
Logger.warn(
|
||||||
|
`Slow request detected: ${req.method} ${
|
||||||
|
req.path
|
||||||
|
} took ${duration.toFixed(2)}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalEnd.call(this, chunk, encoding, cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMetrics = () => {
|
||||||
|
return {
|
||||||
|
totalRequests: requestMetrics.length,
|
||||||
|
averageDuration:
|
||||||
|
requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) /
|
||||||
|
requestMetrics.length,
|
||||||
|
requestsByMethod: requestMetrics.reduce((acc, curr) => {
|
||||||
|
acc[curr.method] = (acc[curr.method] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
requestsByPlatform: requestMetrics.reduce((acc, curr) => {
|
||||||
|
if (curr.platform) {
|
||||||
|
acc[curr.platform] = (acc[curr.platform] || 0) + 1;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
recentRequests: requestMetrics.slice(-10),
|
||||||
|
};
|
||||||
|
};
|
||||||
+144
-8
@@ -53,14 +53,7 @@ message Response {
|
|||||||
optional string message = 2;
|
optional string message = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SystemNotifyRequest {
|
message ExtraScheduleItem { string schedule = 1; }
|
||||||
string title = 1;
|
|
||||||
string content = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ExtraScheduleItem {
|
|
||||||
string schedule = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message CronItem {
|
message CronItem {
|
||||||
optional int32 id = 1;
|
optional int32 id = 1;
|
||||||
@@ -104,6 +97,18 @@ message UpdateCronRequest {
|
|||||||
|
|
||||||
message DeleteCronsRequest { repeated int32 ids = 1; }
|
message DeleteCronsRequest { repeated int32 ids = 1; }
|
||||||
|
|
||||||
|
message GetCronsRequest {
|
||||||
|
optional string searchValue = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetCronByIdRequest { int32 id = 1; }
|
||||||
|
|
||||||
|
message EnableCronsRequest { repeated int32 ids = 1; }
|
||||||
|
|
||||||
|
message DisableCronsRequest { repeated int32 ids = 1; }
|
||||||
|
|
||||||
|
message RunCronsRequest { repeated int32 ids = 1; }
|
||||||
|
|
||||||
message CronsResponse {
|
message CronsResponse {
|
||||||
int32 code = 1;
|
int32 code = 1;
|
||||||
repeated CronItem data = 2;
|
repeated CronItem data = 2;
|
||||||
@@ -124,6 +129,132 @@ message CronDetailResponse {
|
|||||||
optional string message = 3;
|
optional string message = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum NotificationMode {
|
||||||
|
gotify = 0;
|
||||||
|
goCqHttpBot = 1;
|
||||||
|
serverChan = 2;
|
||||||
|
pushDeer = 3;
|
||||||
|
bark = 4;
|
||||||
|
chat = 5;
|
||||||
|
telegramBot = 6;
|
||||||
|
dingtalkBot = 7;
|
||||||
|
weWorkBot = 8;
|
||||||
|
weWorkApp = 9;
|
||||||
|
aibotk = 10;
|
||||||
|
iGot = 11;
|
||||||
|
pushPlus = 12;
|
||||||
|
wePlusBot = 13;
|
||||||
|
email = 14;
|
||||||
|
pushMe = 15;
|
||||||
|
feishu = 16;
|
||||||
|
webhook = 17;
|
||||||
|
chronocat = 18;
|
||||||
|
ntfy = 19;
|
||||||
|
wxPusherBot = 20;
|
||||||
|
wxPusherSpt = 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NotificationInfo {
|
||||||
|
NotificationMode type = 1;
|
||||||
|
|
||||||
|
optional string gotifyUrl = 2;
|
||||||
|
optional string gotifyToken = 3;
|
||||||
|
optional int32 gotifyPriority = 4;
|
||||||
|
|
||||||
|
optional string goCqHttpBotUrl = 5;
|
||||||
|
optional string goCqHttpBotToken = 6;
|
||||||
|
optional string goCqHttpBotQq = 7;
|
||||||
|
|
||||||
|
optional string serverChanKey = 8;
|
||||||
|
|
||||||
|
optional string pushDeerKey = 9;
|
||||||
|
optional string pushDeerUrl = 10;
|
||||||
|
|
||||||
|
optional string synologyChatUrl = 11;
|
||||||
|
|
||||||
|
optional string barkPush = 12;
|
||||||
|
optional string barkIcon = 13;
|
||||||
|
optional string barkSound = 14;
|
||||||
|
optional string barkGroup = 15;
|
||||||
|
optional string barkLevel = 16;
|
||||||
|
optional string barkUrl = 17;
|
||||||
|
optional string barkArchive = 18;
|
||||||
|
|
||||||
|
optional string telegramBotToken = 19;
|
||||||
|
optional string telegramBotUserId = 20;
|
||||||
|
optional string telegramBotProxyHost = 21;
|
||||||
|
optional string telegramBotProxyPort = 22;
|
||||||
|
optional string telegramBotProxyAuth = 23;
|
||||||
|
optional string telegramBotApiHost = 24;
|
||||||
|
|
||||||
|
optional string dingtalkBotToken = 25;
|
||||||
|
optional string dingtalkBotSecret = 26;
|
||||||
|
|
||||||
|
optional string weWorkBotKey = 27;
|
||||||
|
optional string weWorkOrigin = 28;
|
||||||
|
|
||||||
|
optional string weWorkAppKey = 29;
|
||||||
|
|
||||||
|
optional string aibotkKey = 30;
|
||||||
|
optional string aibotkType = 31;
|
||||||
|
optional string aibotkName = 32;
|
||||||
|
|
||||||
|
optional string iGotPushKey = 33;
|
||||||
|
|
||||||
|
optional string pushPlusToken = 34;
|
||||||
|
optional string pushPlusUser = 35;
|
||||||
|
optional string pushPlusTemplate = 36;
|
||||||
|
optional string pushplusChannel = 37;
|
||||||
|
optional string pushplusWebhook = 38;
|
||||||
|
optional string pushplusCallbackUrl = 39;
|
||||||
|
optional string pushplusTo = 40;
|
||||||
|
|
||||||
|
optional string wePlusBotToken = 41;
|
||||||
|
optional string wePlusBotReceiver = 42;
|
||||||
|
optional string wePlusBotVersion = 43;
|
||||||
|
|
||||||
|
optional string emailService = 44;
|
||||||
|
optional string emailUser = 45;
|
||||||
|
optional string emailPass = 46;
|
||||||
|
optional string emailTo = 47;
|
||||||
|
|
||||||
|
optional string pushMeKey = 48;
|
||||||
|
optional string pushMeUrl = 49;
|
||||||
|
|
||||||
|
optional string chronocatURL = 50;
|
||||||
|
optional string chronocatQQ = 51;
|
||||||
|
optional string chronocatToken = 52;
|
||||||
|
|
||||||
|
optional string webhookHeaders = 53;
|
||||||
|
optional string webhookBody = 54;
|
||||||
|
optional string webhookUrl = 55;
|
||||||
|
optional string webhookMethod = 56;
|
||||||
|
optional string webhookContentType = 57;
|
||||||
|
|
||||||
|
optional string larkKey = 58;
|
||||||
|
optional string larkSecret = 69;
|
||||||
|
|
||||||
|
optional string ntfyUrl = 59;
|
||||||
|
optional string ntfyTopic = 60;
|
||||||
|
optional string ntfyPriority = 61;
|
||||||
|
optional string ntfyToken = 62;
|
||||||
|
optional string ntfyUsername = 63;
|
||||||
|
optional string ntfyPassword = 64;
|
||||||
|
optional string ntfyActions = 65;
|
||||||
|
|
||||||
|
optional string wxPusherBotAppToken = 66;
|
||||||
|
optional string wxPusherBotTopicIds = 67;
|
||||||
|
optional string wxPusherBotUids = 68;
|
||||||
|
|
||||||
|
optional string wxPusherSptList = 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SystemNotifyRequest {
|
||||||
|
string title = 1;
|
||||||
|
string content = 2;
|
||||||
|
optional NotificationInfo notificationInfo = 3;
|
||||||
|
}
|
||||||
|
|
||||||
service Api {
|
service Api {
|
||||||
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
||||||
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
||||||
@@ -139,4 +270,9 @@ service Api {
|
|||||||
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
||||||
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
||||||
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
|
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
|
||||||
|
rpc GetCrons(GetCronsRequest) returns (CronsResponse) {}
|
||||||
|
rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {}
|
||||||
|
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
|
||||||
|
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
|
||||||
|
rpc RunCrons(RunCronsRequest) returns (Response) {}
|
||||||
}
|
}
|
||||||
+2045
-82
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-ts_proto v2.6.1
|
// protoc-gen-ts_proto v2.6.1
|
||||||
// protoc v3.17.3
|
// protoc v3.21.12
|
||||||
// source: back/protos/cron.proto
|
// source: back/protos/cron.proto
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-ts_proto v2.6.1
|
// protoc-gen-ts_proto v2.6.1
|
||||||
// protoc v3.17.3
|
// protoc v3.21.12
|
||||||
// source: back/protos/health.proto
|
// source: back/protos/health.proto
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import Logger from './loaders/logger';
|
|
||||||
import config from './config';
|
|
||||||
import { HealthClient } from './protos/health';
|
|
||||||
import { credentials } from '@grpc/grpc-js';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const client = new HealthClient(
|
|
||||||
`0.0.0.0:${config.cronPort}`,
|
|
||||||
credentials.createInsecure(),
|
|
||||||
{ 'grpc.enable_http_proxy': 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => {
|
|
||||||
client.check({ service: 'cron' }, (err, response) => {
|
|
||||||
if (err) {
|
|
||||||
return res.status(200).send({ code: 500, error: err });
|
|
||||||
}
|
|
||||||
return res.status(200).send({ code: 200, data: response });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app
|
|
||||||
.listen(config.publicPort, '0.0.0.0', async () => {
|
|
||||||
await require('./loaders/db').default();
|
|
||||||
|
|
||||||
Logger.debug(`✌️ 公共服务启动成功!`);
|
|
||||||
console.debug(`✌️ 公共服务启动成功!`);
|
|
||||||
process.send?.('ready');
|
|
||||||
})
|
|
||||||
.on('error', (err) => {
|
|
||||||
Logger.error(err);
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
+101
-17
@@ -1,22 +1,85 @@
|
|||||||
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||||
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
||||||
import nodeSchedule from 'node-schedule';
|
import nodeSchedule from 'node-schedule';
|
||||||
import { scheduleStacks } from './data';
|
import { scheduleStacks } from './data';
|
||||||
import { runCron } from '../shared/runCron';
|
import { runCron } from '../shared/runCron';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
|
import { tf } from '../shared/i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预校验 cron 表达式,检测 node-schedule 会拒绝但 cron-parser 会接受的 pattern。
|
||||||
|
* node-schedule 对 bare /N(字段以 / 开头,如前无星号/数字前缀的 /6)返回 null,
|
||||||
|
* 提前拦截避免走 scheduleJob 后才发现无效。
|
||||||
|
*/
|
||||||
|
const isValidCronField = (cron: string): boolean => {
|
||||||
|
// 检测 bare /N 模式:字段以 / 开头如 "/6",或空格后紧跟 "/6"
|
||||||
|
// node-schedule 会对这种字段返回 null
|
||||||
|
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null
|
||||||
|
// cron-parser 接受但 node-schedule 拒绝,提前拦截
|
||||||
|
if (/\?/.test(cron)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const addCron = (
|
const addCron = (
|
||||||
call: ServerUnaryCall<AddCronRequest, AddCronResponse>,
|
call: ServerUnaryCall<AddCronRequest, AddCronResponse>,
|
||||||
callback: sendUnaryData<AddCronResponse>,
|
callback: sendUnaryData<AddCronResponse>,
|
||||||
) => {
|
) => {
|
||||||
|
// ===== 第一遍:预校验所有 cron 表达式 =====
|
||||||
|
const validationErrors: string[] = [];
|
||||||
|
|
||||||
|
for (const item of call.request.crons) {
|
||||||
|
const { id, schedule, extra_schedules } = item;
|
||||||
|
|
||||||
|
if (!isValidCronField(schedule)) {
|
||||||
|
validationErrors.push(
|
||||||
|
tf(
|
||||||
|
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
|
||||||
|
String(id),
|
||||||
|
schedule,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extra_schedules?.length) {
|
||||||
|
extra_schedules.forEach((x) => {
|
||||||
|
if (!isValidCronField(x.schedule)) {
|
||||||
|
validationErrors.push(
|
||||||
|
tf(
|
||||||
|
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
|
||||||
|
String(id),
|
||||||
|
x.schedule,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
const details = validationErrors.join('\n');
|
||||||
|
const err: any = new Error(details);
|
||||||
|
err.code = status.INVALID_ARGUMENT;
|
||||||
|
err.details = details;
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 第二遍:注册所有任务 =====
|
||||||
for (const item of call.request.crons) {
|
for (const item of call.request.crons) {
|
||||||
const { id, schedule, command, extra_schedules, name } = item;
|
const { id, schedule, command, extra_schedules, name } = item;
|
||||||
|
|
||||||
|
// 取消该 id 已有的旧任务
|
||||||
if (scheduleStacks.has(id)) {
|
if (scheduleStacks.has(id)) {
|
||||||
scheduleStacks.get(id)?.forEach((x) => x.cancel());
|
scheduleStacks.get(id)?.forEach((x) => x.cancel());
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(
|
Logger.info(
|
||||||
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
schedule,
|
schedule,
|
||||||
@@ -26,7 +89,7 @@ const addCron = (
|
|||||||
if (extra_schedules?.length) {
|
if (extra_schedules?.length) {
|
||||||
extra_schedules.forEach((x) => {
|
extra_schedules.forEach((x) => {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
x.schedule,
|
x.schedule,
|
||||||
@@ -35,20 +98,41 @@ const addCron = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleStacks.set(id, [
|
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
|
||||||
nodeSchedule.scheduleJob(id, schedule, async () => {
|
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
||||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
runCron(command, item);
|
||||||
runCron(command, item);
|
});
|
||||||
}),
|
|
||||||
...(extra_schedules?.length
|
if (!mainJob) {
|
||||||
? extra_schedules.map((x) =>
|
Logger.warn(
|
||||||
nodeSchedule.scheduleJob(id, x.schedule, async () => {
|
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
|
||||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
id,
|
||||||
runCron(command, item);
|
schedule,
|
||||||
}),
|
);
|
||||||
)
|
}
|
||||||
: []),
|
|
||||||
]);
|
const extraJobs = extra_schedules?.length
|
||||||
|
? extra_schedules.map((x) => {
|
||||||
|
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
|
||||||
|
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
||||||
|
runCron(command, item);
|
||||||
|
});
|
||||||
|
if (!job) {
|
||||||
|
Logger.warn(
|
||||||
|
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
|
||||||
|
id,
|
||||||
|
x.schedule,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 过滤 null(兜底保护,正常情况下预校验已拦截)
|
||||||
|
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
|
||||||
|
if (jobs.length > 0) {
|
||||||
|
scheduleStacks.set(id, jobs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(null, null);
|
callback(null, null);
|
||||||
|
|||||||
+179
-10
@@ -30,7 +30,14 @@ import {
|
|||||||
UpdateCronRequest,
|
UpdateCronRequest,
|
||||||
DeleteCronsRequest,
|
DeleteCronsRequest,
|
||||||
CronResponse,
|
CronResponse,
|
||||||
|
GetCronsRequest,
|
||||||
|
CronsResponse,
|
||||||
|
GetCronByIdRequest,
|
||||||
|
EnableCronsRequest,
|
||||||
|
DisableCronsRequest,
|
||||||
|
RunCronsRequest,
|
||||||
} from '../protos/api';
|
} from '../protos/api';
|
||||||
|
import { NotificationInfo } from '../data/notify';
|
||||||
|
|
||||||
Container.set('logger', LoggerInstance);
|
Container.set('logger', LoggerInstance);
|
||||||
|
|
||||||
@@ -39,13 +46,6 @@ export const getEnvs = async (
|
|||||||
callback: sendUnaryData<EnvsResponse>,
|
callback: sendUnaryData<EnvsResponse>,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
if (!call.request.searchValue) {
|
|
||||||
return callback(null, {
|
|
||||||
code: 400,
|
|
||||||
data: [],
|
|
||||||
message: 'searchValue is required',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
const data = await envService.envs(call.request.searchValue);
|
const data = await envService.envs(call.request.searchValue);
|
||||||
callback(null, {
|
callback(null, {
|
||||||
@@ -79,9 +79,17 @@ export const updateEnv = async (
|
|||||||
callback: sendUnaryData<EnvResponse>,
|
callback: sendUnaryData<EnvResponse>,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
if (!call.request.env?.id) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
data: undefined,
|
||||||
|
message: 'id parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
const data = await envService.update(
|
const data = await envService.update(
|
||||||
pick(call.request.env, ['id', 'name', 'value', 'remark']) as EnvItem,
|
pick(call.request.env, ['id', 'name', 'value', 'remarks']) as EnvItem,
|
||||||
);
|
);
|
||||||
callback(null, { code: 200, data });
|
callback(null, { code: 200, data });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -94,6 +102,13 @@ export const deleteEnvs = async (
|
|||||||
callback: sendUnaryData<Response>,
|
callback: sendUnaryData<Response>,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
if (!call.request.ids || call.request.ids.length === 0) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
message: 'ids parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
await envService.remove(call.request.ids);
|
await envService.remove(call.request.ids);
|
||||||
callback(null, { code: 200 });
|
callback(null, { code: 200 });
|
||||||
@@ -107,6 +122,14 @@ export const moveEnv = async (
|
|||||||
callback: sendUnaryData<EnvResponse>,
|
callback: sendUnaryData<EnvResponse>,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
if (!call.request.id) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
data: undefined,
|
||||||
|
message: 'id parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
const data = await envService.move(call.request.id, {
|
const data = await envService.move(call.request.id, {
|
||||||
fromIndex: call.request.fromIndex,
|
fromIndex: call.request.fromIndex,
|
||||||
@@ -123,6 +146,13 @@ export const disableEnvs = async (
|
|||||||
callback: sendUnaryData<Response>,
|
callback: sendUnaryData<Response>,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
if (!call.request.ids || call.request.ids.length === 0) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
message: 'ids parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
await envService.disabled(call.request.ids);
|
await envService.disabled(call.request.ids);
|
||||||
callback(null, { code: 200 });
|
callback(null, { code: 200 });
|
||||||
@@ -136,6 +166,13 @@ export const enableEnvs = async (
|
|||||||
callback: sendUnaryData<Response>,
|
callback: sendUnaryData<Response>,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
if (!call.request.ids || call.request.ids.length === 0) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
message: 'ids parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
await envService.enabled(call.request.ids);
|
await envService.enabled(call.request.ids);
|
||||||
callback(null, { code: 200 });
|
callback(null, { code: 200 });
|
||||||
@@ -149,6 +186,13 @@ export const updateEnvNames = async (
|
|||||||
callback: sendUnaryData<Response>,
|
callback: sendUnaryData<Response>,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
if (!call.request.ids || call.request.ids.length === 0) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
message: 'ids parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
await envService.updateNames({
|
await envService.updateNames({
|
||||||
ids: call.request.ids,
|
ids: call.request.ids,
|
||||||
@@ -165,6 +209,14 @@ export const getEnvById = async (
|
|||||||
callback: sendUnaryData<EnvResponse>,
|
callback: sendUnaryData<EnvResponse>,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
if (!call.request.id) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
data: undefined,
|
||||||
|
message: 'id parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
const data = await envService.getDb({ id: call.request.id });
|
const data = await envService.getDb({ id: call.request.id });
|
||||||
callback(null, {
|
callback(null, {
|
||||||
@@ -182,7 +234,11 @@ export const systemNotify = async (
|
|||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const systemService = Container.get(SystemService);
|
const systemService = Container.get(SystemService);
|
||||||
const data = await systemService.notify(call.request);
|
const data = await systemService.notify({
|
||||||
|
title: call.request.title,
|
||||||
|
content: call.request.content,
|
||||||
|
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
|
||||||
|
});
|
||||||
callback(null, data);
|
callback(null, data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
callback(e);
|
callback(e);
|
||||||
@@ -194,7 +250,7 @@ const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
|
|||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
sub_id: data.sub_id ?? undefined,
|
sub_id: data.sub_id ?? undefined,
|
||||||
extra_schedules: data.extra_schedules ?? undefined,
|
extra_schedules: data.extra_schedules ?? [],
|
||||||
pid: data.pid ?? undefined,
|
pid: data.pid ?? undefined,
|
||||||
task_before: data.task_before ?? undefined,
|
task_before: data.task_before ?? undefined,
|
||||||
task_after: data.task_after ?? undefined,
|
task_after: data.task_after ?? undefined,
|
||||||
@@ -273,3 +329,116 @@ export const deleteCrons = async (
|
|||||||
callback(e);
|
callback(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCrons = async (
|
||||||
|
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
|
||||||
|
callback: sendUnaryData<CronsResponse>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const cronService = Container.get(CronService);
|
||||||
|
const result = await cronService.crontabs({
|
||||||
|
searchValue: call.request.searchValue || '',
|
||||||
|
page: '0',
|
||||||
|
size: '0',
|
||||||
|
sorter: '',
|
||||||
|
filters: '',
|
||||||
|
queryString: '',
|
||||||
|
});
|
||||||
|
const data = result.data.map((x) => normalizeCronData(x as CronItem));
|
||||||
|
callback(null, {
|
||||||
|
code: 200,
|
||||||
|
data: data.filter((x): x is CronItem => x !== undefined),
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
callback(null, {
|
||||||
|
code: 500,
|
||||||
|
data: [],
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCronById = async (
|
||||||
|
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
|
||||||
|
callback: sendUnaryData<CronResponse>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!call.request.id) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
data: undefined,
|
||||||
|
message: 'id parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cronService = Container.get(CronService);
|
||||||
|
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
|
||||||
|
callback(null, { code: 200, data: normalizeCronData(data) });
|
||||||
|
} catch (e: any) {
|
||||||
|
callback(null, {
|
||||||
|
code: 404,
|
||||||
|
data: undefined,
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enableCrons = async (
|
||||||
|
call: ServerUnaryCall<EnableCronsRequest, Response>,
|
||||||
|
callback: sendUnaryData<Response>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!call.request.ids || call.request.ids.length === 0) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
message: 'ids parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cronService = Container.get(CronService);
|
||||||
|
await cronService.enabled(call.request.ids);
|
||||||
|
callback(null, { code: 200 });
|
||||||
|
} catch (e: any) {
|
||||||
|
callback(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disableCrons = async (
|
||||||
|
call: ServerUnaryCall<DisableCronsRequest, Response>,
|
||||||
|
callback: sendUnaryData<Response>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!call.request.ids || call.request.ids.length === 0) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
message: 'ids parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cronService = Container.get(CronService);
|
||||||
|
await cronService.disabled(call.request.ids);
|
||||||
|
callback(null, { code: 200 });
|
||||||
|
} catch (e: any) {
|
||||||
|
callback(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runCrons = async (
|
||||||
|
call: ServerUnaryCall<RunCronsRequest, Response>,
|
||||||
|
callback: sendUnaryData<Response>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!call.request.ids || call.request.ids.length === 0) {
|
||||||
|
return callback(null, {
|
||||||
|
code: 400,
|
||||||
|
message: 'ids parameter is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cronService = Container.get(CronService);
|
||||||
|
await cronService.run(call.request.ids);
|
||||||
|
callback(null, { code: 200 });
|
||||||
|
} catch (e: any) {
|
||||||
|
callback(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
+18
-5
@@ -7,13 +7,26 @@ import {
|
|||||||
DeleteCronResponse,
|
DeleteCronResponse,
|
||||||
} from '../protos/cron';
|
} from '../protos/cron';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
import { getGrpcCerts } from '../config/grpcCerts';
|
||||||
|
|
||||||
class Client {
|
class Client {
|
||||||
private client = new CronClient(
|
private _client: CronClient | null = null;
|
||||||
`0.0.0.0:${config.cronPort}`,
|
|
||||||
credentials.createInsecure(),
|
private get client(): CronClient {
|
||||||
{ 'grpc.enable_http_proxy': 0 },
|
if (!this._client) {
|
||||||
);
|
const tlsConfig = getGrpcCerts()!;
|
||||||
|
this._client = new CronClient(
|
||||||
|
`localhost:${config.grpcPort}`,
|
||||||
|
credentials.createSsl(
|
||||||
|
Buffer.from(tlsConfig.caCert),
|
||||||
|
Buffer.from(tlsConfig.clientKey),
|
||||||
|
Buffer.from(tlsConfig.clientCert),
|
||||||
|
),
|
||||||
|
{ 'grpc.enable_http_proxy': 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this._client;
|
||||||
|
}
|
||||||
|
|
||||||
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
|
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -10,10 +10,23 @@ const delCron = (
|
|||||||
for (const id of call.request.ids) {
|
for (const id of call.request.ids) {
|
||||||
if (scheduleStacks.has(id)) {
|
if (scheduleStacks.has(id)) {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
'[schedule][取消定时任务], 任务ID: %s',
|
'[schedule][取消定时任务] 任务ID: %s',
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
scheduleStacks.get(id)?.forEach(x => x.cancel());
|
// 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
|
||||||
|
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
|
||||||
|
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
|
||||||
|
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
|
||||||
|
try {
|
||||||
|
x.cancel();
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.warn(
|
||||||
|
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
|
||||||
|
id,
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
scheduleStacks.delete(id);
|
scheduleStacks.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,21 +10,18 @@ const check = async (
|
|||||||
switch (call.request.service) {
|
switch (call.request.service) {
|
||||||
case 'cron':
|
case 'cron':
|
||||||
const res = await promiseExec(
|
const res = await promiseExec(
|
||||||
`curl -s --noproxy '*' http://0.0.0.0:${config.port}/api/system`,
|
`curl -s --noproxy '*' http://localhost:${config.port}/api/system`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (res.includes('200')) {
|
if (res.includes('200')) {
|
||||||
return callback(null, { status: 1 });
|
return callback(null, { status: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelErrLog = await promiseExec(
|
const qinglongErrLog = await promiseExec(
|
||||||
`tail -n 300 ~/.pm2/logs/panel-error.log`,
|
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
|
||||||
);
|
|
||||||
const scheduleErrLog = await promiseExec(
|
|
||||||
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
|
|
||||||
);
|
);
|
||||||
return callback(
|
return callback(
|
||||||
new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()),
|
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Server, ServerCredentials } from '@grpc/grpc-js';
|
|
||||||
import { CronService } from '../protos/cron';
|
|
||||||
import { addCron } from './addCron';
|
|
||||||
import { delCron } from './delCron';
|
|
||||||
import { HealthService } from '../protos/health';
|
|
||||||
import { check } from './health';
|
|
||||||
import config from '../config';
|
|
||||||
import Logger from '../loaders/logger';
|
|
||||||
import { ApiService } from '../protos/api';
|
|
||||||
import * as Api from './api';
|
|
||||||
|
|
||||||
const server = new Server({ 'grpc.enable_http_proxy': 0 });
|
|
||||||
server.addService(HealthService, { check });
|
|
||||||
server.addService(CronService, { addCron, delCron });
|
|
||||||
server.addService(ApiService, Api);
|
|
||||||
server.bindAsync(
|
|
||||||
`0.0.0.0:${config.cronPort}`,
|
|
||||||
ServerCredentials.createInsecure(),
|
|
||||||
(err, port) => {
|
|
||||||
if (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
Logger.debug(`✌️ 定时服务启动成功!`);
|
|
||||||
console.debug(`✌️ 定时服务启动成功!`);
|
|
||||||
process.send?.('ready');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
+23
-16
@@ -2,8 +2,9 @@ import { Service, Inject } from 'typedi';
|
|||||||
import path, { join } from 'path';
|
import path, { join } from 'path';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { getFileContentByName } from '../config/util';
|
import { getFileContentByName } from '../config/util';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import got from 'got';
|
import { request } from 'undici';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class ConfigService {
|
export default class ConfigService {
|
||||||
@@ -11,26 +12,32 @@ export default class ConfigService {
|
|||||||
|
|
||||||
public async getFile(filePath: string, res: Response) {
|
public async getFile(filePath: string, res: Response) {
|
||||||
let content = '';
|
let content = '';
|
||||||
const avaliablePath = [config.rootPath, config.configPath].map((x) =>
|
if (!filePath) {
|
||||||
path.resolve(x, filePath),
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
);
|
}
|
||||||
|
const normalized = path.normalize(filePath);
|
||||||
if (
|
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
|
||||||
config.blackFileList.includes(filePath) ||
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
avaliablePath.every(
|
}
|
||||||
(x) =>
|
const resolvedRoot = path.resolve(config.rootPath, normalized);
|
||||||
!x.startsWith(config.scriptPath) && !x.startsWith(config.configPath),
|
const resolvedConfig = path.resolve(config.configPath, normalized);
|
||||||
) ||
|
const isValidPath =
|
||||||
!filePath
|
resolvedRoot.startsWith(config.scriptPath) ||
|
||||||
) {
|
resolvedRoot.startsWith(config.configPath) ||
|
||||||
return res.send({ code: 403, message: '文件无法访问' });
|
resolvedConfig.startsWith(config.scriptPath) ||
|
||||||
|
resolvedConfig.startsWith(config.configPath);
|
||||||
|
if (!isValidPath) {
|
||||||
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
|
}
|
||||||
|
if (config.blackFileList.includes(path.basename(normalized))) {
|
||||||
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filePath.startsWith('sample/')) {
|
if (filePath.startsWith('sample/')) {
|
||||||
const res = await got.get(
|
const res = await request(
|
||||||
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
|
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
|
||||||
);
|
);
|
||||||
content = res.body;
|
content = await res.body.text();
|
||||||
} else if (filePath.startsWith('data/scripts/')) {
|
} else if (filePath.startsWith('data/scripts/')) {
|
||||||
content = await getFileContentByName(join(config.rootPath, filePath));
|
content = await getFileContentByName(join(config.rootPath, filePath));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+342
-77
@@ -2,15 +2,21 @@ import { Service, Inject } from 'typedi';
|
|||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
|
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
|
||||||
|
import {
|
||||||
|
RunningInstanceModel,
|
||||||
|
InstanceStatus,
|
||||||
|
} from '../data/runningInstance';
|
||||||
import { exec, execSync } from 'child_process';
|
import { exec, execSync } from 'child_process';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import cron_parser from 'cron-parser';
|
import CronExpressionParser from 'cron-parser';
|
||||||
import {
|
import {
|
||||||
getFileContentByName,
|
getFileContentByName,
|
||||||
fileExist,
|
fileExist,
|
||||||
killTask,
|
killTask,
|
||||||
|
killAllTasks,
|
||||||
getUniqPath,
|
getUniqPath,
|
||||||
safeJSONParse,
|
safeJSONParse,
|
||||||
|
isDemoEnv,
|
||||||
} from '../config/util';
|
} from '../config/util';
|
||||||
import { Op, where, col as colFn, FindOptions, fn, Order } from 'sequelize';
|
import { Op, where, col as colFn, FindOptions, fn, Order } from 'sequelize';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -22,11 +28,14 @@ import dayjs from 'dayjs';
|
|||||||
import pickBy from 'lodash/pickBy';
|
import pickBy from 'lodash/pickBy';
|
||||||
import omit from 'lodash/omit';
|
import omit from 'lodash/omit';
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
import { ScheduleType } from '../interface/schedule';
|
import { ScheduleType } from '../interface/schedule';
|
||||||
|
import { logStreamManager } from '../shared/logStreamManager';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class CronService {
|
export default class CronService {
|
||||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
constructor(@Inject('logger') private logger: winston.Logger) { }
|
||||||
|
|
||||||
private isNodeCron(cron: Crontab) {
|
private isNodeCron(cron: Crontab) {
|
||||||
const { schedule, extra_schedules } = cron;
|
const { schedule, extra_schedules } = cron;
|
||||||
@@ -36,6 +45,25 @@ export default class CronService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get schedulerMode(): 'system' | 'node' {
|
||||||
|
const env = process.env.QL_SCHEDULER;
|
||||||
|
if (env === 'system') return 'system';
|
||||||
|
if (env === 'node') return 'node';
|
||||||
|
try {
|
||||||
|
execSync('which crond', { stdio: 'ignore' });
|
||||||
|
return 'system';
|
||||||
|
} catch {
|
||||||
|
return 'node';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldUseCronClient(cron: Crontab): boolean {
|
||||||
|
if (this.schedulerMode === 'node') {
|
||||||
|
return !this.isSpecialSchedule(cron.schedule);
|
||||||
|
}
|
||||||
|
return this.isNodeCron(cron) && !this.isSpecialSchedule(cron.schedule);
|
||||||
|
}
|
||||||
|
|
||||||
private isOnceSchedule(schedule?: string) {
|
private isOnceSchedule(schedule?: string) {
|
||||||
return schedule?.startsWith(ScheduleType.ONCE);
|
return schedule?.startsWith(ScheduleType.ONCE);
|
||||||
}
|
}
|
||||||
@@ -48,21 +76,58 @@ export default class CronService {
|
|||||||
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
|
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getLogName(cron: Crontab) {
|
||||||
|
const { log_name, command, id } = cron;
|
||||||
|
if (log_name === '/dev/null') {
|
||||||
|
return log_name;
|
||||||
|
}
|
||||||
|
let uniqPath = await getUniqPath(command, `${id}`);
|
||||||
|
if (log_name) {
|
||||||
|
const normalizedLogName = log_name.startsWith('/')
|
||||||
|
? log_name
|
||||||
|
: path.join(config.logPath, log_name);
|
||||||
|
if (normalizedLogName.startsWith(config.logPath)) {
|
||||||
|
uniqPath = log_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
|
||||||
|
await fs.mkdir(logDirPath, { recursive: true });
|
||||||
|
return uniqPath;
|
||||||
|
}
|
||||||
|
|
||||||
public async create(payload: Crontab): Promise<Crontab> {
|
public async create(payload: Crontab): Promise<Crontab> {
|
||||||
const tab = new Crontab(payload);
|
const tab = new Crontab(payload);
|
||||||
tab.saved = false;
|
tab.saved = false;
|
||||||
|
tab.log_name = await this.getLogName(tab);
|
||||||
const doc = await this.insert(tab);
|
const doc = await this.insert(tab);
|
||||||
|
|
||||||
if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) {
|
if (isDemoEnv()) {
|
||||||
await cronClient.addCron([
|
return doc;
|
||||||
{
|
}
|
||||||
name: doc.name || '',
|
|
||||||
id: String(doc.id),
|
if (this.shouldUseCronClient(doc)) {
|
||||||
schedule: doc.schedule!,
|
try {
|
||||||
command: this.makeCommand(doc),
|
await cronClient.addCron([
|
||||||
extra_schedules: doc.extra_schedules || [],
|
{
|
||||||
},
|
name: doc.name || '',
|
||||||
]);
|
id: String(doc.id),
|
||||||
|
schedule: doc.schedule!,
|
||||||
|
command: this.makeCommand(doc),
|
||||||
|
extra_schedules: doc.extra_schedules || [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch (error: any) {
|
||||||
|
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
|
||||||
|
// (DB 和 crontab.list 有记录但调度器永远不会执行)
|
||||||
|
await CrontabModel.destroy({ where: { id: doc.id } });
|
||||||
|
this.logger.error(
|
||||||
|
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.setCrontab();
|
await this.setCrontab();
|
||||||
@@ -77,26 +142,62 @@ export default class CronService {
|
|||||||
const doc = await this.getDb({ id: payload.id });
|
const doc = await this.getDb({ id: payload.id });
|
||||||
const tab = new Crontab({ ...doc, ...payload });
|
const tab = new Crontab({ ...doc, ...payload });
|
||||||
tab.saved = false;
|
tab.saved = false;
|
||||||
|
tab.log_name = await this.getLogName(tab);
|
||||||
const newDoc = await this.updateDb(tab);
|
const newDoc = await this.updateDb(tab);
|
||||||
|
|
||||||
if (doc.isDisabled === 1) {
|
if (doc.isDisabled === 1 || isDemoEnv()) {
|
||||||
return newDoc;
|
return newDoc;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isNodeCron(doc)) {
|
try {
|
||||||
await cronClient.delCron([String(doc.id)]);
|
await cronClient.delCron([String(newDoc.id)]);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(
|
||||||
|
'[crontab] Failed to unregister cron job in scheduler:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) {
|
if (this.shouldUseCronClient(newDoc)) {
|
||||||
await cronClient.addCron([
|
try {
|
||||||
{
|
await cronClient.addCron([
|
||||||
name: doc.name || '',
|
{
|
||||||
id: String(newDoc.id),
|
name: doc.name || '',
|
||||||
schedule: newDoc.schedule!,
|
id: String(newDoc.id),
|
||||||
command: this.makeCommand(newDoc),
|
schedule: newDoc.schedule!,
|
||||||
extra_schedules: newDoc.extra_schedules || [],
|
command: this.makeCommand(newDoc),
|
||||||
},
|
extra_schedules: newDoc.extra_schedules || [],
|
||||||
]);
|
},
|
||||||
|
]);
|
||||||
|
} catch (error: any) {
|
||||||
|
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
|
||||||
|
await CrontabModel.update(doc, { where: { id: doc.id } });
|
||||||
|
if (this.shouldUseCronClient(doc)) {
|
||||||
|
try {
|
||||||
|
await cronClient.addCron([
|
||||||
|
{
|
||||||
|
name: doc.name || '',
|
||||||
|
id: String(doc.id),
|
||||||
|
schedule: doc.schedule!,
|
||||||
|
command: this.makeCommand(doc),
|
||||||
|
extra_schedules: doc.extra_schedules || [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch (_recoveryError: any) {
|
||||||
|
this.logger.warn(
|
||||||
|
'[crontab] Failed to restore old cron job in scheduler after rollback:',
|
||||||
|
_recoveryError?.message || _recoveryError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.error(
|
||||||
|
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.setCrontab();
|
await this.setCrontab();
|
||||||
@@ -115,6 +216,7 @@ export default class CronService {
|
|||||||
log_path,
|
log_path,
|
||||||
last_running_time = 0,
|
last_running_time = 0,
|
||||||
last_execution_time = 0,
|
last_execution_time = 0,
|
||||||
|
exit_code,
|
||||||
}: {
|
}: {
|
||||||
ids: number[];
|
ids: number[];
|
||||||
status: CrontabStatus;
|
status: CrontabStatus;
|
||||||
@@ -122,6 +224,7 @@ export default class CronService {
|
|||||||
log_path: string;
|
log_path: string;
|
||||||
last_running_time: number;
|
last_running_time: number;
|
||||||
last_execution_time: number;
|
last_execution_time: number;
|
||||||
|
exit_code?: number;
|
||||||
}) {
|
}) {
|
||||||
let options: any = {
|
let options: any = {
|
||||||
status,
|
status,
|
||||||
@@ -137,13 +240,47 @@ export default class CronService {
|
|||||||
let cron;
|
let cron;
|
||||||
try {
|
try {
|
||||||
cron = await this.getDb({ id });
|
cron = await this.getDb({ id });
|
||||||
} catch (err) {}
|
} catch (err) { }
|
||||||
if (!cron) {
|
if (!cron) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (status === CrontabStatus.idle && log_path !== cron.log_path) {
|
if (status === CrontabStatus.idle && log_path !== cron.log_path) {
|
||||||
options = omit(options, ['status', 'log_path', 'pid']);
|
options = omit(options, ['status', 'log_path', 'pid']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manage RunningInstance records for status transitions from shell scripts
|
||||||
|
if (status === CrontabStatus.running) {
|
||||||
|
// Create a new running instance record
|
||||||
|
await RunningInstanceModel.create({
|
||||||
|
cron_id: id,
|
||||||
|
pid: pid || undefined,
|
||||||
|
log_path: log_path || undefined,
|
||||||
|
started_at: last_execution_time || dayjs().unix(),
|
||||||
|
status: InstanceStatus.running,
|
||||||
|
});
|
||||||
|
} else if (status === CrontabStatus.idle) {
|
||||||
|
// Mark the matching running instance as finished
|
||||||
|
const finishedAt = dayjs().unix();
|
||||||
|
const instanceStatus =
|
||||||
|
exit_code !== undefined && exit_code !== null && exit_code !== 0
|
||||||
|
? InstanceStatus.error
|
||||||
|
: InstanceStatus.finished;
|
||||||
|
await RunningInstanceModel.update(
|
||||||
|
{
|
||||||
|
finished_at: finishedAt,
|
||||||
|
status: instanceStatus,
|
||||||
|
exit_code: exit_code ?? undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
cron_id: id,
|
||||||
|
pid: pid || undefined,
|
||||||
|
status: InstanceStatus.running,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await CrontabModel.update(
|
await CrontabModel.update(
|
||||||
{ ...pickBy(options, (v) => v === 0 || !!v) },
|
{ ...pickBy(options, (v) => v === 0 || !!v) },
|
||||||
{ where: { id } },
|
{ where: { id } },
|
||||||
@@ -153,7 +290,14 @@ export default class CronService {
|
|||||||
|
|
||||||
public async remove(ids: number[]) {
|
public async remove(ids: number[]) {
|
||||||
await CrontabModel.destroy({ where: { id: ids } });
|
await CrontabModel.destroy({ where: { id: ids } });
|
||||||
await cronClient.delCron(ids.map(String));
|
try {
|
||||||
|
await cronClient.delCron(ids.map(String));
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(
|
||||||
|
'[crontab] Failed to unregister cron job in scheduler:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.setCrontab();
|
await this.setCrontab();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,14 +354,24 @@ export default class CronService {
|
|||||||
operate2 = Op.and;
|
operate2 = Op.and;
|
||||||
break;
|
break;
|
||||||
case 'In':
|
case 'In':
|
||||||
q[Op.or] = [
|
if (
|
||||||
{
|
property === 'status' &&
|
||||||
[property]: Array.isArray(value) ? value : [value],
|
!value.includes(CrontabStatus.disabled)
|
||||||
},
|
) {
|
||||||
property === 'status' && value.includes(2)
|
q[Op.and] = [
|
||||||
? { isDisabled: 1 }
|
{ [property]: Array.isArray(value) ? value : [value] },
|
||||||
: {},
|
{ isDisabled: 0 },
|
||||||
];
|
];
|
||||||
|
} else {
|
||||||
|
q[Op.or] = [
|
||||||
|
{
|
||||||
|
[property]: Array.isArray(value) ? value : [value],
|
||||||
|
},
|
||||||
|
property === 'status' && value.includes(CrontabStatus.disabled)
|
||||||
|
? { isDisabled: 1 }
|
||||||
|
: {},
|
||||||
|
];
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'Nin':
|
case 'Nin':
|
||||||
q[Op.and] = [
|
q[Op.and] = [
|
||||||
@@ -312,7 +466,7 @@ export default class CronService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private formatFilterQuery(query: any, filterQuery: any) {
|
private formatFilterQuery(query: any, filterQuery: any) {
|
||||||
if (filterQuery) {
|
if (!isEmpty(filterQuery)) {
|
||||||
if (!query[Op.and]) {
|
if (!query[Op.and]) {
|
||||||
query[Op.and] = [];
|
query[Op.and] = [];
|
||||||
}
|
}
|
||||||
@@ -427,21 +581,70 @@ export default class CronService {
|
|||||||
public async stop(ids: number[]) {
|
public async stop(ids: number[]) {
|
||||||
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
if (doc.pid) {
|
// Kill all running instances of this task
|
||||||
try {
|
try {
|
||||||
|
if (doc.pid) {
|
||||||
await killTask(doc.pid);
|
await killTask(doc.pid);
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(error);
|
|
||||||
}
|
}
|
||||||
|
const command = doc.command.replace(/\s+/g, ' ').trim();
|
||||||
|
await killAllTasks(command);
|
||||||
|
this.logger.info(
|
||||||
|
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark all running instances as stopped
|
||||||
|
const finishedAt = dayjs().unix();
|
||||||
|
await RunningInstanceModel.update(
|
||||||
|
{ status: InstanceStatus.stopped, finished_at: finishedAt },
|
||||||
|
{ where: { cron_id: ids, status: InstanceStatus.running } },
|
||||||
|
);
|
||||||
|
|
||||||
await CrontabModel.update(
|
await CrontabModel.update(
|
||||||
{ status: CrontabStatus.idle, pid: undefined },
|
{ status: CrontabStatus.idle, pid: undefined },
|
||||||
{ where: { id: ids } },
|
{ where: { id: ids } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async stopInstance(instanceId: number) {
|
||||||
|
const instance = await RunningInstanceModel.findOne({
|
||||||
|
where: { id: instanceId, status: InstanceStatus.running },
|
||||||
|
});
|
||||||
|
if (!instance) {
|
||||||
|
return { code: 400, message: t('实例不存在或已停止') };
|
||||||
|
}
|
||||||
|
if (instance.pid) {
|
||||||
|
try {
|
||||||
|
await killTask(instance.pid);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await RunningInstanceModel.update(
|
||||||
|
{ status: InstanceStatus.stopped, finished_at: dayjs().unix(), exit_code: 143 },
|
||||||
|
{ where: { id: instanceId } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if there are still other running instances for this cron
|
||||||
|
const otherRunning = await RunningInstanceModel.count({
|
||||||
|
where: { cron_id: instance.cron_id, status: InstanceStatus.running },
|
||||||
|
});
|
||||||
|
if (otherRunning === 0) {
|
||||||
|
await CrontabModel.update(
|
||||||
|
{ status: CrontabStatus.idle, pid: undefined },
|
||||||
|
{ where: { id: instance.cron_id } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { code: 200, message: t('实例已停止') };
|
||||||
|
}
|
||||||
|
|
||||||
private async runSingle(cronId: number): Promise<number | void> {
|
private async runSingle(cronId: number): Promise<number | void> {
|
||||||
return taskLimit.manualRunWithCronLimit(() => {
|
return taskLimit.manualRunWithCronLimit(() => {
|
||||||
return new Promise(async (resolve: any) => {
|
return new Promise(async (resolve: any) => {
|
||||||
@@ -461,13 +664,15 @@ export default class CronService {
|
|||||||
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
|
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let { id, command, log_path } = cron;
|
let { id, command, log_name } = cron;
|
||||||
const uniqPath = await getUniqPath(command, `${id}`);
|
|
||||||
|
const uniqPath =
|
||||||
|
log_name === '/dev/null' || !log_name
|
||||||
|
? await getUniqPath(command, `${id}`)
|
||||||
|
: log_name;
|
||||||
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
|
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
|
||||||
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
|
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
|
||||||
if (log_path?.split('/')?.every((x) => x !== uniqPath)) {
|
await fs.mkdir(logDirPath, { recursive: true });
|
||||||
await fs.mkdir(logDirPath, { recursive: true });
|
|
||||||
}
|
|
||||||
const logPath = `${uniqPath}/${logTime}.log`;
|
const logPath = `${uniqPath}/${logTime}.log`;
|
||||||
const absolutePath = path.resolve(config.logPath, `${logPath}`);
|
const absolutePath = path.resolve(config.logPath, `${logPath}`);
|
||||||
const cp = spawn(
|
const cp = spawn(
|
||||||
@@ -483,7 +688,7 @@ export default class CronService {
|
|||||||
{ where: { id } },
|
{ where: { id } },
|
||||||
);
|
);
|
||||||
cp.stdout.on('data', async (data) => {
|
cp.stdout.on('data', async (data) => {
|
||||||
await fs.appendFile(absolutePath, data.toString());
|
await logStreamManager.write(absolutePath, data.toString());
|
||||||
});
|
});
|
||||||
cp.stderr.on('data', async (data) => {
|
cp.stderr.on('data', async (data) => {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
@@ -491,7 +696,7 @@ export default class CronService {
|
|||||||
command,
|
command,
|
||||||
data.toString(),
|
data.toString(),
|
||||||
);
|
);
|
||||||
await fs.appendFile(absolutePath, data.toString());
|
await logStreamManager.write(absolutePath, data.toString());
|
||||||
});
|
});
|
||||||
cp.on('error', async (err) => {
|
cp.on('error', async (err) => {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -499,7 +704,7 @@ export default class CronService {
|
|||||||
command,
|
command,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
await fs.appendFile(absolutePath, JSON.stringify(err));
|
await logStreamManager.write(absolutePath, JSON.stringify(err));
|
||||||
});
|
});
|
||||||
|
|
||||||
cp.on('exit', async (code) => {
|
cp.on('exit', async (code) => {
|
||||||
@@ -508,10 +713,7 @@ export default class CronService {
|
|||||||
JSON.stringify(params),
|
JSON.stringify(params),
|
||||||
code,
|
code,
|
||||||
);
|
);
|
||||||
await CrontabModel.update(
|
await logStreamManager.closeStream(absolutePath);
|
||||||
{ status: CrontabStatus.idle, pid: undefined },
|
|
||||||
{ where: { id } },
|
|
||||||
);
|
|
||||||
resolve({ ...params, pid: cp.pid, code });
|
resolve({ ...params, pid: cp.pid, code });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -520,15 +722,22 @@ export default class CronService {
|
|||||||
|
|
||||||
public async disabled(ids: number[]) {
|
public async disabled(ids: number[]) {
|
||||||
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
||||||
await cronClient.delCron(ids.map(String));
|
try {
|
||||||
|
await cronClient.delCron(ids.map(String));
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(
|
||||||
|
'[crontab] Failed to unregister cron job in scheduler:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.setCrontab();
|
await this.setCrontab();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async enabled(ids: number[]) {
|
public async enabled(ids: number[]) {
|
||||||
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
|
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
|
||||||
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
||||||
const sixCron = docs
|
const crons = docs
|
||||||
.filter((x) => this.isNodeCron(x))
|
.filter((x) => this.shouldUseCronClient(x))
|
||||||
.map((doc) => ({
|
.map((doc) => ({
|
||||||
name: doc.name || '',
|
name: doc.name || '',
|
||||||
id: String(doc.id),
|
id: String(doc.id),
|
||||||
@@ -536,22 +745,48 @@ export default class CronService {
|
|||||||
command: this.makeCommand(doc),
|
command: this.makeCommand(doc),
|
||||||
extra_schedules: doc.extra_schedules || [],
|
extra_schedules: doc.extra_schedules || [],
|
||||||
}));
|
}));
|
||||||
await cronClient.addCron(sixCron);
|
|
||||||
|
if (isDemoEnv()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cronClient.addCron(crons);
|
||||||
|
} catch (error: any) {
|
||||||
|
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
|
||||||
|
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
||||||
|
this.logger.error(
|
||||||
|
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.setCrontab();
|
await this.setCrontab();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async log(id: number) {
|
public async log(id: number): Promise<{ content: string; status: string }> {
|
||||||
const doc = await this.getDb({ id });
|
const doc = await this.getDb({ id });
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return '';
|
return { content: '', status: 'empty' };
|
||||||
|
}
|
||||||
|
if (doc.log_name === '/dev/null') {
|
||||||
|
return { content: t('日志设置为忽略'), status: 'ignored' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
|
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
|
||||||
const logFileExist = doc.log_path && (await fileExist(absolutePath));
|
const logFileExist = doc.log_path && (await fileExist(absolutePath));
|
||||||
if (logFileExist) {
|
if (logFileExist) {
|
||||||
return await getFileContentByName(`${absolutePath}`);
|
const content = await getFileContentByName(`${absolutePath}`);
|
||||||
|
const isRunning =
|
||||||
|
typeof doc.status === 'number' &&
|
||||||
|
[CrontabStatus.running, CrontabStatus.queued].includes(doc.status);
|
||||||
|
return { content, status: isRunning ? 'running' : 'completed' };
|
||||||
} else {
|
} else {
|
||||||
return '任务未运行';
|
return typeof doc.status === 'number' &&
|
||||||
|
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
|
||||||
|
? { content: t('运行中...'), status: 'running' }
|
||||||
|
: { content: t('日志不存在...'), status: 'notFound' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,7 +806,7 @@ export default class CronService {
|
|||||||
files.map(async (x) => ({
|
files.map(async (x) => ({
|
||||||
filename: x,
|
filename: x,
|
||||||
directory: relativeDir.replace(config.logPath, ''),
|
directory: relativeDir.replace(config.logPath, ''),
|
||||||
time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(),
|
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
).sort((a, b) => b.time - a.time);
|
).sort((a, b) => b.time - a.time);
|
||||||
@@ -585,9 +820,11 @@ export default class CronService {
|
|||||||
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
|
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
|
||||||
command = `${TASK_PREFIX}${tab.command}`;
|
command = `${TASK_PREFIX}${tab.command}`;
|
||||||
}
|
}
|
||||||
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${
|
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `;
|
||||||
tab.id
|
// Only include log_name if it has a truthy value to avoid passing null/undefined to shell
|
||||||
} `;
|
if (tab.log_name) {
|
||||||
|
commandVariable += `log_name=${tab.log_name} `;
|
||||||
|
}
|
||||||
if (tab.task_before) {
|
if (tab.task_before) {
|
||||||
commandVariable += `task_before='${tab.task_before
|
commandVariable += `task_before='${tab.task_before
|
||||||
.replace(/'/g, "'\\''")
|
.replace(/'/g, "'\\''")
|
||||||
@@ -600,6 +837,9 @@ export default class CronService {
|
|||||||
.replace(/;? *\n/g, ';')
|
.replace(/;? *\n/g, ';')
|
||||||
.trim()}' `;
|
.trim()}' `;
|
||||||
}
|
}
|
||||||
|
if (tab.work_dir) {
|
||||||
|
commandVariable += `work_dir='${tab.work_dir.replace(/'/g, "'\\''")}' `;
|
||||||
|
}
|
||||||
|
|
||||||
const crontab_job_string = `${commandVariable}${command}`;
|
const crontab_job_string = `${commandVariable}${command}`;
|
||||||
return crontab_job_string;
|
return crontab_job_string;
|
||||||
@@ -609,11 +849,9 @@ export default class CronService {
|
|||||||
const tabs = data ?? (await this.crontabs());
|
const tabs = data ?? (await this.crontabs());
|
||||||
var crontab_string = '';
|
var crontab_string = '';
|
||||||
tabs.data.forEach((tab) => {
|
tabs.data.forEach((tab) => {
|
||||||
const _schedule = tab.schedule && tab.schedule.split(/ +/);
|
|
||||||
if (
|
if (
|
||||||
tab.isDisabled === 1 ||
|
tab.isDisabled === 1 ||
|
||||||
_schedule!.length !== 5 ||
|
this.isNodeCron(tab) ||
|
||||||
tab.extra_schedules?.length ||
|
|
||||||
this.isSpecialSchedule(tab.schedule)
|
this.isSpecialSchedule(tab.schedule)
|
||||||
) {
|
) {
|
||||||
crontab_string += '# ';
|
crontab_string += '# ';
|
||||||
@@ -631,12 +869,25 @@ export default class CronService {
|
|||||||
|
|
||||||
await writeFileWithLock(config.crontabFile, crontab_string);
|
await writeFileWithLock(config.crontabFile, crontab_string);
|
||||||
|
|
||||||
execSync(`crontab ${config.crontabFile}`);
|
if (this.schedulerMode === 'system') {
|
||||||
|
try {
|
||||||
|
execSync(`crontab ${config.crontabFile}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMsg = error.message || String(error);
|
||||||
|
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await CrontabModel.update({ saved: true }, { where: {} });
|
await CrontabModel.update({ saved: true }, { where: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
public importCrontab() {
|
public importCrontab() {
|
||||||
exec('crontab -l', (error, stdout, stderr) => {
|
exec('crontab -l', (error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
const errorMsg = error.message || String(error);
|
||||||
|
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
const lines = stdout.split('\n');
|
const lines = stdout.split('\n');
|
||||||
const namePrefix = new Date().getTime();
|
const namePrefix = new Date().getTime();
|
||||||
|
|
||||||
@@ -650,7 +901,7 @@ export default class CronService {
|
|||||||
if (
|
if (
|
||||||
command &&
|
command &&
|
||||||
schedule &&
|
schedule &&
|
||||||
cron_parser.parseExpression(schedule).hasNext()
|
CronExpressionParser.parse(schedule).hasNext()
|
||||||
) {
|
) {
|
||||||
const name = namePrefix + '_' + index;
|
const name = namePrefix + '_' + index;
|
||||||
|
|
||||||
@@ -671,14 +922,11 @@ export default class CronService {
|
|||||||
|
|
||||||
public async autosave_crontab() {
|
public async autosave_crontab() {
|
||||||
const tabs = await this.crontabs();
|
const tabs = await this.crontabs();
|
||||||
this.setCrontab(tabs);
|
|
||||||
|
|
||||||
const regularCrons = tabs.data
|
const regularCrons = tabs.data
|
||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
this.isNodeCron(x) &&
|
|
||||||
x.isDisabled !== 1 &&
|
x.isDisabled !== 1 &&
|
||||||
!this.isSpecialSchedule(x.schedule),
|
this.shouldUseCronClient(x),
|
||||||
)
|
)
|
||||||
.map((doc) => ({
|
.map((doc) => ({
|
||||||
name: doc.name || '',
|
name: doc.name || '',
|
||||||
@@ -687,7 +935,24 @@ export default class CronService {
|
|||||||
command: this.makeCommand(doc),
|
command: this.makeCommand(doc),
|
||||||
extra_schedules: doc.extra_schedules || [],
|
extra_schedules: doc.extra_schedules || [],
|
||||||
}));
|
}));
|
||||||
await cronClient.addCron(regularCrons);
|
|
||||||
|
if (isDemoEnv()) {
|
||||||
|
await writeFileWithLock(config.crontabFile, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
|
||||||
|
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
|
||||||
|
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
|
||||||
|
await this.setCrontab(tabs);
|
||||||
|
try {
|
||||||
|
await cronClient.addCron(regularCrons);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(
|
||||||
|
'[crontab] Failed to register cron job in scheduler:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async bootTask() {
|
public async bootTask() {
|
||||||
@@ -701,7 +966,7 @@ export default class CronService {
|
|||||||
{ where: { id: bootTasks.map((t) => t.id!) } },
|
{ where: { id: bootTasks.map((t) => t.id!) } },
|
||||||
);
|
);
|
||||||
for (const task of bootTasks) {
|
for (const task of bootTasks) {
|
||||||
await this.runSingle(task.id!);
|
this.runSingle(task.id!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-77
@@ -3,12 +3,9 @@ import winston from 'winston';
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
import {
|
import {
|
||||||
Dependence,
|
Dependence,
|
||||||
InstallDependenceCommandTypes,
|
|
||||||
DependenceStatus,
|
DependenceStatus,
|
||||||
DependenceTypes,
|
DependenceTypes,
|
||||||
unInstallDependenceCommandTypes,
|
|
||||||
DependenceModel,
|
DependenceModel,
|
||||||
GetDependenceCommandTypes,
|
|
||||||
versionDependenceCommandTypes,
|
versionDependenceCommandTypes,
|
||||||
} from '../data/dependence';
|
} from '../data/dependence';
|
||||||
import { spawn } from 'cross-spawn';
|
import { spawn } from 'cross-spawn';
|
||||||
@@ -19,16 +16,22 @@ import {
|
|||||||
getPid,
|
getPid,
|
||||||
killTask,
|
killTask,
|
||||||
promiseExecSuccess,
|
promiseExecSuccess,
|
||||||
|
getInstallCommand,
|
||||||
|
getUninstallCommand,
|
||||||
|
getGetCommand,
|
||||||
} from '../config/util';
|
} from '../config/util';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import taskLimit from '../shared/pLimit';
|
import taskLimit from '../shared/pLimit';
|
||||||
|
import { detectOS } from '../config/util';
|
||||||
|
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
|
||||||
|
import { t, tf } from '../shared/i18n';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class DependenceService {
|
export default class DependenceService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('logger') private logger: winston.Logger,
|
@Inject('logger') private logger: winston.Logger,
|
||||||
private sockService: SockService,
|
private sockService: SockService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
public async create(payloads: Dependence[]): Promise<Dependence[]> {
|
public async create(payloads: Dependence[]): Promise<Dependence[]> {
|
||||||
const tabs = payloads.map((x) => {
|
const tabs = payloads.map((x) => {
|
||||||
@@ -67,6 +70,9 @@ export default class DependenceService {
|
|||||||
|
|
||||||
public async remove(ids: number[], force = false): Promise<Dependence[]> {
|
public async remove(ids: number[], force = false): Promise<Dependence[]> {
|
||||||
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
||||||
|
for (const doc of docs) {
|
||||||
|
taskLimit.removeQueuedDependency(doc);
|
||||||
|
}
|
||||||
const unInstalledDeps = docs.filter(
|
const unInstalledDeps = docs.filter(
|
||||||
(x) => x.status !== DependenceStatus.installed,
|
(x) => x.status !== DependenceStatus.installed,
|
||||||
);
|
);
|
||||||
@@ -95,34 +101,32 @@ export default class DependenceService {
|
|||||||
searchValue,
|
searchValue,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
}: { searchValue: string; type: string; status: string },
|
}: {
|
||||||
|
searchValue: string;
|
||||||
|
type: keyof typeof DependenceTypes;
|
||||||
|
status: string;
|
||||||
|
},
|
||||||
sort: any = [],
|
sort: any = [],
|
||||||
query: any = {},
|
query: any = {},
|
||||||
): Promise<Dependence[]> {
|
): Promise<Dependence[]> {
|
||||||
let condition = {
|
let condition = query;
|
||||||
...query,
|
if (type && DependenceTypes[type] !== undefined) {
|
||||||
type: DependenceTypes[type as any],
|
condition.type = DependenceTypes[type];
|
||||||
};
|
}
|
||||||
if (status) {
|
if (status) {
|
||||||
condition.status = status.split(',').map(Number);
|
condition.status = status.split(',').map(Number);
|
||||||
}
|
}
|
||||||
if (searchValue) {
|
if (searchValue) {
|
||||||
const encodeText = encodeURI(searchValue);
|
const encodeText = encodeURI(searchValue);
|
||||||
const reg = {
|
condition.name = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ [Op.like]: `%${searchValue}%` },
|
{ [Op.like]: `%${searchValue}%` },
|
||||||
{ [Op.like]: `%${encodeText}%` },
|
{ [Op.like]: `%${encodeText}%` },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
condition = {
|
|
||||||
...condition,
|
|
||||||
name: reg,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await this.find(condition, sort);
|
return await this.find(condition, sort);
|
||||||
return result as any;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -132,10 +136,12 @@ export default class DependenceService {
|
|||||||
docs: Dependence[],
|
docs: Dependence[],
|
||||||
isInstall: boolean = true,
|
isInstall: boolean = true,
|
||||||
force: boolean = false,
|
force: boolean = false,
|
||||||
) {
|
): Promise<void> {
|
||||||
docs.forEach((dep) => {
|
docs.forEach((dep) => {
|
||||||
this.installOrUninstallDependency(dep, isInstall, force);
|
this.installOrUninstallDependency(dep, isInstall, force);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return taskLimit.waitDependencyQueueDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async reInstall(ids: number[]): Promise<Dependence[]> {
|
public async reInstall(ids: number[]): Promise<Dependence[]> {
|
||||||
@@ -145,6 +151,9 @@ export default class DependenceService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
||||||
|
for (const doc of docs) {
|
||||||
|
taskLimit.removeQueuedDependency(doc);
|
||||||
|
}
|
||||||
this.installDependenceOneByOne(docs, true, true);
|
this.installDependenceOneByOne(docs, true, true);
|
||||||
return docs;
|
return docs;
|
||||||
}
|
}
|
||||||
@@ -153,13 +162,22 @@ export default class DependenceService {
|
|||||||
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
taskLimit.removeQueuedDependency(doc);
|
taskLimit.removeQueuedDependency(doc);
|
||||||
const depInstallCommand = InstallDependenceCommandTypes[doc.type];
|
let depInstallCommand = getInstallCommand(doc.type, doc.name);
|
||||||
const depUnInstallCommand = unInstallDependenceCommandTypes[doc.type];
|
let depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
|
||||||
const installCmd = `${depInstallCommand} ${doc.name.trim()}`;
|
const isLinuxDependence = doc.type === DependenceTypes.linux;
|
||||||
const unInstallCmd = `${depUnInstallCommand} ${doc.name.trim()}`;
|
|
||||||
|
if (isLinuxDependence) {
|
||||||
|
const osType = await detectOS();
|
||||||
|
if (!osType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
|
||||||
|
depInstallCommand = `${linuxCommand.install} ${doc.name.trim()}`;
|
||||||
|
depUnInstallCommand = `${linuxCommand.uninstall} ${doc.name.trim()}`;
|
||||||
|
}
|
||||||
const pids = await Promise.all([
|
const pids = await Promise.all([
|
||||||
getPid(installCmd),
|
getPid(depInstallCommand),
|
||||||
getPid(unInstallCmd),
|
getPid(depUnInstallCommand),
|
||||||
]);
|
]);
|
||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
pid && (await killTask(pid));
|
pid && (await killTask(pid));
|
||||||
@@ -213,41 +231,83 @@ export default class DependenceService {
|
|||||||
if (taskLimit.firstDependencyId !== dependency.id) {
|
if (taskLimit.firstDependencyId !== dependency.id) {
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
taskLimit.removeQueuedDependency(dependency);
|
|
||||||
|
|
||||||
const depIds = [dependency.id!];
|
const depIds = [dependency.id!];
|
||||||
|
let depName = dependency.name.trim();
|
||||||
|
const actionText = isInstall ? t('安装') : t('删除');
|
||||||
|
const socketMessageType = isInstall
|
||||||
|
? 'installDependence'
|
||||||
|
: 'uninstallDependence';
|
||||||
|
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
|
||||||
|
const isLinuxDependence = dependency.type === DependenceTypes.linux;
|
||||||
|
const isPythonDependence = dependency.type === DependenceTypes.python3;
|
||||||
|
const osType = await detectOS();
|
||||||
|
let linuxCommand = {} as typeof LINUX_DEPENDENCE_COMMAND.Alpine;
|
||||||
|
taskLimit.removeQueuedDependency(dependency);
|
||||||
|
if (isLinuxDependence) {
|
||||||
|
if (!osType) {
|
||||||
|
await DependenceModel.update(
|
||||||
|
{ status: DependenceStatus.installFailed },
|
||||||
|
{ where: { id: depIds } },
|
||||||
|
);
|
||||||
|
const startTime = dayjs();
|
||||||
|
const message = tf(
|
||||||
|
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒',
|
||||||
|
actionText,
|
||||||
|
depName,
|
||||||
|
startTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
actionText,
|
||||||
|
startTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
String(startTime.diff(startTime, 'second')),
|
||||||
|
);
|
||||||
|
this.sockService.sendMessage({
|
||||||
|
type: socketMessageType,
|
||||||
|
message,
|
||||||
|
references: depIds,
|
||||||
|
status: DependenceStatus.installFailed,
|
||||||
|
});
|
||||||
|
this.updateLog(depIds, message);
|
||||||
|
return resolve(null);
|
||||||
|
}
|
||||||
|
linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
|
||||||
|
}
|
||||||
|
|
||||||
const status = isInstall
|
const status = isInstall
|
||||||
? DependenceStatus.installing
|
? DependenceStatus.installing
|
||||||
: DependenceStatus.removing;
|
: DependenceStatus.removing;
|
||||||
await DependenceModel.update({ status }, { where: { id: depIds } });
|
await DependenceModel.update({ status }, { where: { id: depIds } });
|
||||||
|
|
||||||
const socketMessageType = isInstall
|
let command = isInstall
|
||||||
? 'installDependence'
|
? getInstallCommand(dependency.type, depName)
|
||||||
: 'uninstallDependence';
|
: getUninstallCommand(dependency.type, depName);
|
||||||
let depName = dependency.name.trim();
|
if (isLinuxDependence) {
|
||||||
const depRunCommand = (
|
command = isInstall
|
||||||
isInstall
|
? `${linuxCommand.install} ${depName.trim()}`
|
||||||
? InstallDependenceCommandTypes
|
: `${linuxCommand.uninstall} ${depName.trim()}`;
|
||||||
: unInstallDependenceCommandTypes
|
}
|
||||||
)[dependency.type];
|
|
||||||
const actionText = isInstall ? '安装' : '删除';
|
|
||||||
const startTime = dayjs();
|
const startTime = dayjs();
|
||||||
|
|
||||||
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
|
const message = tf(
|
||||||
'YYYY-MM-DD HH:mm:ss',
|
'开始%s依赖 %s,开始时间 %s\n\n',
|
||||||
)}\n\n`;
|
actionText,
|
||||||
|
depName,
|
||||||
|
startTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
);
|
||||||
this.sockService.sendMessage({
|
this.sockService.sendMessage({
|
||||||
type: socketMessageType,
|
type: socketMessageType,
|
||||||
message,
|
message,
|
||||||
references: depIds,
|
references: depIds,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
this.updateLog(depIds, message);
|
this.updateLog(depIds, message);
|
||||||
|
|
||||||
// 判断是否已经安装过依赖
|
// 判断是否已经安装过依赖
|
||||||
if (isInstall && !force) {
|
if (isInstall && !force) {
|
||||||
const getCommandPrefix = GetDependenceCommandTypes[dependency.type];
|
let getCommand = getGetCommand(dependency.type, depName);
|
||||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||||
|
if (isLinuxDependence) {
|
||||||
|
getCommand = `${linuxCommand.info} ${depName}`;
|
||||||
|
}
|
||||||
|
|
||||||
let depVersion = '';
|
let depVersion = '';
|
||||||
if (depName.includes(depVersionStr)) {
|
if (depName.includes(depVersionStr)) {
|
||||||
const symbolRegx = new RegExp(
|
const symbolRegx = new RegExp(
|
||||||
@@ -259,17 +319,7 @@ export default class DependenceService {
|
|||||||
depVersion = _depVersion;
|
depVersion = _depVersion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
|
const depInfo = (await promiseExecSuccess(getCommand))
|
||||||
const isLinuxDependence = dependency.type === DependenceTypes.linux;
|
|
||||||
const isPythonDependence =
|
|
||||||
dependency.type === DependenceTypes.python3;
|
|
||||||
const depInfo = (
|
|
||||||
await promiseExecSuccess(
|
|
||||||
isNodeDependence
|
|
||||||
? `${getCommandPrefix} | grep "${depName}" | head -1`
|
|
||||||
: `${getCommandPrefix} ${depName}`,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.replace(/\s{2,}/, ' ')
|
.replace(/\s{2,}/, ' ')
|
||||||
.replace(/\s+$/, '');
|
.replace(/\s+$/, '');
|
||||||
|
|
||||||
@@ -277,18 +327,24 @@ export default class DependenceService {
|
|||||||
depInfo &&
|
depInfo &&
|
||||||
((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
|
((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
|
||||||
(isLinuxDependence &&
|
(isLinuxDependence &&
|
||||||
depInfo.toLocaleLowerCase().includes('installed')) ||
|
linuxCommand.check(depInfo.toLocaleLowerCase())) ||
|
||||||
isPythonDependence) &&
|
isPythonDependence) &&
|
||||||
(!depVersion || depInfo.includes(depVersion))
|
(!depVersion || depInfo.includes(depVersion))
|
||||||
) {
|
) {
|
||||||
const endTime = dayjs();
|
const endTime = dayjs();
|
||||||
const _message = `检测到已经安装 ${depName}\n\n${depInfo}\n\n跳过安装\n\n依赖${actionText}成功,结束时间 ${endTime.format(
|
const _message = tf(
|
||||||
'YYYY-MM-DD HH:mm:ss',
|
'检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒',
|
||||||
)},耗时 ${endTime.diff(startTime, 'second')} 秒`;
|
depName,
|
||||||
|
depInfo,
|
||||||
|
actionText,
|
||||||
|
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
String(endTime.diff(startTime, 'second')),
|
||||||
|
);
|
||||||
this.sockService.sendMessage({
|
this.sockService.sendMessage({
|
||||||
type: socketMessageType,
|
type: socketMessageType,
|
||||||
message: _message,
|
message: _message,
|
||||||
references: depIds,
|
references: depIds,
|
||||||
|
status: DependenceStatus.installed,
|
||||||
});
|
});
|
||||||
this.updateLog(depIds, _message);
|
this.updateLog(depIds, _message);
|
||||||
await DependenceModel.update(
|
await DependenceModel.update(
|
||||||
@@ -304,18 +360,16 @@ export default class DependenceService {
|
|||||||
const proxyStr = dependenceProxyFileExist
|
const proxyStr = dependenceProxyFileExist
|
||||||
? `source ${config.dependenceProxyFile} &&`
|
? `source ${config.dependenceProxyFile} &&`
|
||||||
: '';
|
: '';
|
||||||
const cp = spawn(
|
const cp = spawn(`${proxyStr} ${command}`, {
|
||||||
`${proxyStr} ${depRunCommand} ${dependency.name.trim()}`,
|
shell: '/bin/bash',
|
||||||
{
|
});
|
||||||
shell: '/bin/bash',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
cp.stdout.on('data', async (data) => {
|
cp.stdout.on('data', async (data) => {
|
||||||
this.sockService.sendMessage({
|
this.sockService.sendMessage({
|
||||||
type: socketMessageType,
|
type: socketMessageType,
|
||||||
message: data.toString(),
|
message: data.toString(),
|
||||||
references: depIds,
|
references: depIds,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
this.updateLog(depIds, data.toString());
|
this.updateLog(depIds, data.toString());
|
||||||
});
|
});
|
||||||
@@ -325,6 +379,7 @@ export default class DependenceService {
|
|||||||
type: socketMessageType,
|
type: socketMessageType,
|
||||||
message: data.toString(),
|
message: data.toString(),
|
||||||
references: depIds,
|
references: depIds,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
this.updateLog(depIds, data.toString());
|
this.updateLog(depIds, data.toString());
|
||||||
});
|
});
|
||||||
@@ -334,6 +389,7 @@ export default class DependenceService {
|
|||||||
type: socketMessageType,
|
type: socketMessageType,
|
||||||
message: JSON.stringify(err),
|
message: JSON.stringify(err),
|
||||||
references: depIds,
|
references: depIds,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
this.updateLog(depIds, JSON.stringify(err));
|
this.updateLog(depIds, JSON.stringify(err));
|
||||||
});
|
});
|
||||||
@@ -341,28 +397,27 @@ export default class DependenceService {
|
|||||||
cp.on('exit', async (code) => {
|
cp.on('exit', async (code) => {
|
||||||
const endTime = dayjs();
|
const endTime = dayjs();
|
||||||
const isSucceed = code === 0;
|
const isSucceed = code === 0;
|
||||||
const resultText = isSucceed ? '成功' : '失败';
|
const resultText = isSucceed ? t('成功') : t('失败');
|
||||||
|
|
||||||
const message = `\n依赖${actionText}${resultText},结束时间 ${endTime.format(
|
const message =
|
||||||
'YYYY-MM-DD HH:mm:ss',
|
'\n' +
|
||||||
)},耗时 ${endTime.diff(startTime, 'second')} 秒`;
|
tf('依赖%s%s,结束时间 %s,耗时 %s 秒',
|
||||||
|
actionText,
|
||||||
|
resultText,
|
||||||
|
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
String(endTime.diff(startTime, 'second')),
|
||||||
|
);
|
||||||
|
const exitStatus = isSucceed
|
||||||
|
? (isInstall ? DependenceStatus.installed : DependenceStatus.removed)
|
||||||
|
: (isInstall ? DependenceStatus.installFailed : DependenceStatus.removeFailed);
|
||||||
this.sockService.sendMessage({
|
this.sockService.sendMessage({
|
||||||
type: socketMessageType,
|
type: socketMessageType,
|
||||||
message,
|
message,
|
||||||
references: depIds,
|
references: depIds,
|
||||||
|
status: exitStatus,
|
||||||
});
|
});
|
||||||
this.updateLog(depIds, message);
|
this.updateLog(depIds, message);
|
||||||
|
|
||||||
let status: number;
|
|
||||||
if (isSucceed) {
|
|
||||||
status = isInstall
|
|
||||||
? DependenceStatus.installed
|
|
||||||
: DependenceStatus.removed;
|
|
||||||
} else {
|
|
||||||
status = isInstall
|
|
||||||
? DependenceStatus.installFailed
|
|
||||||
: DependenceStatus.removeFailed;
|
|
||||||
}
|
|
||||||
const docs = await DependenceModel.findAll({ where: { id: depIds } });
|
const docs = await DependenceModel.findAll({ where: { id: depIds } });
|
||||||
const _docIds = docs
|
const _docIds = docs
|
||||||
.filter((x) => x.status !== DependenceStatus.cancelled)
|
.filter((x) => x.status !== DependenceStatus.cancelled)
|
||||||
@@ -370,7 +425,7 @@ export default class DependenceService {
|
|||||||
|
|
||||||
if (_docIds.length > 0) {
|
if (_docIds.length > 0) {
|
||||||
await DependenceModel.update(
|
await DependenceModel.update(
|
||||||
{ status },
|
{ status: exitStatus },
|
||||||
{ where: { id: _docIds } },
|
{ where: { id: _docIds } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-8
@@ -1,7 +1,8 @@
|
|||||||
import { Service, Inject } from 'typedi';
|
import groupBy from 'lodash/groupBy';
|
||||||
|
import { FindOptions, Op } from 'sequelize';
|
||||||
|
import { Inject, Service } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import {
|
import {
|
||||||
Env,
|
Env,
|
||||||
EnvModel,
|
EnvModel,
|
||||||
@@ -11,13 +12,12 @@ import {
|
|||||||
minPosition,
|
minPosition,
|
||||||
stepPosition,
|
stepPosition,
|
||||||
} from '../data/env';
|
} from '../data/env';
|
||||||
import groupBy from 'lodash/groupBy';
|
|
||||||
import { FindOptions, Op } from 'sequelize';
|
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
|
import { sequelize } from '../data';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class EnvService {
|
export default class EnvService {
|
||||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
constructor(@Inject('logger') private logger: winston.Logger) { }
|
||||||
|
|
||||||
public async create(payloads: Env[]): Promise<Env[]> {
|
public async create(payloads: Env[]): Promise<Env[]> {
|
||||||
const envs = await this.envs();
|
const envs = await this.envs();
|
||||||
@@ -27,7 +27,7 @@ export default class EnvService {
|
|||||||
envs.length > 0 &&
|
envs.length > 0 &&
|
||||||
typeof envs[envs.length - 1].position === 'number'
|
typeof envs[envs.length - 1].position === 'number'
|
||||||
) {
|
) {
|
||||||
position = envs[envs.length - 1].position!;
|
position = this.getPrecisionPosition(envs[envs.length - 1].position!);
|
||||||
}
|
}
|
||||||
const tabs = payloads.map((x) => {
|
const tabs = payloads.map((x) => {
|
||||||
position = position - stepPosition;
|
position = position - stepPosition;
|
||||||
@@ -100,7 +100,7 @@ export default class EnvService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkPosition(position: number, edge: number = 0) {
|
private async checkPosition(position: number, edge: number = 0) {
|
||||||
const precisionPosition = parseFloat(position.toPrecision(16));
|
const precisionPosition = this.getPrecisionPosition(position);
|
||||||
if (
|
if (
|
||||||
precisionPosition < minPosition ||
|
precisionPosition < minPosition ||
|
||||||
precisionPosition > maxPosition ||
|
precisionPosition > maxPosition ||
|
||||||
@@ -116,7 +116,7 @@ export default class EnvService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getPrecisionPosition(position: number): number {
|
private getPrecisionPosition(position: number): number {
|
||||||
return parseFloat(position.toPrecision(16));
|
return Math.trunc(parseFloat(position.toPrecision(16)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
|
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
|
||||||
@@ -147,6 +147,7 @@ export default class EnvService {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await this.find(condition, [
|
const result = await this.find(condition, [
|
||||||
|
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'],
|
||||||
['position', 'DESC'],
|
['position', 'DESC'],
|
||||||
['createdAt', 'ASC'],
|
['createdAt', 'ASC'],
|
||||||
]);
|
]);
|
||||||
@@ -190,6 +191,52 @@ export default class EnvService {
|
|||||||
await this.set_envs();
|
await this.set_envs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async pin(ids: number[]) {
|
||||||
|
await EnvModel.update({ isPinned: 1 }, { where: { id: ids } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unPin(ids: number[]) {
|
||||||
|
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addLabels(ids: number[], labels: string[]) {
|
||||||
|
await sequelize.transaction(async (transaction) => {
|
||||||
|
const docs = await EnvModel.findAll({
|
||||||
|
where: { id: ids },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
for (const doc of docs) {
|
||||||
|
const env = doc.get({ plain: true });
|
||||||
|
await EnvModel.update(
|
||||||
|
{ labels: Array.from(new Set([...(env.labels || []), ...labels])) },
|
||||||
|
{ where: { id: env.id }, transaction },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return await this.find({ id: ids });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeLabels(ids: number[], labels: string[]) {
|
||||||
|
await sequelize.transaction(async (transaction) => {
|
||||||
|
const docs = await EnvModel.findAll({
|
||||||
|
where: { id: ids },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
for (const doc of docs) {
|
||||||
|
const env = doc.get({ plain: true });
|
||||||
|
await EnvModel.update(
|
||||||
|
{
|
||||||
|
labels: (env.labels || []).filter(
|
||||||
|
(label: string) => !labels.includes(label),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ where: { id: env.id }, transaction },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return await this.find({ id: ids });
|
||||||
|
}
|
||||||
|
|
||||||
public async set_envs() {
|
public async set_envs() {
|
||||||
const envs = await this.envs('', {
|
const envs = await this.envs('', {
|
||||||
name: { [Op.not]: null },
|
name: { [Op.not]: null },
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Server, ServerCredentials } from '@grpc/grpc-js';
|
||||||
|
import { CronService } from '../protos/cron';
|
||||||
|
import { HealthService } from '../protos/health';
|
||||||
|
import { ApiService } from '../protos/api';
|
||||||
|
import { addCron } from '../schedule/addCron';
|
||||||
|
import { delCron } from '../schedule/delCron';
|
||||||
|
import { check } from '../schedule/health';
|
||||||
|
import * as Api from '../schedule/api';
|
||||||
|
import Logger from '../loaders/logger';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import config from '../config';
|
||||||
|
import { metricsService } from './metrics';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { initGrpcCerts } from '../config/grpcCerts';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class GrpcServerService {
|
||||||
|
private server: Server = new Server({ 'grpc.enable_http_proxy': 0 });
|
||||||
|
|
||||||
|
private formatGrpcAddress(host: string, port: number): string {
|
||||||
|
if (host === '::') {
|
||||||
|
return `[::]:${port}`;
|
||||||
|
}
|
||||||
|
return `${host}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
try {
|
||||||
|
this.server.addService(HealthService, { check });
|
||||||
|
this.server.addService(CronService, { addCron, delCron });
|
||||||
|
this.server.addService(ApiService, Api);
|
||||||
|
|
||||||
|
const tlsConfig = await initGrpcCerts();
|
||||||
|
const credentials = ServerCredentials.createSsl(
|
||||||
|
Buffer.from(tlsConfig.caCert),
|
||||||
|
[{ cert_chain: Buffer.from(tlsConfig.serverCert), private_key: Buffer.from(tlsConfig.serverKey) }],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const grpcPort = config.grpcPort;
|
||||||
|
const hostsToTry = [
|
||||||
|
config.bindHostGrpc,
|
||||||
|
...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : [])
|
||||||
|
];
|
||||||
|
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (const host of hostsToTry) {
|
||||||
|
try {
|
||||||
|
const address = this.formatGrpcAddress(host, grpcPort);
|
||||||
|
await bindAsync(address, credentials);
|
||||||
|
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
|
||||||
|
metricsService.record('grpc_service_start', 1, {
|
||||||
|
port: grpcPort.toString(),
|
||||||
|
host
|
||||||
|
});
|
||||||
|
return grpcPort;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err as Error;
|
||||||
|
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error('Failed to start gRPC service on all hosts');
|
||||||
|
throw lastError || new Error('Failed to start gRPC service');
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error('Failed to start gRPC service:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
try {
|
||||||
|
if (this.server) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
this.server.tryShutdown(() => {
|
||||||
|
Logger.debug('gRPC service stopped');
|
||||||
|
metricsService.record('grpc_service_stop', 1);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error('Error while shutting down gRPC service:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getServer() {
|
||||||
|
return this.server;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Service } from 'typedi';
|
||||||
|
import Logger from '../loaders/logger';
|
||||||
|
import { GrpcServerService } from './grpc';
|
||||||
|
import { HttpServerService } from './http';
|
||||||
|
|
||||||
|
interface HealthStatus {
|
||||||
|
status: 'ok' | 'error';
|
||||||
|
services: {
|
||||||
|
http: boolean;
|
||||||
|
grpc: boolean;
|
||||||
|
};
|
||||||
|
metrics: {
|
||||||
|
uptime: number;
|
||||||
|
memory: {
|
||||||
|
used: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class HealthService {
|
||||||
|
private startTime = Date.now();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private grpcServerService: GrpcServerService,
|
||||||
|
private httpServerService: HttpServerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async check(): Promise<HealthStatus> {
|
||||||
|
const status: HealthStatus = {
|
||||||
|
status: 'ok',
|
||||||
|
services: {
|
||||||
|
http: true,
|
||||||
|
grpc: true,
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||||
|
memory: {
|
||||||
|
used: process.memoryUsage().heapUsed,
|
||||||
|
total: process.memoryUsage().heapTotal,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const httpServer = this.httpServerService.getServer();
|
||||||
|
if (!httpServer) {
|
||||||
|
status.services.http = false;
|
||||||
|
status.status = 'error';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.services.http = false;
|
||||||
|
status.status = 'error';
|
||||||
|
Logger.error('HTTP server check failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const grpcServer = this.grpcServerService.getServer();
|
||||||
|
if (!grpcServer) {
|
||||||
|
status.services.grpc = false;
|
||||||
|
status.status = 'error';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.services.grpc = false;
|
||||||
|
status.status = 'error';
|
||||||
|
Logger.error('gRPC server check failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import Logger from '../loaders/logger';
|
||||||
|
import { metricsService } from './metrics';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { Server } from 'http';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class HttpServerService {
|
||||||
|
private server?: Server = undefined;
|
||||||
|
|
||||||
|
async initialize(expressApp: express.Application, port: number) {
|
||||||
|
const hostsToTry = [
|
||||||
|
config.bindHost,
|
||||||
|
...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : [])
|
||||||
|
];
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (const host of hostsToTry) {
|
||||||
|
try {
|
||||||
|
const server = await this.tryListen(expressApp, port, host);
|
||||||
|
Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`);
|
||||||
|
metricsService.record('http_service_start', 1, {
|
||||||
|
port: port.toString(),
|
||||||
|
host
|
||||||
|
});
|
||||||
|
this.server = server;
|
||||||
|
return server;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err as Error;
|
||||||
|
Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error('Failed to start HTTP service on all hosts');
|
||||||
|
throw lastError || new Error('Failed to start HTTP service');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = expressApp.listen(port, host, () => {
|
||||||
|
resolve(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (err: Error) => {
|
||||||
|
server.close();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
try {
|
||||||
|
if (this.server) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
this.server?.close(() => {
|
||||||
|
Logger.debug('HTTP service stopped');
|
||||||
|
metricsService.record('http_service_stop', 1);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error('Error while shutting down HTTP service:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getServer() {
|
||||||
|
return this.server;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import winston from 'winston';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export default class LogService {
|
||||||
|
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||||
|
|
||||||
|
public checkFilePath(filePath: string, fileName: string) {
|
||||||
|
const finalPath = path.resolve(config.logPath, filePath, fileName);
|
||||||
|
return finalPath.startsWith(config.logPath) ? finalPath : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { performance } from 'perf_hooks';
|
||||||
|
import Logger from '../loaders/logger';
|
||||||
|
|
||||||
|
interface Metric {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
timestamp: number;
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetricsService {
|
||||||
|
private metrics: Metric[] = [];
|
||||||
|
private static instance: MetricsService;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// 定期清理旧数据
|
||||||
|
setInterval(() => {
|
||||||
|
const oneHourAgo = Date.now() - 3600000;
|
||||||
|
this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo);
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): MetricsService {
|
||||||
|
if (!MetricsService.instance) {
|
||||||
|
MetricsService.instance = new MetricsService();
|
||||||
|
}
|
||||||
|
return MetricsService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
record(name: string, value: number, tags?: Record<string, string>) {
|
||||||
|
this.metrics.push({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
measure(name: string, fn: () => void, tags?: Record<string, string>) {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
const duration = performance.now() - start;
|
||||||
|
this.record(name, duration, tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async measureAsync(name: string, fn: () => Promise<void>, tags?: Record<string, string>) {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
const duration = performance.now() - start;
|
||||||
|
this.record(name, duration, tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetrics(name?: string, tags?: Record<string, string>) {
|
||||||
|
let filtered = this.metrics;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
filtered = filtered.filter(m => m.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags) {
|
||||||
|
filtered = filtered.filter(m => {
|
||||||
|
if (!m.tags) return false;
|
||||||
|
return Object.entries(tags).every(([key, value]) => m.tags![key] === value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: filtered.length,
|
||||||
|
average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length,
|
||||||
|
min: Math.min(...filtered.map(m => m.value)),
|
||||||
|
max: Math.max(...filtered.map(m => m.value)),
|
||||||
|
metrics: filtered,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
report() {
|
||||||
|
const report = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
metrics: this.getMetrics(),
|
||||||
|
};
|
||||||
|
Logger.info('性能指标报告:', report);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metricsService = MetricsService.getInstance();
|
||||||
+295
-193
@@ -1,11 +1,12 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import got from 'got';
|
|
||||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
import { parseBody, parseHeaders } from '../config/util';
|
import { parseBody, parseHeaders } from '../config/util';
|
||||||
import { NotificationInfo } from '../data/notify';
|
import { NotificationInfo } from '../data/notify';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
import UserService from './user';
|
import UserService from './user';
|
||||||
|
import { httpClient } from '../config/http';
|
||||||
|
import { ProxyAgent } from 'undici';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class NotificationService {
|
export default class NotificationService {
|
||||||
@@ -34,6 +35,8 @@ export default class NotificationService {
|
|||||||
['chronocat', this.chronocat],
|
['chronocat', this.chronocat],
|
||||||
['ntfy', this.ntfy],
|
['ntfy', this.ntfy],
|
||||||
['wxPusherBot', this.wxPusherBot],
|
['wxPusherBot', this.wxPusherBot],
|
||||||
|
['wxPusherSpt', this.wxPusherSpt],
|
||||||
|
['openiLink', this.openiLink],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
private title = '';
|
private title = '';
|
||||||
@@ -49,17 +52,26 @@ export default class NotificationService {
|
|||||||
public async notify(
|
public async notify(
|
||||||
title: string,
|
title: string,
|
||||||
content: string,
|
content: string,
|
||||||
|
notificationInfo?: NotificationInfo,
|
||||||
): Promise<boolean | undefined> {
|
): Promise<boolean | undefined> {
|
||||||
const { type, ...rest } = await this.userService.getNotificationMode();
|
let { type, ...rest } = await this.userService.getNotificationMode();
|
||||||
|
if (notificationInfo?.type) {
|
||||||
|
type = notificationInfo?.type;
|
||||||
|
}
|
||||||
if (type) {
|
if (type) {
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.params = rest;
|
let params = rest;
|
||||||
|
if (notificationInfo) {
|
||||||
|
const { type: _, ...others } = notificationInfo;
|
||||||
|
params = { ...rest, ...others };
|
||||||
|
}
|
||||||
|
this.params = params;
|
||||||
const notificationModeAction = this.modeMap.get(type);
|
const notificationModeAction = this.modeMap.get(type);
|
||||||
try {
|
try {
|
||||||
return await notificationModeAction?.call(this);
|
return await notificationModeAction?.call(this);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw error;
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -81,11 +93,20 @@ export default class NotificationService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseMailRecipients(value?: string) {
|
||||||
|
const recipients = (value || '')
|
||||||
|
.split(/[;;]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return recipients.length > 0 ? recipients : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private async gotify() {
|
private async gotify() {
|
||||||
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
|
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(
|
||||||
.post(`${gotifyUrl}/message?token=${gotifyToken}`, {
|
`${gotifyUrl}/message?token=${gotifyToken}`,
|
||||||
|
{
|
||||||
...this.gotOption,
|
...this.gotOption,
|
||||||
body: `title=${encodeURIComponent(
|
body: `title=${encodeURIComponent(
|
||||||
this.title,
|
this.title,
|
||||||
@@ -95,8 +116,8 @@ export default class NotificationService {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
.json();
|
);
|
||||||
if (typeof res.id === 'number') {
|
if (typeof res.id === 'number') {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -110,13 +131,11 @@ export default class NotificationService {
|
|||||||
private async goCqHttpBot() {
|
private async goCqHttpBot() {
|
||||||
const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params;
|
const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params;
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
|
||||||
.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
json: { message: `${this.title}\n${this.content}` },
|
||||||
json: { message: `${this.title}\n${this.content}` },
|
headers: { Authorization: 'Bearer ' + goCqHttpBotToken },
|
||||||
headers: { Authorization: 'Bearer ' + goCqHttpBotToken },
|
});
|
||||||
})
|
|
||||||
.json();
|
|
||||||
if (res.retcode === 0) {
|
if (res.retcode === 0) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -136,15 +155,13 @@ export default class NotificationService {
|
|||||||
: `https://sctapi.ftqq.com/${serverChanKey}.send`;
|
: `https://sctapi.ftqq.com/${serverChanKey}.send`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
body: `title=${encodeURIComponent(
|
||||||
body: `title=${encodeURIComponent(
|
this.title,
|
||||||
this.title,
|
)}&desp=${encodeURIComponent(this.content)}`,
|
||||||
)}&desp=${encodeURIComponent(this.content)}`,
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
});
|
||||||
})
|
|
||||||
.json();
|
|
||||||
if (res.errno === 0 || res.data.errno === 0) {
|
if (res.errno === 0 || res.data.errno === 0) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -159,15 +176,13 @@ export default class NotificationService {
|
|||||||
const { pushDeerKey, pushDeerUrl } = this.params;
|
const { pushDeerKey, pushDeerUrl } = this.params;
|
||||||
const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`;
|
const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`;
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
body: `pushkey=${pushDeerKey}&text=${encodeURIComponent(
|
||||||
body: `pushkey=${pushDeerKey}&text=${encodeURIComponent(
|
this.title,
|
||||||
this.title,
|
)}&desp=${encodeURIComponent(this.content)}&type=markdown`,
|
||||||
)}&desp=${encodeURIComponent(this.content)}&type=markdown`,
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
});
|
||||||
})
|
|
||||||
.json();
|
|
||||||
if (
|
if (
|
||||||
res.content.result.length !== undefined &&
|
res.content.result.length !== undefined &&
|
||||||
res.content.result.length > 0
|
res.content.result.length > 0
|
||||||
@@ -184,13 +199,11 @@ export default class NotificationService {
|
|||||||
private async chat() {
|
private async chat() {
|
||||||
const { synologyChatUrl } = this.params;
|
const { synologyChatUrl } = this.params;
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(synologyChatUrl, {
|
||||||
.post(synologyChatUrl, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
body: `payload={"text":"${this.title}\n${this.content}"}`,
|
||||||
body: `payload={"text":"${this.title}\n${this.content}"}`,
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
});
|
||||||
})
|
|
||||||
.json();
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -226,13 +239,11 @@ export default class NotificationService {
|
|||||||
url: barkUrl,
|
url: barkUrl,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
json: body,
|
||||||
json: body,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
});
|
||||||
})
|
|
||||||
.json();
|
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -258,29 +269,17 @@ export default class NotificationService {
|
|||||||
}/bot${telegramBotToken}/sendMessage`;
|
}/bot${telegramBotToken}/sendMessage`;
|
||||||
let agent;
|
let agent;
|
||||||
if (telegramBotProxyHost && telegramBotProxyPort) {
|
if (telegramBotProxyHost && telegramBotProxyPort) {
|
||||||
const options: any = {
|
agent = new ProxyAgent({
|
||||||
keepAlive: true,
|
uri: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`,
|
||||||
keepAliveMsecs: 1000,
|
});
|
||||||
maxSockets: 256,
|
|
||||||
maxFreeSockets: 256,
|
|
||||||
proxy: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`,
|
|
||||||
};
|
|
||||||
const httpAgent = new HttpProxyAgent(options);
|
|
||||||
const httpsAgent = new HttpsProxyAgent(options);
|
|
||||||
agent = {
|
|
||||||
http: httpAgent,
|
|
||||||
https: httpsAgent,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`,
|
||||||
body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`,
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
dispatcher: agent,
|
||||||
agent,
|
});
|
||||||
})
|
|
||||||
.json();
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -303,17 +302,15 @@ export default class NotificationService {
|
|||||||
}
|
}
|
||||||
const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`;
|
const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`;
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
json: {
|
||||||
json: {
|
msgtype: 'text',
|
||||||
msgtype: 'text',
|
text: {
|
||||||
text: {
|
content: ` ${this.title}\n\n${this.content}`,
|
||||||
content: ` ${this.title}\n\n${this.content}`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
.json();
|
});
|
||||||
if (res.errcode === 0) {
|
if (res.errcode === 0) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -329,17 +326,15 @@ export default class NotificationService {
|
|||||||
this.params;
|
this.params;
|
||||||
const url = `${weWorkOrigin}/cgi-bin/webhook/send?key=${weWorkBotKey}`;
|
const url = `${weWorkOrigin}/cgi-bin/webhook/send?key=${weWorkBotKey}`;
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
json: {
|
||||||
json: {
|
msgtype: 'text',
|
||||||
msgtype: 'text',
|
text: {
|
||||||
text: {
|
content: ` ${this.title}\n\n${this.content}`,
|
||||||
content: ` ${this.title}\n\n${this.content}`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
.json();
|
});
|
||||||
if (res.errcode === 0) {
|
if (res.errcode === 0) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -356,15 +351,13 @@ export default class NotificationService {
|
|||||||
const [corpid, corpsecret, touser, agentid, thumb_media_id = '1'] =
|
const [corpid, corpsecret, touser, agentid, thumb_media_id = '1'] =
|
||||||
weWorkAppKey.split(',');
|
weWorkAppKey.split(',');
|
||||||
const url = `${weWorkOrigin}/cgi-bin/gettoken`;
|
const url = `${weWorkOrigin}/cgi-bin/gettoken`;
|
||||||
const tokenRes: any = await got
|
const tokenRes = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
json: {
|
||||||
json: {
|
corpid,
|
||||||
corpid,
|
corpsecret,
|
||||||
corpsecret,
|
},
|
||||||
},
|
});
|
||||||
})
|
|
||||||
.json();
|
|
||||||
|
|
||||||
let options: any = {
|
let options: any = {
|
||||||
msgtype: 'mpnews',
|
msgtype: 'mpnews',
|
||||||
@@ -373,7 +366,7 @@ export default class NotificationService {
|
|||||||
{
|
{
|
||||||
title: `${this.title}`,
|
title: `${this.title}`,
|
||||||
thumb_media_id,
|
thumb_media_id,
|
||||||
author: `智能助手`,
|
author: t('智能助手'),
|
||||||
content_source_url: ``,
|
content_source_url: ``,
|
||||||
content: `${this.content.replace(/\n/g, '<br/>')}`,
|
content: `${this.content.replace(/\n/g, '<br/>')}`,
|
||||||
digest: `${this.content}`,
|
digest: `${this.content}`,
|
||||||
@@ -389,7 +382,7 @@ export default class NotificationService {
|
|||||||
title: `${this.title}`,
|
title: `${this.title}`,
|
||||||
description: `${this.content}`,
|
description: `${this.content}`,
|
||||||
url: 'https://github.com/whyour/qinglong',
|
url: 'https://github.com/whyour/qinglong',
|
||||||
btntxt: '更多',
|
btntxt: t('更多'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
@@ -405,20 +398,18 @@ export default class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(
|
||||||
.post(
|
`${weWorkOrigin}/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
|
||||||
`${weWorkOrigin}/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
|
{
|
||||||
{
|
...this.gotOption,
|
||||||
...this.gotOption,
|
json: {
|
||||||
json: {
|
touser,
|
||||||
touser,
|
agentid,
|
||||||
agentid,
|
safe: '0',
|
||||||
safe: '0',
|
...options,
|
||||||
...options,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
},
|
||||||
.json();
|
);
|
||||||
|
|
||||||
if (res.errcode === 0) {
|
if (res.errcode === 0) {
|
||||||
return true;
|
return true;
|
||||||
@@ -442,7 +433,7 @@ export default class NotificationService {
|
|||||||
roomName: `${aibotkName}`,
|
roomName: `${aibotkName}`,
|
||||||
message: {
|
message: {
|
||||||
type: 1,
|
type: 1,
|
||||||
content: `【青龙快讯】\n\n${this.title}\n${this.content}`,
|
content: `【${t('青龙快讯')}】\n\n${this.title}\n${this.content}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
@@ -453,21 +444,19 @@ export default class NotificationService {
|
|||||||
name: `${aibotkName}`,
|
name: `${aibotkName}`,
|
||||||
message: {
|
message: {
|
||||||
type: 1,
|
type: 1,
|
||||||
content: `【青龙快讯】\n\n${this.title}\n${this.content}`,
|
content: `【${t('青龙快讯')}】\n\n${this.title}\n${this.content}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
json: {
|
||||||
json: {
|
...json,
|
||||||
...json,
|
},
|
||||||
},
|
});
|
||||||
})
|
|
||||||
.json();
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -482,13 +471,11 @@ export default class NotificationService {
|
|||||||
const { iGotPushKey } = this.params;
|
const { iGotPushKey } = this.params;
|
||||||
const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`;
|
const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`;
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
body: `title=${this.title}&content=${this.content}`,
|
||||||
body: `title=${this.title}&content=${this.content}`,
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
});
|
||||||
})
|
|
||||||
.json();
|
|
||||||
|
|
||||||
if (res.ret === 0) {
|
if (res.ret === 0) {
|
||||||
return true;
|
return true;
|
||||||
@@ -527,7 +514,7 @@ export default class NotificationService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const res: any = await got.post(url, body).json();
|
const res = await httpClient.post(url, body);
|
||||||
|
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
return true;
|
return true;
|
||||||
@@ -551,19 +538,17 @@ export default class NotificationService {
|
|||||||
|
|
||||||
const url = `https://www.weplusbot.com/send`;
|
const url = `https://www.weplusbot.com/send`;
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
json: {
|
||||||
json: {
|
token: `${wePlusBotToken}`,
|
||||||
token: `${wePlusBotToken}`,
|
title: `${this.title}`,
|
||||||
title: `${this.title}`,
|
template: `${template}`,
|
||||||
template: `${template}`,
|
content: `${content}`,
|
||||||
content: `${content}`,
|
receiver: `${wePlusBotReceiver || ''}`,
|
||||||
receiver: `${wePlusBotReceiver || ''}`,
|
version: `${wePlusBotVersion || 'pro'}`,
|
||||||
version: `${wePlusBotVersion || 'pro'}`,
|
},
|
||||||
},
|
});
|
||||||
})
|
|
||||||
.json();
|
|
||||||
|
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
return true;
|
return true;
|
||||||
@@ -576,23 +561,35 @@ export default class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async lark() {
|
private async lark() {
|
||||||
let { larkKey } = this.params;
|
let { larkKey, larkSecret } = this.params;
|
||||||
|
|
||||||
if (!larkKey.startsWith('http')) {
|
if (!larkKey.startsWith('http')) {
|
||||||
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
|
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
msg_type: 'text',
|
||||||
|
content: { text: `${this.title}\n\n${this.content}` },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add signature if secret is provided
|
||||||
|
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
|
||||||
|
// and signs an empty message, which differs from typical HMAC usage
|
||||||
|
if (larkSecret) {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||||
|
const stringToSign = `${timestamp}\n${larkSecret}`;
|
||||||
|
const hmac = crypto.createHmac('sha256', stringToSign);
|
||||||
|
const sign = hmac.digest('base64');
|
||||||
|
body.timestamp = timestamp;
|
||||||
|
body.sign = sign;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(larkKey, {
|
||||||
.post(larkKey, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
json: body,
|
||||||
json: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
msg_type: 'text',
|
});
|
||||||
content: { text: `${this.title}\n\n${this.content}` },
|
|
||||||
},
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
.json();
|
|
||||||
if (res.StatusCode === 0 || res.code === 0) {
|
if (res.StatusCode === 0 || res.code === 0) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -604,7 +601,8 @@ export default class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async email() {
|
private async email() {
|
||||||
const { emailPass, emailService, emailUser } = this.params;
|
const { emailPass, emailService, emailUser, emailTo } = this.params;
|
||||||
|
const recipients = this.parseMailRecipients(emailTo) || emailUser;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
@@ -616,8 +614,8 @@ export default class NotificationService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
from: `"青龙快讯" <${emailUser}>`,
|
from: `"${t('青龙快讯')}" <${emailUser}>`,
|
||||||
to: `${emailUser}`,
|
to: recipients,
|
||||||
subject: `${this.title}`,
|
subject: `${this.title}`,
|
||||||
html: `${this.content.replace(/\n/g, '<br/>')}`,
|
html: `${this.content.replace(/\n/g, '<br/>')}`,
|
||||||
});
|
});
|
||||||
@@ -637,19 +635,22 @@ export default class NotificationService {
|
|||||||
private async pushMe() {
|
private async pushMe() {
|
||||||
const { pushMeKey, pushMeUrl } = this.params;
|
const { pushMeKey, pushMeUrl } = this.params;
|
||||||
try {
|
try {
|
||||||
const res: any = await got.post(pushMeUrl || 'https://push.i-i.me/', {
|
const res = await httpClient.post<'text'>(
|
||||||
...this.gotOption,
|
pushMeUrl || 'https://push.i-i.me/',
|
||||||
json: {
|
{
|
||||||
push_key: pushMeKey,
|
...this.gotOption,
|
||||||
title: this.title,
|
json: {
|
||||||
content: this.content,
|
push_key: pushMeKey,
|
||||||
|
title: this.title,
|
||||||
|
content: this.content,
|
||||||
|
},
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
},
|
},
|
||||||
headers: { 'Content-Type': 'application/json' },
|
);
|
||||||
});
|
if (res === 'success') {
|
||||||
if (res.body === 'success') {
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(res.body);
|
throw new Error(res);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw new Error(error.response ? error.response.body : error);
|
throw new Error(error.response ? error.response.body : error);
|
||||||
@@ -657,26 +658,49 @@ export default class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ntfy() {
|
private async ntfy() {
|
||||||
const { ntfyUrl, ntfyTopic, ntfyPriority } = this.params;
|
const {
|
||||||
|
ntfyUrl,
|
||||||
|
ntfyTopic,
|
||||||
|
ntfyPriority,
|
||||||
|
ntfyToken,
|
||||||
|
ntfyUsername,
|
||||||
|
ntfyPassword,
|
||||||
|
ntfyActions,
|
||||||
|
} = this.params;
|
||||||
// 编码函数
|
// 编码函数
|
||||||
const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => {
|
const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => {
|
||||||
const encodedText = Buffer.from(text).toString('base64');
|
const encodedText = Buffer.from(text).toString('base64');
|
||||||
return `=?${charset}?B?${encodedText}?=`;
|
return `=?${charset}?B?${encodedText}?=`;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const encodedTitle = encodeRfc2047(this.title);
|
const headers: Record<string, string> = {
|
||||||
const res: any = await got.post(
|
Title: encodeRfc2047(this.title),
|
||||||
|
Priority: `${ntfyPriority || '3'}`,
|
||||||
|
Icon: 'https://qn.whyour.cn/logo.png',
|
||||||
|
};
|
||||||
|
if (ntfyToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${ntfyToken}`;
|
||||||
|
} else if (ntfyUsername && ntfyPassword) {
|
||||||
|
headers['Authorization'] = `Basic ${Buffer.from(
|
||||||
|
`${ntfyUsername}:${ntfyPassword}`,
|
||||||
|
).toString('base64')}`;
|
||||||
|
}
|
||||||
|
if (ntfyActions) {
|
||||||
|
headers['Actions'] = encodeRfc2047(ntfyActions);
|
||||||
|
}
|
||||||
|
const res = await httpClient.request(
|
||||||
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
|
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
|
||||||
{
|
{
|
||||||
...this.gotOption,
|
...this.gotOption,
|
||||||
body: `${this.content}`,
|
body: `${this.content}`,
|
||||||
headers: { Title: encodedTitle, Priority: `${ntfyPriority || '3'}` },
|
headers: headers,
|
||||||
|
method: 'POST',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (res.statusCode === 200) {
|
if (res.statusCode === 200) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(JSON.stringify(res));
|
throw new Error(await res.body.text());
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw new Error(error.response ? error.response.body : error);
|
throw new Error(error.response ? error.response.body : error);
|
||||||
@@ -705,25 +729,69 @@ export default class NotificationService {
|
|||||||
|
|
||||||
// topic_ids 和 uids 至少要有一个
|
// topic_ids 和 uids 至少要有一个
|
||||||
if (!topicIds.length && !uids.length) {
|
if (!topicIds.length && !uids.length) {
|
||||||
throw new Error('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行');
|
throw new Error(t('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `https://wxpusher.zjiecode.com/api/send/message`;
|
const url = `https://wxpusher.zjiecode.com/api/send/message`;
|
||||||
try {
|
try {
|
||||||
const res: any = await got
|
const res = await httpClient.post(url, {
|
||||||
.post(url, {
|
...this.gotOption,
|
||||||
...this.gotOption,
|
json: {
|
||||||
json: {
|
appToken: wxPusherBotAppToken,
|
||||||
appToken: wxPusherBotAppToken,
|
content: `<h1>${this.title}</h1><br/><div style='white-space: pre-wrap;'>${this.content}</div>`,
|
||||||
content: `<h1>${this.title}</h1><br/><div style='white-space: pre-wrap;'>${this.content}</div>`,
|
summary: this.title,
|
||||||
summary: this.title,
|
contentType: 2,
|
||||||
contentType: 2,
|
topicIds: topicIds,
|
||||||
topicIds: topicIds,
|
uids: uids,
|
||||||
uids: uids,
|
verifyPayType: 0,
|
||||||
verifyPayType: 0,
|
},
|
||||||
},
|
});
|
||||||
})
|
|
||||||
.json();
|
if (res.code === 1000) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(JSON.stringify(res));
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(error.response ? error.response.body : error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async wxPusherSpt() {
|
||||||
|
const { wxPusherSptList } = this.params;
|
||||||
|
// 处理 SPT,将逗号分隔的字符串转为数组
|
||||||
|
const spts = wxPusherSptList
|
||||||
|
? wxPusherSptList
|
||||||
|
.split(',')
|
||||||
|
.map((spt) => spt.trim())
|
||||||
|
.filter((spt) => spt)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!spts.length) {
|
||||||
|
throw new Error(t('wxPusher SPT 不能为空'));
|
||||||
|
}
|
||||||
|
if (spts.length > 10) {
|
||||||
|
throw new Error(t('wxPusher SPT 最多支持 10 个'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://wxpusher.zjiecode.com/api/send/message/simple-push`;
|
||||||
|
const json: any = {
|
||||||
|
content: `<h1>${this.title}</h1><br/><div style='white-space: pre-wrap;'>${this.content}</div>`,
|
||||||
|
summary: this.title,
|
||||||
|
contentType: 2,
|
||||||
|
};
|
||||||
|
// 单个 SPT 用 spt,多个用 sptList
|
||||||
|
if (spts.length === 1) {
|
||||||
|
json.spt = spts[0];
|
||||||
|
} else {
|
||||||
|
json.sptList = spts;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await httpClient.post(url, {
|
||||||
|
...this.gotOption,
|
||||||
|
json,
|
||||||
|
});
|
||||||
|
|
||||||
if (res.code === 1000) {
|
if (res.code === 1000) {
|
||||||
return true;
|
return true;
|
||||||
@@ -774,15 +842,16 @@ export default class NotificationService {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const res: any = await got.post(url, {
|
const res = await httpClient.request(url, {
|
||||||
...this.gotOption,
|
...this.gotOption,
|
||||||
json: data,
|
json: data,
|
||||||
headers,
|
headers,
|
||||||
|
method: 'POST',
|
||||||
});
|
});
|
||||||
if (res.statusCode === 200) {
|
if (res.statusCode === 200) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(res.body);
|
throw new Error(await res.body.text());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -802,7 +871,7 @@ export default class NotificationService {
|
|||||||
} = this.params;
|
} = this.params;
|
||||||
|
|
||||||
if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) {
|
if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) {
|
||||||
throw new Error('Url 或者 Body 中必须包含 $title');
|
throw new Error(t('Url 或者 Body 中必须包含 $title'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = parseHeaders(webhookHeaders);
|
const headers = parseHeaders(webhookHeaders);
|
||||||
@@ -817,15 +886,17 @@ export default class NotificationService {
|
|||||||
allowGetBody: true,
|
allowGetBody: true,
|
||||||
...bodyParam,
|
...bodyParam,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formatUrl = webhookUrl
|
const formatUrl = webhookUrl
|
||||||
?.replaceAll('$title', encodeURIComponent(this.title))
|
?.replaceAll('$title', encodeURIComponent(this.title))
|
||||||
?.replaceAll('$content', encodeURIComponent(this.content));
|
?.replaceAll('$content', encodeURIComponent(this.content));
|
||||||
const res = await got(formatUrl, options);
|
const res = await httpClient.request(formatUrl, options);
|
||||||
|
const text = await res.body.text();
|
||||||
if (String(res.statusCode).startsWith('20')) {
|
if (String(res.statusCode).startsWith('20')) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(JSON.stringify(res));
|
throw new Error(await res.body.text());
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw new Error(error.response ? error.response.body : error);
|
throw new Error(error.response ? error.response.body : error);
|
||||||
@@ -845,4 +916,35 @@ export default class NotificationService {
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async openiLink() {
|
||||||
|
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
|
||||||
|
this.params;
|
||||||
|
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
|
||||||
|
const url = `${baseUrl}/bot/v1/message/send`;
|
||||||
|
const body: Record<string, string> = {
|
||||||
|
type: 'text',
|
||||||
|
content: `${this.title}\n\n${this.content}`,
|
||||||
|
};
|
||||||
|
if (openiLinkContextToken) {
|
||||||
|
body.context_token = openiLinkContextToken;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await httpClient.post(url, {
|
||||||
|
...this.gotOption,
|
||||||
|
json: body,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${openiLinkAppToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(JSON.stringify(res));
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(error.response ? error.response.body : error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { App, AppModel } from '../data/open';
|
|||||||
import { v4 as uuidV4 } from 'uuid';
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
import sequelize, { Op } from 'sequelize';
|
import sequelize, { Op } from 'sequelize';
|
||||||
import { shareStore } from '../shared/store';
|
import { shareStore } from '../shared/store';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class OpenService {
|
export default class OpenService {
|
||||||
@@ -151,7 +152,7 @@ export default class OpenService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return { code: 400, message: 'client_id 或 client_seret 有误' };
|
return { code: 400, message: t('client_id 或 client_seret 有误') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export default class ScheduleService {
|
|||||||
) {
|
) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][创建cron任务], 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
|
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
|
||||||
_id,
|
_id,
|
||||||
schedule,
|
schedule,
|
||||||
name,
|
name,
|
||||||
@@ -172,7 +172,7 @@ export default class ScheduleService {
|
|||||||
|
|
||||||
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
|
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info('[panel][取消定时任务], 任务名: %s', name);
|
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
|
||||||
if (this.scheduleStacks.has(_id)) {
|
if (this.scheduleStacks.has(_id)) {
|
||||||
this.scheduleStacks.get(_id)?.cancel();
|
this.scheduleStacks.get(_id)?.cancel();
|
||||||
this.scheduleStacks.delete(_id);
|
this.scheduleStacks.delete(_id);
|
||||||
@@ -187,7 +187,7 @@ export default class ScheduleService {
|
|||||||
) {
|
) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][创建interval任务], 任务ID: %s, 任务名: %s, 执行命令: %s',
|
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
|
||||||
_id,
|
_id,
|
||||||
name,
|
name,
|
||||||
command,
|
command,
|
||||||
@@ -232,7 +232,7 @@ export default class ScheduleService {
|
|||||||
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
|
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][取消interval任务], 任务ID: %s, 任务名: %s',
|
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
|
||||||
_id,
|
_id,
|
||||||
name,
|
name,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,10 +64,15 @@ export default class ScriptService {
|
|||||||
return { code: 200 };
|
return { code: 200 };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFile(filePath: string, fileName: string) {
|
public checkFilePath(filePath: string, fileName: string) {
|
||||||
const finalPath = path.resolve(config.scriptPath, filePath, fileName);
|
const finalPath = path.resolve(config.scriptPath, filePath, fileName);
|
||||||
|
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
|
||||||
|
}
|
||||||
|
|
||||||
if (!finalPath.startsWith(config.scriptPath)) {
|
public async getFile(filePath: string, fileName: string) {
|
||||||
|
const finalPath = this.checkFilePath(filePath, fileName);
|
||||||
|
|
||||||
|
if (!finalPath) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-9
@@ -26,12 +26,13 @@ export default class SshKeyService {
|
|||||||
if (_exist) {
|
if (_exist) {
|
||||||
config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' });
|
config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' });
|
||||||
} else {
|
} else {
|
||||||
await writeFileWithLock(this.sshConfigFilePath, '');
|
await writeFileWithLock(this.sshConfigFilePath, '', { mode: '600' });
|
||||||
}
|
}
|
||||||
if (!config.includes(this.sshConfigHeader)) {
|
if (!config.includes(this.sshConfigHeader)) {
|
||||||
await writeFileWithLock(
|
await writeFileWithLock(
|
||||||
this.sshConfigFilePath,
|
this.sshConfigFilePath,
|
||||||
`${this.sshConfigHeader}\n\n${config}`,
|
`${this.sshConfigHeader}\n\n${config}`,
|
||||||
|
{ mode: '600' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,14 +42,11 @@ export default class SshKeyService {
|
|||||||
key: string,
|
key: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await writeFileWithLock(
|
const filePath = path.join(this.sshPath, alias);
|
||||||
path.join(this.sshPath, alias),
|
try {
|
||||||
`${key}${os.EOL}`,
|
await rmPath(filePath);
|
||||||
{
|
} catch { }
|
||||||
encoding: 'utf8',
|
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
|
||||||
mode: '400',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('生成私钥文件失败', error);
|
this.logger.error('生成私钥文件失败', error);
|
||||||
}
|
}
|
||||||
@@ -81,6 +79,10 @@ export default class SshKeyService {
|
|||||||
await writeFileWithLock(
|
await writeFileWithLock(
|
||||||
`${path.join(this.sshPath, `${alias}.config`)}`,
|
`${path.join(this.sshPath, `${alias}.config`)}`,
|
||||||
config,
|
config,
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: '600',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,4 +129,32 @@ export default class SshKeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
|
||||||
|
await this.generatePrivateKeyFile(`~global_${alias}`, key);
|
||||||
|
// Create a global SSH config entry that matches all hosts
|
||||||
|
// This allows the key to be used for any Git repository
|
||||||
|
await this.generateGlobalSshConfig(`~global_${alias}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeGlobalSSHKey(alias: string): Promise<void> {
|
||||||
|
await this.removePrivateKeyFile(`~global_${alias}`);
|
||||||
|
await this.removeSshConfig(`~global_${alias}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateGlobalSshConfig(alias: string) {
|
||||||
|
// Create a config that matches all hosts, making this key globally available
|
||||||
|
const config = `Host *\n IdentityFile ${path.join(
|
||||||
|
this.sshPath,
|
||||||
|
alias,
|
||||||
|
)}\n StrictHostKeyChecking no\n`;
|
||||||
|
await writeFileWithLock(
|
||||||
|
`${path.join(this.sshPath, `${alias}.config`)}`,
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: '600',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import path, { join } from 'path';
|
|||||||
import ScheduleService, { TaskCallbacks } from './schedule';
|
import ScheduleService, { TaskCallbacks } from './schedule';
|
||||||
import { SimpleIntervalSchedule } from 'toad-scheduler';
|
import { SimpleIntervalSchedule } from 'toad-scheduler';
|
||||||
import SockService from './sock';
|
import SockService from './sock';
|
||||||
|
import { t, tf } from '../shared/i18n';
|
||||||
import SshKeyService from './sshKey';
|
import SshKeyService from './sshKey';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LOG_END_SYMBOL } from '../config/const';
|
import { LOG_END_SYMBOL } from '../config/const';
|
||||||
@@ -31,6 +32,7 @@ import { formatCommand, formatUrl } from '../config/subscription';
|
|||||||
import { CrontabModel } from '../data/cron';
|
import { CrontabModel } from '../data/cron';
|
||||||
import CrontabService from './cron';
|
import CrontabService from './cron';
|
||||||
import taskLimit from '../shared/pLimit';
|
import taskLimit from '../shared/pLimit';
|
||||||
|
import { logStreamManager } from '../shared/logStreamManager';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class SubscriptionService {
|
export default class SubscriptionService {
|
||||||
@@ -129,14 +131,14 @@ export default class SubscriptionService {
|
|||||||
);
|
);
|
||||||
const absolutePath = await handleLogPath(
|
const absolutePath = await handleLogPath(
|
||||||
logPath as string,
|
logPath as string,
|
||||||
`## 开始执行... ${startTime.format('YYYY-MM-DD HH:mm:ss')}\n`,
|
tf('## 开始执行... %s\n', startTime.format('YYYY-MM-DD HH:mm:ss')),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 执行sub_before
|
// 执行sub_before
|
||||||
let beforeStr = '';
|
let beforeStr = '';
|
||||||
try {
|
try {
|
||||||
if (doc.sub_before) {
|
if (doc.sub_before) {
|
||||||
await fs.appendFile(absolutePath, `\n## 执行before命令...\n\n`);
|
await logStreamManager.write(absolutePath, `\n## ${t('执行before命令...')}\n\n`);
|
||||||
beforeStr = await promiseExec(doc.sub_before);
|
beforeStr = await promiseExec(doc.sub_before);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -144,7 +146,7 @@ export default class SubscriptionService {
|
|||||||
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
|
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
|
||||||
}
|
}
|
||||||
if (beforeStr) {
|
if (beforeStr) {
|
||||||
await fs.appendFile(absolutePath, `${beforeStr}\n`);
|
await logStreamManager.write(absolutePath, `${beforeStr}\n`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
|
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
|
||||||
@@ -163,7 +165,7 @@ export default class SubscriptionService {
|
|||||||
let afterStr = '';
|
let afterStr = '';
|
||||||
try {
|
try {
|
||||||
if (sub.sub_after) {
|
if (sub.sub_after) {
|
||||||
await fs.appendFile(absolutePath, `\n\n## 执行after命令...\n\n`);
|
await logStreamManager.write(absolutePath, `\n\n## ${t('执行after命令...')}\n\n`);
|
||||||
afterStr = await promiseExec(sub.sub_after);
|
afterStr = await promiseExec(sub.sub_after);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -171,16 +173,23 @@ export default class SubscriptionService {
|
|||||||
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
|
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
|
||||||
}
|
}
|
||||||
if (afterStr) {
|
if (afterStr) {
|
||||||
await fs.appendFile(absolutePath, `${afterStr}\n`);
|
await logStreamManager.write(absolutePath, `${afterStr}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.appendFile(
|
await logStreamManager.write(
|
||||||
absolutePath,
|
absolutePath,
|
||||||
`\n## 执行结束... ${endTime.format(
|
'\n' +
|
||||||
'YYYY-MM-DD HH:mm:ss',
|
tf(
|
||||||
)} 耗时 ${diff} 秒${LOG_END_SYMBOL}`,
|
'## 执行结束... %s 耗时 %s 秒',
|
||||||
|
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
String(diff),
|
||||||
|
) +
|
||||||
|
LOG_END_SYMBOL,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Close the stream after task completion
|
||||||
|
await logStreamManager.closeStream(absolutePath);
|
||||||
|
|
||||||
await SubscriptionModel.update(
|
await SubscriptionModel.update(
|
||||||
{ status: SubscriptionStatus.idle, pid: undefined },
|
{ status: SubscriptionStatus.idle, pid: undefined },
|
||||||
{ where: { id: sub.id } },
|
{ where: { id: sub.id } },
|
||||||
@@ -188,19 +197,19 @@ export default class SubscriptionService {
|
|||||||
|
|
||||||
this.sockService.sendMessage({
|
this.sockService.sendMessage({
|
||||||
type: 'runSubscriptionEnd',
|
type: 'runSubscriptionEnd',
|
||||||
message: '订阅执行完成',
|
message: t('订阅执行完成'),
|
||||||
references: [doc.id as number],
|
references: [doc.id as number],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: async (message: string) => {
|
onError: async (message: string) => {
|
||||||
const sub = await this.getDb({ id: doc.id });
|
const sub = await this.getDb({ id: doc.id });
|
||||||
const absolutePath = await handleLogPath(sub.log_path as string);
|
const absolutePath = await handleLogPath(sub.log_path as string);
|
||||||
await fs.appendFile(absolutePath, `\n${message}`);
|
await logStreamManager.write(absolutePath, `\n${message}`);
|
||||||
},
|
},
|
||||||
onLog: async (message: string) => {
|
onLog: async (message: string) => {
|
||||||
const sub = await this.getDb({ id: doc.id });
|
const sub = await this.getDb({ id: doc.id });
|
||||||
const absolutePath = await handleLogPath(sub.log_path as string);
|
const absolutePath = await handleLogPath(sub.log_path as string);
|
||||||
await fs.appendFile(absolutePath, `\n${message}`);
|
await logStreamManager.write(absolutePath, `\n${message}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -384,7 +393,7 @@ export default class SubscriptionService {
|
|||||||
files.map(async (x) => ({
|
files.map(async (x) => ({
|
||||||
filename: x,
|
filename: x,
|
||||||
directory: relativeDir.replace(config.logPath, ''),
|
directory: relativeDir.replace(config.logPath, ''),
|
||||||
time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(),
|
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
).sort((a, b) => b.time - a.time);
|
).sort((a, b) => b.time - a.time);
|
||||||
|
|||||||
+144
-47
@@ -1,13 +1,13 @@
|
|||||||
import { spawn } from 'cross-spawn';
|
import { spawn } from 'cross-spawn';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import got from 'got';
|
import { Agent, request } from 'undici';
|
||||||
import sum from 'lodash/sum';
|
import sum from 'lodash/sum';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { TASK_COMMAND } from '../config/const';
|
import { NotificationModeStringMap, TASK_COMMAND } from '../config/const';
|
||||||
import {
|
import {
|
||||||
getPid,
|
getPid,
|
||||||
killTask,
|
killTask,
|
||||||
@@ -37,6 +37,8 @@ import ScheduleService, { TaskCallbacks } from './schedule';
|
|||||||
import SockService from './sock';
|
import SockService from './sock';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { t, setLang } from '../shared/i18n';
|
||||||
|
import { updateLinuxMirrorFile } from '../config/util';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class SystemService {
|
export default class SystemService {
|
||||||
@@ -47,7 +49,7 @@ export default class SystemService {
|
|||||||
@Inject('logger') private logger: winston.Logger,
|
@Inject('logger') private logger: winston.Logger,
|
||||||
private scheduleService: ScheduleService,
|
private scheduleService: ScheduleService,
|
||||||
private sockService: SockService,
|
private sockService: SockService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
public async getSystemConfig() {
|
public async getSystemConfig() {
|
||||||
const doc = await this.getDb({ type: AuthDataType.systemConfig });
|
const doc = await this.getDb({ type: AuthDataType.systemConfig });
|
||||||
@@ -76,8 +78,8 @@ export default class SystemService {
|
|||||||
const code = Math.random().toString().slice(-6);
|
const code = Math.random().toString().slice(-6);
|
||||||
const isSuccess = await this.notificationService.testNotify(
|
const isSuccess = await this.notificationService.testNotify(
|
||||||
notificationInfo,
|
notificationInfo,
|
||||||
'青龙',
|
t('青龙'),
|
||||||
`【蛟龙】测试通知 https://t.me/jiao_long`,
|
t('【蛟龙】测试通知 https://t.me/jiao_long'),
|
||||||
);
|
);
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
const result = await this.updateAuthDb({
|
const result = await this.updateAuthDb({
|
||||||
@@ -86,7 +88,7 @@ export default class SystemService {
|
|||||||
});
|
});
|
||||||
return { code: 200, data: { ...result, code } };
|
return { code: 200, data: { ...result, code } };
|
||||||
} else {
|
} else {
|
||||||
return { code: 400, message: '通知发送失败,请检查参数' };
|
return { code: 400, message: t('通知发送失败,请检查参数') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ export default class SystemService {
|
|||||||
});
|
});
|
||||||
const cron = {
|
const cron = {
|
||||||
id: result.id as number,
|
id: result.id as number,
|
||||||
name: '删除日志',
|
name: t('删除日志'),
|
||||||
command: `ql rmlog ${info.logRemoveFrequency}`,
|
command: `ql rmlog ${info.logRemoveFrequency}`,
|
||||||
runOrigin: 'system' as const,
|
runOrigin: 'system' as const,
|
||||||
};
|
};
|
||||||
@@ -177,6 +179,7 @@ export default class SystemService {
|
|||||||
this.sockService.sendMessage({
|
this.sockService.sendMessage({
|
||||||
type: 'updateNodeMirror',
|
type: 'updateNodeMirror',
|
||||||
message: 'update node mirror end',
|
message: 'update node mirror end',
|
||||||
|
status: 'completed',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: async (message: string) => {
|
onError: async (message: string) => {
|
||||||
@@ -214,33 +217,11 @@ export default class SystemService {
|
|||||||
onEnd?: () => void,
|
onEnd?: () => void,
|
||||||
) {
|
) {
|
||||||
const oDoc = await this.getSystemConfig();
|
const oDoc = await this.getSystemConfig();
|
||||||
await this.updateAuthDb({
|
|
||||||
...oDoc,
|
|
||||||
info: { ...oDoc.info, ...info },
|
|
||||||
});
|
|
||||||
let defaultDomain = 'https://dl-cdn.alpinelinux.org';
|
|
||||||
let targetDomain = 'https://dl-cdn.alpinelinux.org';
|
|
||||||
if (os.platform() !== 'linux') {
|
if (os.platform() !== 'linux') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = await fs.promises.readFile('/etc/apk/repositories', {
|
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
|
||||||
encoding: 'utf-8',
|
let hasError = false;
|
||||||
});
|
|
||||||
const domainMatch = content.match(/(http.*)\/alpine\/.*/);
|
|
||||||
if (domainMatch) {
|
|
||||||
defaultDomain = domainMatch[1];
|
|
||||||
}
|
|
||||||
if (info.linuxMirror) {
|
|
||||||
targetDomain = info.linuxMirror;
|
|
||||||
}
|
|
||||||
const command = `sed -i 's/${defaultDomain.replace(
|
|
||||||
/\//g,
|
|
||||||
'\\/',
|
|
||||||
)}/${targetDomain.replace(
|
|
||||||
/\//g,
|
|
||||||
'\\/',
|
|
||||||
)}/g' /etc/apk/repositories && apk update -f`;
|
|
||||||
|
|
||||||
this.scheduleService.runTask(
|
this.scheduleService.runTask(
|
||||||
command,
|
command,
|
||||||
{
|
{
|
||||||
@@ -252,10 +233,18 @@ export default class SystemService {
|
|||||||
this.sockService.sendMessage({
|
this.sockService.sendMessage({
|
||||||
type: 'updateLinuxMirror',
|
type: 'updateLinuxMirror',
|
||||||
message: 'update linux mirror end',
|
message: 'update linux mirror end',
|
||||||
|
status: 'completed',
|
||||||
});
|
});
|
||||||
onEnd?.();
|
onEnd?.();
|
||||||
|
if (!hasError) {
|
||||||
|
await this.updateAuthDb({
|
||||||
|
...oDoc,
|
||||||
|
info: { ...oDoc.info, ...info },
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: async (message: string) => {
|
onError: async (message: string) => {
|
||||||
|
hasError = true;
|
||||||
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
|
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
|
||||||
},
|
},
|
||||||
onLog: async (message: string) => {
|
onLog: async (message: string) => {
|
||||||
@@ -276,14 +265,18 @@ export default class SystemService {
|
|||||||
|
|
||||||
let lastVersionContent;
|
let lastVersionContent;
|
||||||
try {
|
try {
|
||||||
const result = await got.get(
|
const { body } = await request(
|
||||||
`${config.lastVersionFile}?t=${Date.now()}`,
|
`${config.lastVersionFile}?t=${Date.now()}`,
|
||||||
{
|
{
|
||||||
timeout: 30000,
|
dispatcher: new Agent({
|
||||||
|
keepAliveTimeout: 30000,
|
||||||
|
keepAliveMaxTimeout: 30000,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
lastVersionContent = parseContentVersion(result.body);
|
const text = await body.text();
|
||||||
} catch (error) {}
|
lastVersionContent = parseContentVersion(text);
|
||||||
|
} catch (error) { }
|
||||||
|
|
||||||
if (!lastVersionContent) {
|
if (!lastVersionContent) {
|
||||||
lastVersionContent = currentVersionContent;
|
lastVersionContent = currentVersionContent;
|
||||||
@@ -349,6 +342,15 @@ export default class SystemService {
|
|||||||
this.sockService.sendMessage({
|
this.sockService.sendMessage({
|
||||||
type: 'updateSystemVersion',
|
type: 'updateSystemVersion',
|
||||||
message: JSON.stringify(err),
|
message: JSON.stringify(err),
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cp.on('exit', (code) => {
|
||||||
|
this.sockService.sendMessage({
|
||||||
|
type: 'updateSystemVersion',
|
||||||
|
message: '',
|
||||||
|
status: code === 0 ? 'success' : 'failed',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,25 +359,52 @@ export default class SystemService {
|
|||||||
|
|
||||||
public async reloadSystem(target?: 'system' | 'data') {
|
public async reloadSystem(target?: 'system' | 'data') {
|
||||||
const cmd = `real_time=true ql reload ${target || ''}`;
|
const cmd = `real_time=true ql reload ${target || ''}`;
|
||||||
const cp = spawn(cmd, { shell: '/bin/bash' });
|
const cp = spawn(cmd, {
|
||||||
|
shell: '/bin/bash',
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
cp.unref();
|
cp.unref();
|
||||||
|
setTimeout(() => {
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
return { code: 200 };
|
return { code: 200 };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async notify({ title, content }: { title: string; content: string }) {
|
public async notify({
|
||||||
const isSuccess = await this.notificationService.notify(title, content);
|
title,
|
||||||
|
content,
|
||||||
|
notificationInfo,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
notificationInfo?: NotificationInfo;
|
||||||
|
}) {
|
||||||
|
const typeString =
|
||||||
|
typeof notificationInfo?.type === 'number'
|
||||||
|
? NotificationModeStringMap[notificationInfo.type]
|
||||||
|
: undefined;
|
||||||
|
if (notificationInfo && typeString) {
|
||||||
|
notificationInfo.type = typeString;
|
||||||
|
}
|
||||||
|
const isSuccess = await this.notificationService.notify(
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
notificationInfo,
|
||||||
|
);
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
return { code: 200, message: '通知发送成功' };
|
return { code: 200, message: t('通知发送成功') };
|
||||||
} else {
|
} else {
|
||||||
return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' };
|
return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async run({ command }: { command: string }, callback: TaskCallbacks) {
|
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
|
||||||
if (!command.startsWith(TASK_COMMAND)) {
|
if (!command.startsWith(TASK_COMMAND)) {
|
||||||
command = `${TASK_COMMAND} ${command}`;
|
command = `${TASK_COMMAND} ${command}`;
|
||||||
}
|
}
|
||||||
this.scheduleService.runTask(`real_time=true ${command}`, callback, {
|
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
|
||||||
|
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
|
||||||
command,
|
command,
|
||||||
id: command.replace(/ /g, '-'),
|
id: command.replace(/ /g, '-'),
|
||||||
runOrigin: 'system',
|
runOrigin: 'system',
|
||||||
@@ -384,7 +413,7 @@ export default class SystemService {
|
|||||||
|
|
||||||
public async stop({ command, pid }: { command: string; pid: number }) {
|
public async stop({ command, pid }: { command: string; pid: number }) {
|
||||||
if (!pid && !command) {
|
if (!pid && !command) {
|
||||||
return { code: 400, message: '参数错误' };
|
return { code: 400, message: t('参数错误') };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pid) {
|
if (pid) {
|
||||||
@@ -400,14 +429,20 @@ export default class SystemService {
|
|||||||
await killTask(_pid);
|
await killTask(_pid);
|
||||||
return { code: 200 };
|
return { code: 200 };
|
||||||
} else {
|
} else {
|
||||||
return { code: 400, message: '任务未找到' };
|
return { code: 400, message: t('任务未找到') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exportData(res: Response) {
|
public async exportData(res: Response, type?: string[]) {
|
||||||
try {
|
try {
|
||||||
|
let dataDirs = ['db', 'upload'];
|
||||||
|
if (type && type.length) {
|
||||||
|
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
||||||
|
}
|
||||||
|
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
|
||||||
await promiseExec(
|
await promiseExec(
|
||||||
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile} data/`,
|
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
|
||||||
|
} ${dataPaths.join(' ')}`,
|
||||||
);
|
);
|
||||||
res.download(config.dataTgzFile);
|
res.download(config.dataTgzFile);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -489,7 +524,69 @@ export default class SystemService {
|
|||||||
if (success) {
|
if (success) {
|
||||||
return { code: 200, data: info };
|
return { code: 200, data: info };
|
||||||
} else {
|
} else {
|
||||||
return { code: 400, message: '设置时区失败' };
|
return { code: 400, message: t('设置时区失败') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateLanguage(info: SystemModelInfo) {
|
||||||
|
const oDoc = await this.getSystemConfig();
|
||||||
|
const lang = info.lang || 'zh';
|
||||||
|
await this.updateAuthDb({
|
||||||
|
...oDoc,
|
||||||
|
info: { ...oDoc.info, lang },
|
||||||
|
});
|
||||||
|
// Write to standalone lang_env.sh, sourced by shell scripts
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
config.langEnvFile,
|
||||||
|
`export QL_LANG='${lang}'\n`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to write lang_env.sh: ${error}`);
|
||||||
|
}
|
||||||
|
setLang(lang);
|
||||||
|
return { code: 200, data: { lang } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updatePanelTitle(info: SystemModelInfo) {
|
||||||
|
const oDoc = await this.getSystemConfig();
|
||||||
|
const panelTitle = info.panelTitle?.trim() || '';
|
||||||
|
await this.updateAuthDb({
|
||||||
|
...oDoc,
|
||||||
|
info: { ...oDoc.info, panelTitle },
|
||||||
|
});
|
||||||
|
return { code: 200, data: { panelTitle } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateGlobalSshKey(info: SystemModelInfo) {
|
||||||
|
const oDoc = await this.getSystemConfig();
|
||||||
|
const result = await this.updateAuthDb({
|
||||||
|
...oDoc,
|
||||||
|
info: { ...oDoc.info, ...info },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply the global SSH key
|
||||||
|
const SshKeyService = require('./sshKey').default;
|
||||||
|
const Container = require('typedi').Container;
|
||||||
|
const sshKeyService = Container.get(SshKeyService);
|
||||||
|
|
||||||
|
if (info.globalSshKey) {
|
||||||
|
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
|
||||||
|
} else {
|
||||||
|
await sshKeyService.removeGlobalSSHKey('global');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code: 200, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cleanDependence(type: 'node' | 'python3') {
|
||||||
|
if (!type || !['node', 'python3'].includes(type)) {
|
||||||
|
return { code: 400, message: t('参数错误') };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const finalPath = path.join(config.dependenceCachePath, type);
|
||||||
|
await fs.promises.rm(finalPath, { recursive: true });
|
||||||
|
} catch (error) { }
|
||||||
|
return { code: 200 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+217
-39
@@ -1,6 +1,6 @@
|
|||||||
import { Service, Inject } from 'typedi';
|
import { Service, Inject } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createRandomString, getNetIp } from '../config/util';
|
import { createRandomString } from '../config/util';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { authenticator } from '@otplib/preset-default';
|
import { authenticator } from '@otplib/preset-default';
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
SystemModelInfo,
|
SystemModelInfo,
|
||||||
LoginStatus,
|
LoginStatus,
|
||||||
AuthInfo,
|
AuthInfo,
|
||||||
|
TokenInfo,
|
||||||
} from '../data/system';
|
} from '../data/system';
|
||||||
import { NotificationInfo } from '../data/notify';
|
import { NotificationInfo } from '../data/notify';
|
||||||
import NotificationService from './notify';
|
import NotificationService from './notify';
|
||||||
@@ -21,7 +22,10 @@ import dayjs from 'dayjs';
|
|||||||
import IP2Region from 'ip2region';
|
import IP2Region from 'ip2region';
|
||||||
import requestIp from 'request-ip';
|
import requestIp from 'request-ip';
|
||||||
import uniq from 'lodash/uniq';
|
import uniq from 'lodash/uniq';
|
||||||
|
import pickBy from 'lodash/pickBy';
|
||||||
|
import isNil from 'lodash/isNil';
|
||||||
import { shareStore } from '../shared/store';
|
import { shareStore } from '../shared/store';
|
||||||
|
import { t, tf } from '../shared/i18n';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class UserService {
|
export default class UserService {
|
||||||
@@ -63,7 +67,7 @@ export default class UserService {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
code: 410,
|
code: 410,
|
||||||
message: `失败次数过多,请${waitTime}秒后重试`,
|
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
||||||
data: waitTime,
|
data: waitTime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -93,18 +97,29 @@ export default class UserService {
|
|||||||
}
|
}
|
||||||
if (username === cUsername && password === cPassword) {
|
if (username === cUsername && password === cPassword) {
|
||||||
const data = createRandomString(50, 100);
|
const data = createRandomString(50, 100);
|
||||||
const expiration = twoFactorActivated ? 60 : 20;
|
const expiration = twoFactorActivated ? '60d' : '20d';
|
||||||
let token = jwt.sign({ data }, config.secret as any, {
|
let token = jwt.sign({ data }, config.jwt.secret, {
|
||||||
expiresIn: 60 * 60 * 24 * expiration,
|
expiresIn: config.jwt.expiresIn || expiration,
|
||||||
algorithm: 'HS384',
|
algorithm: 'HS384',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tokenInfo: TokenInfo = {
|
||||||
|
value: token,
|
||||||
|
timestamp,
|
||||||
|
ip,
|
||||||
|
address,
|
||||||
|
platform: req.platform,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedTokens = this.addTokenToList(
|
||||||
|
tokens,
|
||||||
|
req.platform,
|
||||||
|
tokenInfo,
|
||||||
|
);
|
||||||
|
|
||||||
await this.updateAuthInfo(content, {
|
await this.updateAuthInfo(content, {
|
||||||
token,
|
token,
|
||||||
tokens: {
|
tokens: updatedTokens,
|
||||||
...tokens,
|
|
||||||
[req.platform]: token,
|
|
||||||
},
|
|
||||||
lastlogon: timestamp,
|
lastlogon: timestamp,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
lastip: ip,
|
lastip: ip,
|
||||||
@@ -113,10 +128,19 @@ export default class UserService {
|
|||||||
isTwoFactorChecking: false,
|
isTwoFactorChecking: false,
|
||||||
});
|
});
|
||||||
this.notificationService.notify(
|
this.notificationService.notify(
|
||||||
'登录通知',
|
t('登录通知'),
|
||||||
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${
|
t('你于') +
|
||||||
req.platform
|
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
||||||
}端 登录成功,ip地址 ${ip}`,
|
t('在') +
|
||||||
|
address +
|
||||||
|
' ' +
|
||||||
|
req.platform +
|
||||||
|
t('端') +
|
||||||
|
' ' +
|
||||||
|
t('登录成功') +
|
||||||
|
t(',ip地址') +
|
||||||
|
' ' +
|
||||||
|
ip,
|
||||||
);
|
);
|
||||||
await this.insertDb({
|
await this.insertDb({
|
||||||
type: AuthDataType.loginLog,
|
type: AuthDataType.loginLog,
|
||||||
@@ -131,7 +155,14 @@ export default class UserService {
|
|||||||
this.getLoginLog();
|
this.getLoginLog();
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
data: { token, lastip, lastaddr, lastlogon, retries, platform },
|
data: {
|
||||||
|
token,
|
||||||
|
lastip,
|
||||||
|
lastaddr,
|
||||||
|
lastlogon,
|
||||||
|
retries,
|
||||||
|
platform,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
await this.updateAuthInfo(content, {
|
await this.updateAuthInfo(content, {
|
||||||
@@ -142,10 +173,19 @@ export default class UserService {
|
|||||||
platform: req.platform,
|
platform: req.platform,
|
||||||
});
|
});
|
||||||
this.notificationService.notify(
|
this.notificationService.notify(
|
||||||
'登录通知',
|
t('登录通知'),
|
||||||
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${
|
t('你于') +
|
||||||
req.platform
|
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
||||||
}端 登录失败,ip地址 ${ip}`,
|
t('在') +
|
||||||
|
address +
|
||||||
|
' ' +
|
||||||
|
req.platform +
|
||||||
|
t('端') +
|
||||||
|
' ' +
|
||||||
|
t('登录失败') +
|
||||||
|
t(',ip地址') +
|
||||||
|
' ' +
|
||||||
|
ip,
|
||||||
);
|
);
|
||||||
await this.insertDb({
|
await this.insertDb({
|
||||||
type: AuthDataType.loginLog,
|
type: AuthDataType.loginLog,
|
||||||
@@ -162,20 +202,46 @@ export default class UserService {
|
|||||||
const waitTime = Math.round(Math.pow(3, retries + 1));
|
const waitTime = Math.round(Math.pow(3, retries + 1));
|
||||||
return {
|
return {
|
||||||
code: 410,
|
code: 410,
|
||||||
message: `失败次数过多,请${waitTime}秒后重试`,
|
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
||||||
data: waitTime,
|
data: waitTime,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return { code: 400, message: config.authError };
|
return { code: 400, message: t('错误的用户名密码,请重试') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async logout(platform: string): Promise<any> {
|
public async logout(platform: string, tokenValue: string): Promise<any> {
|
||||||
|
if (!platform || !tokenValue) {
|
||||||
|
this.logger.warn('Invalid logout parameters - empty platform or token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
|
|
||||||
|
// Verify the token exists before attempting to remove it
|
||||||
|
const tokenExists = this.findTokenInList(
|
||||||
|
authInfo.tokens,
|
||||||
|
platform,
|
||||||
|
tokenValue,
|
||||||
|
);
|
||||||
|
if (!tokenExists && authInfo.token !== tokenValue) {
|
||||||
|
// Token not found, but don't throw error - user may have already logged out
|
||||||
|
this.logger.info(
|
||||||
|
`Logout attempted for non-existent token on platform: ${platform}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTokens = this.removeTokenFromList(
|
||||||
|
authInfo.tokens,
|
||||||
|
platform,
|
||||||
|
tokenValue,
|
||||||
|
);
|
||||||
|
|
||||||
await this.updateAuthInfo(authInfo, {
|
await this.updateAuthInfo(authInfo, {
|
||||||
token: '',
|
token: authInfo.token === tokenValue ? '' : authInfo.token,
|
||||||
tokens: { ...authInfo.tokens, [platform]: '' },
|
tokens: updatedTokens,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,17 +277,17 @@ export default class UserService {
|
|||||||
password: string;
|
password: string;
|
||||||
}) {
|
}) {
|
||||||
if (password === 'admin') {
|
if (password === 'admin') {
|
||||||
return { code: 400, message: '密码不能设置为admin' };
|
return { code: 400, message: t('密码不能设置为admin') };
|
||||||
}
|
}
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
await this.updateAuthInfo(authInfo, { username, password });
|
await this.updateAuthInfo(authInfo, { username, password });
|
||||||
return { code: 200, message: '更新成功' };
|
return { code: 200, message: t('更新成功') };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateAvatar(avatar: string) {
|
public async updateAvatar(avatar: string) {
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
await this.updateAuthInfo(authInfo, { avatar });
|
await this.updateAuthInfo(authInfo, { avatar });
|
||||||
return { code: 200, data: avatar, message: '更新成功' };
|
return { code: 200, data: avatar, message: t('更新成功') };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async initTwoFactor() {
|
public async initTwoFactor() {
|
||||||
@@ -255,7 +321,7 @@ export default class UserService {
|
|||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
|
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
|
||||||
if (!isTwoFactorChecking) {
|
if (!isTwoFactorChecking) {
|
||||||
return { code: 450, message: '未知错误' };
|
return { code: 450, message: t('未知错误') };
|
||||||
}
|
}
|
||||||
const isValid = authenticator.verify({
|
const isValid = authenticator.verify({
|
||||||
token: code,
|
token: code,
|
||||||
@@ -264,17 +330,26 @@ export default class UserService {
|
|||||||
if (isValid) {
|
if (isValid) {
|
||||||
return this.login({ username, password }, req, false);
|
return this.login({ username, password }, req, false);
|
||||||
} else {
|
} else {
|
||||||
const { ip, address } = await getNetIp(req);
|
const ip = requestIp.getClientIp(req) || '';
|
||||||
|
const query = new IP2Region();
|
||||||
|
const ipAddress = query.search(ip);
|
||||||
|
let address = '';
|
||||||
|
if (ipAddress) {
|
||||||
|
const { country, province, city, isp } = ipAddress;
|
||||||
|
address = uniq([country, province, city, isp])
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
await this.updateAuthInfo(authInfo, {
|
await this.updateAuthInfo(authInfo, {
|
||||||
lastip: ip,
|
lastip: ip,
|
||||||
lastaddr: address,
|
lastaddr: address,
|
||||||
platform: req.platform,
|
platform: req.platform,
|
||||||
});
|
});
|
||||||
return { code: 430, message: '验证失败' };
|
return { code: 430, message: t('验证失败') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deactiveTwoFactor() {
|
public async deactivateTwoFactor() {
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
await this.updateAuthInfo(authInfo, {
|
await this.updateAuthInfo(authInfo, {
|
||||||
twoFactorActivated: false,
|
twoFactorActivated: false,
|
||||||
@@ -332,8 +407,8 @@ export default class UserService {
|
|||||||
const code = Math.random().toString().slice(-6);
|
const code = Math.random().toString().slice(-6);
|
||||||
const isSuccess = await this.notificationService.testNotify(
|
const isSuccess = await this.notificationService.testNotify(
|
||||||
notificationInfo,
|
notificationInfo,
|
||||||
'青龙',
|
t('青龙'),
|
||||||
`【蛟龙】测试通知 https://t.me/jiao_long`,
|
t('【蛟龙】测试通知 https://t.me/jiao_long'),
|
||||||
);
|
);
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
const result = await this.updateAuthDb({
|
const result = await this.updateAuthDb({
|
||||||
@@ -342,17 +417,120 @@ export default class UserService {
|
|||||||
});
|
});
|
||||||
return { code: 200, data: { ...result, code } };
|
return { code: 200, data: { ...result, code } };
|
||||||
} else {
|
} else {
|
||||||
return { code: 400, message: '通知发送失败,请检查参数' };
|
return { code: 400, message: t('通知发送失败,请检查参数') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeTokens(
|
||||||
|
tokens: Record<string, string | TokenInfo[]>,
|
||||||
|
): Record<string, TokenInfo[]> {
|
||||||
|
const normalized: Record<string, TokenInfo[]> = {};
|
||||||
|
|
||||||
|
for (const [platform, value] of Object.entries(tokens)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// Legacy format: convert string token to TokenInfo array
|
||||||
|
if (value) {
|
||||||
|
normalized[platform] = [
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
ip: '',
|
||||||
|
address: '',
|
||||||
|
platform,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
normalized[platform] = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Already in new format
|
||||||
|
normalized[platform] = value || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addTokenToList(
|
||||||
|
tokens: Record<string, string | TokenInfo[]>,
|
||||||
|
platform: string,
|
||||||
|
tokenInfo: TokenInfo,
|
||||||
|
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
|
||||||
|
): Record<string, TokenInfo[]> {
|
||||||
|
// Validate maxTokensPerPlatform parameter
|
||||||
|
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
|
||||||
|
);
|
||||||
|
maxTokensPerPlatform = config.maxTokensPerPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = this.normalizeTokens(tokens);
|
||||||
|
|
||||||
|
if (!normalized[platform]) {
|
||||||
|
normalized[platform] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new token
|
||||||
|
normalized[platform].unshift(tokenInfo);
|
||||||
|
|
||||||
|
// Limit the number of active tokens per platform
|
||||||
|
if (normalized[platform].length > maxTokensPerPlatform) {
|
||||||
|
normalized[platform] = normalized[platform].slice(
|
||||||
|
0,
|
||||||
|
maxTokensPerPlatform,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeTokenFromList(
|
||||||
|
tokens: Record<string, string | TokenInfo[]>,
|
||||||
|
platform: string,
|
||||||
|
tokenValue: string,
|
||||||
|
): Record<string, TokenInfo[]> {
|
||||||
|
const normalized = this.normalizeTokens(tokens);
|
||||||
|
|
||||||
|
if (normalized[platform]) {
|
||||||
|
normalized[platform] = normalized[platform].filter(
|
||||||
|
(t) => t.value !== tokenValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTokenInList(
|
||||||
|
tokens: Record<string, string | TokenInfo[]>,
|
||||||
|
platform: string,
|
||||||
|
tokenValue: string,
|
||||||
|
): TokenInfo | undefined {
|
||||||
|
const normalized = this.normalizeTokens(tokens);
|
||||||
|
|
||||||
|
if (normalized[platform]) {
|
||||||
|
return normalized[platform].find((t) => t.value === tokenValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
public async resetAuthInfo(info: Partial<AuthInfo>) {
|
public async resetAuthInfo(info: Partial<AuthInfo>) {
|
||||||
const { retries, twoFactorActivated, password } = info;
|
const { retries, twoFactorActivated, password, username } = info;
|
||||||
|
if (password === 'admin') {
|
||||||
|
return { code: 400, message: t('密码不能设置为admin') };
|
||||||
|
}
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
await this.updateAuthInfo(authInfo, {
|
const payload = pickBy(
|
||||||
retries,
|
{
|
||||||
twoFactorActivated,
|
retries,
|
||||||
password,
|
twoFactorActivated,
|
||||||
});
|
password,
|
||||||
|
username,
|
||||||
|
},
|
||||||
|
(x) => !isNil(x),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.updateAuthInfo(authInfo, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { AuthInfo, TokenInfo } from '../data/system';
|
||||||
|
|
||||||
|
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
|
||||||
|
return (
|
||||||
|
Object.keys(authInfo).length === 2 &&
|
||||||
|
authInfo.username === 'admin' &&
|
||||||
|
authInfo.password === 'admin'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if a token exists in the authentication info.
|
||||||
|
* Supports both legacy string tokens and new TokenInfo array format.
|
||||||
|
*
|
||||||
|
* @param authInfo - The authentication information
|
||||||
|
* @param headerToken - The token to validate
|
||||||
|
* @param platform - The platform (desktop, mobile)
|
||||||
|
* @returns true if the token is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export function isValidToken(
|
||||||
|
authInfo: AuthInfo | null | undefined,
|
||||||
|
headerToken: string,
|
||||||
|
platform: string,
|
||||||
|
): boolean {
|
||||||
|
if (!authInfo || !headerToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token = '', tokens = {} } = authInfo;
|
||||||
|
|
||||||
|
// Check legacy token field
|
||||||
|
if (headerToken === token) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check platform-specific tokens (support both legacy string and new TokenInfo[] format)
|
||||||
|
const platformTokens = tokens[platform];
|
||||||
|
|
||||||
|
// Handle null/undefined platformTokens
|
||||||
|
if (platformTokens === null || platformTokens === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof platformTokens === 'string') {
|
||||||
|
// Legacy format: single string token
|
||||||
|
return headerToken === platformTokens;
|
||||||
|
} else if (Array.isArray(platformTokens)) {
|
||||||
|
// New format: array of TokenInfo objects
|
||||||
|
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unexpected type - log warning and reject
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { shareStore } from './store';
|
||||||
|
|
||||||
|
const messages: Record<string, Record<string, string>> = {
|
||||||
|
zh: {},
|
||||||
|
en: {
|
||||||
|
'暂无权限': 'Access denied',
|
||||||
|
'参数错误': 'Invalid parameter',
|
||||||
|
'参数不正确': 'Invalid parameter',
|
||||||
|
'文件无法访问': 'File not accessible',
|
||||||
|
'文件不存在': 'File not found',
|
||||||
|
'路径不存在': 'Path does not exist',
|
||||||
|
'路径不正确': 'Invalid path',
|
||||||
|
'通知发送失败,请检查参数': 'Notification failed, check parameters',
|
||||||
|
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings',
|
||||||
|
'通知发送成功': 'Notification sent successfully',
|
||||||
|
'设置时区失败': 'Failed to set timezone',
|
||||||
|
'任务未找到': 'Task not found',
|
||||||
|
'密码不能设置为admin': 'Password cannot be admin',
|
||||||
|
'更新成功': 'Update successful',
|
||||||
|
'未知错误': 'Unknown error',
|
||||||
|
'验证失败': 'Verification failed',
|
||||||
|
'实例不存在或已停止': 'Instance does not exist or stopped',
|
||||||
|
'实例已停止': 'Instance stopped',
|
||||||
|
'确认停止实例': 'Confirm to stop instance',
|
||||||
|
'确认停止运行实例': 'Confirm to stop running instance',
|
||||||
|
'确认停止': 'Confirm to stop',
|
||||||
|
'确认停止定时任务': 'Confirm to stop scheduled task',
|
||||||
|
'确认删除': 'Confirm to delete',
|
||||||
|
'确认删除定时任务': 'Confirm to delete scheduled task',
|
||||||
|
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?',
|
||||||
|
'确认运行': 'Confirm to run',
|
||||||
|
'确认运行定时任务': 'Confirm to run scheduled task',
|
||||||
|
'确认保存': 'Confirm to save',
|
||||||
|
'确认保存文件': 'Confirm to save file',
|
||||||
|
'确认重启': 'Confirm restart',
|
||||||
|
'确认启用': 'Confirm to enable',
|
||||||
|
'确认禁用': 'Confirm to disable',
|
||||||
|
'确认': 'Confirm',
|
||||||
|
'删除成功': 'Deleted successfully',
|
||||||
|
'操作成功': 'Operation successful',
|
||||||
|
'参数不完整': 'Incomplete parameters',
|
||||||
|
'默认路径不支持删除': 'Default path cannot be deleted',
|
||||||
|
'必须在日志目录下': 'Must be within log directory',
|
||||||
|
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
|
||||||
|
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
|
||||||
|
'系统将在': 'System will',
|
||||||
|
'秒后自动刷新': 'refresh in seconds',
|
||||||
|
'生成数据中...': 'Generating data...',
|
||||||
|
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format',
|
||||||
|
'不支持当前依赖类型': 'Unsupported dependency type',
|
||||||
|
'依赖已存在': 'Dependency already exists',
|
||||||
|
'依赖不存在': 'Dependency does not exist',
|
||||||
|
'该脚本正在运行中': 'Script is running',
|
||||||
|
'该脚本未在运行中': 'Script is not running',
|
||||||
|
'文件内容为空': 'File content is empty',
|
||||||
|
'文件名不能为空': 'File name cannot be empty',
|
||||||
|
'标签不能为空': 'Label cannot be empty',
|
||||||
|
'名称不能为空': 'Name cannot be empty',
|
||||||
|
'名称不能为保留关键字': 'Name cannot be reserved keyword',
|
||||||
|
'名称已存在': 'Name already exists',
|
||||||
|
'密码错误': 'Incorrect password',
|
||||||
|
'用户不存在': 'User does not exist',
|
||||||
|
'请输入用户名和密码': 'Please enter username and password',
|
||||||
|
'无权访问': 'Access denied',
|
||||||
|
'登录成功': 'Login successful',
|
||||||
|
'退出成功': 'Logout successful',
|
||||||
|
'Token 已失效': 'Token expired',
|
||||||
|
'Token 无效': 'Invalid token',
|
||||||
|
'两步骤验证已开启': '2FA enabled',
|
||||||
|
'两步骤验证已关闭': '2FA disabled',
|
||||||
|
'验证码错误': 'Invalid verification code',
|
||||||
|
'验证码已过期': 'Verification code expired',
|
||||||
|
'请先开启两步骤验证': 'Please enable 2FA first',
|
||||||
|
'两步骤验证密钥不能为空': '2FA secret cannot be empty',
|
||||||
|
'用户已存在': 'User already exists',
|
||||||
|
'用户名不能为admin': 'Username cannot be admin',
|
||||||
|
'不能删除自己': 'Cannot delete yourself',
|
||||||
|
'不能禁用自己': 'Cannot disable yourself',
|
||||||
|
'文件路径无效': 'Invalid file path',
|
||||||
|
'保存成功': 'Saved successfully',
|
||||||
|
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
|
||||||
|
'订阅执行完成': 'Subscription completed',
|
||||||
|
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids',
|
||||||
|
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
|
||||||
|
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
|
||||||
|
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
|
||||||
|
'绝对路径必须在日志目录内或使用 /dev/null':
|
||||||
|
'Absolute path must be within log directory or use /dev/null',
|
||||||
|
'请先登录': 'Please login first',
|
||||||
|
'运行中...': 'Running...',
|
||||||
|
'日志不存在...': 'Log does not exist...',
|
||||||
|
'未分类': 'Uncategorized',
|
||||||
|
'任务重复运行': 'Duplicate task execution',
|
||||||
|
'日志设置为忽略': 'Log set to ignore',
|
||||||
|
'定时规则不能为空': 'Schedule rule cannot be empty',
|
||||||
|
'无效的定时规则': 'Invalid schedule rule',
|
||||||
|
'日志名称只能包含字母、数字、下划线和连字符':
|
||||||
|
'Log name can only contain letters, numbers, underscores, and hyphens',
|
||||||
|
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
|
||||||
|
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
|
||||||
|
'青龙快讯': 'QingLong',
|
||||||
|
'登录通知': 'Login Notification',
|
||||||
|
'你于': 'You at ',
|
||||||
|
'在': ' in ',
|
||||||
|
端: '',
|
||||||
|
登录失败: 'login failed',
|
||||||
|
',ip地址': ', IP: ',
|
||||||
|
'任务#%s': 'Task#%s',
|
||||||
|
安装: 'Install',
|
||||||
|
删除: 'Uninstall',
|
||||||
|
成功: 'Succeeded',
|
||||||
|
失败: 'Failed',
|
||||||
|
'失败次数过多,请%s秒后重试':
|
||||||
|
'Too many failed attempts, please retry in %s seconds',
|
||||||
|
智能助手: 'Smart Assistant',
|
||||||
|
更多: 'More',
|
||||||
|
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒':
|
||||||
|
'Start %s dependency %s, start time %s\n\nCurrent system not supported\n\nDependency %s failed, end time %s, elapsed %s seconds',
|
||||||
|
'检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒':
|
||||||
|
'Already installed %s\n\n%s\n\nSkipping install\n\nDependency %s succeeded, end time %s, elapsed %s seconds',
|
||||||
|
'开始%s依赖 %s,开始时间 %s\n\n':
|
||||||
|
'Start %s dependency %s, start time %s\n\n',
|
||||||
|
'依赖%s%s,结束时间 %s,耗时 %s 秒':
|
||||||
|
'Dependency %s%s, end time %s, elapsed %s seconds',
|
||||||
|
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置':
|
||||||
|
'Task: %s, command: %s, schedule: %s, more than %d instances running, please check schedule settings',
|
||||||
|
青龙: 'QingLong',
|
||||||
|
'【蛟龙】测试通知 https://t.me/jiao_long':
|
||||||
|
'[JiaoLong] Test notification https://t.me/jiao_long',
|
||||||
|
'生成token': 'Generate token',
|
||||||
|
'删除日志': 'Delete logs',
|
||||||
|
'## 开始执行... %s\n': '## Start executing... %s\n',
|
||||||
|
'执行before命令...': 'Execute before command...',
|
||||||
|
'执行after命令...': 'Execute after command...',
|
||||||
|
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
|
||||||
|
'调度器注册失败,任务创建已回滚':
|
||||||
|
'Scheduler registration failed, task creation rolled back',
|
||||||
|
'调度器注册失败,任务更新已回滚':
|
||||||
|
'Scheduler registration failed, task update rolled back',
|
||||||
|
'调度器注册失败,任务启用已回滚':
|
||||||
|
'Scheduler registration failed, task enable rolled back',
|
||||||
|
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
|
||||||
|
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)',
|
||||||
|
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
|
||||||
|
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentLang: string = 'zh';
|
||||||
|
|
||||||
|
export function setLang(lang: string) {
|
||||||
|
currentLang = lang || 'zh';
|
||||||
|
shareStore.setLang(currentLang);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
|
||||||
|
export function systemLang(): string {
|
||||||
|
const prefix = Intl.DateTimeFormat().resolvedOptions().locale.split('-')[0];
|
||||||
|
return prefix === 'en' ? 'en' : 'zh';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key: string, lang?: string): string {
|
||||||
|
const effectiveLang = lang || currentLang;
|
||||||
|
if (effectiveLang === 'en' && messages.en[key]) {
|
||||||
|
return messages.en[key];
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tf(key: string, ...args: (string | number)[]): string {
|
||||||
|
return args.reduce<string>(
|
||||||
|
(str, arg) => str.replace(/%s|%d/, String(arg)),
|
||||||
|
t(key),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { createWriteStream, WriteStream } from 'fs';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages write streams for log files to improve performance by avoiding repeated file opens
|
||||||
|
*/
|
||||||
|
export class LogStreamManager extends EventEmitter {
|
||||||
|
private streams: Map<string, WriteStream> = new Map();
|
||||||
|
private pendingWrites: Map<string, Promise<void>> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write data to a log file using a managed stream
|
||||||
|
* @param filePath - Absolute path to the log file
|
||||||
|
* @param data - Data to write to the log file
|
||||||
|
*/
|
||||||
|
async write(filePath: string, data: string): Promise<void> {
|
||||||
|
// Wait for any pending writes to this file to complete
|
||||||
|
const pending = this.pendingWrites.get(filePath);
|
||||||
|
if (pending) {
|
||||||
|
await pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new promise for this write operation
|
||||||
|
const writePromise = new Promise<void>((resolve, reject) => {
|
||||||
|
let stream = this.streams.get(filePath);
|
||||||
|
|
||||||
|
if (!stream) {
|
||||||
|
// Create a new write stream if one doesn't exist
|
||||||
|
stream = createWriteStream(filePath, { flags: 'a' });
|
||||||
|
this.streams.set(filePath, stream);
|
||||||
|
|
||||||
|
// Handle stream errors
|
||||||
|
stream.on('error', (error) => {
|
||||||
|
this.emit('error', { filePath, error });
|
||||||
|
// Remove the stream from the map on error
|
||||||
|
this.streams.delete(filePath);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the data
|
||||||
|
const canContinue = stream.write(data, 'utf8', (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle backpressure
|
||||||
|
if (!canContinue) {
|
||||||
|
stream.once('drain', () => {
|
||||||
|
// Stream is ready for more data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pendingWrites.set(filePath, writePromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writePromise;
|
||||||
|
} finally {
|
||||||
|
this.pendingWrites.delete(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the stream for a specific file path
|
||||||
|
* @param filePath - Absolute path to the log file
|
||||||
|
*/
|
||||||
|
async closeStream(filePath: string): Promise<void> {
|
||||||
|
// Wait for any pending writes to complete
|
||||||
|
const pending = this.pendingWrites.get(filePath);
|
||||||
|
if (pending) {
|
||||||
|
await pending.catch(() => {
|
||||||
|
// Ignore errors on pending writes during close
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = this.streams.get(filePath);
|
||||||
|
if (stream) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
stream.end(() => {
|
||||||
|
this.streams.delete(filePath);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close all open streams
|
||||||
|
*/
|
||||||
|
async closeAll(): Promise<void> {
|
||||||
|
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
|
||||||
|
this.closeStream(filePath),
|
||||||
|
);
|
||||||
|
await Promise.all(closePromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of open streams
|
||||||
|
*/
|
||||||
|
getOpenStreamCount(): number {
|
||||||
|
return this.streams.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton instance for shared use
|
||||||
|
export const logStreamManager = new LogStreamManager();
|
||||||
+43
-7
@@ -4,6 +4,7 @@ import { AuthDataType, SystemModel } from '../data/system';
|
|||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
import { Dependence } from '../data/dependence';
|
import { Dependence } from '../data/dependence';
|
||||||
import NotificationService from '../services/notify';
|
import NotificationService from '../services/notify';
|
||||||
|
import { t, tf } from '../shared/i18n';
|
||||||
import {
|
import {
|
||||||
ICronFn,
|
ICronFn,
|
||||||
IDependencyFn,
|
IDependencyFn,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { credentials } from '@grpc/grpc-js';
|
import { credentials } from '@grpc/grpc-js';
|
||||||
import { ApiClient } from '../protos/api';
|
import { ApiClient } from '../protos/api';
|
||||||
|
import { getGrpcCerts } from '../config/grpcCerts';
|
||||||
|
|
||||||
class TaskLimit {
|
class TaskLimit {
|
||||||
private dependenyLimit = new PQueue({ concurrency: 1 });
|
private dependenyLimit = new PQueue({ concurrency: 1 });
|
||||||
@@ -36,11 +38,26 @@ class TaskLimit {
|
|||||||
private systemLimit = new PQueue({
|
private systemLimit = new PQueue({
|
||||||
concurrency: Math.max(os.cpus().length, 4),
|
concurrency: Math.max(os.cpus().length, 4),
|
||||||
});
|
});
|
||||||
private client = new ApiClient(
|
private _client: ApiClient | null = null;
|
||||||
`0.0.0.0:${config.cronPort}`,
|
|
||||||
credentials.createInsecure(),
|
private get client(): ApiClient {
|
||||||
{ 'grpc.enable_http_proxy': 0 },
|
if (!this._client) {
|
||||||
);
|
const tlsConfig = getGrpcCerts();
|
||||||
|
const creds = tlsConfig
|
||||||
|
? credentials.createSsl(
|
||||||
|
Buffer.from(tlsConfig.caCert),
|
||||||
|
Buffer.from(tlsConfig.clientKey),
|
||||||
|
Buffer.from(tlsConfig.clientCert),
|
||||||
|
)
|
||||||
|
: credentials.createInsecure();
|
||||||
|
this._client = new ApiClient(
|
||||||
|
`localhost:${config.grpcPort}`,
|
||||||
|
creds,
|
||||||
|
{ 'grpc.enable_http_proxy': 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this._client;
|
||||||
|
}
|
||||||
|
|
||||||
get cronLimitActiveCount() {
|
get cronLimitActiveCount() {
|
||||||
return this.cronLimit.pending;
|
return this.cronLimit.pending;
|
||||||
@@ -136,8 +153,14 @@ class TaskLimit {
|
|||||||
this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1);
|
this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1);
|
||||||
this.client.systemNotify(
|
this.client.systemNotify(
|
||||||
{
|
{
|
||||||
title: '任务重复运行',
|
title: t('任务重复运行'),
|
||||||
content: `任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`,
|
content: tf(
|
||||||
|
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置',
|
||||||
|
cron.name || '',
|
||||||
|
cron.command || '',
|
||||||
|
cron.schedule || '',
|
||||||
|
5,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
(err, res) => {
|
(err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -189,6 +212,19 @@ class TaskLimit {
|
|||||||
return this.scriptLimit.add(fn, options);
|
return this.scriptLimit.add(fn, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async waitDependencyQueueDone(): Promise<void> {
|
||||||
|
if (this.dependenyLimit.size === 0 && this.dependenyLimit.pending === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const onIdle = () => {
|
||||||
|
this.dependenyLimit.removeListener('idle', onIdle);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
this.dependenyLimit.on('idle', onIdle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public runDependeny<T>(
|
public runDependeny<T>(
|
||||||
dependency: Dependence,
|
dependency: Dependence,
|
||||||
fn: IDependencyFn<T>,
|
fn: IDependencyFn<T>,
|
||||||
|
|||||||
@@ -2,10 +2,56 @@ import { spawn } from 'cross-spawn';
|
|||||||
import taskLimit from './pLimit';
|
import taskLimit from './pLimit';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
import { ICron } from '../protos/cron';
|
import { ICron } from '../protos/cron';
|
||||||
|
import { CrontabModel, CrontabStatus } from '../data/cron';
|
||||||
|
import { killTask } from '../config/util';
|
||||||
|
import {
|
||||||
|
RunningInstanceModel,
|
||||||
|
InstanceStatus,
|
||||||
|
} from '../data/runningInstance';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||||
return taskLimit.runWithCronLimit(cron, () => {
|
return taskLimit.runWithCronLimit(cron, () => {
|
||||||
return new Promise(async (resolve: any) => {
|
return new Promise(async (resolve: any) => {
|
||||||
|
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
|
||||||
|
try {
|
||||||
|
const existingCron = await CrontabModel.findOne({
|
||||||
|
where: { id: Number(cron.id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default to single instance mode (0) for backward compatibility
|
||||||
|
const allowSingleInstances =
|
||||||
|
existingCron?.allow_multiple_instances === 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
allowSingleInstances &&
|
||||||
|
existingCron &&
|
||||||
|
existingCron.pid &&
|
||||||
|
(existingCron.status === CrontabStatus.running ||
|
||||||
|
existingCron.status === CrontabStatus.queued)
|
||||||
|
) {
|
||||||
|
Logger.info(
|
||||||
|
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
|
||||||
|
);
|
||||||
|
await killTask(existingCron.pid);
|
||||||
|
// Mark old running instances as stopped
|
||||||
|
const stoppedAt = dayjs().unix();
|
||||||
|
await RunningInstanceModel.update(
|
||||||
|
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
|
||||||
|
{ where: { cron_id: Number(cron.id), status: InstanceStatus.running } },
|
||||||
|
);
|
||||||
|
// Update the status to idle after killing
|
||||||
|
await CrontabModel.update(
|
||||||
|
{ status: CrontabStatus.idle, pid: undefined },
|
||||||
|
{ where: { id: Number(cron.id) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][开始执行任务] 参数 ${JSON.stringify({
|
`[schedule][开始执行任务] 参数 ${JSON.stringify({
|
||||||
...cron,
|
...cron,
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import path from 'path';
|
|||||||
export enum EKeyv {
|
export enum EKeyv {
|
||||||
'apps' = 'apps',
|
'apps' = 'apps',
|
||||||
'authInfo' = 'authInfo',
|
'authInfo' = 'authInfo',
|
||||||
|
'lang' = 'lang',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IKeyvStore {
|
export interface IKeyvStore {
|
||||||
apps: App[];
|
apps: App[];
|
||||||
authInfo: AuthInfo;
|
authInfo: AuthInfo;
|
||||||
|
lang: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite'));
|
const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite'));
|
||||||
@@ -31,4 +33,10 @@ export const shareStore = {
|
|||||||
updateApps(apps: App[]) {
|
updateApps(apps: App[]) {
|
||||||
return keyvStore.set<IKeyvStore['apps']>(EKeyv.apps, apps);
|
return keyvStore.set<IKeyvStore['apps']>(EKeyv.apps, apps);
|
||||||
},
|
},
|
||||||
|
getLang() {
|
||||||
|
return keyvStore.get<IKeyvStore['lang']>(EKeyv.lang);
|
||||||
|
},
|
||||||
|
setLang(value: IKeyvStore['lang']) {
|
||||||
|
return keyvStore.set<IKeyvStore['lang']>(EKeyv.lang, value);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function getUniqueLockPath(filePath: string) {
|
|||||||
|
|
||||||
export async function writeFileWithLock(
|
export async function writeFileWithLock(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
content: string | Buffer,
|
content: string,
|
||||||
options: Parameters<typeof writeFile>[2] = {},
|
options: Parameters<typeof writeFile>[2] = {},
|
||||||
) {
|
) {
|
||||||
if (typeof options === 'string') {
|
if (typeof options === 'string') {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'reflect-metadata';
|
|||||||
import OpenService from './services/open';
|
import OpenService from './services/open';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import LoggerInstance from './loaders/logger';
|
import LoggerInstance from './loaders/logger';
|
||||||
import fs from 'fs';
|
|
||||||
import config from './config';
|
import config from './config';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2017",
|
"target": "es2017",
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"typeRoots": ["./node_modules/celebrate/lib", "./node_modules/@types"],
|
"typeRoots": [
|
||||||
|
"./types",
|
||||||
|
"../node_modules/celebrate/lib",
|
||||||
|
"../node_modules/@types"
|
||||||
|
],
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
@@ -13,12 +17,11 @@
|
|||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"pretty": true,
|
"pretty": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./static/build",
|
"outDir": "../static/build",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"esModuleInterop": true
|
"esModuleInterop": true
|
||||||
},
|
},
|
||||||
"include": ["./back/**/*", "./back.d.ts"],
|
"include": ["./**/*"],
|
||||||
"exclude": ["node_modules"],
|
"exclude": ["node_modules"]
|
||||||
"files": ["./back/index.d.ts"]
|
|
||||||
}
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="express" />
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
platform: 'desktop' | 'mobile';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import 'reflect-metadata'; // We need this in order to use @Decorators
|
|
||||||
import config from './config';
|
|
||||||
import express from 'express';
|
|
||||||
import depInjectorLoader from './loaders/depInjector';
|
|
||||||
import Logger from './loaders/logger';
|
|
||||||
|
|
||||||
|
|
||||||
async function startServer() {
|
|
||||||
const app = express();
|
|
||||||
depInjectorLoader();
|
|
||||||
|
|
||||||
await require('./loaders/update').default({ app });
|
|
||||||
|
|
||||||
app
|
|
||||||
.listen(config.updatePort, '0.0.0.0', () => {
|
|
||||||
Logger.debug(`✌️ 更新服务启动成功!`);
|
|
||||||
console.debug(`✌️ 更新服务启动成功!`);
|
|
||||||
process.send?.('ready');
|
|
||||||
})
|
|
||||||
.on('error', (err) => {
|
|
||||||
Logger.error(err);
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startServer();
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Joi } from 'celebrate';
|
import { Joi } from 'celebrate';
|
||||||
import cron_parser from 'cron-parser';
|
import CronExpressionParser from 'cron-parser';
|
||||||
import { ScheduleType } from '../interface/schedule';
|
import { ScheduleType } from '../interface/schedule';
|
||||||
|
import path from 'path';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
const validateSchedule = (value: string, helpers: any) => {
|
const validateSchedule = (value: string, helpers: any) => {
|
||||||
if (
|
if (
|
||||||
@@ -10,8 +12,18 @@ const validateSchedule = (value: string, helpers: any) => {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测裸 /N 模式:cron-parser 会接受,但 node-schedule 会返回 null
|
||||||
|
// 提前拦截,避免任务入库后调度器注册失败
|
||||||
|
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
|
||||||
|
return helpers.error('any.invalid');
|
||||||
|
}
|
||||||
|
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数字段上返回 null
|
||||||
|
if (/\?/.test(value)) {
|
||||||
|
return helpers.error('any.invalid');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (cron_parser.parseExpression(value).hasNext()) {
|
if (CronExpressionParser.parse(value).hasNext()) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -37,4 +49,48 @@ export const commonCronSchema = {
|
|||||||
extra_schedules: Joi.array().optional().allow(null),
|
extra_schedules: Joi.array().optional().allow(null),
|
||||||
task_before: Joi.string().optional().allow('').allow(null),
|
task_before: Joi.string().optional().allow('').allow(null),
|
||||||
task_after: Joi.string().optional().allow('').allow(null),
|
task_after: Joi.string().optional().allow('').allow(null),
|
||||||
|
log_name: Joi.string()
|
||||||
|
.optional()
|
||||||
|
.allow('')
|
||||||
|
.allow(null)
|
||||||
|
.custom((value, helpers) => {
|
||||||
|
if (!value) return value;
|
||||||
|
|
||||||
|
// Check if it's an absolute path
|
||||||
|
if (value.startsWith('/')) {
|
||||||
|
// Allow /dev/null as special case
|
||||||
|
if (value === '/dev/null') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other absolute paths, ensure they are within the safe log directory
|
||||||
|
const normalizedValue = path.normalize(value);
|
||||||
|
const normalizedLogPath = path.normalize(config.logPath);
|
||||||
|
|
||||||
|
if (!normalizedValue.startsWith(normalizedLogPath)) {
|
||||||
|
return helpers.error('string.unsafePath');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return helpers.error('string.pattern.base');
|
||||||
|
}
|
||||||
|
if (value.length > 100) {
|
||||||
|
return helpers.error('string.max');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
.messages({
|
||||||
|
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
|
||||||
|
'string.max': '日志名称不能超过100个字符',
|
||||||
|
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
|
||||||
|
}),
|
||||||
|
allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null),
|
||||||
|
work_dir: Joi.string().optional().allow('').allow(null),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Kubernetes deployment
|
||||||
|
|
||||||
|
This deploys Qinglong as a single-replica `StatefulSet` with persistent data at `/ql/data`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -k deploy/kubernetes/overlays/local
|
||||||
|
kubectl -n qinglong rollout status statefulset/qinglong
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the panel locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n qinglong port-forward svc/qinglong 5700:5700
|
||||||
|
```
|
||||||
|
|
||||||
|
Then visit <http://127.0.0.1:5700>.
|
||||||
|
|
||||||
|
## Image registry overlays
|
||||||
|
|
||||||
|
Use `overlays/example` as the committed template for registry customization:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
whyour/qinglong:debian -> registry.example.com/whyour/qinglong:debian
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `overlays/local/kustomization.yaml` for the actual cluster image. The `local` overlay is ignored by git so private registry names, digests, and credentials-related references stay local.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
The manifest creates a 5 GiB `ReadWriteOnce` PVC from the cluster's default `StorageClass`.
|
||||||
|
If your cluster has no default storage class, add `storageClassName` under:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
storageClassName: your-storage-class
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `replicas: 1`. Qinglong stores state in the persistent data directory, including SQLite files, so multiple replicas should not share the same data volume.
|
||||||
|
|
||||||
|
## Ingress example
|
||||||
|
|
||||||
|
If you expose Qinglong through an Ingress path other than `/`, set `QlBaseUrl` to the same path with leading and trailing slashes.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
- name: QlBaseUrl
|
||||||
|
value: "/qinglong/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Example Ingress:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: qinglong
|
||||||
|
namespace: qinglong
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: qinglong.example.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: qinglong
|
||||||
|
port:
|
||||||
|
number: 5700
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintenance commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n qinglong logs -f statefulset/qinglong
|
||||||
|
kubectl -n qinglong exec -it statefulset/qinglong -- ql check
|
||||||
|
kubectl -n qinglong exec -it statefulset/qinglong -- ql update
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- qinglong.yaml
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: qinglong
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: qinglong
|
||||||
|
namespace: qinglong
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: qinglong
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: qinglong
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 5700
|
||||||
|
targetPort: http
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: qinglong
|
||||||
|
namespace: qinglong
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: qinglong
|
||||||
|
spec:
|
||||||
|
serviceName: qinglong
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: qinglong
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: qinglong
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 5432
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
containers:
|
||||||
|
- name: qinglong
|
||||||
|
image: whyour/qinglong:debian
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: QlBaseUrl
|
||||||
|
value: "/"
|
||||||
|
- name: TZ
|
||||||
|
value: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 5700
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 20
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 6
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 20
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 6
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 60
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
memory: 1Gi
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /ql/data
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- ../../base
|
||||||
|
|
||||||
|
images:
|
||||||
|
- name: whyour/qinglong
|
||||||
|
newName: registry.example.com/whyour/qinglong
|
||||||
|
newTag: debian
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
FROM python:3.10-alpine3.18 AS builder
|
|
||||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
|
||||||
RUN set -x \
|
|
||||||
&& apk update \
|
|
||||||
&& apk add nodejs npm git \
|
|
||||||
&& npm i -g pnpm@8.3.1 pm2 ts-node \
|
|
||||||
&& cd /tmp/build \
|
|
||||||
&& pnpm install --prod
|
|
||||||
|
|
||||||
FROM python:3.10-alpine
|
|
||||||
|
|
||||||
ARG QL_MAINTAINER="whyour"
|
|
||||||
LABEL maintainer="${QL_MAINTAINER}"
|
|
||||||
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
|
|
||||||
ARG QL_BRANCH=develop
|
|
||||||
|
|
||||||
ENV PNPM_HOME=/root/.local/share/pnpm \
|
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules \
|
|
||||||
NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules:/root/.local/share/pnpm/global/5/node_modules \
|
|
||||||
LANG=C.UTF-8 \
|
|
||||||
SHELL=/bin/bash \
|
|
||||||
PS1="\u@\h:\w \$ " \
|
|
||||||
QL_DIR=/ql \
|
|
||||||
QL_BRANCH=${QL_BRANCH}
|
|
||||||
|
|
||||||
VOLUME /ql/data
|
|
||||||
|
|
||||||
EXPOSE 5700
|
|
||||||
|
|
||||||
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
|
|
||||||
COPY --from=builder /usr/local/bin/. /usr/local/bin/
|
|
||||||
|
|
||||||
RUN set -x \
|
|
||||||
&& apk update -f \
|
|
||||||
&& apk upgrade \
|
|
||||||
&& apk --no-cache add -f bash \
|
|
||||||
coreutils \
|
|
||||||
git \
|
|
||||||
curl \
|
|
||||||
wget \
|
|
||||||
tzdata \
|
|
||||||
perl \
|
|
||||||
openssl \
|
|
||||||
nginx \
|
|
||||||
nodejs \
|
|
||||||
jq \
|
|
||||||
openssh \
|
|
||||||
procps \
|
|
||||||
netcat-openbsd \
|
|
||||||
unzip \
|
|
||||||
npm \
|
|
||||||
&& rm -rf /var/cache/apk/* \
|
|
||||||
&& apk update \
|
|
||||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
|
||||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
|
||||||
&& git config --global user.email "qinglong@@users.noreply.github.com" \
|
|
||||||
&& git config --global user.name "qinglong" \
|
|
||||||
&& git config --global http.postBuffer 524288000 \
|
|
||||||
&& rm -rf /root/.pnpm-store \
|
|
||||||
&& rm -rf /root/.local/share/pnpm/store \
|
|
||||||
&& rm -rf /root/.cache \
|
|
||||||
&& ulimit -c 0 \
|
|
||||||
&& pip3 install requests
|
|
||||||
|
|
||||||
ARG SOURCE_COMMIT
|
|
||||||
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
|
|
||||||
&& cd ${QL_DIR} \
|
|
||||||
&& cp -f .env.example .env \
|
|
||||||
&& chmod 777 ${QL_DIR}/shell/*.sh \
|
|
||||||
&& chmod 777 ${QL_DIR}/docker/*.sh \
|
|
||||||
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
|
|
||||||
&& mkdir -p ${QL_DIR}/static \
|
|
||||||
&& cp -rf /static/* ${QL_DIR}/static \
|
|
||||||
&& rm -rf /static
|
|
||||||
|
|
||||||
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
|
||||||
|
|
||||||
WORKDIR ${QL_DIR}
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
|
|
||||||
CMD curl -sf --noproxy '*' http://127.0.0.1:5400/api/health || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
|
|
||||||
+47
-21
@@ -1,11 +1,26 @@
|
|||||||
FROM python:3.11-alpine3.18 AS builder
|
# Run Node package installation natively on the builder. Node/npm can spin at
|
||||||
|
# 100% CPU when Alpine s390x is emulated through QEMU.
|
||||||
|
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV NPM_CONFIG_PREFIX=/opt/node-global
|
||||||
|
ENV PATH=/opt/node-global/bin:${PATH}
|
||||||
|
|
||||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
&& apk update \
|
&& apk add --no-cache git \
|
||||||
&& apk add nodejs npm git \
|
&& npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
|
||||||
&& npm i -g pnpm@8.3.1 pm2 ts-node \
|
|
||||||
&& cd /tmp/build \
|
&& cd /tmp/build \
|
||||||
&& pnpm install --prod
|
&& case "${TARGETARCH}" in \
|
||||||
|
amd64) NODE_ARCH=x64 ;; \
|
||||||
|
386) NODE_ARCH=ia32 ;; \
|
||||||
|
ppc64le) NODE_ARCH=ppc64 ;; \
|
||||||
|
*) NODE_ARCH="${TARGETARCH}" ;; \
|
||||||
|
esac \
|
||||||
|
&& npm_config_target_platform=linux \
|
||||||
|
npm_config_target_arch="${NODE_ARCH}" \
|
||||||
|
npm_config_target_libc=musl \
|
||||||
|
pnpm install --prod
|
||||||
|
|
||||||
FROM python:3.11-alpine
|
FROM python:3.11-alpine
|
||||||
|
|
||||||
@@ -13,22 +28,21 @@ ARG QL_MAINTAINER="whyour"
|
|||||||
LABEL maintainer="${QL_MAINTAINER}"
|
LABEL maintainer="${QL_MAINTAINER}"
|
||||||
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
|
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
|
||||||
ARG QL_BRANCH=develop
|
ARG QL_BRANCH=develop
|
||||||
|
ARG PYTHON_SHORT_VERSION=3.11
|
||||||
|
|
||||||
ENV PNPM_HOME=/root/.local/share/pnpm \
|
ENV QL_DIR=/ql \
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules \
|
QL_BRANCH=${QL_BRANCH} \
|
||||||
NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules:/root/.local/share/pnpm/global/5/node_modules \
|
QL_CONTAINER=true \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
SHELL=/bin/bash \
|
SHELL=/bin/bash \
|
||||||
PS1="\u@\h:\w \$ " \
|
PS1="\u@\h:\w \$ "
|
||||||
QL_DIR=/ql \
|
|
||||||
QL_BRANCH=${QL_BRANCH}
|
|
||||||
|
|
||||||
VOLUME /ql/data
|
VOLUME /ql/data
|
||||||
|
|
||||||
EXPOSE 5700
|
EXPOSE 5700
|
||||||
|
|
||||||
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
|
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
||||||
COPY --from=builder /usr/local/bin/. /usr/local/bin/
|
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
&& apk update -f \
|
&& apk update -f \
|
||||||
@@ -41,7 +55,6 @@ RUN set -x \
|
|||||||
tzdata \
|
tzdata \
|
||||||
perl \
|
perl \
|
||||||
openssl \
|
openssl \
|
||||||
nginx \
|
|
||||||
nodejs \
|
nodejs \
|
||||||
jq \
|
jq \
|
||||||
openssh \
|
openssh \
|
||||||
@@ -53,14 +66,11 @@ RUN set -x \
|
|||||||
&& apk update \
|
&& apk update \
|
||||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||||
&& git config --global user.email "qinglong@@users.noreply.github.com" \
|
&& git config --global user.email "qinglong@users.noreply.github.com" \
|
||||||
&& git config --global user.name "qinglong" \
|
&& git config --global user.name "qinglong" \
|
||||||
&& git config --global http.postBuffer 524288000 \
|
&& git config --global http.postBuffer 524288000 \
|
||||||
&& rm -rf /root/.pnpm-store \
|
|
||||||
&& rm -rf /root/.local/share/pnpm/store \
|
|
||||||
&& rm -rf /root/.cache \
|
&& rm -rf /root/.cache \
|
||||||
&& ulimit -c 0 \
|
&& ulimit -c 0
|
||||||
&& pip3 install requests
|
|
||||||
|
|
||||||
ARG SOURCE_COMMIT
|
ARG SOURCE_COMMIT
|
||||||
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
|
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
|
||||||
@@ -73,11 +83,27 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
|
|||||||
&& cp -rf /static/* ${QL_DIR}/static \
|
&& cp -rf /static/* ${QL_DIR}/static \
|
||||||
&& rm -rf /static
|
&& rm -rf /static
|
||||||
|
|
||||||
|
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||||
|
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||||
|
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
|
||||||
|
HOME=/root
|
||||||
|
|
||||||
|
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
|
||||||
|
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
|
||||||
|
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
|
||||||
|
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
|
||||||
|
|
||||||
|
RUN pip3 install --prefix ${PYTHON_HOME} requests
|
||||||
|
|
||||||
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
||||||
|
|
||||||
|
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
|
||||||
|
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
|
||||||
|
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
|
||||||
|
|
||||||
WORKDIR ${QL_DIR}
|
WORKDIR ${QL_DIR}
|
||||||
|
|
||||||
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
|
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
|
||||||
CMD curl -sf --noproxy '*' http://127.0.0.1:5400/api/health || exit 1
|
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
|
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Run Node package installation natively on the builder. Node/npm can spin at
|
||||||
|
# 100% CPU when Alpine s390x is emulated through QEMU.
|
||||||
|
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV NPM_CONFIG_PREFIX=/opt/node-global
|
||||||
|
ENV PATH=/opt/node-global/bin:${PATH}
|
||||||
|
|
||||||
|
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||||
|
RUN set -x \
|
||||||
|
&& apk add --no-cache git \
|
||||||
|
&& npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
|
||||||
|
&& cd /tmp/build \
|
||||||
|
&& case "${TARGETARCH}" in \
|
||||||
|
amd64) NODE_ARCH=x64 ;; \
|
||||||
|
386) NODE_ARCH=ia32 ;; \
|
||||||
|
ppc64le) NODE_ARCH=ppc64 ;; \
|
||||||
|
*) NODE_ARCH="${TARGETARCH}" ;; \
|
||||||
|
esac \
|
||||||
|
&& npm_config_target_platform=linux \
|
||||||
|
npm_config_target_arch="${NODE_ARCH}" \
|
||||||
|
npm_config_target_libc=musl \
|
||||||
|
pnpm install --prod
|
||||||
|
|
||||||
|
FROM python:3.10-alpine
|
||||||
|
|
||||||
|
ARG QL_MAINTAINER="whyour"
|
||||||
|
LABEL maintainer="${QL_MAINTAINER}"
|
||||||
|
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
|
||||||
|
ARG QL_BRANCH=develop
|
||||||
|
ARG PYTHON_SHORT_VERSION=3.10
|
||||||
|
|
||||||
|
ENV QL_DIR=/ql \
|
||||||
|
QL_BRANCH=${QL_BRANCH} \
|
||||||
|
QL_CONTAINER=true \
|
||||||
|
LANG=C.UTF-8 \
|
||||||
|
SHELL=/bin/bash \
|
||||||
|
PS1="\u@\h:\w \$ "
|
||||||
|
|
||||||
|
VOLUME /ql/data
|
||||||
|
|
||||||
|
EXPOSE 5700
|
||||||
|
|
||||||
|
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
||||||
|
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
||||||
|
|
||||||
|
RUN set -x \
|
||||||
|
&& apk update -f \
|
||||||
|
&& apk upgrade \
|
||||||
|
&& apk --no-cache add -f bash \
|
||||||
|
coreutils \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
tzdata \
|
||||||
|
perl \
|
||||||
|
openssl \
|
||||||
|
nodejs \
|
||||||
|
jq \
|
||||||
|
openssh \
|
||||||
|
procps \
|
||||||
|
netcat-openbsd \
|
||||||
|
unzip \
|
||||||
|
npm \
|
||||||
|
&& rm -rf /var/cache/apk/* \
|
||||||
|
&& apk update \
|
||||||
|
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||||
|
&& git config --global user.email "qinglong@users.noreply.github.com" \
|
||||||
|
&& git config --global user.name "qinglong" \
|
||||||
|
&& git config --global http.postBuffer 524288000 \
|
||||||
|
&& rm -rf /root/.cache \
|
||||||
|
&& ulimit -c 0
|
||||||
|
|
||||||
|
ARG SOURCE_COMMIT
|
||||||
|
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
|
||||||
|
&& cd ${QL_DIR} \
|
||||||
|
&& cp -f .env.example .env \
|
||||||
|
&& chmod 777 ${QL_DIR}/shell/*.sh \
|
||||||
|
&& chmod 777 ${QL_DIR}/docker/*.sh \
|
||||||
|
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
|
||||||
|
&& mkdir -p ${QL_DIR}/static \
|
||||||
|
&& cp -rf /static/* ${QL_DIR}/static \
|
||||||
|
&& rm -rf /static
|
||||||
|
|
||||||
|
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||||
|
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||||
|
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
|
||||||
|
HOME=/root
|
||||||
|
|
||||||
|
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
|
||||||
|
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
|
||||||
|
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
|
||||||
|
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
|
||||||
|
|
||||||
|
RUN pip3 install --prefix ${PYTHON_HOME} requests
|
||||||
|
|
||||||
|
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
||||||
|
|
||||||
|
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
|
||||||
|
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
|
||||||
|
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
|
||||||
|
|
||||||
|
WORKDIR ${QL_DIR}
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
|
||||||
|
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user