From 43f885e22a53e9a64859f8b77998a4522075c56d Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Mon, 13 Apr 2026 13:48:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20MySQL=20=E8=BF=9C=E7=A8=8B=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=20+=20CI/CD=20=E6=B5=81=E6=B0=B4=E7=BA=BF=20?= =?UTF-8?q?+=20K8s=20=E9=83=A8=E7=BD=B2=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据库迁移: - 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) --- .gitea/workflows/deploy.yaml | 132 ++++++++ backend/.env.example | 22 +- backend/Dockerfile | 14 +- backend/bun.lock | 23 ++ backend/package.json | 15 +- backend/src/config.ts | 7 +- backend/src/db/index.ts | 223 +------------ backend/src/db/schema.ts | 293 +++++++++--------- frontend/Dockerfile | 6 +- frontend/src/components/layout/AppLayout.vue | 2 + frontend/src/components/layout/AppSidebar.vue | 132 ++++---- frontend/src/styles/global.css | 6 + frontend/src/views/GitActivity.vue | 44 ++- k8s/backend-deployment.yaml | 85 +++++ k8s/backend-ingress.yaml | 23 ++ k8s/cert-manager-issuer.yaml | 14 + k8s/web-deployment.yaml | 59 ++++ k8s/web-ingress.yaml | 23 ++ 18 files changed, 666 insertions(+), 457 deletions(-) create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 k8s/backend-deployment.yaml create mode 100644 k8s/backend-ingress.yaml create mode 100644 k8s/cert-manager-issuer.yaml create mode 100644 k8s/web-deployment.yaml create mode 100644 k8s/web-ingress.yaml diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..4f97449 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 9d33513..a721954 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,17 +1,23 @@ # ---- Required ---- -DATABASE_PATH=./data/devperf.db JWT_SECRET=your-jwt-secret-here-change-in-production PORT=3200 -# ---- Plane Connection ---- -PLANE_BASE_URL=http://plane-api:8000 -PLANE_API_TOKEN= # Generate in Plane Settings > API Tokens -PLANE_WORKSPACE_SLUG=jasonqiyuan +# ---- MySQL Connection ---- +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD= +MYSQL_DATABASE=devperf # ---- Gitea Connection ---- -GITEA_BASE_URL=http://gitea:3000 -GITEA_API_TOKEN= # Generate in Gitea Settings > Applications -GITEA_ORG=jasonqiyuan +GITEA_BASE_URL=https://gitea.airlabs.art +GITEA_API_TOKEN= +GITEA_ORG=zyc + +# ---- Plane Connection (optional) ---- +PLANE_BASE_URL=http://plane-api:8000 +PLANE_API_TOKEN= +PLANE_WORKSPACE_SLUG=jasonqiyuan # ---- Sync Intervals (minutes) ---- SYNC_PLANE_INTERVAL=15 diff --git a/backend/Dockerfile b/backend/Dockerfile index b460812..7ea19af 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,16 +1,8 @@ -FROM oven/bun:1 AS builder +FROM oven/bun:1 WORKDIR /app COPY package.json bun.lockb* ./ RUN bun install --frozen-lockfile || bun install COPY src/ src/ -COPY drizzle.config.ts tsconfig.json ./ -RUN bun build src/index.ts --outdir=dist --target=bun - -FROM oven/bun:1-slim -WORKDIR /app -COPY --from=builder /app/dist/ ./dist/ -COPY --from=builder /app/node_modules/ ./node_modules/ -COPY --from=builder /app/package.json ./ -RUN mkdir -p /data +COPY tsconfig.json ./ EXPOSE 3200 -CMD ["bun", "run", "dist/index.js"] +CMD ["bun", "run", "src/index.ts"] diff --git a/backend/bun.lock b/backend/bun.lock index 698a22a..ca36bda 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -13,6 +13,7 @@ "drizzle-orm": "^0.36.0", "hono": "^4.7.0", "jose": "^5.9.0", + "mysql2": "^3.22.0", "uuid": "^11.0.0", "zod": "^3.24.0", }, @@ -101,6 +102,8 @@ "are-we-there-yet": ["are-we-there-yet@2.0.0", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -141,6 +144,8 @@ "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "drizzle-kit": ["drizzle-kit@0.28.1", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-JimOV+ystXTWMgZkLHYHf2w3oS28hxiH1FR0dkmJLc7GHzdGJoJAQtQS5DRppnabsRZwE2U1F6CuezVBgmsBBQ=="], @@ -167,6 +172,8 @@ "gauge": ["gauge@3.0.2", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.2", "console-control-strings": "^1.0.0", "has-unicode": "^2.0.1", "object-assign": "^4.1.1", "signal-exit": "^3.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.2" } }, "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], @@ -179,6 +186,8 @@ "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], @@ -189,8 +198,14 @@ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + "make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="], "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], @@ -209,6 +224,10 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mysql2": ["mysql2@3.22.0", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-4jaJYBObj7FhD3lnZhqX1yDMuZN4mQNz+IolDySDXT7fbozMBpeGQNcuWXKUqo4ahkAEfkjUHPjnwuDI0/6VKw=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], "node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="], @@ -241,6 +260,8 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], @@ -255,6 +276,8 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], diff --git a/backend/package.json b/backend/package.json index 8d7baa3..1126608 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,16 +13,17 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "hono": "^4.7.0", - "drizzle-orm": "^0.36.0", - "better-sqlite3": "^11.7.0", - "jose": "^5.9.0", - "bcrypt": "^5.1.1", - "zod": "^3.24.0", "@hono/zod-validator": "^0.4.0", + "bcrypt": "^5.1.1", + "better-sqlite3": "^11.7.0", "croner": "^9.0.0", "dayjs": "^1.11.13", - "uuid": "^11.0.0" + "drizzle-orm": "^0.36.0", + "hono": "^4.7.0", + "jose": "^5.9.0", + "mysql2": "^3.22.0", + "uuid": "^11.0.0", + "zod": "^3.24.0" }, "devDependencies": { "@types/bcrypt": "^5.0.2", diff --git a/backend/src/config.ts b/backend/src/config.ts index c6492f6..fcf8d6e 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,10 +1,15 @@ import { z } from 'zod'; const envSchema = z.object({ - DATABASE_PATH: z.string().default('./data/devperf.db'), JWT_SECRET: z.string().min(16, 'JWT_SECRET must be at least 16 characters'), PORT: z.coerce.number().default(3200), + MYSQL_HOST: z.string().default('localhost'), + MYSQL_PORT: z.coerce.number().default(3306), + MYSQL_USER: z.string().default('root'), + MYSQL_PASSWORD: z.string().default(''), + MYSQL_DATABASE: z.string().default('devperf'), + PLANE_BASE_URL: z.string().url().default('http://plane-api:8000'), PLANE_API_TOKEN: z.string().default(''), PLANE_WORKSPACE_SLUG: z.string().default('jasonqiyuan'), diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 3cd021b..8f783a6 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,213 +1,20 @@ -import { drizzle } from 'drizzle-orm/bun-sqlite'; -import { Database } from 'bun:sqlite'; -import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; +import { drizzle } from 'drizzle-orm/mysql2'; +import mysql from 'mysql2/promise'; import * as schema from './schema'; import { config } from '../config'; -import { mkdirSync, existsSync } from 'fs'; -import { dirname, resolve } from 'path'; -// Ensure data directory exists -const dbDir = dirname(config.DATABASE_PATH); -if (!existsSync(dbDir)) { - mkdirSync(dbDir, { recursive: true }); -} +const pool = mysql.createPool({ + host: config.MYSQL_HOST, + port: config.MYSQL_PORT, + user: config.MYSQL_USER, + password: config.MYSQL_PASSWORD, + database: config.MYSQL_DATABASE, + waitForConnections: true, + connectionLimit: 10, + timezone: '+08:00', +}); -const sqlite = new Database(config.DATABASE_PATH, { create: true }); -sqlite.exec('PRAGMA journal_mode = WAL'); -sqlite.exec('PRAGMA foreign_keys = ON'); +export const db = drizzle(pool, { schema, mode: 'default' }); +export { pool }; -export const db = drizzle(sqlite, { schema }); -export { sqlite }; - -/** - * Run database migrations automatically on startup. - * Uses CREATE TABLE IF NOT EXISTS semantics (via drizzle migrate) - * so it is safe to call on every boot -- already-applied migrations - * are tracked in the drizzle __drizzle_migrations journal table. - * - * The migrationsFolder path is resolved relative to the project root - * (where package.json lives) so it works regardless of cwd. - */ -function autoMigrate() { - try { - // Resolve the drizzle folder relative to this file's location: - // src/db/index.ts -> ../../drizzle - const migrationsFolder = resolve(import.meta.dir, '../../drizzle'); - migrate(db, { migrationsFolder }); - console.info('[DB] Auto-migration completed successfully.'); - } catch (err) { - console.error('[DB] Auto-migration failed:', err); - // Do not crash the process -- tables may already exist from a prior run. - // Fallback: create core tables with raw SQL if drizzle migrations folder is missing. - try { - createTablesIfNotExist(); - console.info('[DB] Fallback table creation completed.'); - } catch (fallbackErr) { - console.error('[DB] Fallback table creation also failed:', fallbackErr); - } - } -} - -/** - * Fallback: create all required tables using raw SQL CREATE TABLE IF NOT EXISTS. - * This is only used when the drizzle migrations folder cannot be found. - */ -function createTablesIfNotExist() { - sqlite.exec(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY NOT NULL, - plane_user_id TEXT, - display_name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - git_username TEXT, - role TEXT NOT NULL, - password_hash TEXT NOT NULL, - login_attempts INTEGER DEFAULT 0, - locked_until INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS projects ( - id TEXT PRIMARY KEY NOT NULL, - plane_project_id TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - identifier TEXT, - last_synced_at INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS sprint_snapshots ( - id TEXT PRIMARY KEY NOT NULL, - project_id TEXT REFERENCES projects(id), - plane_cycle_id TEXT NOT NULL, - name TEXT NOT NULL, - start_date TEXT, - end_date TEXT, - total_points INTEGER DEFAULT 0, - completed_points INTEGER DEFAULT 0, - total_issues INTEGER DEFAULT 0, - completed_issues INTEGER DEFAULT 0, - burndown_data TEXT, - status TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS task_snapshots ( - id TEXT PRIMARY KEY NOT NULL, - plane_issue_id TEXT NOT NULL, - project_id TEXT REFERENCES projects(id), - sprint_id TEXT REFERENCES sprint_snapshots(id), - title TEXT NOT NULL, - status TEXT, - priority TEXT, - assignee_id TEXT REFERENCES users(id), - story_points INTEGER, - created_at INTEGER, - completed_at INTEGER, - due_date TEXT, - labels TEXT, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS milestones ( - id TEXT PRIMARY KEY NOT NULL, - plane_module_id TEXT NOT NULL, - project_id TEXT REFERENCES projects(id), - name TEXT NOT NULL, - status TEXT, - target_date TEXT, - total_issues INTEGER DEFAULT 0, - completed_issues INTEGER DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS git_commits ( - id TEXT PRIMARY KEY NOT NULL, - repo_name TEXT NOT NULL, - sha TEXT NOT NULL UNIQUE, - author_email TEXT, - author_name TEXT, - user_id TEXT REFERENCES users(id), - message TEXT, - additions INTEGER DEFAULT 0, - deletions INTEGER DEFAULT 0, - committed_at INTEGER NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS git_prs ( - id TEXT PRIMARY KEY NOT NULL, - repo_name TEXT NOT NULL, - external_id INTEGER NOT NULL, - title TEXT, - user_id TEXT REFERENCES users(id), - author_username TEXT, - state TEXT, - additions INTEGER DEFAULT 0, - deletions INTEGER DEFAULT 0, - review_comments INTEGER DEFAULT 0, - created_at INTEGER, - merged_at INTEGER, - merge_time_hours REAL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS objectives ( - id TEXT PRIMARY KEY NOT NULL, - title TEXT NOT NULL, - owner_id TEXT REFERENCES users(id), - project_id TEXT REFERENCES projects(id), - period TEXT NOT NULL, - progress REAL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS key_results ( - id TEXT PRIMARY KEY NOT NULL, - objective_id TEXT NOT NULL REFERENCES objectives(id), - title TEXT NOT NULL, - target_value REAL NOT NULL, - current_value REAL DEFAULT 0, - unit TEXT, - weight REAL DEFAULT 1, - linked_plane_cycle_id TEXT, - linked_plane_module_id TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS author_mappings ( - id TEXT PRIMARY KEY NOT NULL, - git_email TEXT, - git_username TEXT, - user_id TEXT REFERENCES users(id), - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS project_repos ( - id TEXT PRIMARY KEY NOT NULL, - project_id TEXT NOT NULL REFERENCES projects(id), - repo_name TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS sync_logs ( - id TEXT PRIMARY KEY NOT NULL, - source TEXT NOT NULL, - status TEXT NOT NULL, - message TEXT, - records_processed INTEGER DEFAULT 0, - synced_at INTEGER NOT NULL - ); - `); -} - -// Run auto-migration on module load (i.e. on server startup) -autoMigrate(); +console.info('[DB] MySQL pool created:', config.MYSQL_HOST + ':' + config.MYSQL_PORT + '/' + config.MYSQL_DATABASE); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 71904bd..04c3483 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,72 +1,83 @@ -import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { + mysqlTable, + varchar, + text, + int, + double, + datetime, + json, + mysqlEnum, + index, + uniqueIndex, +} from 'drizzle-orm/mysql-core'; // ── Users ── -export const users = sqliteTable('users', { - id: text('id').primaryKey(), - planeUserId: text('plane_user_id'), - displayName: text('display_name').notNull(), - email: text('email').notNull().unique(), - gitUsername: text('git_username'), - role: text('role', { enum: ['admin', 'manager', 'developer', 'viewer'] }).notNull(), - passwordHash: text('password_hash').notNull(), - loginAttempts: integer('login_attempts').default(0), - lockedUntil: integer('locked_until', { mode: 'timestamp' }), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +export const users = mysqlTable('users', { + id: varchar('id', { length: 50 }).primaryKey(), + planeUserId: varchar('plane_user_id', { length: 200 }), + displayName: varchar('display_name', { length: 200 }).notNull(), + email: varchar('email', { length: 200 }).notNull().unique(), + gitUsername: varchar('git_username', { length: 200 }), + role: mysqlEnum('role', ['admin', 'manager', 'developer', 'viewer']).notNull(), + passwordHash: varchar('password_hash', { length: 500 }).notNull(), + loginAttempts: int('login_attempts').default(0), + lockedUntil: datetime('locked_until'), + createdAt: datetime('created_at').notNull(), + updatedAt: datetime('updated_at').notNull(), }, (table) => ({ emailIdx: uniqueIndex('uniq_users_email').on(table.email), })); // ── Projects ── -export const projects = sqliteTable('projects', { - id: text('id').primaryKey(), - planeProjectId: text('plane_project_id').notNull(), - name: text('name').notNull(), - identifier: text('identifier'), - lastSyncedAt: integer('last_synced_at', { mode: 'timestamp' }), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +export const projects = mysqlTable('projects', { + id: varchar('id', { length: 50 }).primaryKey(), + planeProjectId: varchar('plane_project_id', { length: 200 }).notNull(), + name: varchar('name', { length: 200 }).notNull(), + identifier: varchar('identifier', { length: 200 }), + lastSyncedAt: datetime('last_synced_at'), + createdAt: datetime('created_at').notNull(), + updatedAt: datetime('updated_at').notNull(), }, (table) => ({ planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId), })); // ── Sprint Snapshots ── -export const sprintSnapshots = sqliteTable('sprint_snapshots', { - id: text('id').primaryKey(), - projectId: text('project_id').references(() => projects.id), - planeCycleId: text('plane_cycle_id').notNull(), - name: text('name').notNull(), - startDate: text('start_date'), - endDate: text('end_date'), - totalPoints: integer('total_points').default(0), - completedPoints: integer('completed_points').default(0), - totalIssues: integer('total_issues').default(0), - completedIssues: integer('completed_issues').default(0), - burndownData: text('burndown_data', { mode: 'json' }), - status: text('status', { enum: ['upcoming', 'active', 'completed'] }), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +export const sprintSnapshots = mysqlTable('sprint_snapshots', { + id: varchar('id', { length: 50 }).primaryKey(), + projectId: varchar('project_id', { length: 50 }).references(() => projects.id), + planeCycleId: varchar('plane_cycle_id', { length: 200 }).notNull(), + name: varchar('name', { length: 200 }).notNull(), + startDate: varchar('start_date', { length: 50 }), + endDate: varchar('end_date', { length: 50 }), + totalPoints: int('total_points').default(0), + completedPoints: int('completed_points').default(0), + totalIssues: int('total_issues').default(0), + completedIssues: int('completed_issues').default(0), + burndownData: json('burndown_data'), + status: mysqlEnum('status', ['upcoming', 'active', 'completed']), + createdAt: datetime('created_at').notNull(), + updatedAt: datetime('updated_at').notNull(), }, (table) => ({ projectIdx: index('idx_sprint_project').on(table.projectId), statusIdx: index('idx_sprint_status').on(table.status), })); // ── Task Snapshots ── -export const taskSnapshots = sqliteTable('task_snapshots', { - id: text('id').primaryKey(), - planeIssueId: text('plane_issue_id').notNull(), - projectId: text('project_id').references(() => projects.id), - sprintId: text('sprint_id').references(() => sprintSnapshots.id), - title: text('title').notNull(), - status: text('status'), - priority: text('priority'), - assigneeId: text('assignee_id').references(() => users.id), - storyPoints: integer('story_points'), - createdAt: integer('created_at', { mode: 'timestamp' }), - completedAt: integer('completed_at', { mode: 'timestamp' }), - dueDate: text('due_date'), - labels: text('labels', { mode: 'json' }), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +export const taskSnapshots = mysqlTable('task_snapshots', { + id: varchar('id', { length: 50 }).primaryKey(), + planeIssueId: varchar('plane_issue_id', { length: 200 }).notNull(), + projectId: varchar('project_id', { length: 50 }).references(() => projects.id), + sprintId: varchar('sprint_id', { length: 50 }).references(() => sprintSnapshots.id), + title: varchar('title', { length: 500 }).notNull(), + status: varchar('status', { length: 100 }), + priority: varchar('priority', { length: 100 }), + assigneeId: varchar('assignee_id', { length: 50 }).references(() => users.id), + storyPoints: int('story_points'), + createdAt: datetime('created_at'), + completedAt: datetime('completed_at'), + dueDate: varchar('due_date', { length: 50 }), + labels: json('labels'), + updatedAt: datetime('updated_at').notNull(), }, (table) => ({ projectIdx: index('idx_task_project').on(table.projectId), sprintIdx: index('idx_task_sprint').on(table.sprintId), @@ -75,35 +86,35 @@ export const taskSnapshots = sqliteTable('task_snapshots', { })); // ── Milestones ── -export const milestones = sqliteTable('milestones', { - id: text('id').primaryKey(), - planeModuleId: text('plane_module_id').notNull(), - projectId: text('project_id').references(() => projects.id), - name: text('name').notNull(), - status: text('status'), - targetDate: text('target_date'), - totalIssues: integer('total_issues').default(0), - completedIssues: integer('completed_issues').default(0), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +export const milestones = mysqlTable('milestones', { + id: varchar('id', { length: 50 }).primaryKey(), + planeModuleId: varchar('plane_module_id', { length: 200 }).notNull(), + projectId: varchar('project_id', { length: 50 }).references(() => projects.id), + name: varchar('name', { length: 200 }).notNull(), + status: varchar('status', { length: 100 }), + targetDate: varchar('target_date', { length: 50 }), + totalIssues: int('total_issues').default(0), + completedIssues: int('completed_issues').default(0), + createdAt: datetime('created_at').notNull(), + updatedAt: datetime('updated_at').notNull(), }, (table) => ({ projectIdx: index('idx_milestone_project').on(table.projectId), })); // ── Git Commits ── -export const gitCommits = sqliteTable('git_commits', { - id: text('id').primaryKey(), - repoName: text('repo_name').notNull(), - sha: text('sha').notNull().unique(), - authorEmail: text('author_email'), - authorName: text('author_name'), - userId: text('user_id').references(() => users.id), +export const gitCommits = mysqlTable('git_commits', { + id: varchar('id', { length: 50 }).primaryKey(), + repoName: varchar('repo_name', { length: 200 }).notNull(), + sha: varchar('sha', { length: 200 }).notNull().unique(), + authorEmail: varchar('author_email', { length: 200 }), + authorName: varchar('author_name', { length: 200 }), + userId: varchar('user_id', { length: 50 }).references(() => users.id), message: text('message'), - additions: integer('additions').default(0), - deletions: integer('deletions').default(0), - committedAt: integer('committed_at', { mode: 'timestamp' }).notNull(), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + additions: int('additions').default(0), + deletions: int('deletions').default(0), + committedAt: datetime('committed_at').notNull(), + createdAt: datetime('created_at').notNull(), + updatedAt: datetime('updated_at').notNull(), }, (table) => ({ shaIdx: uniqueIndex('uniq_commits_sha').on(table.sha), userIdx: index('idx_commits_user').on(table.userId), @@ -112,21 +123,21 @@ export const gitCommits = sqliteTable('git_commits', { })); // ── Git PRs ── -export const gitPRs = sqliteTable('git_prs', { - id: text('id').primaryKey(), - repoName: text('repo_name').notNull(), - externalId: integer('external_id').notNull(), - title: text('title'), - userId: text('user_id').references(() => users.id), - authorUsername: text('author_username'), - state: text('state'), - additions: integer('additions').default(0), - deletions: integer('deletions').default(0), - reviewComments: integer('review_comments').default(0), - createdAt: integer('created_at', { mode: 'timestamp' }), - mergedAt: integer('merged_at', { mode: 'timestamp' }), - mergeTimeHours: real('merge_time_hours'), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +export const gitPRs = mysqlTable('git_prs', { + id: varchar('id', { length: 50 }).primaryKey(), + repoName: varchar('repo_name', { length: 200 }).notNull(), + externalId: int('external_id').notNull(), + title: varchar('title', { length: 500 }), + userId: varchar('user_id', { length: 50 }).references(() => users.id), + authorUsername: varchar('author_username', { length: 200 }), + state: varchar('state', { length: 100 }), + additions: int('additions').default(0), + deletions: int('deletions').default(0), + reviewComments: int('review_comments').default(0), + createdAt: datetime('created_at'), + mergedAt: datetime('merged_at'), + mergeTimeHours: double('merge_time_hours'), + updatedAt: datetime('updated_at').notNull(), }, (table) => ({ userIdx: index('idx_prs_user').on(table.userId), repoIdx: index('idx_prs_repo').on(table.repoName), @@ -134,85 +145,85 @@ export const gitPRs = sqliteTable('git_prs', { })); // ── Objectives (OKR) ── -export const objectives = sqliteTable('objectives', { - id: text('id').primaryKey(), - title: text('title').notNull(), - ownerId: text('owner_id').references(() => users.id), - projectId: text('project_id').references(() => projects.id), - period: text('period').notNull(), - startDate: text('start_date'), - endDate: text('end_date'), - progress: real('progress').default(0), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +export const objectives = mysqlTable('objectives', { + id: varchar('id', { length: 50 }).primaryKey(), + title: varchar('title', { length: 500 }).notNull(), + ownerId: varchar('owner_id', { length: 50 }).references(() => users.id), + projectId: varchar('project_id', { length: 50 }).references(() => projects.id), + period: varchar('period', { length: 100 }).notNull(), + startDate: varchar('start_date', { length: 50 }), + endDate: varchar('end_date', { length: 50 }), + progress: double('progress').default(0), + createdAt: datetime('created_at').notNull(), + updatedAt: datetime('updated_at').notNull(), }, (table) => ({ periodIdx: index('idx_obj_period').on(table.period), ownerIdx: index('idx_obj_owner').on(table.ownerId), })); // ── Key Results (OKR) ── -export const keyResults = sqliteTable('key_results', { - id: text('id').primaryKey(), - objectiveId: text('objective_id').references(() => objectives.id).notNull(), - title: text('title').notNull(), - targetValue: real('target_value').notNull(), - currentValue: real('current_value').default(0), - unit: text('unit'), - weight: real('weight').default(1), - status: text('status').default('active'), // active / paused / cancelled - startDate: text('start_date'), - endDate: text('end_date'), - linkedPlaneCycleId: text('linked_plane_cycle_id'), - linkedPlaneModuleId: text('linked_plane_module_id'), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +export const keyResults = mysqlTable('key_results', { + id: varchar('id', { length: 50 }).primaryKey(), + objectiveId: varchar('objective_id', { length: 50 }).references(() => objectives.id).notNull(), + title: varchar('title', { length: 500 }).notNull(), + targetValue: double('target_value').notNull(), + currentValue: double('current_value').default(0), + unit: varchar('unit', { length: 100 }), + weight: double('weight').default(1), + status: varchar('status', { length: 100 }).default('active'), // active / paused / cancelled + startDate: varchar('start_date', { length: 50 }), + endDate: varchar('end_date', { length: 50 }), + linkedPlaneCycleId: varchar('linked_plane_cycle_id', { length: 200 }), + linkedPlaneModuleId: varchar('linked_plane_module_id', { length: 200 }), + createdAt: datetime('created_at').notNull(), + updatedAt: datetime('updated_at').notNull(), }, (table) => ({ objectiveIdx: index('idx_kr_objective').on(table.objectiveId), })); // ── KR Operation Logs ── -export const krLogs = sqliteTable('kr_logs', { - id: text('id').primaryKey(), - krId: text('kr_id').references(() => keyResults.id).notNull(), - action: text('action').notNull(), // created / postponed / paused / resumed / cancelled / completed / progress_update +export const krLogs = mysqlTable('kr_logs', { + id: varchar('id', { length: 50 }).primaryKey(), + krId: varchar('kr_id', { length: 50 }).references(() => keyResults.id).notNull(), + action: varchar('action', { length: 200 }).notNull(), // created / postponed / paused / resumed / cancelled / completed / progress_update detail: text('detail'), - operatorId: text('operator_id').references(() => users.id), - operatorName: text('operator_name'), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + operatorId: varchar('operator_id', { length: 50 }).references(() => users.id), + operatorName: varchar('operator_name', { length: 200 }), + createdAt: datetime('created_at').notNull(), }, (table) => ({ krIdx: index('idx_kr_logs_kr').on(table.krId), })); // ── Author Mappings ── -export const authorMappings = sqliteTable('author_mappings', { - id: text('id').primaryKey(), - gitEmail: text('git_email'), - gitUsername: text('git_username'), - userId: text('user_id').references(() => users.id), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +export const authorMappings = mysqlTable('author_mappings', { + id: varchar('id', { length: 50 }).primaryKey(), + gitEmail: varchar('git_email', { length: 200 }), + gitUsername: varchar('git_username', { length: 200 }), + userId: varchar('user_id', { length: 50 }).references(() => users.id), + createdAt: datetime('created_at').notNull(), + updatedAt: datetime('updated_at').notNull(), }, (table) => ({ emailIdx: uniqueIndex('uniq_mapping_email').on(table.gitEmail), usernameIdx: uniqueIndex('uniq_mapping_username').on(table.gitUsername), })); -// ── Project ↔ Repo Mapping ── -export const projectRepos = sqliteTable('project_repos', { - id: text('id').primaryKey(), - projectId: text('project_id').references(() => projects.id).notNull(), - repoName: text('repo_name').notNull(), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), +// ── Project <-> Repo Mapping ── +export const projectRepos = mysqlTable('project_repos', { + id: varchar('id', { length: 50 }).primaryKey(), + projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(), + repoName: varchar('repo_name', { length: 200 }).notNull(), + createdAt: datetime('created_at').notNull(), }, (table) => ({ projectIdx: index('idx_project_repos_project').on(table.projectId), repoIdx: index('idx_project_repos_repo').on(table.repoName), })); // ── Sync Logs ── -export const syncLogs = sqliteTable('sync_logs', { - id: text('id').primaryKey(), - source: text('source', { enum: ['plane', 'gitea'] }).notNull(), - status: text('status', { enum: ['success', 'error'] }).notNull(), +export const syncLogs = mysqlTable('sync_logs', { + id: varchar('id', { length: 50 }).primaryKey(), + source: mysqlEnum('source', ['plane', 'gitea']).notNull(), + status: mysqlEnum('status', ['success', 'error']).notNull(), message: text('message'), - recordsProcessed: integer('records_processed').default(0), - syncedAt: integer('synced_at', { mode: 'timestamp' }).notNull(), + recordsProcessed: int('records_processed').default(0), + syncedAt: datetime('synced_at').notNull(), }); diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9473add..f2c528d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,7 @@ FROM node:22-slim AS builder WORKDIR /app +ARG VITE_API_BASE_URL=http://localhost:3200 +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} COPY package.json package-lock.json* ./ RUN npm ci || npm install COPY . . @@ -7,5 +9,7 @@ RUN npm run build FROM nginx:alpine COPY --from=builder /app/dist/ /usr/share/nginx/html/ -COPY ../deploy/nginx.conf /etc/nginx/conf.d/default.conf +# SPA fallback: all routes to index.html +RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n index index.html;\n location / {\n try_files $uri $uri/ /index.html;\n }\n location /api/ {\n return 404;\n }\n}\n' > /etc/nginx/conf.d/default.conf EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/src/components/layout/AppLayout.vue b/frontend/src/components/layout/AppLayout.vue index fd2195d..d4a5362 100644 --- a/frontend/src/components/layout/AppLayout.vue +++ b/frontend/src/components/layout/AppLayout.vue @@ -53,6 +53,8 @@ onUnmounted(() => { transition: margin-left var(--duration-collapse) var(--ease-default); display: flex; flex-direction: column; + min-width: 0; + overflow-x: hidden; } .main-container.collapsed { diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index c8b4570..395f481 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -6,7 +6,7 @@ */ import { computed, ref, onMounted } from 'vue'; import { useRoute, useRouter } from 'vue-router'; -import { NAvatar, NTag, NTooltip } from 'naive-ui'; +import { NTag, NTooltip } from 'naive-ui'; import { useAuthStore } from '@/stores/auth'; import { useDashboardStore } from '@/stores/dashboard'; import { getOverviewApi } from '@/api/overview'; @@ -126,22 +126,14 @@ const roleTagType = computed(() => { }" >