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:
zyc 2026-04-13 13:48:54 +08:00
parent 58fe2b1ea8
commit 43f885e22a
18 changed files with 666 additions and 457 deletions

View 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

View File

@ -1,17 +1,23 @@
# ---- Required ---- # ---- Required ----
DATABASE_PATH=./data/devperf.db
JWT_SECRET=your-jwt-secret-here-change-in-production JWT_SECRET=your-jwt-secret-here-change-in-production
PORT=3200 PORT=3200
# ---- Plane Connection ---- # ---- MySQL Connection ----
PLANE_BASE_URL=http://plane-api:8000 MYSQL_HOST=localhost
PLANE_API_TOKEN= # Generate in Plane Settings > API Tokens MYSQL_PORT=3306
PLANE_WORKSPACE_SLUG=jasonqiyuan MYSQL_USER=root
MYSQL_PASSWORD=
MYSQL_DATABASE=devperf
# ---- Gitea Connection ---- # ---- Gitea Connection ----
GITEA_BASE_URL=http://gitea:3000 GITEA_BASE_URL=https://gitea.airlabs.art
GITEA_API_TOKEN= # Generate in Gitea Settings > Applications GITEA_API_TOKEN=
GITEA_ORG=jasonqiyuan GITEA_ORG=zyc
# ---- Plane Connection (optional) ----
PLANE_BASE_URL=http://plane-api:8000
PLANE_API_TOKEN=
PLANE_WORKSPACE_SLUG=jasonqiyuan
# ---- Sync Intervals (minutes) ---- # ---- Sync Intervals (minutes) ----
SYNC_PLANE_INTERVAL=15 SYNC_PLANE_INTERVAL=15

View File

@ -1,16 +1,8 @@
FROM oven/bun:1 AS builder FROM oven/bun:1
WORKDIR /app WORKDIR /app
COPY package.json bun.lockb* ./ COPY package.json bun.lockb* ./
RUN bun install --frozen-lockfile || bun install RUN bun install --frozen-lockfile || bun install
COPY src/ src/ COPY src/ src/
COPY drizzle.config.ts tsconfig.json ./ COPY 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
EXPOSE 3200 EXPOSE 3200
CMD ["bun", "run", "dist/index.js"] CMD ["bun", "run", "src/index.ts"]

View File

@ -13,6 +13,7 @@
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.0",
"hono": "^4.7.0", "hono": "^4.7.0",
"jose": "^5.9.0", "jose": "^5.9.0",
"mysql2": "^3.22.0",
"uuid": "^11.0.0", "uuid": "^11.0.0",
"zod": "^3.24.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=="], "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=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],

View File

@ -13,16 +13,17 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "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", "@hono/zod-validator": "^0.4.0",
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.7.0",
"croner": "^9.0.0", "croner": "^9.0.0",
"dayjs": "^1.11.13", "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": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",

View File

@ -1,10 +1,15 @@
import { z } from 'zod'; import { z } from 'zod';
const envSchema = z.object({ 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'), JWT_SECRET: z.string().min(16, 'JWT_SECRET must be at least 16 characters'),
PORT: z.coerce.number().default(3200), 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_BASE_URL: z.string().url().default('http://plane-api:8000'),
PLANE_API_TOKEN: z.string().default(''), PLANE_API_TOKEN: z.string().default(''),
PLANE_WORKSPACE_SLUG: z.string().default('jasonqiyuan'), PLANE_WORKSPACE_SLUG: z.string().default('jasonqiyuan'),

View File

@ -1,213 +1,20 @@
import { drizzle } from 'drizzle-orm/bun-sqlite'; import { drizzle } from 'drizzle-orm/mysql2';
import { Database } from 'bun:sqlite'; import mysql from 'mysql2/promise';
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
import * as schema from './schema'; import * as schema from './schema';
import { config } from '../config'; import { config } from '../config';
import { mkdirSync, existsSync } from 'fs';
import { dirname, resolve } from 'path';
// Ensure data directory exists const pool = mysql.createPool({
const dbDir = dirname(config.DATABASE_PATH); host: config.MYSQL_HOST,
if (!existsSync(dbDir)) { port: config.MYSQL_PORT,
mkdirSync(dbDir, { recursive: true }); 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 }); export const db = drizzle(pool, { schema, mode: 'default' });
sqlite.exec('PRAGMA journal_mode = WAL'); export { pool };
sqlite.exec('PRAGMA foreign_keys = ON');
export const db = drizzle(sqlite, { schema }); console.info('[DB] MySQL pool created:', config.MYSQL_HOST + ':' + config.MYSQL_PORT + '/' + config.MYSQL_DATABASE);
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();

View File

@ -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 ── // ── Users ──
export const users = sqliteTable('users', { export const users = mysqlTable('users', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
planeUserId: text('plane_user_id'), planeUserId: varchar('plane_user_id', { length: 200 }),
displayName: text('display_name').notNull(), displayName: varchar('display_name', { length: 200 }).notNull(),
email: text('email').notNull().unique(), email: varchar('email', { length: 200 }).notNull().unique(),
gitUsername: text('git_username'), gitUsername: varchar('git_username', { length: 200 }),
role: text('role', { enum: ['admin', 'manager', 'developer', 'viewer'] }).notNull(), role: mysqlEnum('role', ['admin', 'manager', 'developer', 'viewer']).notNull(),
passwordHash: text('password_hash').notNull(), passwordHash: varchar('password_hash', { length: 500 }).notNull(),
loginAttempts: integer('login_attempts').default(0), loginAttempts: int('login_attempts').default(0),
lockedUntil: integer('locked_until', { mode: 'timestamp' }), lockedUntil: datetime('locked_until'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: datetime('created_at').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
emailIdx: uniqueIndex('uniq_users_email').on(table.email), emailIdx: uniqueIndex('uniq_users_email').on(table.email),
})); }));
// ── Projects ── // ── Projects ──
export const projects = sqliteTable('projects', { export const projects = mysqlTable('projects', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
planeProjectId: text('plane_project_id').notNull(), planeProjectId: varchar('plane_project_id', { length: 200 }).notNull(),
name: text('name').notNull(), name: varchar('name', { length: 200 }).notNull(),
identifier: text('identifier'), identifier: varchar('identifier', { length: 200 }),
lastSyncedAt: integer('last_synced_at', { mode: 'timestamp' }), lastSyncedAt: datetime('last_synced_at'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: datetime('created_at').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId), planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId),
})); }));
// ── Sprint Snapshots ── // ── Sprint Snapshots ──
export const sprintSnapshots = sqliteTable('sprint_snapshots', { export const sprintSnapshots = mysqlTable('sprint_snapshots', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
projectId: text('project_id').references(() => projects.id), projectId: varchar('project_id', { length: 50 }).references(() => projects.id),
planeCycleId: text('plane_cycle_id').notNull(), planeCycleId: varchar('plane_cycle_id', { length: 200 }).notNull(),
name: text('name').notNull(), name: varchar('name', { length: 200 }).notNull(),
startDate: text('start_date'), startDate: varchar('start_date', { length: 50 }),
endDate: text('end_date'), endDate: varchar('end_date', { length: 50 }),
totalPoints: integer('total_points').default(0), totalPoints: int('total_points').default(0),
completedPoints: integer('completed_points').default(0), completedPoints: int('completed_points').default(0),
totalIssues: integer('total_issues').default(0), totalIssues: int('total_issues').default(0),
completedIssues: integer('completed_issues').default(0), completedIssues: int('completed_issues').default(0),
burndownData: text('burndown_data', { mode: 'json' }), burndownData: json('burndown_data'),
status: text('status', { enum: ['upcoming', 'active', 'completed'] }), status: mysqlEnum('status', ['upcoming', 'active', 'completed']),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: datetime('created_at').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
projectIdx: index('idx_sprint_project').on(table.projectId), projectIdx: index('idx_sprint_project').on(table.projectId),
statusIdx: index('idx_sprint_status').on(table.status), statusIdx: index('idx_sprint_status').on(table.status),
})); }));
// ── Task Snapshots ── // ── Task Snapshots ──
export const taskSnapshots = sqliteTable('task_snapshots', { export const taskSnapshots = mysqlTable('task_snapshots', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
planeIssueId: text('plane_issue_id').notNull(), planeIssueId: varchar('plane_issue_id', { length: 200 }).notNull(),
projectId: text('project_id').references(() => projects.id), projectId: varchar('project_id', { length: 50 }).references(() => projects.id),
sprintId: text('sprint_id').references(() => sprintSnapshots.id), sprintId: varchar('sprint_id', { length: 50 }).references(() => sprintSnapshots.id),
title: text('title').notNull(), title: varchar('title', { length: 500 }).notNull(),
status: text('status'), status: varchar('status', { length: 100 }),
priority: text('priority'), priority: varchar('priority', { length: 100 }),
assigneeId: text('assignee_id').references(() => users.id), assigneeId: varchar('assignee_id', { length: 50 }).references(() => users.id),
storyPoints: integer('story_points'), storyPoints: int('story_points'),
createdAt: integer('created_at', { mode: 'timestamp' }), createdAt: datetime('created_at'),
completedAt: integer('completed_at', { mode: 'timestamp' }), completedAt: datetime('completed_at'),
dueDate: text('due_date'), dueDate: varchar('due_date', { length: 50 }),
labels: text('labels', { mode: 'json' }), labels: json('labels'),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
projectIdx: index('idx_task_project').on(table.projectId), projectIdx: index('idx_task_project').on(table.projectId),
sprintIdx: index('idx_task_sprint').on(table.sprintId), sprintIdx: index('idx_task_sprint').on(table.sprintId),
@ -75,35 +86,35 @@ export const taskSnapshots = sqliteTable('task_snapshots', {
})); }));
// ── Milestones ── // ── Milestones ──
export const milestones = sqliteTable('milestones', { export const milestones = mysqlTable('milestones', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
planeModuleId: text('plane_module_id').notNull(), planeModuleId: varchar('plane_module_id', { length: 200 }).notNull(),
projectId: text('project_id').references(() => projects.id), projectId: varchar('project_id', { length: 50 }).references(() => projects.id),
name: text('name').notNull(), name: varchar('name', { length: 200 }).notNull(),
status: text('status'), status: varchar('status', { length: 100 }),
targetDate: text('target_date'), targetDate: varchar('target_date', { length: 50 }),
totalIssues: integer('total_issues').default(0), totalIssues: int('total_issues').default(0),
completedIssues: integer('completed_issues').default(0), completedIssues: int('completed_issues').default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: datetime('created_at').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
projectIdx: index('idx_milestone_project').on(table.projectId), projectIdx: index('idx_milestone_project').on(table.projectId),
})); }));
// ── Git Commits ── // ── Git Commits ──
export const gitCommits = sqliteTable('git_commits', { export const gitCommits = mysqlTable('git_commits', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
repoName: text('repo_name').notNull(), repoName: varchar('repo_name', { length: 200 }).notNull(),
sha: text('sha').notNull().unique(), sha: varchar('sha', { length: 200 }).notNull().unique(),
authorEmail: text('author_email'), authorEmail: varchar('author_email', { length: 200 }),
authorName: text('author_name'), authorName: varchar('author_name', { length: 200 }),
userId: text('user_id').references(() => users.id), userId: varchar('user_id', { length: 50 }).references(() => users.id),
message: text('message'), message: text('message'),
additions: integer('additions').default(0), additions: int('additions').default(0),
deletions: integer('deletions').default(0), deletions: int('deletions').default(0),
committedAt: integer('committed_at', { mode: 'timestamp' }).notNull(), committedAt: datetime('committed_at').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: datetime('created_at').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
shaIdx: uniqueIndex('uniq_commits_sha').on(table.sha), shaIdx: uniqueIndex('uniq_commits_sha').on(table.sha),
userIdx: index('idx_commits_user').on(table.userId), userIdx: index('idx_commits_user').on(table.userId),
@ -112,21 +123,21 @@ export const gitCommits = sqliteTable('git_commits', {
})); }));
// ── Git PRs ── // ── Git PRs ──
export const gitPRs = sqliteTable('git_prs', { export const gitPRs = mysqlTable('git_prs', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
repoName: text('repo_name').notNull(), repoName: varchar('repo_name', { length: 200 }).notNull(),
externalId: integer('external_id').notNull(), externalId: int('external_id').notNull(),
title: text('title'), title: varchar('title', { length: 500 }),
userId: text('user_id').references(() => users.id), userId: varchar('user_id', { length: 50 }).references(() => users.id),
authorUsername: text('author_username'), authorUsername: varchar('author_username', { length: 200 }),
state: text('state'), state: varchar('state', { length: 100 }),
additions: integer('additions').default(0), additions: int('additions').default(0),
deletions: integer('deletions').default(0), deletions: int('deletions').default(0),
reviewComments: integer('review_comments').default(0), reviewComments: int('review_comments').default(0),
createdAt: integer('created_at', { mode: 'timestamp' }), createdAt: datetime('created_at'),
mergedAt: integer('merged_at', { mode: 'timestamp' }), mergedAt: datetime('merged_at'),
mergeTimeHours: real('merge_time_hours'), mergeTimeHours: double('merge_time_hours'),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
userIdx: index('idx_prs_user').on(table.userId), userIdx: index('idx_prs_user').on(table.userId),
repoIdx: index('idx_prs_repo').on(table.repoName), repoIdx: index('idx_prs_repo').on(table.repoName),
@ -134,85 +145,85 @@ export const gitPRs = sqliteTable('git_prs', {
})); }));
// ── Objectives (OKR) ── // ── Objectives (OKR) ──
export const objectives = sqliteTable('objectives', { export const objectives = mysqlTable('objectives', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
title: text('title').notNull(), title: varchar('title', { length: 500 }).notNull(),
ownerId: text('owner_id').references(() => users.id), ownerId: varchar('owner_id', { length: 50 }).references(() => users.id),
projectId: text('project_id').references(() => projects.id), projectId: varchar('project_id', { length: 50 }).references(() => projects.id),
period: text('period').notNull(), period: varchar('period', { length: 100 }).notNull(),
startDate: text('start_date'), startDate: varchar('start_date', { length: 50 }),
endDate: text('end_date'), endDate: varchar('end_date', { length: 50 }),
progress: real('progress').default(0), progress: double('progress').default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: datetime('created_at').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
periodIdx: index('idx_obj_period').on(table.period), periodIdx: index('idx_obj_period').on(table.period),
ownerIdx: index('idx_obj_owner').on(table.ownerId), ownerIdx: index('idx_obj_owner').on(table.ownerId),
})); }));
// ── Key Results (OKR) ── // ── Key Results (OKR) ──
export const keyResults = sqliteTable('key_results', { export const keyResults = mysqlTable('key_results', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
objectiveId: text('objective_id').references(() => objectives.id).notNull(), objectiveId: varchar('objective_id', { length: 50 }).references(() => objectives.id).notNull(),
title: text('title').notNull(), title: varchar('title', { length: 500 }).notNull(),
targetValue: real('target_value').notNull(), targetValue: double('target_value').notNull(),
currentValue: real('current_value').default(0), currentValue: double('current_value').default(0),
unit: text('unit'), unit: varchar('unit', { length: 100 }),
weight: real('weight').default(1), weight: double('weight').default(1),
status: text('status').default('active'), // active / paused / cancelled status: varchar('status', { length: 100 }).default('active'), // active / paused / cancelled
startDate: text('start_date'), startDate: varchar('start_date', { length: 50 }),
endDate: text('end_date'), endDate: varchar('end_date', { length: 50 }),
linkedPlaneCycleId: text('linked_plane_cycle_id'), linkedPlaneCycleId: varchar('linked_plane_cycle_id', { length: 200 }),
linkedPlaneModuleId: text('linked_plane_module_id'), linkedPlaneModuleId: varchar('linked_plane_module_id', { length: 200 }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: datetime('created_at').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
objectiveIdx: index('idx_kr_objective').on(table.objectiveId), objectiveIdx: index('idx_kr_objective').on(table.objectiveId),
})); }));
// ── KR Operation Logs ── // ── KR Operation Logs ──
export const krLogs = sqliteTable('kr_logs', { export const krLogs = mysqlTable('kr_logs', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
krId: text('kr_id').references(() => keyResults.id).notNull(), krId: varchar('kr_id', { length: 50 }).references(() => keyResults.id).notNull(),
action: text('action').notNull(), // created / postponed / paused / resumed / cancelled / completed / progress_update action: varchar('action', { length: 200 }).notNull(), // created / postponed / paused / resumed / cancelled / completed / progress_update
detail: text('detail'), detail: text('detail'),
operatorId: text('operator_id').references(() => users.id), operatorId: varchar('operator_id', { length: 50 }).references(() => users.id),
operatorName: text('operator_name'), operatorName: varchar('operator_name', { length: 200 }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: datetime('created_at').notNull(),
}, (table) => ({ }, (table) => ({
krIdx: index('idx_kr_logs_kr').on(table.krId), krIdx: index('idx_kr_logs_kr').on(table.krId),
})); }));
// ── Author Mappings ── // ── Author Mappings ──
export const authorMappings = sqliteTable('author_mappings', { export const authorMappings = mysqlTable('author_mappings', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
gitEmail: text('git_email'), gitEmail: varchar('git_email', { length: 200 }),
gitUsername: text('git_username'), gitUsername: varchar('git_username', { length: 200 }),
userId: text('user_id').references(() => users.id), userId: varchar('user_id', { length: 50 }).references(() => users.id),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: datetime('created_at').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
emailIdx: uniqueIndex('uniq_mapping_email').on(table.gitEmail), emailIdx: uniqueIndex('uniq_mapping_email').on(table.gitEmail),
usernameIdx: uniqueIndex('uniq_mapping_username').on(table.gitUsername), usernameIdx: uniqueIndex('uniq_mapping_username').on(table.gitUsername),
})); }));
// ── Project Repo Mapping ── // ── Project <-> Repo Mapping ──
export const projectRepos = sqliteTable('project_repos', { export const projectRepos = mysqlTable('project_repos', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
projectId: text('project_id').references(() => projects.id).notNull(), projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
repoName: text('repo_name').notNull(), repoName: varchar('repo_name', { length: 200 }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: datetime('created_at').notNull(),
}, (table) => ({ }, (table) => ({
projectIdx: index('idx_project_repos_project').on(table.projectId), projectIdx: index('idx_project_repos_project').on(table.projectId),
repoIdx: index('idx_project_repos_repo').on(table.repoName), repoIdx: index('idx_project_repos_repo').on(table.repoName),
})); }));
// ── Sync Logs ── // ── Sync Logs ──
export const syncLogs = sqliteTable('sync_logs', { export const syncLogs = mysqlTable('sync_logs', {
id: text('id').primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
source: text('source', { enum: ['plane', 'gitea'] }).notNull(), source: mysqlEnum('source', ['plane', 'gitea']).notNull(),
status: text('status', { enum: ['success', 'error'] }).notNull(), status: mysqlEnum('status', ['success', 'error']).notNull(),
message: text('message'), message: text('message'),
recordsProcessed: integer('records_processed').default(0), recordsProcessed: int('records_processed').default(0),
syncedAt: integer('synced_at', { mode: 'timestamp' }).notNull(), syncedAt: datetime('synced_at').notNull(),
}); });

View File

@ -1,5 +1,7 @@
FROM node:22-slim AS builder FROM node:22-slim AS builder
WORKDIR /app 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* ./ COPY package.json package-lock.json* ./
RUN npm ci || npm install RUN npm ci || npm install
COPY . . COPY . .
@ -7,5 +9,7 @@ RUN npm run build
FROM nginx:alpine FROM nginx:alpine
COPY --from=builder /app/dist/ /usr/share/nginx/html/ 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 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -53,6 +53,8 @@ onUnmounted(() => {
transition: margin-left var(--duration-collapse) var(--ease-default); transition: margin-left var(--duration-collapse) var(--ease-default);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
overflow-x: hidden;
} }
.main-container.collapsed { .main-container.collapsed {

View File

@ -6,7 +6,7 @@
*/ */
import { computed, ref, onMounted } from 'vue'; import { computed, ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; 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 { useAuthStore } from '@/stores/auth';
import { useDashboardStore } from '@/stores/dashboard'; import { useDashboardStore } from '@/stores/dashboard';
import { getOverviewApi } from '@/api/overview'; import { getOverviewApi } from '@/api/overview';
@ -126,22 +126,14 @@ const roleTagType = computed(() => {
}" }"
> >
<div class="sidebar-header"> <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-icon">DP</span>
<span class="logo-text">DevPerf</span> <span class="logo-text" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">DevPerf</span>
</div> </div>
<div class="logo" v-else> <button v-if="dashStore.isMobile" class="close-btn" @click="dashStore.closeMobileSidebar">&times;</button>
<span class="logo-icon">DP</span> <span v-else class="collapse-toggle" @click="dashStore.toggleSidebar" :title="dashStore.sidebarCollapsed ? '展开' : '收起'">
</div> {{ dashStore.sidebarCollapsed ? '»' : '«' }}
<!-- Close button on mobile --> </span>
<button
v-if="dashStore.isMobile"
class="close-btn"
aria-label="关闭侧边栏"
@click="dashStore.closeMobileSidebar"
>
&times;
</button>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@ -188,22 +180,21 @@ const roleTagType = computed(() => {
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<!-- Only show collapse button on desktop --> <div class="user-row" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">
<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-info"> <div class="user-info">
<span class="user-name">{{ authStore.user?.displayName }}</span> <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> </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> </div>
<button class="logout-btn" @click="authStore.logout" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">
退出登录
</button>
</div> </div>
</aside> </aside>
</template> </template>
@ -223,6 +214,7 @@ const roleTagType = computed(() => {
transform 0.3s ease; transform 0.3s ease;
z-index: var(--z-sticky); z-index: var(--z-sticky);
overflow: hidden; overflow: hidden;
overflow-y: auto;
} }
.sidebar.collapsed { .sidebar.collapsed {
@ -243,13 +235,24 @@ const roleTagType = computed(() => {
} }
.sidebar-header { .sidebar-header {
padding: var(--space-4) var(--space-4); padding: var(--space-4);
border-bottom: 1px solid rgba(255,255,255,0.1); border-bottom: 1px solid rgba(255,255,255,0.08);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; 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 { .logo {
display: flex; display: flex;
align-items: center; align-items: center;
@ -368,64 +371,63 @@ const roleTagType = computed(() => {
} }
.sidebar-footer { .sidebar-footer {
padding: var(--space-4); padding: var(--space-3) var(--space-4);
border-top: 1px solid rgba(255,255,255,0.1); border-top: 1px solid rgba(255,255,255,0.08);
} }
.collapse-btn { .user-row {
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 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); justify-content: space-between;
margin-bottom: var(--space-3);
} }
.user-info { .user-info {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 2px; gap: 8px;
overflow: hidden; min-width: 0;
} }
.user-name { .user-name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 500;
color: #E5E7EB;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.logout-btn { .user-role {
width: 100%; font-size: 11px;
padding: var(--space-2); color: #6B7280;
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);
} }
.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); 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 button - positioned fixed on mobile */
.hamburger-btn { .hamburger-btn {
position: fixed; position: fixed;

View File

@ -80,6 +80,12 @@
padding: 0; padding: 0;
} }
html, body {
margin: 0;
padding: 0;
overflow-x: hidden;
}
html { html {
font-family: var(--font-body); font-family: var(--font-body);
font-size: 14px; font-size: 14px;

View File

@ -1,19 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed, watch, nextTick } from 'vue';
import { NSpin } from 'naive-ui'; import { NSpin } from 'naive-ui';
import { getGitActivityApi } from '@/api/git'; import { getGitActivityApi } from '@/api/git';
import DataCard from '@/components/shared/DataCard.vue'; import DataCard from '@/components/shared/DataCard.vue';
import ContributionHeatmap from '@/components/charts/ContributionHeatmap.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'; import type { HeatmapDay } from '@/types';
echarts.use([BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
const loading = ref(true); const loading = ref(true);
const data = ref<any>(null); const data = ref<any>(null);
const weeklyChartRef = ref<HTMLElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
onMounted(async () => { onMounted(async () => {
try { try {
const res = await getGitActivityApi({ weeks: 12 }); const res = await getGitActivityApi({ weeks: 12 });
data.value = res.data.data; data.value = res.data.data;
await nextTick();
renderWeeklyChart();
} catch (err) { } catch (err) {
console.error('Failed to load git activity:', err); console.error('Failed to load git activity:', err);
} finally { } finally {
@ -32,12 +41,19 @@ const heatmapDays = computed<HeatmapDay[]>(() => {
})); }));
}); });
// const COLORS = ['#3B5998', '#0D9668', '#D4920A', '#7C4DBA', '#DC2626', '#06B6D4', '#8B5CF6', '#EC4899'];
const weeklyOptions = computed(() => {
const weeks = data.value?.weeklyTrend || [];
if (!weeks.length) return {};
// 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>(); const userSet = new Map<string, string>();
for (const w of weeks) { for (const w of weeks) {
for (const u of (w.byUser || [])) { for (const u of (w.byUser || [])) {
@ -46,17 +62,17 @@ const weeklyOptions = computed(() => {
} }
const series = Array.from(userSet.entries()).map(([userId, name], i) => ({ const series = Array.from(userSet.entries()).map(([userId, name], i) => ({
type: 'bar', type: 'bar' as const,
name, name,
stack: 'commits', stack: 'commits',
data: weeks.map((w: any) => { data: weeks.map((w: any) => {
const found = (w.byUser || []).find((u: any) => u.userId === userId); const found = (w.byUser || []).find((u: any) => u.userId === userId);
return found?.commits || 0; 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: { tooltip: {
trigger: 'axis', trigger: 'axis',
formatter(params: any) { formatter(params: any) {
@ -77,10 +93,8 @@ const weeklyOptions = computed(() => {
xAxis: { type: 'category', data: weeks.map((w: any) => w.weekStart), axisLabel: { fontSize: 10 } }, xAxis: { type: 'category', data: weeks.map((w: any) => w.weekStart), axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', name: '提交数' }, yAxis: { type: 'value', name: '提交数' },
series, series,
}; }, true);
}); }
const { chartRef: weeklyRef } = useECharts(weeklyOptions);
</script> </script>
<template> <template>
@ -116,7 +130,7 @@ const { chartRef: weeklyRef } = useECharts(weeklyOptions);
<!-- 每周提交趋势按人堆叠 --> <!-- 每周提交趋势按人堆叠 -->
<DataCard title="每周提交趋势" subtitle="按贡献者堆叠" style="margin-top: var(--space-5)"> <DataCard title="每周提交趋势" subtitle="按贡献者堆叠" style="margin-top: var(--space-5)">
<div ref="weeklyRef" style="height: 300px" /> <div ref="weeklyChartRef" style="height: 300px" />
</DataCard> </DataCard>
</template> </template>
</NSpin> </NSpin>

View 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
View 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

View 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
View 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
View 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