feat: MySQL 远程数据库 + CI/CD 流水线 + K8s 部署配置
数据库迁移: - SQLite → MySQL (mysql-8351f937d637-public.rds.volces.com) - Schema 从 drizzle-orm/sqlite-core 改为 drizzle-orm/mysql-core - 全量数据迁移完成(13 张表 525 条记录) CI/CD 流水线: - .gitea/workflows/deploy.yaml(airlabs 分支触发) - 前后端分别构建镜像推到火山引擎 CR internal 命名空间 - 自动部署到内部 K3s 集群 K8s 配置: - backend-deployment.yaml(Bun 3200 端口 + MySQL 私网连接) - web-deployment.yaml(Nginx 80 端口 + SPA fallback) - backend-ingress.yaml(devperf-api.airlabs.art + TLS) - web-ingress.yaml(devperf.airlabs.art + TLS) - cert-manager-issuer.yaml(Let's Encrypt) 其他: - 前端 Dockerfile 支持 VITE_API_BASE_URL 构建参数 - 后端 Dockerfile 改为直接运行源码(兼容 mysql2) - 侧边栏/全局样式优化 + Git 图表修复 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
58fe2b1ea8
commit
43f885e22a
132
.gitea/workflows/deploy.yaml
Normal file
132
.gitea/workflows/deploy.yaml
Normal file
@ -0,0 +1,132 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- airlabs
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth=1 --branch=${{ github.ref_name }} https://gitea.airlabs.art/${{ github.repository }}.git .
|
||||
|
||||
- name: Set environment by branch
|
||||
run: |
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
BUILD_DATE=$(date +%Y%m%d)
|
||||
|
||||
echo "IMAGE_TAG=internal-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV
|
||||
echo "CR_SERVER_ACTIVE=${{ secrets.CR_SERVER }}" >> $GITHUB_ENV
|
||||
echo "CR_USERNAME_ACTIVE=${{ secrets.CR_USERNAME }}" >> $GITHUB_ENV
|
||||
echo "CR_PASSWORD_ACTIVE=${{ secrets.CR_PASSWORD }}" >> $GITHUB_ENV
|
||||
echo "CR_ORG=internal" >> $GITHUB_ENV
|
||||
echo "DEPLOY_ENV=internal" >> $GITHUB_ENV
|
||||
echo "DOMAIN_WEB=devperf.airlabs.art" >> $GITHUB_ENV
|
||||
echo "DOMAIN_API=devperf-api.airlabs.art" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Volcano Engine CR
|
||||
run: |
|
||||
echo "${{ env.CR_PASSWORD_ACTIVE }}" | docker login --username "${{ env.CR_USERNAME_ACTIVE }}" --password-stdin ${{ env.CR_SERVER_ACTIVE }}
|
||||
|
||||
- name: Build and Push Backend
|
||||
id: build_backend
|
||||
run: |
|
||||
set -o pipefail
|
||||
for attempt in 1 2 3; do
|
||||
echo "Build backend attempt $attempt/3..."
|
||||
DOCKER_BUILDKIT=0 docker build \
|
||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/devperf-backend:${{ env.IMAGE_TAG }} \
|
||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/devperf-backend:latest \
|
||||
./backend 2>&1 | tee /tmp/build.log && break
|
||||
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||
done
|
||||
for attempt in 1 2 3; do
|
||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/devperf-backend:${{ env.IMAGE_TAG }} && \
|
||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/devperf-backend:latest && break
|
||||
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||
done
|
||||
|
||||
- name: Build and Push Web
|
||||
id: build_web
|
||||
run: |
|
||||
set -o pipefail
|
||||
for attempt in 1 2 3; do
|
||||
echo "Build web attempt $attempt/3..."
|
||||
DOCKER_BUILDKIT=0 docker build \
|
||||
--build-arg VITE_API_BASE_URL=https://${{ env.DOMAIN_API }} \
|
||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/devperf-web:${{ env.IMAGE_TAG }} \
|
||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/devperf-web:latest \
|
||||
./frontend 2>&1 | tee -a /tmp/build.log && break
|
||||
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||
done
|
||||
for attempt in 1 2 3; do
|
||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/devperf-web:${{ env.IMAGE_TAG }} && \
|
||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/devperf-web:latest && break
|
||||
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||
done
|
||||
|
||||
- name: Setup Kubectl
|
||||
run: |
|
||||
if ! command -v kubectl &>/dev/null; then
|
||||
for attempt in 1 2 3; do
|
||||
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && break
|
||||
echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5
|
||||
done
|
||||
chmod +x kubectl && mv kubectl /usr/bin/kubectl
|
||||
fi
|
||||
kubectl version --client
|
||||
|
||||
- name: Set kubeconfig
|
||||
run: |
|
||||
mkdir -p $HOME/.kube
|
||||
printf '%s\n' '${{ secrets.VOLCANO_INTERNAL_KUBE_CONFIG }}' > $HOME/.kube/config
|
||||
chmod 600 $HOME/.kube/config
|
||||
|
||||
- name: Deploy to K3s
|
||||
id: deploy
|
||||
run: |
|
||||
echo "Environment: ${{ env.DEPLOY_ENV }}"
|
||||
CR_IMAGE="${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}"
|
||||
|
||||
# Replace image placeholders
|
||||
sed -i "s|\${CI_REGISTRY_IMAGE}/backend:latest|${CR_IMAGE}/devperf-backend:${{ env.IMAGE_TAG }}|g" k8s/backend-deployment.yaml
|
||||
sed -i "s|\${CI_REGISTRY_IMAGE}/web:latest|${CR_IMAGE}/devperf-web:${{ env.IMAGE_TAG }}|g" k8s/web-deployment.yaml
|
||||
|
||||
# Replace domain placeholders in ingress
|
||||
sed -i "s|devperf.airlabs.art|${{ env.DOMAIN_WEB }}|g" k8s/web-ingress.yaml
|
||||
sed -i "s|devperf-api.airlabs.art|${{ env.DOMAIN_API }}|g" k8s/backend-ingress.yaml
|
||||
|
||||
# Replace CORS origin
|
||||
sed -i "s|https://devperf.airlabs.art|https://${{ env.DOMAIN_WEB }}|g" k8s/backend-deployment.yaml
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
echo "Deploy attempt $attempt/3..."
|
||||
{
|
||||
kubectl create secret docker-registry cr-pull-secret \
|
||||
--docker-server="${{ env.CR_SERVER_ACTIVE }}" \
|
||||
--docker-username="${{ env.CR_USERNAME_ACTIVE }}" \
|
||||
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl apply -f k8s/cert-manager-issuer.yaml
|
||||
kubectl apply -f k8s/backend-deployment.yaml
|
||||
kubectl apply -f k8s/backend-ingress.yaml
|
||||
kubectl apply -f k8s/web-deployment.yaml
|
||||
kubectl apply -f k8s/web-ingress.yaml
|
||||
|
||||
kubectl rollout restart deployment/devperf-backend
|
||||
kubectl rollout restart deployment/devperf-web
|
||||
} 2>&1 | tee /tmp/deploy.log && break
|
||||
echo "Attempt $attempt failed, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
- name: Docker Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker container prune -f
|
||||
docker image prune -f
|
||||
docker builder prune -a -f
|
||||
@ -1,17 +1,23 @@
|
||||
# ---- Required ----
|
||||
DATABASE_PATH=./data/devperf.db
|
||||
JWT_SECRET=your-jwt-secret-here-change-in-production
|
||||
PORT=3200
|
||||
|
||||
# ---- Plane Connection ----
|
||||
PLANE_BASE_URL=http://plane-api:8000
|
||||
PLANE_API_TOKEN= # Generate in Plane Settings > API Tokens
|
||||
PLANE_WORKSPACE_SLUG=jasonqiyuan
|
||||
# ---- MySQL Connection ----
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_DATABASE=devperf
|
||||
|
||||
# ---- Gitea Connection ----
|
||||
GITEA_BASE_URL=http://gitea:3000
|
||||
GITEA_API_TOKEN= # Generate in Gitea Settings > Applications
|
||||
GITEA_ORG=jasonqiyuan
|
||||
GITEA_BASE_URL=https://gitea.airlabs.art
|
||||
GITEA_API_TOKEN=
|
||||
GITEA_ORG=zyc
|
||||
|
||||
# ---- Plane Connection (optional) ----
|
||||
PLANE_BASE_URL=http://plane-api:8000
|
||||
PLANE_API_TOKEN=
|
||||
PLANE_WORKSPACE_SLUG=jasonqiyuan
|
||||
|
||||
# ---- Sync Intervals (minutes) ----
|
||||
SYNC_PLANE_INTERVAL=15
|
||||
|
||||
@ -1,16 +1,8 @@
|
||||
FROM oven/bun:1 AS builder
|
||||
FROM oven/bun:1
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lockb* ./
|
||||
RUN bun install --frozen-lockfile || bun install
|
||||
COPY src/ src/
|
||||
COPY drizzle.config.ts tsconfig.json ./
|
||||
RUN bun build src/index.ts --outdir=dist --target=bun
|
||||
|
||||
FROM oven/bun:1-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/dist/ ./dist/
|
||||
COPY --from=builder /app/node_modules/ ./node_modules/
|
||||
COPY --from=builder /app/package.json ./
|
||||
RUN mkdir -p /data
|
||||
COPY tsconfig.json ./
|
||||
EXPOSE 3200
|
||||
CMD ["bun", "run", "dist/index.js"]
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"hono": "^4.7.0",
|
||||
"jose": "^5.9.0",
|
||||
"mysql2": "^3.22.0",
|
||||
"uuid": "^11.0.0",
|
||||
"zod": "^3.24.0",
|
||||
},
|
||||
@ -101,6 +102,8 @@
|
||||
|
||||
"are-we-there-yet": ["are-we-there-yet@2.0.0", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw=="],
|
||||
|
||||
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
@ -141,6 +144,8 @@
|
||||
|
||||
"delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="],
|
||||
|
||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.28.1", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-JimOV+ystXTWMgZkLHYHf2w3oS28hxiH1FR0dkmJLc7GHzdGJoJAQtQS5DRppnabsRZwE2U1F6CuezVBgmsBBQ=="],
|
||||
@ -167,6 +172,8 @@
|
||||
|
||||
"gauge": ["gauge@3.0.2", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.2", "console-control-strings": "^1.0.0", "has-unicode": "^2.0.1", "object-assign": "^4.1.1", "signal-exit": "^3.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.2" } }, "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q=="],
|
||||
|
||||
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
|
||||
|
||||
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||
@ -179,6 +186,8 @@
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
@ -189,8 +198,14 @@
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||
|
||||
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
|
||||
|
||||
"make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
@ -209,6 +224,10 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mysql2": ["mysql2@3.22.0", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-4jaJYBObj7FhD3lnZhqX1yDMuZN4mQNz+IolDySDXT7fbozMBpeGQNcuWXKUqo4ahkAEfkjUHPjnwuDI0/6VKw=="],
|
||||
|
||||
"named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="],
|
||||
|
||||
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||
|
||||
"node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="],
|
||||
@ -241,6 +260,8 @@
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
@ -255,6 +276,8 @@
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
@ -13,16 +13,17 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"jose": "^5.9.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"zod": "^3.24.0",
|
||||
"@hono/zod-validator": "^0.4.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"croner": "^9.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"uuid": "^11.0.0"
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"hono": "^4.7.0",
|
||||
"jose": "^5.9.0",
|
||||
"mysql2": "^3.22.0",
|
||||
"uuid": "^11.0.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_PATH: z.string().default('./data/devperf.db'),
|
||||
JWT_SECRET: z.string().min(16, 'JWT_SECRET must be at least 16 characters'),
|
||||
PORT: z.coerce.number().default(3200),
|
||||
|
||||
MYSQL_HOST: z.string().default('localhost'),
|
||||
MYSQL_PORT: z.coerce.number().default(3306),
|
||||
MYSQL_USER: z.string().default('root'),
|
||||
MYSQL_PASSWORD: z.string().default(''),
|
||||
MYSQL_DATABASE: z.string().default('devperf'),
|
||||
|
||||
PLANE_BASE_URL: z.string().url().default('http://plane-api:8000'),
|
||||
PLANE_API_TOKEN: z.string().default(''),
|
||||
PLANE_WORKSPACE_SLUG: z.string().default('jasonqiyuan'),
|
||||
|
||||
@ -1,213 +1,20 @@
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
||||
import { drizzle } from 'drizzle-orm/mysql2';
|
||||
import mysql from 'mysql2/promise';
|
||||
import * as schema from './schema';
|
||||
import { config } from '../config';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
// Ensure data directory exists
|
||||
const dbDir = dirname(config.DATABASE_PATH);
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
const pool = mysql.createPool({
|
||||
host: config.MYSQL_HOST,
|
||||
port: config.MYSQL_PORT,
|
||||
user: config.MYSQL_USER,
|
||||
password: config.MYSQL_PASSWORD,
|
||||
database: config.MYSQL_DATABASE,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
timezone: '+08:00',
|
||||
});
|
||||
|
||||
const sqlite = new Database(config.DATABASE_PATH, { create: true });
|
||||
sqlite.exec('PRAGMA journal_mode = WAL');
|
||||
sqlite.exec('PRAGMA foreign_keys = ON');
|
||||
export const db = drizzle(pool, { schema, mode: 'default' });
|
||||
export { pool };
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export { sqlite };
|
||||
|
||||
/**
|
||||
* Run database migrations automatically on startup.
|
||||
* Uses CREATE TABLE IF NOT EXISTS semantics (via drizzle migrate)
|
||||
* so it is safe to call on every boot -- already-applied migrations
|
||||
* are tracked in the drizzle __drizzle_migrations journal table.
|
||||
*
|
||||
* The migrationsFolder path is resolved relative to the project root
|
||||
* (where package.json lives) so it works regardless of cwd.
|
||||
*/
|
||||
function autoMigrate() {
|
||||
try {
|
||||
// Resolve the drizzle folder relative to this file's location:
|
||||
// src/db/index.ts -> ../../drizzle
|
||||
const migrationsFolder = resolve(import.meta.dir, '../../drizzle');
|
||||
migrate(db, { migrationsFolder });
|
||||
console.info('[DB] Auto-migration completed successfully.');
|
||||
} catch (err) {
|
||||
console.error('[DB] Auto-migration failed:', err);
|
||||
// Do not crash the process -- tables may already exist from a prior run.
|
||||
// Fallback: create core tables with raw SQL if drizzle migrations folder is missing.
|
||||
try {
|
||||
createTablesIfNotExist();
|
||||
console.info('[DB] Fallback table creation completed.');
|
||||
} catch (fallbackErr) {
|
||||
console.error('[DB] Fallback table creation also failed:', fallbackErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: create all required tables using raw SQL CREATE TABLE IF NOT EXISTS.
|
||||
* This is only used when the drizzle migrations folder cannot be found.
|
||||
*/
|
||||
function createTablesIfNotExist() {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
plane_user_id TEXT,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
git_username TEXT,
|
||||
role TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
login_attempts INTEGER DEFAULT 0,
|
||||
locked_until INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
plane_project_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
identifier TEXT,
|
||||
last_synced_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sprint_snapshots (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT REFERENCES projects(id),
|
||||
plane_cycle_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
total_points INTEGER DEFAULT 0,
|
||||
completed_points INTEGER DEFAULT 0,
|
||||
total_issues INTEGER DEFAULT 0,
|
||||
completed_issues INTEGER DEFAULT 0,
|
||||
burndown_data TEXT,
|
||||
status TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_snapshots (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
plane_issue_id TEXT NOT NULL,
|
||||
project_id TEXT REFERENCES projects(id),
|
||||
sprint_id TEXT REFERENCES sprint_snapshots(id),
|
||||
title TEXT NOT NULL,
|
||||
status TEXT,
|
||||
priority TEXT,
|
||||
assignee_id TEXT REFERENCES users(id),
|
||||
story_points INTEGER,
|
||||
created_at INTEGER,
|
||||
completed_at INTEGER,
|
||||
due_date TEXT,
|
||||
labels TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS milestones (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
plane_module_id TEXT NOT NULL,
|
||||
project_id TEXT REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
status TEXT,
|
||||
target_date TEXT,
|
||||
total_issues INTEGER DEFAULT 0,
|
||||
completed_issues INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS git_commits (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
repo_name TEXT NOT NULL,
|
||||
sha TEXT NOT NULL UNIQUE,
|
||||
author_email TEXT,
|
||||
author_name TEXT,
|
||||
user_id TEXT REFERENCES users(id),
|
||||
message TEXT,
|
||||
additions INTEGER DEFAULT 0,
|
||||
deletions INTEGER DEFAULT 0,
|
||||
committed_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS git_prs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
repo_name TEXT NOT NULL,
|
||||
external_id INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
user_id TEXT REFERENCES users(id),
|
||||
author_username TEXT,
|
||||
state TEXT,
|
||||
additions INTEGER DEFAULT 0,
|
||||
deletions INTEGER DEFAULT 0,
|
||||
review_comments INTEGER DEFAULT 0,
|
||||
created_at INTEGER,
|
||||
merged_at INTEGER,
|
||||
merge_time_hours REAL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS objectives (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
owner_id TEXT REFERENCES users(id),
|
||||
project_id TEXT REFERENCES projects(id),
|
||||
period TEXT NOT NULL,
|
||||
progress REAL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_results (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
objective_id TEXT NOT NULL REFERENCES objectives(id),
|
||||
title TEXT NOT NULL,
|
||||
target_value REAL NOT NULL,
|
||||
current_value REAL DEFAULT 0,
|
||||
unit TEXT,
|
||||
weight REAL DEFAULT 1,
|
||||
linked_plane_cycle_id TEXT,
|
||||
linked_plane_module_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS author_mappings (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
git_email TEXT,
|
||||
git_username TEXT,
|
||||
user_id TEXT REFERENCES users(id),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_repos (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
repo_name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_logs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
records_processed INTEGER DEFAULT 0,
|
||||
synced_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// Run auto-migration on module load (i.e. on server startup)
|
||||
autoMigrate();
|
||||
console.info('[DB] MySQL pool created:', config.MYSQL_HOST + ':' + config.MYSQL_PORT + '/' + config.MYSQL_DATABASE);
|
||||
|
||||
@ -1,72 +1,83 @@
|
||||
import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
text,
|
||||
int,
|
||||
double,
|
||||
datetime,
|
||||
json,
|
||||
mysqlEnum,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
|
||||
// ── Users ──
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
planeUserId: text('plane_user_id'),
|
||||
displayName: text('display_name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
gitUsername: text('git_username'),
|
||||
role: text('role', { enum: ['admin', 'manager', 'developer', 'viewer'] }).notNull(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
loginAttempts: integer('login_attempts').default(0),
|
||||
lockedUntil: integer('locked_until', { mode: 'timestamp' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
export const users = mysqlTable('users', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
planeUserId: varchar('plane_user_id', { length: 200 }),
|
||||
displayName: varchar('display_name', { length: 200 }).notNull(),
|
||||
email: varchar('email', { length: 200 }).notNull().unique(),
|
||||
gitUsername: varchar('git_username', { length: 200 }),
|
||||
role: mysqlEnum('role', ['admin', 'manager', 'developer', 'viewer']).notNull(),
|
||||
passwordHash: varchar('password_hash', { length: 500 }).notNull(),
|
||||
loginAttempts: int('login_attempts').default(0),
|
||||
lockedUntil: datetime('locked_until'),
|
||||
createdAt: datetime('created_at').notNull(),
|
||||
updatedAt: datetime('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
emailIdx: uniqueIndex('uniq_users_email').on(table.email),
|
||||
}));
|
||||
|
||||
// ── Projects ──
|
||||
export const projects = sqliteTable('projects', {
|
||||
id: text('id').primaryKey(),
|
||||
planeProjectId: text('plane_project_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
identifier: text('identifier'),
|
||||
lastSyncedAt: integer('last_synced_at', { mode: 'timestamp' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
export const projects = mysqlTable('projects', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
planeProjectId: varchar('plane_project_id', { length: 200 }).notNull(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
identifier: varchar('identifier', { length: 200 }),
|
||||
lastSyncedAt: datetime('last_synced_at'),
|
||||
createdAt: datetime('created_at').notNull(),
|
||||
updatedAt: datetime('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId),
|
||||
}));
|
||||
|
||||
// ── Sprint Snapshots ──
|
||||
export const sprintSnapshots = sqliteTable('sprint_snapshots', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').references(() => projects.id),
|
||||
planeCycleId: text('plane_cycle_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
startDate: text('start_date'),
|
||||
endDate: text('end_date'),
|
||||
totalPoints: integer('total_points').default(0),
|
||||
completedPoints: integer('completed_points').default(0),
|
||||
totalIssues: integer('total_issues').default(0),
|
||||
completedIssues: integer('completed_issues').default(0),
|
||||
burndownData: text('burndown_data', { mode: 'json' }),
|
||||
status: text('status', { enum: ['upcoming', 'active', 'completed'] }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
export const sprintSnapshots = mysqlTable('sprint_snapshots', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
projectId: varchar('project_id', { length: 50 }).references(() => projects.id),
|
||||
planeCycleId: varchar('plane_cycle_id', { length: 200 }).notNull(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
startDate: varchar('start_date', { length: 50 }),
|
||||
endDate: varchar('end_date', { length: 50 }),
|
||||
totalPoints: int('total_points').default(0),
|
||||
completedPoints: int('completed_points').default(0),
|
||||
totalIssues: int('total_issues').default(0),
|
||||
completedIssues: int('completed_issues').default(0),
|
||||
burndownData: json('burndown_data'),
|
||||
status: mysqlEnum('status', ['upcoming', 'active', 'completed']),
|
||||
createdAt: datetime('created_at').notNull(),
|
||||
updatedAt: datetime('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
projectIdx: index('idx_sprint_project').on(table.projectId),
|
||||
statusIdx: index('idx_sprint_status').on(table.status),
|
||||
}));
|
||||
|
||||
// ── Task Snapshots ──
|
||||
export const taskSnapshots = sqliteTable('task_snapshots', {
|
||||
id: text('id').primaryKey(),
|
||||
planeIssueId: text('plane_issue_id').notNull(),
|
||||
projectId: text('project_id').references(() => projects.id),
|
||||
sprintId: text('sprint_id').references(() => sprintSnapshots.id),
|
||||
title: text('title').notNull(),
|
||||
status: text('status'),
|
||||
priority: text('priority'),
|
||||
assigneeId: text('assignee_id').references(() => users.id),
|
||||
storyPoints: integer('story_points'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }),
|
||||
completedAt: integer('completed_at', { mode: 'timestamp' }),
|
||||
dueDate: text('due_date'),
|
||||
labels: text('labels', { mode: 'json' }),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
export const taskSnapshots = mysqlTable('task_snapshots', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
planeIssueId: varchar('plane_issue_id', { length: 200 }).notNull(),
|
||||
projectId: varchar('project_id', { length: 50 }).references(() => projects.id),
|
||||
sprintId: varchar('sprint_id', { length: 50 }).references(() => sprintSnapshots.id),
|
||||
title: varchar('title', { length: 500 }).notNull(),
|
||||
status: varchar('status', { length: 100 }),
|
||||
priority: varchar('priority', { length: 100 }),
|
||||
assigneeId: varchar('assignee_id', { length: 50 }).references(() => users.id),
|
||||
storyPoints: int('story_points'),
|
||||
createdAt: datetime('created_at'),
|
||||
completedAt: datetime('completed_at'),
|
||||
dueDate: varchar('due_date', { length: 50 }),
|
||||
labels: json('labels'),
|
||||
updatedAt: datetime('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
projectIdx: index('idx_task_project').on(table.projectId),
|
||||
sprintIdx: index('idx_task_sprint').on(table.sprintId),
|
||||
@ -75,35 +86,35 @@ export const taskSnapshots = sqliteTable('task_snapshots', {
|
||||
}));
|
||||
|
||||
// ── Milestones ──
|
||||
export const milestones = sqliteTable('milestones', {
|
||||
id: text('id').primaryKey(),
|
||||
planeModuleId: text('plane_module_id').notNull(),
|
||||
projectId: text('project_id').references(() => projects.id),
|
||||
name: text('name').notNull(),
|
||||
status: text('status'),
|
||||
targetDate: text('target_date'),
|
||||
totalIssues: integer('total_issues').default(0),
|
||||
completedIssues: integer('completed_issues').default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
export const milestones = mysqlTable('milestones', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
planeModuleId: varchar('plane_module_id', { length: 200 }).notNull(),
|
||||
projectId: varchar('project_id', { length: 50 }).references(() => projects.id),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
status: varchar('status', { length: 100 }),
|
||||
targetDate: varchar('target_date', { length: 50 }),
|
||||
totalIssues: int('total_issues').default(0),
|
||||
completedIssues: int('completed_issues').default(0),
|
||||
createdAt: datetime('created_at').notNull(),
|
||||
updatedAt: datetime('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
projectIdx: index('idx_milestone_project').on(table.projectId),
|
||||
}));
|
||||
|
||||
// ── Git Commits ──
|
||||
export const gitCommits = sqliteTable('git_commits', {
|
||||
id: text('id').primaryKey(),
|
||||
repoName: text('repo_name').notNull(),
|
||||
sha: text('sha').notNull().unique(),
|
||||
authorEmail: text('author_email'),
|
||||
authorName: text('author_name'),
|
||||
userId: text('user_id').references(() => users.id),
|
||||
export const gitCommits = mysqlTable('git_commits', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
repoName: varchar('repo_name', { length: 200 }).notNull(),
|
||||
sha: varchar('sha', { length: 200 }).notNull().unique(),
|
||||
authorEmail: varchar('author_email', { length: 200 }),
|
||||
authorName: varchar('author_name', { length: 200 }),
|
||||
userId: varchar('user_id', { length: 50 }).references(() => users.id),
|
||||
message: text('message'),
|
||||
additions: integer('additions').default(0),
|
||||
deletions: integer('deletions').default(0),
|
||||
committedAt: integer('committed_at', { mode: 'timestamp' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
additions: int('additions').default(0),
|
||||
deletions: int('deletions').default(0),
|
||||
committedAt: datetime('committed_at').notNull(),
|
||||
createdAt: datetime('created_at').notNull(),
|
||||
updatedAt: datetime('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
shaIdx: uniqueIndex('uniq_commits_sha').on(table.sha),
|
||||
userIdx: index('idx_commits_user').on(table.userId),
|
||||
@ -112,21 +123,21 @@ export const gitCommits = sqliteTable('git_commits', {
|
||||
}));
|
||||
|
||||
// ── Git PRs ──
|
||||
export const gitPRs = sqliteTable('git_prs', {
|
||||
id: text('id').primaryKey(),
|
||||
repoName: text('repo_name').notNull(),
|
||||
externalId: integer('external_id').notNull(),
|
||||
title: text('title'),
|
||||
userId: text('user_id').references(() => users.id),
|
||||
authorUsername: text('author_username'),
|
||||
state: text('state'),
|
||||
additions: integer('additions').default(0),
|
||||
deletions: integer('deletions').default(0),
|
||||
reviewComments: integer('review_comments').default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }),
|
||||
mergedAt: integer('merged_at', { mode: 'timestamp' }),
|
||||
mergeTimeHours: real('merge_time_hours'),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
export const gitPRs = mysqlTable('git_prs', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
repoName: varchar('repo_name', { length: 200 }).notNull(),
|
||||
externalId: int('external_id').notNull(),
|
||||
title: varchar('title', { length: 500 }),
|
||||
userId: varchar('user_id', { length: 50 }).references(() => users.id),
|
||||
authorUsername: varchar('author_username', { length: 200 }),
|
||||
state: varchar('state', { length: 100 }),
|
||||
additions: int('additions').default(0),
|
||||
deletions: int('deletions').default(0),
|
||||
reviewComments: int('review_comments').default(0),
|
||||
createdAt: datetime('created_at'),
|
||||
mergedAt: datetime('merged_at'),
|
||||
mergeTimeHours: double('merge_time_hours'),
|
||||
updatedAt: datetime('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
userIdx: index('idx_prs_user').on(table.userId),
|
||||
repoIdx: index('idx_prs_repo').on(table.repoName),
|
||||
@ -134,85 +145,85 @@ export const gitPRs = sqliteTable('git_prs', {
|
||||
}));
|
||||
|
||||
// ── Objectives (OKR) ──
|
||||
export const objectives = sqliteTable('objectives', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
ownerId: text('owner_id').references(() => users.id),
|
||||
projectId: text('project_id').references(() => projects.id),
|
||||
period: text('period').notNull(),
|
||||
startDate: text('start_date'),
|
||||
endDate: text('end_date'),
|
||||
progress: real('progress').default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
export const objectives = mysqlTable('objectives', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
title: varchar('title', { length: 500 }).notNull(),
|
||||
ownerId: varchar('owner_id', { length: 50 }).references(() => users.id),
|
||||
projectId: varchar('project_id', { length: 50 }).references(() => projects.id),
|
||||
period: varchar('period', { length: 100 }).notNull(),
|
||||
startDate: varchar('start_date', { length: 50 }),
|
||||
endDate: varchar('end_date', { length: 50 }),
|
||||
progress: double('progress').default(0),
|
||||
createdAt: datetime('created_at').notNull(),
|
||||
updatedAt: datetime('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
periodIdx: index('idx_obj_period').on(table.period),
|
||||
ownerIdx: index('idx_obj_owner').on(table.ownerId),
|
||||
}));
|
||||
|
||||
// ── Key Results (OKR) ──
|
||||
export const keyResults = sqliteTable('key_results', {
|
||||
id: text('id').primaryKey(),
|
||||
objectiveId: text('objective_id').references(() => objectives.id).notNull(),
|
||||
title: text('title').notNull(),
|
||||
targetValue: real('target_value').notNull(),
|
||||
currentValue: real('current_value').default(0),
|
||||
unit: text('unit'),
|
||||
weight: real('weight').default(1),
|
||||
status: text('status').default('active'), // active / paused / cancelled
|
||||
startDate: text('start_date'),
|
||||
endDate: text('end_date'),
|
||||
linkedPlaneCycleId: text('linked_plane_cycle_id'),
|
||||
linkedPlaneModuleId: text('linked_plane_module_id'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
export const keyResults = mysqlTable('key_results', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
objectiveId: varchar('objective_id', { length: 50 }).references(() => objectives.id).notNull(),
|
||||
title: varchar('title', { length: 500 }).notNull(),
|
||||
targetValue: double('target_value').notNull(),
|
||||
currentValue: double('current_value').default(0),
|
||||
unit: varchar('unit', { length: 100 }),
|
||||
weight: double('weight').default(1),
|
||||
status: varchar('status', { length: 100 }).default('active'), // active / paused / cancelled
|
||||
startDate: varchar('start_date', { length: 50 }),
|
||||
endDate: varchar('end_date', { length: 50 }),
|
||||
linkedPlaneCycleId: varchar('linked_plane_cycle_id', { length: 200 }),
|
||||
linkedPlaneModuleId: varchar('linked_plane_module_id', { length: 200 }),
|
||||
createdAt: datetime('created_at').notNull(),
|
||||
updatedAt: datetime('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
objectiveIdx: index('idx_kr_objective').on(table.objectiveId),
|
||||
}));
|
||||
|
||||
// ── KR Operation Logs ──
|
||||
export const krLogs = sqliteTable('kr_logs', {
|
||||
id: text('id').primaryKey(),
|
||||
krId: text('kr_id').references(() => keyResults.id).notNull(),
|
||||
action: text('action').notNull(), // created / postponed / paused / resumed / cancelled / completed / progress_update
|
||||
export const krLogs = mysqlTable('kr_logs', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
krId: varchar('kr_id', { length: 50 }).references(() => keyResults.id).notNull(),
|
||||
action: varchar('action', { length: 200 }).notNull(), // created / postponed / paused / resumed / cancelled / completed / progress_update
|
||||
detail: text('detail'),
|
||||
operatorId: text('operator_id').references(() => users.id),
|
||||
operatorName: text('operator_name'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
operatorId: varchar('operator_id', { length: 50 }).references(() => users.id),
|
||||
operatorName: varchar('operator_name', { length: 200 }),
|
||||
createdAt: datetime('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
krIdx: index('idx_kr_logs_kr').on(table.krId),
|
||||
}));
|
||||
|
||||
// ── Author Mappings ──
|
||||
export const authorMappings = sqliteTable('author_mappings', {
|
||||
id: text('id').primaryKey(),
|
||||
gitEmail: text('git_email'),
|
||||
gitUsername: text('git_username'),
|
||||
userId: text('user_id').references(() => users.id),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
export const authorMappings = mysqlTable('author_mappings', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
gitEmail: varchar('git_email', { length: 200 }),
|
||||
gitUsername: varchar('git_username', { length: 200 }),
|
||||
userId: varchar('user_id', { length: 50 }).references(() => users.id),
|
||||
createdAt: datetime('created_at').notNull(),
|
||||
updatedAt: datetime('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
emailIdx: uniqueIndex('uniq_mapping_email').on(table.gitEmail),
|
||||
usernameIdx: uniqueIndex('uniq_mapping_username').on(table.gitUsername),
|
||||
}));
|
||||
|
||||
// ── Project ↔ Repo Mapping ──
|
||||
export const projectRepos = sqliteTable('project_repos', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').references(() => projects.id).notNull(),
|
||||
repoName: text('repo_name').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
// ── Project <-> Repo Mapping ──
|
||||
export const projectRepos = mysqlTable('project_repos', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
|
||||
repoName: varchar('repo_name', { length: 200 }).notNull(),
|
||||
createdAt: datetime('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
projectIdx: index('idx_project_repos_project').on(table.projectId),
|
||||
repoIdx: index('idx_project_repos_repo').on(table.repoName),
|
||||
}));
|
||||
|
||||
// ── Sync Logs ──
|
||||
export const syncLogs = sqliteTable('sync_logs', {
|
||||
id: text('id').primaryKey(),
|
||||
source: text('source', { enum: ['plane', 'gitea'] }).notNull(),
|
||||
status: text('status', { enum: ['success', 'error'] }).notNull(),
|
||||
export const syncLogs = mysqlTable('sync_logs', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
source: mysqlEnum('source', ['plane', 'gitea']).notNull(),
|
||||
status: mysqlEnum('status', ['success', 'error']).notNull(),
|
||||
message: text('message'),
|
||||
recordsProcessed: integer('records_processed').default(0),
|
||||
syncedAt: integer('synced_at', { mode: 'timestamp' }).notNull(),
|
||||
recordsProcessed: int('records_processed').default(0),
|
||||
syncedAt: datetime('synced_at').notNull(),
|
||||
});
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
FROM node:22-slim AS builder
|
||||
WORKDIR /app
|
||||
ARG VITE_API_BASE_URL=http://localhost:3200
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci || npm install
|
||||
COPY . .
|
||||
@ -7,5 +9,7 @@ RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
||||
COPY ../deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# SPA fallback: all routes to index.html
|
||||
RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n index index.html;\n location / {\n try_files $uri $uri/ /index.html;\n }\n location /api/ {\n return 404;\n }\n}\n' > /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@ -53,6 +53,8 @@ onUnmounted(() => {
|
||||
transition: margin-left var(--duration-collapse) var(--ease-default);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.main-container.collapsed {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { NAvatar, NTag, NTooltip } from 'naive-ui';
|
||||
import { NTag, NTooltip } from 'naive-ui';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useDashboardStore } from '@/stores/dashboard';
|
||||
import { getOverviewApi } from '@/api/overview';
|
||||
@ -126,22 +126,14 @@ const roleTagType = computed(() => {
|
||||
}"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<div class="logo" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">DP</span>
|
||||
<span class="logo-text">DevPerf</span>
|
||||
<span class="logo-text" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">DevPerf</span>
|
||||
</div>
|
||||
<div class="logo" v-else>
|
||||
<span class="logo-icon">DP</span>
|
||||
</div>
|
||||
<!-- Close button on mobile -->
|
||||
<button
|
||||
v-if="dashStore.isMobile"
|
||||
class="close-btn"
|
||||
aria-label="关闭侧边栏"
|
||||
@click="dashStore.closeMobileSidebar"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<button v-if="dashStore.isMobile" class="close-btn" @click="dashStore.closeMobileSidebar">×</button>
|
||||
<span v-else class="collapse-toggle" @click="dashStore.toggleSidebar" :title="dashStore.sidebarCollapsed ? '展开' : '收起'">
|
||||
{{ dashStore.sidebarCollapsed ? '»' : '«' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
@ -188,22 +180,21 @@ const roleTagType = computed(() => {
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<!-- Only show collapse button on desktop -->
|
||||
<button v-if="!dashStore.isMobile" class="collapse-btn" @click="dashStore.toggleSidebar">
|
||||
{{ dashStore.sidebarCollapsed ? '>' : '<' }}
|
||||
</button>
|
||||
<div class="user-area" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">
|
||||
<NAvatar round :size="36" :style="{ backgroundColor: '#3B5998' }">
|
||||
{{ authStore.user?.displayName?.[0] || '?' }}
|
||||
</NAvatar>
|
||||
<div class="user-row" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ authStore.user?.displayName }}</span>
|
||||
<NTag :type="roleTagType" size="tiny">{{ authStore.user?.role }}</NTag>
|
||||
<span class="user-role">{{ authStore.user?.role }}</span>
|
||||
</div>
|
||||
<span class="logout-link" @click="authStore.logout" title="退出登录">退出</span>
|
||||
</div>
|
||||
<div v-else class="user-row-mini">
|
||||
<NTooltip placement="right">
|
||||
<template #trigger>
|
||||
<span class="user-initial">{{ authStore.user?.displayName?.[0] || '?' }}</span>
|
||||
</template>
|
||||
{{ authStore.user?.displayName }} · {{ authStore.user?.role }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
<button class="logout-btn" @click="authStore.logout" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@ -223,6 +214,7 @@ const roleTagType = computed(() => {
|
||||
transform 0.3s ease;
|
||||
z-index: var(--z-sticky);
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
@ -243,13 +235,24 @@ const roleTagType = computed(() => {
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: var(--space-4) var(--space-4);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.collapse-toggle {
|
||||
font-size: 14px;
|
||||
color: #6B7280;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.collapse-toggle:hover { color: #E5E7EB; background: rgba(255,255,255,0.08); }
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -368,64 +371,63 @@ const roleTagType = computed(() => {
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-2);
|
||||
background: none;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
color: #9CA3AF;
|
||||
border-radius: var(--radius-btn);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-3);
|
||||
transition: background var(--duration-hover);
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.user-area {
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: #E5E7EB;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-2);
|
||||
background: none;
|
||||
border: 1px solid rgba(220,38,38,0.3);
|
||||
color: #DC2626;
|
||||
border-radius: var(--radius-btn);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background var(--duration-hover);
|
||||
.user-role {
|
||||
font-size: 11px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
.logout-link {
|
||||
font-size: 12px;
|
||||
color: #6B7280;
|
||||
cursor: pointer;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logout-link:hover {
|
||||
color: #DC2626;
|
||||
background: rgba(220,38,38,0.1);
|
||||
}
|
||||
|
||||
.user-row-mini {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-initial {
|
||||
width: 28px; height: 28px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(59,89,152,0.3); border-radius: 50%;
|
||||
font-size: 12px; font-weight: 600; color: #E5E7EB; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hamburger button - positioned fixed on mobile */
|
||||
.hamburger-btn {
|
||||
position: fixed;
|
||||
|
||||
@ -80,6 +80,12 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
|
||||
@ -1,19 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue';
|
||||
import { NSpin } from 'naive-ui';
|
||||
import { getGitActivityApi } from '@/api/git';
|
||||
import DataCard from '@/components/shared/DataCard.vue';
|
||||
import ContributionHeatmap from '@/components/charts/ContributionHeatmap.vue';
|
||||
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { BarChart } from 'echarts/charts';
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import type { HeatmapDay } from '@/types';
|
||||
|
||||
echarts.use([BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
|
||||
|
||||
const loading = ref(true);
|
||||
const data = ref<any>(null);
|
||||
const weeklyChartRef = ref<HTMLElement | null>(null);
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getGitActivityApi({ weeks: 12 });
|
||||
data.value = res.data.data;
|
||||
await nextTick();
|
||||
renderWeeklyChart();
|
||||
} catch (err) {
|
||||
console.error('Failed to load git activity:', err);
|
||||
} finally {
|
||||
@ -32,12 +41,19 @@ const heatmapDays = computed<HeatmapDay[]>(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
// 每周趋势:按人堆叠的柱状图
|
||||
const weeklyOptions = computed(() => {
|
||||
const weeks = data.value?.weeklyTrend || [];
|
||||
if (!weeks.length) return {};
|
||||
const COLORS = ['#3B5998', '#0D9668', '#D4920A', '#7C4DBA', '#DC2626', '#06B6D4', '#8B5CF6', '#EC4899'];
|
||||
|
||||
// 收集所有出现过的用户
|
||||
function renderWeeklyChart() {
|
||||
if (!weeklyChartRef.value || !data.value?.weeklyTrend) return;
|
||||
|
||||
if (!chartInstance) {
|
||||
chartInstance = echarts.init(weeklyChartRef.value);
|
||||
new ResizeObserver(() => chartInstance?.resize()).observe(weeklyChartRef.value);
|
||||
}
|
||||
|
||||
const weeks = data.value.weeklyTrend;
|
||||
|
||||
// 收集所有用户
|
||||
const userSet = new Map<string, string>();
|
||||
for (const w of weeks) {
|
||||
for (const u of (w.byUser || [])) {
|
||||
@ -46,17 +62,17 @@ const weeklyOptions = computed(() => {
|
||||
}
|
||||
|
||||
const series = Array.from(userSet.entries()).map(([userId, name], i) => ({
|
||||
type: 'bar',
|
||||
type: 'bar' as const,
|
||||
name,
|
||||
stack: 'commits',
|
||||
data: weeks.map((w: any) => {
|
||||
const found = (w.byUser || []).find((u: any) => u.userId === userId);
|
||||
return found?.commits || 0;
|
||||
}),
|
||||
itemStyle: { color: ['#3B5998', '#0D9668', '#D4920A', '#7C4DBA', '#DC2626', '#06B6D4', '#8B5CF6', '#EC4899'][i % 8], borderRadius: i === userSet.size - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0] },
|
||||
itemStyle: { color: COLORS[i % COLORS.length], borderRadius: i === userSet.size - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0] },
|
||||
}));
|
||||
|
||||
return {
|
||||
chartInstance.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter(params: any) {
|
||||
@ -77,10 +93,8 @@ const weeklyOptions = computed(() => {
|
||||
xAxis: { type: 'category', data: weeks.map((w: any) => w.weekStart), axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: 'value', name: '提交数' },
|
||||
series,
|
||||
};
|
||||
});
|
||||
|
||||
const { chartRef: weeklyRef } = useECharts(weeklyOptions);
|
||||
}, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -116,7 +130,7 @@ const { chartRef: weeklyRef } = useECharts(weeklyOptions);
|
||||
|
||||
<!-- 每周提交趋势(按人堆叠) -->
|
||||
<DataCard title="每周提交趋势" subtitle="按贡献者堆叠" style="margin-top: var(--space-5)">
|
||||
<div ref="weeklyRef" style="height: 300px" />
|
||||
<div ref="weeklyChartRef" style="height: 300px" />
|
||||
</DataCard>
|
||||
</template>
|
||||
</NSpin>
|
||||
|
||||
85
k8s/backend-deployment.yaml
Normal file
85
k8s/backend-deployment.yaml
Normal file
@ -0,0 +1,85 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: devperf-backend
|
||||
labels:
|
||||
app: devperf-backend
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: devperf-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: devperf-backend
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: cr-pull-secret
|
||||
containers:
|
||||
- name: devperf-backend
|
||||
image: ${CI_REGISTRY_IMAGE}/backend:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3200
|
||||
env:
|
||||
- name: PORT
|
||||
value: "3200"
|
||||
- name: JWT_SECRET
|
||||
value: "devperf-jwt-secret-prod-2026"
|
||||
# MySQL (私网地址)
|
||||
- name: MYSQL_HOST
|
||||
value: "mysql8351f937d637.rds.ivolces.com"
|
||||
- name: MYSQL_PORT
|
||||
value: "3306"
|
||||
- name: MYSQL_USER
|
||||
value: "zyc"
|
||||
- name: MYSQL_PASSWORD
|
||||
value: "Zyc188208"
|
||||
- name: MYSQL_DATABASE
|
||||
value: "devperf"
|
||||
# Gitea
|
||||
- name: GITEA_BASE_URL
|
||||
value: "https://gitea.airlabs.art"
|
||||
- name: GITEA_API_TOKEN
|
||||
value: "5d37e09cab2735055f3fc1498931206f666f0539"
|
||||
- name: GITEA_ORG
|
||||
value: "zyc"
|
||||
# CORS
|
||||
- name: CORS_ORIGINS
|
||||
value: "https://devperf.airlabs.art"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3200
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3200
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: devperf-backend
|
||||
spec:
|
||||
selector:
|
||||
app: devperf-backend
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3200
|
||||
targetPort: 3200
|
||||
23
k8s/backend-ingress.yaml
Normal file
23
k8s/backend-ingress.yaml
Normal file
@ -0,0 +1,23 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: devperf-backend-ingress
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "traefik"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- devperf-api.airlabs.art
|
||||
secretName: devperf-backend-tls
|
||||
rules:
|
||||
- host: devperf-api.airlabs.art
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: devperf-backend
|
||||
port:
|
||||
number: 3200
|
||||
14
k8s/cert-manager-issuer.yaml
Normal file
14
k8s/cert-manager-issuer.yaml
Normal file
@ -0,0 +1,14 @@
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: airlabsv001@gmail.com
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod-key
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: traefik
|
||||
59
k8s/web-deployment.yaml
Normal file
59
k8s/web-deployment.yaml
Normal file
@ -0,0 +1,59 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: devperf-web
|
||||
labels:
|
||||
app: devperf-web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: devperf-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: devperf-web
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: cr-pull-secret
|
||||
containers:
|
||||
- name: devperf-web
|
||||
image: ${CI_REGISTRY_IMAGE}/web:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: devperf-web
|
||||
spec:
|
||||
selector:
|
||||
app: devperf-web
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
23
k8s/web-ingress.yaml
Normal file
23
k8s/web-ingress.yaml
Normal file
@ -0,0 +1,23 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: devperf-web-ingress
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "traefik"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- devperf.airlabs.art
|
||||
secretName: devperf-web-tls
|
||||
rules:
|
||||
- host: devperf.airlabs.art
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: devperf-web
|
||||
port:
|
||||
number: 80
|
||||
Loading…
x
Reference in New Issue
Block a user