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 ----
|
# ---- 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
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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=="],
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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();
|
|
||||||
|
|||||||
@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;"]
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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">×</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"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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