Compare commits
10 Commits
bfb0c23ae1
...
9b0de6a5b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0de6a5b8 | ||
|
|
57d2c43da7 | ||
|
|
e65669fede | ||
|
|
69171f7778 | ||
|
|
1799f2fee1 | ||
|
|
e0d1cd342e | ||
|
|
cafaf8cf5d | ||
|
|
bf84e64ee9 | ||
|
|
dc2c731119 | ||
|
|
344df5184c |
57
.github/workflows/build-and-push-image.yml
vendored
57
.github/workflows/build-and-push-image.yml
vendored
@@ -1,57 +0,0 @@
|
||||
name: Build and Push Images to Dockerhub and GHCR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
release:
|
||||
types: ["published"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
service: [backend, frontend]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: "${{ secrets.DH_USER }}"
|
||||
password: "${{ secrets.DH_PASS }}"
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: "${{ secrets.GHCR_USER }}"
|
||||
password: "${{ secrets.GHCR_PAT }}"
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
context: workflow
|
||||
images: |
|
||||
ericgullickson/motovaultpro-${{ matrix.service }}
|
||||
ghcr.io/ericgullickson/motovaultpro-${{ matrix.service }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=tag
|
||||
labels: |
|
||||
org.opencontainers.image.title=MotoVaultPro ${{ matrix.service }}
|
||||
org.opencontainers.image.description=MotoVaultPro ${{ matrix.service }} service
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./${{ matrix.service }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
448
.gitlab-ci.yml
448
.gitlab-ci.yml
@@ -1,30 +1,50 @@
|
||||
# MotoVaultPro GitLab CI/CD Pipeline
|
||||
# GitLab 18.6+ with shell executor
|
||||
# MotoVaultPro GitLab CI/CD Pipeline - Blue-Green Deployment
|
||||
# GitLab 18.6+ with separate build and production runners
|
||||
# See docs/CICD-DEPLOY.md for complete documentation
|
||||
# v1.5
|
||||
# v2.0 - Blue-Green with Auto-Rollback
|
||||
|
||||
stages:
|
||||
- validate
|
||||
- build
|
||||
- deploy
|
||||
- deploy-prepare
|
||||
- deploy-switch
|
||||
- verify
|
||||
- rollback
|
||||
- notify
|
||||
|
||||
variables:
|
||||
# Use stable clone path instead of runner-specific path
|
||||
GIT_CLONE_PATH: $CI_BUILDS_DIR/motovaultpro
|
||||
DEPLOY_PATH: $CI_BUILDS_DIR/motovaultpro
|
||||
DOCKER_COMPOSE_FILE: docker-compose.yml
|
||||
DOCKER_COMPOSE_PROD_FILE: docker-compose.prod.yml
|
||||
# Registry configuration
|
||||
REGISTRY: registry.motovaultpro.com
|
||||
REGISTRY_MIRRORS: ${REGISTRY}/mirrors
|
||||
IMAGE_TAG: ${CI_COMMIT_SHORT_SHA}
|
||||
BACKEND_IMAGE: ${REGISTRY}/motovaultpro/backend:${IMAGE_TAG}
|
||||
FRONTEND_IMAGE: ${REGISTRY}/motovaultpro/frontend:${IMAGE_TAG}
|
||||
|
||||
# Fix permissions after every job - docker creates files as root
|
||||
# Deployment configuration
|
||||
GIT_CLONE_PATH: ${CI_BUILDS_DIR}/motovaultpro
|
||||
DEPLOY_PATH: ${CI_BUILDS_DIR}/motovaultpro
|
||||
COMPOSE_FILE: docker-compose.yml
|
||||
COMPOSE_BLUE_GREEN: docker-compose.blue-green.yml
|
||||
|
||||
# Health check configuration
|
||||
HEALTH_CHECK_TIMEOUT: "60"
|
||||
|
||||
# Default after_script to fix permissions
|
||||
default:
|
||||
after_script:
|
||||
- echo "Fixing file permissions..."
|
||||
- sudo chown -R gitlab-runner:gitlab-runner "$DEPLOY_PATH" 2>/dev/null || true
|
||||
- sudo chown -R 1001:1001 "$DEPLOY_PATH/data/backups" "$DEPLOY_PATH/data/documents" 2>/dev/null || true
|
||||
|
||||
# Validate Stage - Check prerequisites
|
||||
# ============================================
|
||||
# Stage 1: VALIDATE
|
||||
# Check prerequisites before starting pipeline
|
||||
# ============================================
|
||||
validate:
|
||||
stage: validate
|
||||
tags:
|
||||
- production
|
||||
- shell
|
||||
only:
|
||||
- main
|
||||
script:
|
||||
@@ -32,129 +52,385 @@ validate:
|
||||
- echo "Validating deployment prerequisites..."
|
||||
- echo "=========================================="
|
||||
- echo "Checking Docker..."
|
||||
- 'docker info > /dev/null 2>&1 || (echo "ERROR: Docker not accessible" && exit 1)'
|
||||
- echo "OK Docker is accessible"
|
||||
- docker info > /dev/null 2>&1 || (echo "ERROR - Docker not accessible" && exit 1)
|
||||
- echo "OK - Docker is accessible"
|
||||
- echo "Checking Docker Compose..."
|
||||
- 'docker compose version > /dev/null 2>&1 || (echo "ERROR: Docker Compose not available" && exit 1)'
|
||||
- echo "OK Docker Compose is available"
|
||||
- docker compose version > /dev/null 2>&1 || (echo "ERROR - Docker Compose not available" && exit 1)
|
||||
- echo "OK - Docker Compose is available"
|
||||
- echo "Checking deployment path..."
|
||||
- 'test -d "$DEPLOY_PATH" || (echo "ERROR: DEPLOY_PATH not found" && exit 1)'
|
||||
- echo "OK Deployment path exists"
|
||||
- test -d "$DEPLOY_PATH" || (echo "ERROR - DEPLOY_PATH not found" && exit 1)
|
||||
- echo "OK - Deployment path exists"
|
||||
- echo "Checking registry access..."
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$REGISTRY" || true
|
||||
- echo "OK - Registry authentication configured"
|
||||
- echo "Determining target stack..."
|
||||
- |
|
||||
STATE_FILE="$DEPLOY_PATH/config/deployment/state.json"
|
||||
if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then
|
||||
ACTIVE_STACK=$(jq -r '.active_stack // "blue"' "$STATE_FILE")
|
||||
if [ "$ACTIVE_STACK" = "blue" ]; then
|
||||
echo "TARGET_STACK=green" >> deploy.env
|
||||
else
|
||||
echo "TARGET_STACK=blue" >> deploy.env
|
||||
fi
|
||||
else
|
||||
echo "TARGET_STACK=green" >> deploy.env
|
||||
fi
|
||||
cat deploy.env
|
||||
- echo "=========================================="
|
||||
- echo "Validation complete"
|
||||
- echo "=========================================="
|
||||
artifacts:
|
||||
reports:
|
||||
dotenv: deploy.env
|
||||
|
||||
# Build Stage - Build Docker images
|
||||
# ============================================
|
||||
# Stage 2: BUILD
|
||||
# Build and push images to GitLab Container Registry
|
||||
# Runs on dedicated build server (shell executor)
|
||||
# ============================================
|
||||
build:
|
||||
stage: build
|
||||
tags:
|
||||
- build
|
||||
only:
|
||||
- main
|
||||
script:
|
||||
- echo "Authenticating with GitLab Container Registry..."
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$REGISTRY"
|
||||
- echo "=========================================="
|
||||
- echo "Building Docker images..."
|
||||
- echo "Commit - ${CI_COMMIT_SHORT_SHA}"
|
||||
- echo "Backend - ${BACKEND_IMAGE}"
|
||||
- echo "Frontend - ${FRONTEND_IMAGE}"
|
||||
- echo "=========================================="
|
||||
- cd "$DEPLOY_PATH"
|
||||
- echo "Building images..."
|
||||
- docker compose -f $DOCKER_COMPOSE_FILE build --no-cache
|
||||
|
||||
# Build backend
|
||||
- echo "Building backend..."
|
||||
- |
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--cache-from ${REGISTRY}/motovaultpro/backend:latest \
|
||||
-t ${BACKEND_IMAGE} \
|
||||
-t ${REGISTRY}/motovaultpro/backend:latest \
|
||||
-f backend/Dockerfile \
|
||||
.
|
||||
|
||||
# Build frontend
|
||||
- echo "Building frontend..."
|
||||
- |
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--build-arg VITE_AUTH0_DOMAIN=${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com} \
|
||||
--build-arg VITE_AUTH0_CLIENT_ID=${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3} \
|
||||
--build-arg VITE_AUTH0_AUDIENCE=${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com} \
|
||||
--build-arg VITE_API_BASE_URL=/api \
|
||||
--cache-from ${REGISTRY}/motovaultpro/frontend:latest \
|
||||
-t ${FRONTEND_IMAGE} \
|
||||
-t ${REGISTRY}/motovaultpro/frontend:latest \
|
||||
-f frontend/Dockerfile \
|
||||
frontend
|
||||
|
||||
# Push images
|
||||
- echo "Pushing images to registry..."
|
||||
- docker push ${BACKEND_IMAGE}
|
||||
- docker push ${FRONTEND_IMAGE}
|
||||
- docker push ${REGISTRY}/motovaultpro/backend:latest
|
||||
- docker push ${REGISTRY}/motovaultpro/frontend:latest
|
||||
|
||||
- echo "=========================================="
|
||||
- echo "Build complete"
|
||||
- echo "=========================================="
|
||||
|
||||
# Deploy Stage - Inject secrets and deploy services
|
||||
deploy:
|
||||
stage: deploy
|
||||
# ============================================
|
||||
# Stage 3: DEPLOY-PREPARE
|
||||
# Pull images, start inactive stack, run health checks
|
||||
# ============================================
|
||||
deploy-prepare:
|
||||
stage: deploy-prepare
|
||||
tags:
|
||||
- production
|
||||
- shell
|
||||
only:
|
||||
- main
|
||||
needs:
|
||||
- job: validate
|
||||
artifacts: true
|
||||
- job: build
|
||||
environment:
|
||||
name: production
|
||||
url: https://motovaultpro.com
|
||||
script:
|
||||
- echo "=========================================="
|
||||
- echo "Deploying MotoVaultPro..."
|
||||
- echo "Preparing deployment to ${TARGET_STACK} stack..."
|
||||
- echo "=========================================="
|
||||
- cd "$DEPLOY_PATH"
|
||||
- echo "Step 1/7 Injecting secrets..."
|
||||
|
||||
# Authenticate with registry
|
||||
- echo "Authenticating with registry..."
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$REGISTRY"
|
||||
|
||||
# Inject secrets
|
||||
- echo "Step 1/5 - Injecting secrets..."
|
||||
- chmod +x scripts/inject-secrets.sh
|
||||
- ./scripts/inject-secrets.sh
|
||||
- echo "Step 2/7 Stopping existing services..."
|
||||
- docker compose -f $DOCKER_COMPOSE_FILE -f $DOCKER_COMPOSE_PROD_FILE down --timeout 30 || true
|
||||
- echo "Step 3/7 Pulling base images..."
|
||||
- docker compose -f $DOCKER_COMPOSE_FILE pull
|
||||
- echo "Step 4/7 Starting database services..."
|
||||
- docker compose -f $DOCKER_COMPOSE_FILE -f $DOCKER_COMPOSE_PROD_FILE up -d mvp-postgres mvp-redis
|
||||
- echo "Waiting for database to be ready..."
|
||||
- sleep 15
|
||||
- echo "Step 5/7 Running database migrations..."
|
||||
- docker compose -f $DOCKER_COMPOSE_FILE run --rm mvp-backend npm run migrate || echo "Migration skipped"
|
||||
- echo "Step 6/7 Running vehicle ETL import..."
|
||||
|
||||
# Initialize data directories
|
||||
- echo "Step 2/5 - Initializing data directories..."
|
||||
- sudo mkdir -p data/backups data/documents
|
||||
- sudo chown -R 1001:1001 data/backups data/documents
|
||||
- sudo chmod 755 data/backups data/documents
|
||||
|
||||
# Pull new images
|
||||
- echo "Step 3/5 - Pulling images..."
|
||||
- docker pull ${BACKEND_IMAGE}
|
||||
- docker pull ${FRONTEND_IMAGE}
|
||||
|
||||
# Start inactive stack
|
||||
- echo "Step 4/5 - Starting ${TARGET_STACK} stack..."
|
||||
- |
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/migrations/001_create_vehicle_database.sql
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro -c "TRUNCATE TABLE vehicle_options RESTART IDENTITY CASCADE; TRUNCATE TABLE engines RESTART IDENTITY CASCADE; TRUNCATE TABLE transmissions RESTART IDENTITY CASCADE;"
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/output/01_engines.sql
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/output/02_transmissions.sql
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/output/03_vehicle_options.sql
|
||||
- echo "Flushing Redis cache..."
|
||||
- docker exec mvp-redis redis-cli FLUSHALL
|
||||
- echo "Vehicle ETL import completed"
|
||||
- echo "Step 7/7 Starting all services..."
|
||||
- docker compose -f $DOCKER_COMPOSE_FILE -f $DOCKER_COMPOSE_PROD_FILE up -d
|
||||
- echo "Waiting for services to initialize..."
|
||||
- sleep 30
|
||||
export BACKEND_IMAGE=${BACKEND_IMAGE}
|
||||
export FRONTEND_IMAGE=${FRONTEND_IMAGE}
|
||||
docker compose -f $COMPOSE_FILE -f $COMPOSE_BLUE_GREEN up -d \
|
||||
mvp-frontend-${TARGET_STACK} mvp-backend-${TARGET_STACK}
|
||||
|
||||
# Wait for stack to be ready
|
||||
- echo "Step 5/5 - Waiting for stack health..."
|
||||
- sleep 10
|
||||
|
||||
# Run health check
|
||||
- echo "Running health check on ${TARGET_STACK} stack..."
|
||||
- chmod +x scripts/ci/health-check.sh
|
||||
- ./scripts/ci/health-check.sh ${TARGET_STACK} ${HEALTH_CHECK_TIMEOUT}
|
||||
|
||||
# Update state with deployment info
|
||||
- |
|
||||
STATE_FILE="config/deployment/state.json"
|
||||
if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
jq --arg stack "$TARGET_STACK" \
|
||||
--arg commit "$CI_COMMIT_SHORT_SHA" \
|
||||
--arg ts "$TIMESTAMP" \
|
||||
'.[$stack].version = $commit | .[$stack].commit = $commit | .[$stack].deployed_at = $ts | .[$stack].healthy = true' \
|
||||
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
fi
|
||||
|
||||
- echo "=========================================="
|
||||
- echo "Deployment complete"
|
||||
- echo "Deploy preparation complete"
|
||||
- echo "=========================================="
|
||||
|
||||
# Verify Stage - Health checks
|
||||
verify:
|
||||
stage: verify
|
||||
# ============================================
|
||||
# Stage 4: DEPLOY-SWITCH
|
||||
# Switch traffic to new stack
|
||||
# ============================================
|
||||
deploy-switch:
|
||||
stage: deploy-switch
|
||||
tags:
|
||||
- production
|
||||
- shell
|
||||
only:
|
||||
- main
|
||||
needs:
|
||||
- job: validate
|
||||
artifacts: true
|
||||
- job: deploy-prepare
|
||||
script:
|
||||
- echo "=========================================="
|
||||
- echo "Verifying deployment..."
|
||||
- echo "Switching traffic to ${TARGET_STACK} stack..."
|
||||
- echo "=========================================="
|
||||
- cd "$DEPLOY_PATH"
|
||||
- echo "Checking container status..."
|
||||
|
||||
# Switch traffic
|
||||
- chmod +x scripts/ci/switch-traffic.sh
|
||||
- ./scripts/ci/switch-traffic.sh ${TARGET_STACK} instant
|
||||
|
||||
# Update state
|
||||
- |
|
||||
FAILED=0
|
||||
for service in mvp-traefik mvp-frontend mvp-backend mvp-postgres mvp-redis; do
|
||||
status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found")
|
||||
if [ "$status" != "running" ]; then
|
||||
echo "ERROR: $service is not running (status: $status)"
|
||||
docker logs $service --tail 50 2>/dev/null || true
|
||||
FAILED=1
|
||||
else
|
||||
echo "OK: $service is running"
|
||||
fi
|
||||
done
|
||||
if [ $FAILED -eq 1 ]; then
|
||||
echo "One or more services failed to start"
|
||||
exit 1
|
||||
STATE_FILE="config/deployment/state.json"
|
||||
if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
jq --arg commit "$CI_COMMIT_SHORT_SHA" \
|
||||
--arg ts "$TIMESTAMP" \
|
||||
'.last_deployment = $ts | .last_deployment_commit = $commit | .last_deployment_status = "success" | .rollback_available = true' \
|
||||
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
fi
|
||||
- echo "Checking backend health..."
|
||||
|
||||
- echo "=========================================="
|
||||
- echo "Traffic switch complete"
|
||||
- echo "=========================================="
|
||||
|
||||
# ============================================
|
||||
# Stage 5: VERIFY
|
||||
# Production health verification after switch
|
||||
# ============================================
|
||||
verify:
|
||||
stage: verify
|
||||
tags:
|
||||
- production
|
||||
- shell
|
||||
only:
|
||||
- main
|
||||
needs:
|
||||
- job: validate
|
||||
artifacts: true
|
||||
- job: deploy-switch
|
||||
script:
|
||||
- echo "=========================================="
|
||||
- echo "Verifying production deployment..."
|
||||
- echo "=========================================="
|
||||
- cd "$DEPLOY_PATH"
|
||||
|
||||
# Wait for Traefik to propagate routing
|
||||
- echo "Waiting for traffic routing to stabilize..."
|
||||
- sleep 5
|
||||
|
||||
# Verify via external endpoint
|
||||
- echo "Checking external endpoint..."
|
||||
- |
|
||||
HEALTH_OK=0
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if docker exec mvp-backend curl -sf http://localhost:3001/health > /dev/null 2>&1; then
|
||||
echo "OK: Backend health check passed"
|
||||
HEALTH_OK=1
|
||||
if curl -sf https://motovaultpro.com/api/health > /dev/null 2>&1; then
|
||||
echo "OK - External health check passed"
|
||||
break
|
||||
fi
|
||||
echo "Attempt $i/6: Backend not ready, waiting 10s..."
|
||||
if [ $i -eq 6 ]; then
|
||||
echo "ERROR - External health check failed after 6 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $i/6 - Waiting 10s..."
|
||||
sleep 10
|
||||
done
|
||||
if [ $HEALTH_OK -eq 0 ]; then
|
||||
echo "ERROR: Backend health check failed after 6 attempts"
|
||||
docker logs mvp-backend --tail 100
|
||||
exit 1
|
||||
fi
|
||||
- echo "Checking frontend..."
|
||||
|
||||
# Verify container status
|
||||
- echo "Checking container status..."
|
||||
- |
|
||||
if docker compose -f $DOCKER_COMPOSE_FILE exec -T mvp-frontend curl -sf http://localhost:3000 > /dev/null 2>&1; then
|
||||
echo "OK: Frontend is accessible"
|
||||
else
|
||||
echo "WARNING: Frontend check failed (might need Traefik routing)"
|
||||
fi
|
||||
for service in mvp-frontend-${TARGET_STACK} mvp-backend-${TARGET_STACK}; do
|
||||
status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found")
|
||||
health=$(docker inspect --format='{{.State.Health.Status}}' $service 2>/dev/null || echo "unknown")
|
||||
if [ "$status" != "running" ] || [ "$health" != "healthy" ]; then
|
||||
echo "ERROR - $service is not healthy (status: $status, health: $health)"
|
||||
docker logs $service --tail 50 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "OK - $service is running and healthy"
|
||||
done
|
||||
|
||||
- echo "=========================================="
|
||||
- echo "Deployment verified successfully!"
|
||||
- echo "Version ${CI_COMMIT_SHORT_SHA} is now live"
|
||||
- echo "=========================================="
|
||||
|
||||
# ============================================
|
||||
# Stage 6: ROLLBACK (on failure)
|
||||
# Automatic rollback if verify stage fails
|
||||
# ============================================
|
||||
rollback:
|
||||
stage: rollback
|
||||
tags:
|
||||
- production
|
||||
- shell
|
||||
only:
|
||||
- main
|
||||
when: on_failure
|
||||
needs:
|
||||
- job: validate
|
||||
artifacts: true
|
||||
- job: deploy-switch
|
||||
- job: verify
|
||||
script:
|
||||
- echo "=========================================="
|
||||
- echo "INITIATING AUTO-ROLLBACK"
|
||||
- echo "=========================================="
|
||||
- cd "$DEPLOY_PATH"
|
||||
|
||||
# Run rollback script
|
||||
- chmod +x scripts/ci/auto-rollback.sh
|
||||
- ./scripts/ci/auto-rollback.sh "Verify stage failed - automatic rollback"
|
||||
|
||||
# Update state
|
||||
- |
|
||||
STATE_FILE="config/deployment/state.json"
|
||||
if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then
|
||||
jq '.last_deployment_status = "rolled_back"' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
fi
|
||||
|
||||
- echo "=========================================="
|
||||
- echo "Rollback complete"
|
||||
- echo "=========================================="
|
||||
|
||||
# ============================================
|
||||
# Stage 7: NOTIFY
|
||||
# Send deployment notifications
|
||||
# ============================================
|
||||
notify-success:
|
||||
stage: notify
|
||||
tags:
|
||||
- production
|
||||
- shell
|
||||
only:
|
||||
- main
|
||||
needs:
|
||||
- job: verify
|
||||
script:
|
||||
- echo "Sending success notification..."
|
||||
- cd "$DEPLOY_PATH"
|
||||
- chmod +x scripts/ci/notify.sh
|
||||
- ./scripts/ci/notify.sh success "Version ${CI_COMMIT_SHORT_SHA} deployed successfully" ${CI_COMMIT_SHORT_SHA}
|
||||
|
||||
notify-failure:
|
||||
stage: notify
|
||||
tags:
|
||||
- production
|
||||
- shell
|
||||
only:
|
||||
- main
|
||||
when: on_failure
|
||||
needs:
|
||||
- job: build
|
||||
optional: true
|
||||
- job: deploy-prepare
|
||||
optional: true
|
||||
- job: deploy-switch
|
||||
optional: true
|
||||
- job: verify
|
||||
optional: true
|
||||
script:
|
||||
- echo "Sending failure notification..."
|
||||
- cd "$DEPLOY_PATH"
|
||||
- chmod +x scripts/ci/notify.sh
|
||||
- ./scripts/ci/notify.sh failure "Deployment of ${CI_COMMIT_SHORT_SHA} failed" ${CI_COMMIT_SHORT_SHA}
|
||||
|
||||
# ============================================
|
||||
# Manual Jobs
|
||||
# ============================================
|
||||
|
||||
# Manual maintenance migration job
|
||||
maintenance-migration:
|
||||
stage: deploy-prepare
|
||||
tags:
|
||||
- production
|
||||
- shell
|
||||
only:
|
||||
- main
|
||||
when: manual
|
||||
script:
|
||||
- echo "=========================================="
|
||||
- echo "MAINTENANCE MODE MIGRATION"
|
||||
- echo "=========================================="
|
||||
- cd "$DEPLOY_PATH"
|
||||
- chmod +x scripts/ci/maintenance-migrate.sh
|
||||
- ./scripts/ci/maintenance-migrate.sh backup
|
||||
|
||||
# Mirror base images (scheduled or manual)
|
||||
mirror-images:
|
||||
stage: build
|
||||
tags:
|
||||
- build
|
||||
only:
|
||||
- schedules
|
||||
- web
|
||||
when: manual
|
||||
script:
|
||||
- echo "Mirroring base images to GitLab Container Registry..."
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$REGISTRY"
|
||||
- chmod +x scripts/ci/mirror-base-images.sh
|
||||
- REGISTRY=${REGISTRY}/mirrors ./scripts/ci/mirror-base-images.sh
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
# Production Dockerfile for MotoVaultPro Backend
|
||||
# Uses mirrored base images from GitLab Container Registry
|
||||
|
||||
# Build argument for registry (defaults to GitLab mirrors, falls back to Docker Hub)
|
||||
ARG REGISTRY_MIRRORS=registry.motovaultpro.com/mirrors
|
||||
|
||||
# Stage 1: Build stage
|
||||
FROM node:lts-alpine AS builder
|
||||
FROM ${REGISTRY_MIRRORS}/node:20-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache dumb-init git curl
|
||||
@@ -9,20 +13,25 @@ RUN apk add --no-cache dumb-init git curl
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
# Copy package files from backend directory
|
||||
COPY backend/package*.json ./
|
||||
|
||||
# Install all dependencies (including dev for building)
|
||||
RUN npm install && npm cache clean --force
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
# Copy logo from frontend for email templates (needed for build)
|
||||
RUN mkdir -p frontend/public/images/logos
|
||||
COPY frontend/public/images/logos/motovaultpro-logo-title.png frontend/public/images/logos/
|
||||
|
||||
# Build the application
|
||||
# Copy backend source code
|
||||
COPY backend/ .
|
||||
|
||||
# Build the application (prebuild will encode logo)
|
||||
ENV DOCKER_BUILD=true
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production runtime
|
||||
FROM node:lts-alpine AS production
|
||||
FROM ${REGISTRY_MIRRORS}/node:20-alpine AS production
|
||||
|
||||
# Install runtime dependencies only (postgresql-client for backup/restore)
|
||||
RUN apk add --no-cache dumb-init curl postgresql-client
|
||||
@@ -31,7 +40,7 @@ RUN apk add --no-cache dumb-init curl postgresql-client
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and any lock file generated in builder stage
|
||||
COPY package*.json ./
|
||||
COPY backend/package*.json ./
|
||||
COPY --from=builder /app/package-lock.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
@@ -51,6 +60,10 @@ RUN mkdir -p /app/migrations/features /app/migrations/core
|
||||
COPY --from=builder /app/src/features /app/migrations/features
|
||||
COPY --from=builder /app/src/core /app/migrations/core
|
||||
|
||||
# Copy entrypoint script for permission checks
|
||||
COPY backend/scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown -R nodejs:nodejs /app
|
||||
|
||||
@@ -64,8 +77,8 @@ EXPOSE 3001
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
|
||||
|
||||
# Use dumb-init for proper signal handling
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
# Use dumb-init with entrypoint for permission checks
|
||||
ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# Run production application with auto-migrate (idempotent)
|
||||
CMD ["sh", "-lc", "node dist/_system/migrations/run-all.js && npm start"]
|
||||
|
||||
32
backend/scripts/docker-entrypoint.sh
Executable file
32
backend/scripts/docker-entrypoint.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
# docker-entrypoint.sh
|
||||
# Ensures data directories have correct permissions on container startup
|
||||
|
||||
set -e
|
||||
|
||||
echo "Checking data directory permissions..."
|
||||
|
||||
# Directories that need to be writable by nodejs user (UID 1001)
|
||||
DATA_DIRS="/app/data/backups /app/data/documents"
|
||||
|
||||
for dir in $DATA_DIRS; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "Creating directory: $dir"
|
||||
mkdir -p "$dir"
|
||||
fi
|
||||
|
||||
# Check if we can write to the directory
|
||||
if ! touch "$dir/.write-test" 2>/dev/null; then
|
||||
echo "WARNING: Cannot write to $dir"
|
||||
echo "This may cause backup/document operations to fail"
|
||||
echo "Fix: Run 'sudo chown -R 1001:1001 ./data' on the host"
|
||||
else
|
||||
rm "$dir/.write-test"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Permission checks complete"
|
||||
echo "Starting application..."
|
||||
|
||||
# Execute the CMD from Dockerfile
|
||||
exec "$@"
|
||||
@@ -59,7 +59,7 @@ export class CatalogImportService {
|
||||
async previewImport(csvContent: string): Promise<ImportPreviewResult> {
|
||||
const previewId = uuidv4();
|
||||
const toCreate: ImportRow[] = [];
|
||||
const toUpdate: ImportRow[] = [];
|
||||
const toUpdate: ImportRow[] = []; // Kept for interface compatibility (will be empty)
|
||||
const errors: ImportError[] = [];
|
||||
|
||||
const lines = csvContent.trim().split('\n');
|
||||
@@ -146,21 +146,8 @@ export class CatalogImportService {
|
||||
transmissionType,
|
||||
};
|
||||
|
||||
// Check if record exists to determine create vs update (upsert logic)
|
||||
const existsResult = await this.pool.query(
|
||||
`SELECT id FROM vehicle_options
|
||||
WHERE year = $1 AND make = $2 AND model = $3 AND trim = $4
|
||||
LIMIT 1`,
|
||||
[year, make, model, trim]
|
||||
);
|
||||
const exists = (existsResult.rowCount || 0) > 0;
|
||||
|
||||
// Auto-detect: if exists -> update, else -> create
|
||||
if (exists) {
|
||||
toUpdate.push(row);
|
||||
} else {
|
||||
toCreate.push(row);
|
||||
}
|
||||
// All rows will be inserted with ON CONFLICT handling (proper upsert)
|
||||
toCreate.push(row);
|
||||
} catch (error: any) {
|
||||
errors.push({ row: rowNum, error: error.message || 'Parse error' });
|
||||
}
|
||||
@@ -239,61 +226,29 @@ export class CatalogImportService {
|
||||
transmissionId = transResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Insert vehicle option
|
||||
await client.query(
|
||||
// Upsert vehicle option (insert or update if exists)
|
||||
const upsertResult = await client.query(
|
||||
`INSERT INTO vehicle_options (year, make, model, trim, engine_id, transmission_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (year, make, model, trim, engine_id, transmission_id)
|
||||
DO UPDATE SET updated_at = NOW()
|
||||
RETURNING (xmax = 0) AS inserted`,
|
||||
[row.year, row.make, row.model, row.trim, engineId, transmissionId]
|
||||
);
|
||||
|
||||
result.created++;
|
||||
// Check if this was an insert (xmax=0) or update (xmax!=0)
|
||||
const wasInserted = upsertResult.rows[0].inserted;
|
||||
if (wasInserted) {
|
||||
result.created++;
|
||||
} else {
|
||||
result.updated++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push({ row: 0, error: `Failed to create ${row.year} ${row.make} ${row.model} ${row.trim}: ${error.message}` });
|
||||
result.errors.push({ row: 0, error: `Failed to upsert ${row.year} ${row.make} ${row.model} ${row.trim}: ${error.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Process updates
|
||||
for (const row of preview.toUpdate) {
|
||||
try {
|
||||
// Get or create engine
|
||||
let engineId: number | null = null;
|
||||
if (row.engineName) {
|
||||
const engineResult = await client.query(
|
||||
`INSERT INTO engines (name, fuel_type)
|
||||
VALUES ($1, 'Gas')
|
||||
ON CONFLICT ((lower(name))) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id`,
|
||||
[row.engineName]
|
||||
);
|
||||
engineId = engineResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Get or create transmission
|
||||
let transmissionId: number | null = null;
|
||||
if (row.transmissionType) {
|
||||
const transResult = await client.query(
|
||||
`INSERT INTO transmissions (type)
|
||||
VALUES ($1)
|
||||
ON CONFLICT ((lower(type))) DO UPDATE SET type = EXCLUDED.type
|
||||
RETURNING id`,
|
||||
[row.transmissionType]
|
||||
);
|
||||
transmissionId = transResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Update vehicle option
|
||||
await client.query(
|
||||
`UPDATE vehicle_options
|
||||
SET engine_id = $5, transmission_id = $6, updated_at = NOW()
|
||||
WHERE year = $1 AND make = $2 AND model = $3 AND trim = $4`,
|
||||
[row.year, row.make, row.model, row.trim, engineId, transmissionId]
|
||||
);
|
||||
|
||||
result.updated++;
|
||||
} catch (error: any) {
|
||||
result.errors.push({ row: 0, error: `Failed to update ${row.year} ${row.make} ${row.model} ${row.trim}: ${error.message}` });
|
||||
}
|
||||
}
|
||||
// Note: Separate "Process updates" loop removed - ON CONFLICT handles both INSERT and UPDATE
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
@@ -306,13 +261,23 @@ export class CatalogImportService {
|
||||
logger.debug('Vehicle data cache invalidated after import');
|
||||
}
|
||||
|
||||
logger.info('Catalog import completed', {
|
||||
previewId,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
errors: result.errors.length,
|
||||
changedBy,
|
||||
});
|
||||
// Log completion with appropriate level
|
||||
if (result.errors.length > 0) {
|
||||
logger.warn('Catalog import completed with errors', {
|
||||
previewId,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
errors: result.errors.length,
|
||||
changedBy,
|
||||
});
|
||||
} else {
|
||||
logger.info('Catalog import completed successfully', {
|
||||
previewId,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
changedBy,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { Pool } from 'pg';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { BackupService } from '../domain/backup.service';
|
||||
import { BackupRestoreService } from '../domain/backup-restore.service';
|
||||
import {
|
||||
@@ -179,8 +180,14 @@ export class BackupController {
|
||||
const preview = await this.restoreService.previewRestore(request.params.id);
|
||||
reply.send(preview);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to preview restore';
|
||||
logger.error('Preview restore failed', {
|
||||
backupId: request.params.id,
|
||||
error: errorMessage,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
reply.status(400).send({
|
||||
error: error instanceof Error ? error.message : 'Failed to preview restore',
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -192,7 +199,7 @@ export class BackupController {
|
||||
try {
|
||||
const result = await this.restoreService.executeRestore({
|
||||
backupId: request.params.id,
|
||||
createSafetyBackup: request.body.createSafetyBackup,
|
||||
createSafetyBackup: request.body?.createSafetyBackup ?? true,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -211,9 +218,15 @@ export class BackupController {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to execute restore';
|
||||
logger.error('Restore execution failed', {
|
||||
backupId: request.params.id,
|
||||
error: errorMessage,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
reply.status(400).send({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to execute restore',
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Base HTML Email Layout
|
||||
* @ai-summary Main email wrapper with MotoVaultPro branding
|
||||
* @ai-context Uses table-based layout for email client compatibility
|
||||
*/
|
||||
|
||||
import { EMAIL_STYLES } from './email-styles';
|
||||
|
||||
// External logo URL - hosted on GitHub for reliability
|
||||
const LOGO_URL = 'https://raw.githubusercontent.com/ericgullickson/images/c58b0e4773e8395b532f97f6ab529e38ea4dc8be/motovaultpro-auth0-small.png';
|
||||
|
||||
/**
|
||||
* Renders the complete HTML email layout with branding
|
||||
* @param content - The rendered email body content (HTML)
|
||||
* @returns Complete HTML email string with DOCTYPE and layout
|
||||
*/
|
||||
export function renderEmailLayout(content: string): string {
|
||||
return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>MotoVaultPro</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
table { border-collapse: collapse; }
|
||||
.outlook-fix { font-family: Arial, sans-serif; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f8f9fa;">
|
||||
<!-- Wrapper table for full width background -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="${EMAIL_STYLES.wrapper}">
|
||||
<tr>
|
||||
<td align="center" style="${EMAIL_STYLES.cell}">
|
||||
<!-- Main container table (max-width 600px) -->
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="${EMAIL_STYLES.container}" class="outlook-fix">
|
||||
|
||||
<!-- Header with logo -->
|
||||
<tr>
|
||||
<td style="${EMAIL_STYLES.header}">
|
||||
<img src="${LOGO_URL}" alt="MotoVaultPro" style="${EMAIL_STYLES.logo}" width="280" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content area -->
|
||||
<tr>
|
||||
<td style="${EMAIL_STYLES.content}">
|
||||
${content}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="${EMAIL_STYLES.footer}">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<p style="${EMAIL_STYLES.footerText}">
|
||||
<strong>Professional Vehicle Management & Maintenance Tracking</strong>
|
||||
</p>
|
||||
<p style="${EMAIL_STYLES.footerText}">
|
||||
<a href="https://motovaultpro.com" style="${EMAIL_STYLES.footerLink}" target="_blank">Login to MotoVaultPro</a>
|
||||
</p>
|
||||
<p style="${EMAIL_STYLES.footerText}">
|
||||
<a href="{{unsubscribeUrl}}" style="${EMAIL_STYLES.footerLink}" target="_blank">Manage Email Preferences</a>
|
||||
</p>
|
||||
<p style="${EMAIL_STYLES.copyright}">
|
||||
© 2025 MotoVaultPro. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Email Template Inline Styles
|
||||
* @ai-summary Reusable inline CSS constants for email templates
|
||||
* @ai-context Email clients require inline styles for proper rendering
|
||||
*/
|
||||
|
||||
export const EMAIL_STYLES = {
|
||||
// Layout containers
|
||||
wrapper: 'width: 100%; background-color: #f8f9fa; padding: 20px 0;',
|
||||
container: 'max-width: 600px; margin: 0 auto; background-color: #ffffff;',
|
||||
innerContainer: 'width: 100%;',
|
||||
|
||||
// Header
|
||||
header: 'background-color: #7A212A; padding: 30px 20px; text-align: center;',
|
||||
logo: 'max-width: 280px; height: auto; display: block; margin: 0 auto;',
|
||||
|
||||
// Content area
|
||||
content: 'padding: 40px 30px; font-family: Arial, Helvetica, sans-serif; color: #1e293b; line-height: 1.6; font-size: 16px;',
|
||||
|
||||
// Typography
|
||||
heading: 'color: #7A212A; font-size: 24px; font-weight: bold; margin: 0 0 20px 0; font-family: Arial, Helvetica, sans-serif;',
|
||||
subheading: 'color: #1e293b; font-size: 18px; font-weight: bold; margin: 0 0 16px 0; font-family: Arial, Helvetica, sans-serif;',
|
||||
paragraph: 'margin: 0 0 16px 0; font-size: 16px; color: #1e293b; font-family: Arial, Helvetica, sans-serif; line-height: 1.6;',
|
||||
strong: 'font-weight: bold; color: #7A212A;',
|
||||
|
||||
// Footer
|
||||
footer: 'background-color: #f1f5f9; padding: 30px 20px; text-align: center; border-top: 2px solid #7A212A;',
|
||||
footerText: 'font-size: 14px; color: #64748b; margin: 8px 0; font-family: Arial, Helvetica, sans-serif;',
|
||||
footerLink: 'color: #7A212A; text-decoration: none; font-weight: bold;',
|
||||
footerLinkHover: 'color: #9c2a36; text-decoration: underline;',
|
||||
copyright: 'font-size: 12px; color: #94a3b8; margin: 16px 0 0 0; font-family: Arial, Helvetica, sans-serif;',
|
||||
|
||||
// Divider
|
||||
divider: 'border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;',
|
||||
|
||||
// Table cells
|
||||
cell: 'padding: 0;',
|
||||
cellWithPadding: 'padding: 20px;',
|
||||
} as const;
|
||||
@@ -23,7 +23,7 @@ export class EmailService {
|
||||
* Send an email using Resend
|
||||
* @param to Recipient email address
|
||||
* @param subject Email subject line
|
||||
* @param html Email body (HTML format)
|
||||
* @param html Email body (HTML format with inline styles for email client compatibility)
|
||||
* @returns Promise that resolves when email is sent
|
||||
*/
|
||||
async send(to: string, subject: string, html: string): Promise<void> {
|
||||
@@ -39,16 +39,4 @@ export class EmailService {
|
||||
throw new Error(`Failed to send email: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email with plain text body (converted to HTML)
|
||||
* @param to Recipient email address
|
||||
* @param subject Email subject line
|
||||
* @param text Email body (plain text)
|
||||
*/
|
||||
async sendText(to: string, subject: string, text: string): Promise<void> {
|
||||
// Convert plain text to HTML with proper line breaks
|
||||
const html = text.split('\n').map(line => `<p>${line}</p>`).join('');
|
||||
await this.send(to, subject, html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,10 +94,15 @@ export class NotificationsService {
|
||||
subject: string,
|
||||
body: string,
|
||||
variables: Record<string, string | number | boolean | null | undefined>
|
||||
): Promise<{ subject: string; body: string }> {
|
||||
): Promise<{ subject: string; body: string; html: string }> {
|
||||
const renderedSubject = this.templateService.render(subject, variables);
|
||||
const renderedBody = this.templateService.render(body, variables);
|
||||
const renderedHtml = this.templateService.renderEmailHtml(body, variables);
|
||||
|
||||
return {
|
||||
subject: this.templateService.render(subject, variables),
|
||||
body: this.templateService.render(body, variables),
|
||||
subject: renderedSubject,
|
||||
body: renderedBody,
|
||||
html: renderedHtml,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,10 +135,10 @@ export class NotificationsService {
|
||||
};
|
||||
|
||||
const subject = this.templateService.render(template.subject, variables);
|
||||
const body = this.templateService.render(template.body, variables);
|
||||
const htmlBody = this.templateService.renderEmailHtml(template.body, variables);
|
||||
|
||||
try {
|
||||
await this.emailService.sendText(userEmail, subject, body);
|
||||
await this.emailService.send(userEmail, subject, htmlBody);
|
||||
|
||||
await this.repository.insertNotificationLog({
|
||||
user_id: userId,
|
||||
@@ -188,10 +193,10 @@ export class NotificationsService {
|
||||
};
|
||||
|
||||
const subject = this.templateService.render(template.subject, variables);
|
||||
const body = this.templateService.render(template.body, variables);
|
||||
const htmlBody = this.templateService.renderEmailHtml(template.body, variables);
|
||||
|
||||
try {
|
||||
await this.emailService.sendText(userEmail, subject, body);
|
||||
await this.emailService.send(userEmail, subject, htmlBody);
|
||||
|
||||
await this.repository.insertNotificationLog({
|
||||
user_id: userId,
|
||||
@@ -249,9 +254,10 @@ export class NotificationsService {
|
||||
|
||||
const subject = this.templateService.render(template.subject, sampleVariables);
|
||||
const body = this.templateService.render(template.body, sampleVariables);
|
||||
const htmlBody = this.templateService.renderEmailHtml(template.body, sampleVariables);
|
||||
|
||||
try {
|
||||
await this.emailService.sendText(recipientEmail, `[TEST] ${subject}`, body);
|
||||
await this.emailService.send(recipientEmail, `[TEST] ${subject}`, htmlBody);
|
||||
|
||||
return {
|
||||
subject,
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* @ai-context Replaces {{variableName}} with values
|
||||
*/
|
||||
|
||||
import { renderEmailLayout } from './email-layout/base-layout';
|
||||
import { EMAIL_STYLES } from './email-layout/email-styles';
|
||||
|
||||
export class TemplateService {
|
||||
/**
|
||||
* Render a template string by replacing {{variableName}} with values
|
||||
@@ -22,6 +25,38 @@ export class TemplateService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a template as HTML email with branded layout
|
||||
* @param template Template string with {{variable}} placeholders
|
||||
* @param variables Object mapping variable names to values
|
||||
* @returns Complete HTML email string with layout wrapper
|
||||
*/
|
||||
renderEmailHtml(template: string, variables: Record<string, string | number | boolean | null | undefined>): string {
|
||||
// 1. Replace variables in template body
|
||||
const renderedContent = this.render(template, variables);
|
||||
|
||||
// 2. Escape HTML special characters to prevent XSS
|
||||
const escapeHtml = (text: string): string => {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 3. Convert plain text line breaks to HTML paragraphs
|
||||
const htmlContent = renderedContent
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.map(line => `<p style="${EMAIL_STYLES.paragraph}">${escapeHtml(line)}</p>`)
|
||||
.join('\n');
|
||||
|
||||
// 4. Wrap in branded email layout
|
||||
return renderEmailLayout(htmlContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract variable names from a template string
|
||||
* @param template Template string with {{variable}} placeholders
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Migration: Add HTML body column to email templates
|
||||
* @ai-summary Non-breaking migration for future HTML template support
|
||||
* @ai-context Existing plain text templates auto-convert to HTML
|
||||
*/
|
||||
|
||||
-- Add optional html_body column for custom HTML templates (future enhancement)
|
||||
ALTER TABLE email_templates
|
||||
ADD COLUMN html_body TEXT DEFAULT NULL;
|
||||
|
||||
-- Add comment explaining the column purpose
|
||||
COMMENT ON COLUMN email_templates.html_body IS
|
||||
'Optional custom HTML body. If NULL, the plain text body will be auto-converted to HTML with base layout.';
|
||||
|
||||
-- No data updates needed - existing templates continue to work
|
||||
-- The system will auto-convert plain text body to HTML using renderEmailHtml()
|
||||
2574
backend/src/features/platform/data/engines.sql
Normal file
2574
backend/src/features/platform/data/engines.sql
Normal file
File diff suppressed because it is too large
Load Diff
47
backend/src/features/platform/data/transmissions.sql
Normal file
47
backend/src/features/platform/data/transmissions.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
INSERT INTO public.transmissions VALUES (3393, '8-Speed Dual-Clutch', NULL, NULL, '2025-12-27 20:24:19.358069', '2025-12-27 20:24:19.358069');
|
||||
INSERT INTO public.transmissions VALUES (11, 'Continuously Variable Transmission', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (3404, 'Single-Speed Direct Drive', NULL, NULL, '2025-12-27 20:24:19.358069', '2025-12-27 20:24:19.358069');
|
||||
INSERT INTO public.transmissions VALUES (15, '5-Speed Automatic Overdrive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (3413, '2-Speed Direct Drive', NULL, NULL, '2025-12-27 20:24:19.358069', '2025-12-27 20:24:19.358069');
|
||||
INSERT INTO public.transmissions VALUES (32, '4-Speed CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (3072, 'Single-Speed Transmission', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (24, '5-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (9, '4-Speed Automatic Overdrive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (5304, 'ISR Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (5081, 'Electric', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (10, '5-Speed Manual Overdrive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (36, '10-Speed Automatic Transmission', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (13, '6-Speed Manual Overdrive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (22, '1-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (18, '6-Speed CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (29, '8-Speed CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (4, '5-Speed Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (5, '4-Speed Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (3, '3-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (6, '3-Speed Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (35, '2-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (1184, '9-Speed DCT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (23, '7-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (33, '10-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (34, '10-Speed CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (1159, '8-Speed DCT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (1172, '7-Speed DCT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (17, '6-Speed Automatic Overdrive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (7, '4-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (14, '1-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (25, '7-Speed CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (30, '9-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (12, '5-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (20, '6-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (19, '7-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (28, '8-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (26, '7-Speed Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (8, '6-Speed Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (115, 'CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (2, 'Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (1, 'Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (27, '9-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (21, '8-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (119, '1-Speed Direct Drive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (16, '6-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
INSERT INTO public.transmissions VALUES (31, '10-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
|
||||
239339
backend/src/features/platform/data/vehicle_options.sql
Normal file
239339
backend/src/features/platform/data/vehicle_options.sql
Normal file
File diff suppressed because it is too large
Load Diff
75
backend/src/features/platform/domain/catalog-seed.service.ts
Normal file
75
backend/src/features/platform/domain/catalog-seed.service.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @ai-summary Vehicle catalog data seeding service
|
||||
* @ai-context Loads vehicle catalog data from exported SQL files after migrations
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export class CatalogSeedService {
|
||||
private readonly dataDir = '/app/migrations/features/platform/data';
|
||||
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
/**
|
||||
* Seed vehicle catalog data if tables are empty
|
||||
*/
|
||||
async seedIfEmpty(): Promise<void> {
|
||||
try {
|
||||
// Check if data already exists
|
||||
const count = await this.pool.query('SELECT COUNT(*) FROM vehicle_options');
|
||||
const rowCount = parseInt(count.rows[0].count, 10);
|
||||
|
||||
if (rowCount > 0) {
|
||||
logger.info('Vehicle catalog already seeded, skipping', { rowCount });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Seeding vehicle catalog data...');
|
||||
|
||||
// Load data files in order
|
||||
await this.loadDataFile('engines.sql');
|
||||
await this.loadDataFile('transmissions.sql');
|
||||
await this.loadDataFile('vehicle_options.sql');
|
||||
|
||||
// Verify data loaded
|
||||
const finalCount = await this.pool.query('SELECT COUNT(*) FROM vehicle_options');
|
||||
const finalRowCount = parseInt(finalCount.rows[0].count, 10);
|
||||
|
||||
logger.info('Vehicle catalog seeding complete', { rowCount: finalRowCount });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to seed vehicle catalog', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and execute a SQL data file
|
||||
*/
|
||||
private async loadDataFile(filename: string): Promise<void> {
|
||||
const filePath = path.join(this.dataDir, filename);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.warn('Data file not found, skipping', { filePath });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Loading data file', { filename });
|
||||
|
||||
try {
|
||||
// Read SQL file
|
||||
const sql = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Execute SQL (pg library handles INSERT statements properly)
|
||||
await this.pool.query(sql);
|
||||
|
||||
logger.info('Data file loaded successfully', { filename });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to load data file', { filename, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { buildApp } from './app';
|
||||
import { appConfig } from './core/config/config-loader';
|
||||
import { logger } from './core/logging/logger';
|
||||
import { initializeScheduler } from './core/scheduler';
|
||||
import { pool } from './core/config/database';
|
||||
import { CatalogSeedService } from './features/platform/domain/catalog-seed.service';
|
||||
|
||||
const PORT = appConfig.config.server.port;
|
||||
|
||||
@@ -13,6 +15,15 @@ async function start() {
|
||||
try {
|
||||
const app = await buildApp();
|
||||
|
||||
// Seed vehicle catalog data if needed (runs after migrations)
|
||||
try {
|
||||
const catalogSeedService = new CatalogSeedService(pool);
|
||||
await catalogSeedService.seedIfEmpty();
|
||||
} catch (seedError) {
|
||||
logger.warn('Vehicle catalog seeding failed, continuing startup', { seedError });
|
||||
// Continue startup even if seeding fails (data can be imported later via admin UI)
|
||||
}
|
||||
|
||||
await app.listen({
|
||||
port: PORT,
|
||||
host: '0.0.0.0'
|
||||
|
||||
21
config/deployment/state.json
Normal file
21
config/deployment/state.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"active_stack": "blue",
|
||||
"inactive_stack": "green",
|
||||
"last_deployment": null,
|
||||
"last_deployment_commit": null,
|
||||
"last_deployment_status": null,
|
||||
"blue": {
|
||||
"version": null,
|
||||
"commit": null,
|
||||
"deployed_at": null,
|
||||
"healthy": false
|
||||
},
|
||||
"green": {
|
||||
"version": null,
|
||||
"commit": null,
|
||||
"deployed_at": null,
|
||||
"healthy": false
|
||||
},
|
||||
"rollback_available": false,
|
||||
"maintenance_mode": false
|
||||
}
|
||||
116
config/traefik/dynamic/blue-green.yml
Normal file
116
config/traefik/dynamic/blue-green.yml
Normal file
@@ -0,0 +1,116 @@
|
||||
# Traefik Dynamic Configuration for Blue-Green Deployment
|
||||
# This file is watched by Traefik and reloaded on changes
|
||||
# Traffic weights are updated by scripts/ci/switch-traffic.sh
|
||||
#
|
||||
# Current active stack is determined by weights:
|
||||
# - blue=100, green=0 -> Blue is active
|
||||
# - blue=0, green=100 -> Green is active
|
||||
# - Gradual: 75/25, 50/50, 25/75 for canary deployments
|
||||
|
||||
http:
|
||||
# ========================================
|
||||
# Routers - Route traffic to weighted services
|
||||
# ========================================
|
||||
routers:
|
||||
# Frontend router with weighted service
|
||||
mvp-frontend-bluegreen:
|
||||
rule: "(Host(`motovaultpro.com`) || Host(`www.motovaultpro.com`)) && !PathPrefix(`/api`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
service: mvp-frontend-weighted
|
||||
priority: 10
|
||||
|
||||
# Backend API router with weighted service
|
||||
mvp-backend-bluegreen:
|
||||
rule: "(Host(`motovaultpro.com`) || Host(`www.motovaultpro.com`)) && PathPrefix(`/api`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
service: mvp-backend-weighted
|
||||
priority: 20
|
||||
|
||||
# Health check router (always routes to active stack)
|
||||
mvp-backend-health:
|
||||
rule: "(Host(`motovaultpro.com`) || Host(`www.motovaultpro.com`)) && Path(`/api/health`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
service: mvp-backend-weighted
|
||||
priority: 30
|
||||
|
||||
# ========================================
|
||||
# Services - Weighted load balancers
|
||||
# ========================================
|
||||
services:
|
||||
# Frontend weighted service
|
||||
# Weights are updated by switch-traffic.sh
|
||||
mvp-frontend-weighted:
|
||||
weighted:
|
||||
services:
|
||||
- name: mvp-frontend-blue-svc
|
||||
weight: 100
|
||||
- name: mvp-frontend-green-svc
|
||||
weight: 0
|
||||
healthCheck: {}
|
||||
|
||||
# Backend weighted service
|
||||
# Weights are updated by switch-traffic.sh
|
||||
mvp-backend-weighted:
|
||||
weighted:
|
||||
services:
|
||||
- name: mvp-backend-blue-svc
|
||||
weight: 100
|
||||
- name: mvp-backend-green-svc
|
||||
weight: 0
|
||||
healthCheck: {}
|
||||
|
||||
# Individual stack services
|
||||
mvp-frontend-blue-svc:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://mvp-frontend-blue:3000"
|
||||
healthCheck:
|
||||
path: /
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
passHostHeader: true
|
||||
|
||||
mvp-frontend-green-svc:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://mvp-frontend-green:3000"
|
||||
healthCheck:
|
||||
path: /
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
passHostHeader: true
|
||||
|
||||
mvp-backend-blue-svc:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://mvp-backend-blue:3001"
|
||||
healthCheck:
|
||||
path: /health
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
passHostHeader: true
|
||||
|
||||
mvp-backend-green-svc:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://mvp-backend-green:3001"
|
||||
healthCheck:
|
||||
path: /health
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
passHostHeader: true
|
||||
|
||||
# Maintenance mode service (optional)
|
||||
mvp-maintenance:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://mvp-maintenance:80"
|
||||
180
config/traefik/dynamic/middleware.yml
Executable file
180
config/traefik/dynamic/middleware.yml
Executable file
@@ -0,0 +1,180 @@
|
||||
http:
|
||||
middlewares:
|
||||
# Security headers middleware
|
||||
secure-headers:
|
||||
headers:
|
||||
accessControlAllowMethods:
|
||||
- GET
|
||||
- OPTIONS
|
||||
- PUT
|
||||
- POST
|
||||
- DELETE
|
||||
accessControlAllowOriginList:
|
||||
- "https://admin.motovaultpro.com"
|
||||
- "https://motovaultpro.com"
|
||||
accessControlMaxAge: 100
|
||||
addVaryHeader: true
|
||||
browserXssFilter: true
|
||||
contentTypeNosniff: true
|
||||
forceSTSHeader: true
|
||||
frameDeny: true
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
stsSeconds: 31536000
|
||||
customRequestHeaders:
|
||||
X-Forwarded-Proto: https
|
||||
|
||||
# CORS middleware for API endpoints
|
||||
cors:
|
||||
headers:
|
||||
accessControlAllowCredentials: true
|
||||
accessControlAllowHeaders:
|
||||
- "Authorization"
|
||||
- "Content-Type"
|
||||
- "X-Requested-With"
|
||||
- "X-Tenant-ID"
|
||||
accessControlAllowMethods:
|
||||
- "GET"
|
||||
- "POST"
|
||||
- "PUT"
|
||||
- "DELETE"
|
||||
- "OPTIONS"
|
||||
accessControlAllowOriginList:
|
||||
- "https://admin.motovaultpro.com"
|
||||
- "https://motovaultpro.com"
|
||||
accessControlMaxAge: 100
|
||||
|
||||
# API authentication middleware
|
||||
api-auth:
|
||||
forwardAuth:
|
||||
address: "http://admin-backend:3001/auth/verify"
|
||||
authResponseHeaders:
|
||||
- "X-Auth-User"
|
||||
- "X-Auth-Roles"
|
||||
- "X-Tenant-ID"
|
||||
authRequestHeaders:
|
||||
- "Authorization"
|
||||
- "X-Tenant-ID"
|
||||
trustForwardHeader: true
|
||||
|
||||
# Platform API authentication middleware
|
||||
platform-auth:
|
||||
forwardAuth:
|
||||
address: "http://admin-backend:3001/auth/verify-platform"
|
||||
authResponseHeaders:
|
||||
- "X-Service-Name"
|
||||
- "X-Auth-Scope"
|
||||
authRequestHeaders:
|
||||
- "X-API-Key"
|
||||
- "Authorization"
|
||||
trustForwardHeader: true
|
||||
|
||||
# Rate limiting middleware
|
||||
rate-limit:
|
||||
rateLimit:
|
||||
burst: 100
|
||||
average: 50
|
||||
period: 1m
|
||||
|
||||
# Request/response size limits
|
||||
size-limit:
|
||||
buffering:
|
||||
maxRequestBodyBytes: 26214400 # 25MB
|
||||
maxResponseBodyBytes: 26214400 # 25MB
|
||||
|
||||
# IP whitelist for development (optional)
|
||||
local-ips:
|
||||
ipWhiteList:
|
||||
sourceRange:
|
||||
- "127.0.0.1/32"
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
|
||||
# Advanced security headers for production
|
||||
security-headers-strict:
|
||||
headers:
|
||||
accessControlAllowCredentials: false
|
||||
accessControlAllowMethods:
|
||||
- GET
|
||||
- POST
|
||||
- OPTIONS
|
||||
accessControlAllowOriginList:
|
||||
- "https://admin.motovaultpro.com"
|
||||
- "https://motovaultpro.com"
|
||||
browserXssFilter: true
|
||||
contentTypeNosniff: true
|
||||
customRequestHeaders:
|
||||
X-Forwarded-Proto: https
|
||||
customResponseHeaders:
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: "geolocation=(), microphone=(), camera=()"
|
||||
forceSTSHeader: true
|
||||
frameDeny: true
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
stsSeconds: 31536000
|
||||
|
||||
# Circuit breaker for reliability
|
||||
circuit-breaker:
|
||||
circuitBreaker:
|
||||
expression: "NetworkErrorRatio() > 0.3 || ResponseCodeRatio(500, 600, 0, 600) > 0.3"
|
||||
checkPeriod: 30s
|
||||
fallbackDuration: 10s
|
||||
recoveryDuration: 30s
|
||||
|
||||
# Request retry for resilience
|
||||
retry-policy:
|
||||
retry:
|
||||
attempts: 3
|
||||
initialInterval: 100ms
|
||||
|
||||
# Timeout middleware
|
||||
timeout:
|
||||
timeout: 30s
|
||||
|
||||
# Compress responses for performance
|
||||
compression:
|
||||
compress: {}
|
||||
|
||||
# Health check middleware chain
|
||||
health-check-chain:
|
||||
chain:
|
||||
middlewares:
|
||||
- compression
|
||||
- secure-headers
|
||||
- timeout
|
||||
|
||||
# API middleware chain
|
||||
api-chain:
|
||||
chain:
|
||||
middlewares:
|
||||
- compression
|
||||
- security-headers-strict
|
||||
- cors
|
||||
- rate-limit
|
||||
- api-auth
|
||||
- retry-policy
|
||||
- timeout
|
||||
|
||||
# Platform API middleware chain
|
||||
platform-chain:
|
||||
chain:
|
||||
middlewares:
|
||||
- compression
|
||||
- security-headers-strict
|
||||
- rate-limit
|
||||
- platform-auth
|
||||
- circuit-breaker
|
||||
- retry-policy
|
||||
- timeout
|
||||
|
||||
# Public frontend middleware chain
|
||||
frontend-chain:
|
||||
chain:
|
||||
middlewares:
|
||||
- compression
|
||||
- secure-headers
|
||||
- timeout
|
||||
@@ -21,7 +21,8 @@ providers:
|
||||
exposedByDefault: false
|
||||
# Network auto-discovery - Traefik will use the networks it's connected to
|
||||
file:
|
||||
filename: /etc/traefik/middleware.yml
|
||||
# Watch directory for dynamic configuration (blue-green routing, middleware)
|
||||
directory: /etc/traefik/dynamic
|
||||
watch: true
|
||||
|
||||
certificatesResolvers:
|
||||
|
||||
Binary file not shown.
@@ -1,84 +0,0 @@
|
||||
# Vehicle Catalog Data Export
|
||||
|
||||
Export the current vehicle catalog database to SQL files for GitLab CI/CD deployment.
|
||||
|
||||
## Export Workflow
|
||||
|
||||
### Export from Running Database
|
||||
|
||||
```bash
|
||||
cd data/vehicle-etl
|
||||
python3 export_from_postgres.py
|
||||
```
|
||||
|
||||
**Output:** Creates output/01_engines.sql, output/02_transmissions.sql, output/03_vehicle_options.sql
|
||||
|
||||
**Requirements:**
|
||||
- mvp-postgres container running
|
||||
- Python 3.7+
|
||||
|
||||
### Commit and Deploy
|
||||
|
||||
```bash
|
||||
git add output/*.sql
|
||||
git commit -m "Update vehicle catalog data from PostgreSQL export"
|
||||
git push
|
||||
```
|
||||
|
||||
GitLab CI/CD will automatically import these SQL files during deployment.
|
||||
|
||||
---
|
||||
|
||||
## When to Export
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Admin uploaded CSVs to database | Export and commit |
|
||||
| Manual corrections in PostgreSQL | Export and commit |
|
||||
| After adding new vehicle data | Export and commit |
|
||||
| Preparing for deployment | Export and commit |
|
||||
|
||||
---
|
||||
|
||||
## Local Testing
|
||||
|
||||
```bash
|
||||
# Export current database state
|
||||
python3 export_from_postgres.py
|
||||
|
||||
# Test import locally
|
||||
./reset_database.sh
|
||||
./import_data.sh
|
||||
docker compose exec mvp-redis redis-cli FLUSHALL
|
||||
|
||||
# Verify data
|
||||
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM engines) as engines,
|
||||
(SELECT COUNT(*) FROM transmissions) as transmissions,
|
||||
(SELECT COUNT(*) FROM vehicle_options) as vehicle_options,
|
||||
(SELECT MIN(year) FROM vehicle_options) as min_year,
|
||||
(SELECT MAX(year) FROM vehicle_options) as max_year;
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GitLab CI/CD Integration
|
||||
|
||||
The pipeline automatically imports SQL files from `output/` directory during deployment (/.gitlab-ci.yml lines 89-98):
|
||||
- data/vehicle-etl/output/01_engines.sql
|
||||
- data/vehicle-etl/output/02_transmissions.sql
|
||||
- data/vehicle-etl/output/03_vehicle_options.sql
|
||||
|
||||
Commit updated SQL files to trigger deployment with new data.
|
||||
|
||||
---
|
||||
|
||||
## Legacy Scripts (Not Used)
|
||||
|
||||
The following scripts are legacy from the VehAPI integration and are no longer used:
|
||||
- vehapi_fetch_snapshot.py (obsolete - VehAPI not used)
|
||||
- etl_generate_sql.py (obsolete - database export used instead)
|
||||
|
||||
These scripts are preserved for historical reference but should not be executed.
|
||||
@@ -1,322 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Export PostgreSQL database to SQL files.
|
||||
Extracts current state from running mvp-postgres container and generates
|
||||
SQL import files compatible with the GitLab CI/CD pipeline.
|
||||
|
||||
Usage:
|
||||
python3 export_from_postgres.py
|
||||
python3 export_from_postgres.py --output-dir custom/path
|
||||
|
||||
Output files:
|
||||
- output/01_engines.sql
|
||||
- output/02_transmissions.sql
|
||||
- output/03_vehicle_options.sql
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Sequence
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def check_python_version():
|
||||
"""Ensure Python 3.7+ is being used."""
|
||||
if sys.version_info < (3, 7):
|
||||
raise RuntimeError(
|
||||
f"Python 3.7 or higher required. Current version: {sys.version_info.major}.{sys.version_info.minor}"
|
||||
)
|
||||
|
||||
|
||||
def check_container_running():
|
||||
"""Verify mvp-postgres container is running."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "--filter", "name=mvp-postgres", "--format", "{{.Names}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
if "mvp-postgres" not in result.stdout:
|
||||
raise RuntimeError(
|
||||
"mvp-postgres container is not running.\n"
|
||||
"Start with: docker compose up -d mvp-postgres"
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"Failed to check Docker containers: {e}")
|
||||
|
||||
|
||||
def sql_value(value):
|
||||
"""
|
||||
Convert a Python value to its SQL representation.
|
||||
|
||||
- None -> NULL
|
||||
- str -> 'escaped string' (single quotes doubled)
|
||||
- int/other -> str(value)
|
||||
"""
|
||||
if value is None:
|
||||
return "NULL"
|
||||
if isinstance(value, str):
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
return str(value)
|
||||
|
||||
|
||||
def chunked(seq: Iterable[Dict], size: int) -> Iterable[List[Dict]]:
|
||||
"""
|
||||
Yield successive chunks of `size` from sequence.
|
||||
Used to batch INSERT statements for better performance.
|
||||
"""
|
||||
chunk: List[Dict] = []
|
||||
for item in seq:
|
||||
chunk.append(item)
|
||||
if len(chunk) >= size:
|
||||
yield chunk
|
||||
chunk = []
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
|
||||
def write_insert_file(
|
||||
path: Path,
|
||||
table: str,
|
||||
columns: Sequence[str],
|
||||
rows: Sequence[Dict],
|
||||
):
|
||||
"""
|
||||
Write batched INSERT statements to a SQL file.
|
||||
|
||||
Args:
|
||||
path: Output file path
|
||||
table: Table name
|
||||
columns: Column names to insert
|
||||
rows: List of row dictionaries
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
f.write(f"-- Auto-generated by export_from_postgres.py\n")
|
||||
if not rows:
|
||||
f.write(f"-- No rows for {table}\n")
|
||||
return
|
||||
|
||||
for batch in chunked(rows, BATCH_SIZE):
|
||||
values_sql = ",\n".join(
|
||||
"(" + ",".join(sql_value(row[col]) for col in columns) + ")"
|
||||
for row in batch
|
||||
)
|
||||
f.write(f"INSERT INTO {table} ({', '.join(columns)}) VALUES\n{values_sql};\n\n")
|
||||
|
||||
|
||||
def execute_psql_copy(query: str) -> str:
|
||||
"""
|
||||
Execute a PostgreSQL COPY command via docker exec.
|
||||
|
||||
Args:
|
||||
query: SQL COPY query to execute
|
||||
|
||||
Returns:
|
||||
CSV output as string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If command fails
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"mvp-postgres",
|
||||
"psql",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-d",
|
||||
"motovaultpro",
|
||||
"-c",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = e.stderr if e.stderr else str(e)
|
||||
raise RuntimeError(f"PostgreSQL query failed: {error_msg}")
|
||||
|
||||
|
||||
def export_engines(output_dir: Path) -> int:
|
||||
"""
|
||||
Export engines table to 01_engines.sql.
|
||||
|
||||
Returns:
|
||||
Number of records exported
|
||||
"""
|
||||
query = "COPY (SELECT id, name, fuel_type FROM engines ORDER BY id) TO STDOUT WITH CSV HEADER"
|
||||
csv_output = execute_psql_copy(query)
|
||||
|
||||
rows = []
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(csv_output))
|
||||
for row in reader:
|
||||
rows.append({
|
||||
"id": int(row["id"]),
|
||||
"name": row["name"],
|
||||
"fuel_type": row["fuel_type"] if row["fuel_type"] else None,
|
||||
})
|
||||
except (csv.Error, KeyError, ValueError) as e:
|
||||
raise RuntimeError(f"Failed to parse engines CSV output: {e}")
|
||||
|
||||
write_insert_file(
|
||||
output_dir / "01_engines.sql",
|
||||
"engines",
|
||||
["id", "name", "fuel_type"],
|
||||
rows,
|
||||
)
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def export_transmissions(output_dir: Path) -> int:
|
||||
"""
|
||||
Export transmissions table to 02_transmissions.sql.
|
||||
|
||||
Returns:
|
||||
Number of records exported
|
||||
"""
|
||||
query = "COPY (SELECT id, type FROM transmissions ORDER BY id) TO STDOUT WITH CSV HEADER"
|
||||
csv_output = execute_psql_copy(query)
|
||||
|
||||
rows = []
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(csv_output))
|
||||
for row in reader:
|
||||
rows.append({
|
||||
"id": int(row["id"]),
|
||||
"type": row["type"],
|
||||
})
|
||||
except (csv.Error, KeyError, ValueError) as e:
|
||||
raise RuntimeError(f"Failed to parse transmissions CSV output: {e}")
|
||||
|
||||
write_insert_file(
|
||||
output_dir / "02_transmissions.sql",
|
||||
"transmissions",
|
||||
["id", "type"],
|
||||
rows,
|
||||
)
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def export_vehicle_options(output_dir: Path) -> tuple:
|
||||
"""
|
||||
Export vehicle_options table to 03_vehicle_options.sql.
|
||||
|
||||
Returns:
|
||||
Tuple of (record_count, min_year, max_year)
|
||||
"""
|
||||
query = """COPY (
|
||||
SELECT year, make, model, trim, engine_id, transmission_id
|
||||
FROM vehicle_options
|
||||
ORDER BY year, make, model, trim
|
||||
) TO STDOUT WITH CSV HEADER"""
|
||||
csv_output = execute_psql_copy(query)
|
||||
|
||||
rows = []
|
||||
years = []
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(csv_output))
|
||||
for row in reader:
|
||||
year = int(row["year"])
|
||||
years.append(year)
|
||||
rows.append({
|
||||
"year": year,
|
||||
"make": row["make"],
|
||||
"model": row["model"],
|
||||
"trim": row["trim"],
|
||||
"engine_id": int(row["engine_id"]) if row["engine_id"] else None,
|
||||
"transmission_id": int(row["transmission_id"]) if row["transmission_id"] else None,
|
||||
})
|
||||
except (csv.Error, KeyError, ValueError) as e:
|
||||
raise RuntimeError(f"Failed to parse vehicle_options CSV output: {e}")
|
||||
|
||||
write_insert_file(
|
||||
output_dir / "03_vehicle_options.sql",
|
||||
"vehicle_options",
|
||||
["year", "make", "model", "trim", "engine_id", "transmission_id"],
|
||||
rows,
|
||||
)
|
||||
|
||||
min_year = min(years) if years else None
|
||||
max_year = max(years) if years else None
|
||||
|
||||
return len(rows), min_year, max_year
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Export PostgreSQL vehicle catalog to SQL files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
default=Path("output"),
|
||||
help="Directory to write SQL output files (default: output)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main export workflow."""
|
||||
check_python_version()
|
||||
args = parse_args()
|
||||
output_dir: Path = args.output_dir
|
||||
|
||||
print("Exporting from PostgreSQL database...")
|
||||
print()
|
||||
|
||||
# Verify container is running
|
||||
try:
|
||||
check_container_running()
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Export each table
|
||||
try:
|
||||
engines_count = export_engines(output_dir)
|
||||
print(f" Engines: {engines_count:,} records")
|
||||
|
||||
trans_count = export_transmissions(output_dir)
|
||||
print(f" Transmissions: {trans_count:,} records")
|
||||
|
||||
vehicles_count, min_year, max_year = export_vehicle_options(output_dir)
|
||||
print(f" Vehicle options: {vehicles_count:,} records")
|
||||
print()
|
||||
|
||||
except RuntimeError as e:
|
||||
print(f"Error during export: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Print summary
|
||||
print("SQL files generated:")
|
||||
for sql_file in sorted(output_dir.glob("*.sql")):
|
||||
size_kb = sql_file.stat().st_size / 1024
|
||||
print(f" - {sql_file} ({size_kb:.0f}KB)")
|
||||
print()
|
||||
|
||||
if min_year and max_year:
|
||||
print(f"Year coverage: {min_year}-{max_year}")
|
||||
|
||||
print()
|
||||
print("Export complete! Commit these files to deploy:")
|
||||
print(f" git add {output_dir}/*.sql")
|
||||
print(f" git commit -m \"Update vehicle catalog from PostgreSQL export ({min_year}-{max_year})\"")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,117 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Vehicle Catalog CSV Bulk Import Wrapper
|
||||
#
|
||||
# Copies CSV file into mvp-backend container and executes bulk import script.
|
||||
# Handles large CSV files (250k+ rows) that fail in web import.
|
||||
#
|
||||
# Usage:
|
||||
# ./import_catalog.sh <path_to_csv_file>
|
||||
#
|
||||
# Example:
|
||||
# ./import_catalog.sh data/vehicle-etl/import/vehicle-catalog-master.csv
|
||||
#
|
||||
# Requirements:
|
||||
# - mvp-backend container must be running
|
||||
# - CSV file must have headers: year, make, model, trim
|
||||
# - Optional headers: engine_name, transmission_type
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="mvp-backend"
|
||||
TEMP_CSV_PATH="/tmp/catalog-import.csv"
|
||||
SCRIPT_PATH="dist/features/admin/scripts/bulk-import-catalog.js"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Print error and exit
|
||||
error() {
|
||||
echo -e "${RED}Error: $1${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Print success message
|
||||
success() {
|
||||
echo -e "${GREEN}$1${NC}"
|
||||
}
|
||||
|
||||
# Print warning message
|
||||
warn() {
|
||||
echo -e "${YELLOW}$1${NC}"
|
||||
}
|
||||
|
||||
# Check if CSV file argument provided
|
||||
if [ $# -eq 0 ]; then
|
||||
error "No CSV file specified.
|
||||
|
||||
Usage: $0 <path_to_csv_file>
|
||||
|
||||
Example:
|
||||
$0 data/vehicle-etl/import/vehicle-catalog-master.csv"
|
||||
fi
|
||||
|
||||
CSV_FILE="$1"
|
||||
|
||||
# Validate CSV file exists
|
||||
if [ ! -f "$CSV_FILE" ]; then
|
||||
error "CSV file not found: $CSV_FILE"
|
||||
fi
|
||||
|
||||
# Get absolute path to CSV file
|
||||
CSV_FILE_ABS=$(cd "$(dirname "$CSV_FILE")" && pwd)/$(basename "$CSV_FILE")
|
||||
|
||||
# Check if container is running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
|
||||
error "Container '${CONTAINER}' is not running. Start it with: make start"
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "Vehicle Catalog Bulk Import"
|
||||
echo "=========================================="
|
||||
echo "CSV File: $CSV_FILE_ABS"
|
||||
echo "Container: $CONTAINER"
|
||||
echo ""
|
||||
|
||||
# Copy CSV file into container
|
||||
echo "Step 1: Copying CSV file into container..."
|
||||
if ! docker cp "$CSV_FILE_ABS" "${CONTAINER}:${TEMP_CSV_PATH}"; then
|
||||
error "Failed to copy CSV file into container"
|
||||
fi
|
||||
success "CSV file copied successfully"
|
||||
echo ""
|
||||
|
||||
# Execute import script inside container
|
||||
echo "Step 2: Running import script..."
|
||||
echo ""
|
||||
|
||||
if docker exec -it "$CONTAINER" node "$SCRIPT_PATH"; then
|
||||
success "Import completed successfully!"
|
||||
IMPORT_SUCCESS=true
|
||||
else
|
||||
error "Import failed. Check the logs above for details."
|
||||
IMPORT_SUCCESS=false
|
||||
fi
|
||||
|
||||
# Cleanup: Remove temp CSV file from container
|
||||
echo ""
|
||||
echo "Step 3: Cleaning up..."
|
||||
if docker exec "$CONTAINER" rm -f "$TEMP_CSV_PATH" 2>/dev/null; then
|
||||
success "Temporary files cleaned up"
|
||||
else
|
||||
warn "Warning: Failed to cleanup temp CSV file in container"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$IMPORT_SUCCESS" = true ]; then
|
||||
echo "=========================================="
|
||||
success "Import process completed successfully!"
|
||||
echo "=========================================="
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Offline import of generated SQL files into PostgreSQL (no network).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "=========================================="
|
||||
echo "📥 Automotive Database Import (offline)"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
require_file() {
|
||||
if [ ! -f "$1" ]; then
|
||||
echo "❌ Missing required file: $1"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if ! docker ps --filter "name=mvp-postgres" --format "{{.Names}}" | grep -q "mvp-postgres"; then
|
||||
echo "❌ Error: mvp-postgres container is not running"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_file "output/01_engines.sql"
|
||||
require_file "output/02_transmissions.sql"
|
||||
require_file "output/03_vehicle_options.sql"
|
||||
|
||||
echo "📋 Step 1: Running database schema migration..."
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < migrations/001_create_vehicle_database.sql
|
||||
echo "✓ Schema migration completed"
|
||||
echo ""
|
||||
|
||||
echo "🧹 Step 2: Truncating existing data..."
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro <<'EOF'
|
||||
TRUNCATE TABLE vehicle_options RESTART IDENTITY CASCADE;
|
||||
TRUNCATE TABLE engines RESTART IDENTITY CASCADE;
|
||||
TRUNCATE TABLE transmissions RESTART IDENTITY CASCADE;
|
||||
EOF
|
||||
echo "✓ Tables truncated"
|
||||
echo ""
|
||||
|
||||
echo "📥 Step 3: Importing engines..."
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < output/01_engines.sql
|
||||
echo "✓ Engines imported"
|
||||
echo ""
|
||||
|
||||
echo "📥 Step 4: Importing transmissions..."
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < output/02_transmissions.sql
|
||||
echo "✓ Transmissions imported"
|
||||
echo ""
|
||||
|
||||
echo "📥 Step 5: Importing vehicle options (observed pairs only)..."
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < output/03_vehicle_options.sql
|
||||
echo "✓ Vehicle options imported"
|
||||
echo ""
|
||||
|
||||
echo "=========================================="
|
||||
echo "✅ Import completed"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "🔍 Database verification:"
|
||||
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT COUNT(*) as engines FROM engines;"
|
||||
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT COUNT(*) as transmissions FROM transmissions;"
|
||||
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT COUNT(*) as vehicle_options FROM vehicle_options;"
|
||||
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT MIN(year) as min_year, MAX(year) as max_year FROM vehicle_options;"
|
||||
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT DISTINCT year FROM vehicle_options ORDER BY year LIMIT 5;"
|
||||
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT DISTINCT year FROM vehicle_options ORDER BY year DESC LIMIT 5;"
|
||||
echo ""
|
||||
echo "✓ Database ready for dropdown use."
|
||||
@@ -1,293 +0,0 @@
|
||||
-- Migration: Create Automotive Vehicle Selection Database
|
||||
-- Optimized for dropdown cascade queries
|
||||
-- Date: 2025-11-10
|
||||
|
||||
-- Drop existing tables if they exist
|
||||
DROP TABLE IF EXISTS vehicle_options CASCADE;
|
||||
DROP TABLE IF EXISTS engines CASCADE;
|
||||
DROP TABLE IF EXISTS transmissions CASCADE;
|
||||
DROP INDEX IF EXISTS idx_vehicle_year;
|
||||
DROP INDEX IF EXISTS idx_vehicle_make;
|
||||
DROP INDEX IF EXISTS idx_vehicle_model;
|
||||
DROP INDEX IF EXISTS idx_vehicle_trim;
|
||||
DROP INDEX IF EXISTS idx_vehicle_composite;
|
||||
|
||||
-- Create engines table with detailed specifications
|
||||
CREATE TABLE engines (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
displacement VARCHAR(50),
|
||||
configuration VARCHAR(50),
|
||||
horsepower VARCHAR(100),
|
||||
torque VARCHAR(100),
|
||||
fuel_type VARCHAR(100),
|
||||
fuel_system VARCHAR(255),
|
||||
aspiration VARCHAR(100),
|
||||
specs_json JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
-- Prevent duplicate engine display names (case-insensitive)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_engines_name_lower ON engines (LOWER(name));
|
||||
|
||||
CREATE INDEX idx_engines_displacement ON engines(displacement);
|
||||
CREATE INDEX idx_engines_config ON engines(configuration);
|
||||
|
||||
-- Create transmissions table
|
||||
CREATE TABLE transmissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type VARCHAR(100) NOT NULL,
|
||||
speeds VARCHAR(50),
|
||||
drive_type VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
-- Prevent duplicate transmission display names (case-insensitive)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_transmissions_type_lower ON transmissions (LOWER(type));
|
||||
|
||||
CREATE INDEX idx_transmissions_type ON transmissions(type);
|
||||
|
||||
-- Create denormalized vehicle_options table optimized for dropdown queries
|
||||
CREATE TABLE vehicle_options (
|
||||
id SERIAL PRIMARY KEY,
|
||||
year INTEGER NOT NULL,
|
||||
make VARCHAR(100) NOT NULL,
|
||||
model VARCHAR(255) NOT NULL,
|
||||
trim VARCHAR(255) NOT NULL,
|
||||
engine_id INTEGER REFERENCES engines(id) ON DELETE SET NULL,
|
||||
transmission_id INTEGER REFERENCES transmissions(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
-- Prevent duplicate vehicle option rows
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_vehicle_options_full ON vehicle_options (
|
||||
year, make, model, trim, engine_id, transmission_id
|
||||
);
|
||||
|
||||
-- Indexes for cascading dropdown performance
|
||||
CREATE INDEX idx_vehicle_year ON vehicle_options(year);
|
||||
CREATE INDEX idx_vehicle_make ON vehicle_options(make);
|
||||
CREATE INDEX idx_vehicle_model ON vehicle_options(model);
|
||||
CREATE INDEX idx_vehicle_trim ON vehicle_options(trim);
|
||||
CREATE INDEX idx_vehicle_year_make ON vehicle_options(year, make);
|
||||
CREATE INDEX idx_vehicle_year_make_model ON vehicle_options(year, make, model);
|
||||
CREATE INDEX idx_vehicle_year_make_model_trim ON vehicle_options(year, make, model, trim);
|
||||
CREATE INDEX idx_vehicle_year_make_model_trim_engine ON vehicle_options(year, make, model, trim, engine_id);
|
||||
CREATE INDEX idx_vehicle_year_make_model_trim_trans ON vehicle_options(year, make, model, trim, transmission_id);
|
||||
|
||||
-- Full-text search index for admin catalog search
|
||||
CREATE INDEX idx_vehicle_options_fts ON vehicle_options
|
||||
USING gin(to_tsvector('english', year::text || ' ' || make || ' ' || model || ' ' || trim));
|
||||
|
||||
-- Index on engines.name for join performance during search
|
||||
CREATE INDEX idx_engines_name ON engines(name);
|
||||
|
||||
-- Views for dropdown queries
|
||||
|
||||
-- View: Get all available years
|
||||
CREATE OR REPLACE VIEW available_years AS
|
||||
SELECT DISTINCT year
|
||||
FROM vehicle_options
|
||||
ORDER BY year DESC;
|
||||
|
||||
-- View: Get makes by year
|
||||
CREATE OR REPLACE VIEW makes_by_year AS
|
||||
SELECT DISTINCT year, make
|
||||
FROM vehicle_options
|
||||
ORDER BY year DESC, make ASC;
|
||||
|
||||
-- View: Get models by year and make
|
||||
CREATE OR REPLACE VIEW models_by_year_make AS
|
||||
SELECT DISTINCT year, make, model
|
||||
FROM vehicle_options
|
||||
ORDER BY year DESC, make ASC, model ASC;
|
||||
|
||||
-- View: Get trims by year, make, and model
|
||||
CREATE OR REPLACE VIEW trims_by_year_make_model AS
|
||||
SELECT DISTINCT year, make, model, trim
|
||||
FROM vehicle_options
|
||||
ORDER BY year DESC, make ASC, model ASC, trim ASC;
|
||||
|
||||
-- View: Get complete vehicle configurations with engine and transmission details
|
||||
CREATE OR REPLACE VIEW complete_vehicle_configs AS
|
||||
SELECT
|
||||
vo.id,
|
||||
vo.year,
|
||||
vo.make,
|
||||
vo.model,
|
||||
vo.trim,
|
||||
e.name AS engine_name,
|
||||
e.displacement,
|
||||
e.configuration,
|
||||
e.horsepower,
|
||||
e.torque,
|
||||
e.fuel_type,
|
||||
t.type AS transmission_type,
|
||||
t.speeds AS transmission_speeds,
|
||||
t.drive_type
|
||||
FROM vehicle_options vo
|
||||
LEFT JOIN engines e ON vo.engine_id = e.id
|
||||
LEFT JOIN transmissions t ON vo.transmission_id = t.id
|
||||
ORDER BY vo.year DESC, vo.make ASC, vo.model ASC, vo.trim ASC;
|
||||
|
||||
-- Function to get makes for a specific year
|
||||
CREATE OR REPLACE FUNCTION get_makes_for_year(p_year INTEGER)
|
||||
RETURNS TABLE(make VARCHAR) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT DISTINCT vehicle_options.make
|
||||
FROM vehicle_options
|
||||
WHERE vehicle_options.year = p_year
|
||||
ORDER BY vehicle_options.make ASC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to get models for a specific year and make
|
||||
CREATE OR REPLACE FUNCTION get_models_for_year_make(p_year INTEGER, p_make VARCHAR)
|
||||
RETURNS TABLE(model VARCHAR) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT DISTINCT vehicle_options.model
|
||||
FROM vehicle_options
|
||||
WHERE vehicle_options.year = p_year
|
||||
AND vehicle_options.make = p_make
|
||||
ORDER BY vehicle_options.model ASC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to get trims for a specific year, make, and model
|
||||
CREATE OR REPLACE FUNCTION get_trims_for_year_make_model(p_year INTEGER, p_make VARCHAR, p_model VARCHAR)
|
||||
RETURNS TABLE(trim_name VARCHAR) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT DISTINCT vehicle_options.trim
|
||||
FROM vehicle_options
|
||||
WHERE vehicle_options.year = p_year
|
||||
AND vehicle_options.make = p_make
|
||||
AND vehicle_options.model = p_model
|
||||
ORDER BY vehicle_options.trim ASC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to get engine and transmission options for a specific vehicle
|
||||
CREATE OR REPLACE FUNCTION get_options_for_vehicle(p_year INTEGER, p_make VARCHAR, p_model VARCHAR, p_trim VARCHAR)
|
||||
RETURNS TABLE(
|
||||
engine_name VARCHAR,
|
||||
engine_displacement VARCHAR,
|
||||
engine_horsepower VARCHAR,
|
||||
transmission_type VARCHAR,
|
||||
transmission_speeds VARCHAR,
|
||||
drive_type VARCHAR
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
e.name,
|
||||
e.displacement,
|
||||
e.horsepower,
|
||||
t.type,
|
||||
t.speeds,
|
||||
t.drive_type
|
||||
FROM vehicle_options vo
|
||||
LEFT JOIN engines e ON vo.engine_id = e.id
|
||||
LEFT JOIN transmissions t ON vo.transmission_id = t.id
|
||||
WHERE vo.year = p_year
|
||||
AND vo.make = p_make
|
||||
AND vo.model = p_model
|
||||
AND vo.trim = p_trim;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Helper functions for trim-level options and pair-safe filtering
|
||||
CREATE OR REPLACE FUNCTION get_transmissions_for_vehicle(p_year INTEGER, p_make VARCHAR, p_model VARCHAR, p_trim VARCHAR)
|
||||
RETURNS TABLE(
|
||||
transmission_id INTEGER,
|
||||
transmission_type VARCHAR
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT DISTINCT
|
||||
t.id,
|
||||
t.type
|
||||
FROM vehicle_options vo
|
||||
JOIN transmissions t ON vo.transmission_id = t.id
|
||||
WHERE vo.year = p_year
|
||||
AND vo.make = p_make
|
||||
AND vo.model = p_model
|
||||
AND vo.trim = p_trim
|
||||
ORDER BY t.type ASC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_engines_for_vehicle(p_year INTEGER, p_make VARCHAR, p_model VARCHAR, p_trim VARCHAR)
|
||||
RETURNS TABLE(
|
||||
engine_id INTEGER,
|
||||
engine_name VARCHAR
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT DISTINCT
|
||||
e.id,
|
||||
e.name
|
||||
FROM vehicle_options vo
|
||||
JOIN engines e ON vo.engine_id = e.id
|
||||
WHERE vo.year = p_year
|
||||
AND vo.make = p_make
|
||||
AND vo.model = p_model
|
||||
AND vo.trim = p_trim
|
||||
ORDER BY e.name ASC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_transmissions_for_vehicle_engine(p_year INTEGER, p_make VARCHAR, p_model VARCHAR, p_trim VARCHAR, p_engine_name VARCHAR)
|
||||
RETURNS TABLE(
|
||||
transmission_id INTEGER,
|
||||
transmission_type VARCHAR
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT DISTINCT
|
||||
t.id,
|
||||
t.type
|
||||
FROM vehicle_options vo
|
||||
JOIN engines e ON vo.engine_id = e.id
|
||||
JOIN transmissions t ON vo.transmission_id = t.id
|
||||
WHERE vo.year = p_year
|
||||
AND vo.make = p_make
|
||||
AND vo.model = p_model
|
||||
AND vo.trim = p_trim
|
||||
AND e.name = p_engine_name
|
||||
ORDER BY t.type ASC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_engines_for_vehicle_trans(p_year INTEGER, p_make VARCHAR, p_model VARCHAR, p_trim VARCHAR, p_trans_type VARCHAR)
|
||||
RETURNS TABLE(
|
||||
engine_id INTEGER,
|
||||
engine_name VARCHAR
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT DISTINCT
|
||||
e.id,
|
||||
e.name
|
||||
FROM vehicle_options vo
|
||||
JOIN engines e ON vo.engine_id = e.id
|
||||
JOIN transmissions t ON vo.transmission_id = t.id
|
||||
WHERE vo.year = p_year
|
||||
AND vo.make = p_make
|
||||
AND vo.model = p_model
|
||||
AND vo.trim = p_trim
|
||||
AND t.type = p_trans_type
|
||||
ORDER BY e.name ASC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON TABLE vehicle_options IS 'Denormalized table optimized for cascading dropdown queries';
|
||||
COMMENT ON TABLE engines IS 'Engine specifications with detailed technical data';
|
||||
COMMENT ON TABLE transmissions IS 'Transmission specifications';
|
||||
COMMENT ON VIEW available_years IS 'Returns all distinct years available in the database';
|
||||
COMMENT ON VIEW makes_by_year IS 'Returns makes grouped by year for dropdown population';
|
||||
COMMENT ON VIEW models_by_year_make IS 'Returns models grouped by year and make';
|
||||
COMMENT ON VIEW trims_by_year_make_model IS 'Returns trims grouped by year, make, and model';
|
||||
COMMENT ON VIEW complete_vehicle_configs IS 'Complete vehicle configurations with all details';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
||||
-- Auto-generated by export_from_postgres.py
|
||||
INSERT INTO transmissions (id, type) VALUES
|
||||
(1,'Automatic'),
|
||||
(2,'Manual'),
|
||||
(3,'3-Speed Automatic'),
|
||||
(4,'5-Speed Manual'),
|
||||
(5,'4-Speed Manual'),
|
||||
(6,'3-Speed Manual'),
|
||||
(7,'4-Speed Automatic'),
|
||||
(8,'6-Speed Manual'),
|
||||
(9,'4-Speed Automatic Overdrive'),
|
||||
(10,'5-Speed Manual Overdrive'),
|
||||
(11,'Continuously Variable Transmission'),
|
||||
(12,'5-Speed Automatic'),
|
||||
(13,'6-Speed Manual Overdrive'),
|
||||
(14,'1-Speed Dual Clutch'),
|
||||
(15,'5-Speed Automatic Overdrive'),
|
||||
(16,'6-Speed Automatic'),
|
||||
(17,'6-Speed Automatic Overdrive'),
|
||||
(18,'6-Speed CVT'),
|
||||
(19,'7-Speed Automatic'),
|
||||
(20,'6-Speed Dual Clutch'),
|
||||
(21,'8-Speed Automatic'),
|
||||
(22,'1-Speed Automatic'),
|
||||
(23,'7-Speed Dual Clutch'),
|
||||
(24,'5-Speed Dual Clutch'),
|
||||
(25,'7-Speed CVT'),
|
||||
(26,'7-Speed Manual'),
|
||||
(27,'9-Speed Automatic'),
|
||||
(28,'8-Speed Dual Clutch'),
|
||||
(29,'8-Speed CVT'),
|
||||
(30,'9-Speed Dual Clutch'),
|
||||
(31,'10-Speed Automatic'),
|
||||
(32,'4-Speed CVT'),
|
||||
(33,'10-Speed Dual Clutch'),
|
||||
(34,'10-Speed CVT'),
|
||||
(35,'2-Speed Automatic'),
|
||||
(36,'10-Speed Automatic Transmission'),
|
||||
(115,'CVT'),
|
||||
(119,'1-Speed Direct Drive'),
|
||||
(1159,'8-Speed DCT'),
|
||||
(1172,'7-Speed DCT'),
|
||||
(1184,'9-Speed DCT'),
|
||||
(3072,'Single-Speed Transmission'),
|
||||
(5081,'Electric'),
|
||||
(5304,'ISR Automatic');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,190 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Post-import QA validation for vehicle dropdown data.
|
||||
Runs basic duplicate and range checks against the motovaultpro Postgres container.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_psql(query: str) -> str:
|
||||
cmd = [
|
||||
"docker",
|
||||
"exec",
|
||||
"mvp-postgres",
|
||||
"psql",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-d",
|
||||
"motovaultpro",
|
||||
"-At",
|
||||
"-c",
|
||||
query,
|
||||
]
|
||||
return subprocess.check_output(cmd, text=True)
|
||||
|
||||
|
||||
def check_container():
|
||||
try:
|
||||
subprocess.check_output(["docker", "ps"], text=True)
|
||||
except Exception:
|
||||
print("❌ Docker not available.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
containers = subprocess.check_output(
|
||||
["docker", "ps", "--filter", "name=mvp-postgres", "--format", "{{.Names}}"],
|
||||
text=True,
|
||||
).strip()
|
||||
if not containers:
|
||||
print("❌ mvp-postgres container not running.")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"❌ Failed to check containers: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
def check_invalid_combinations():
|
||||
"""Verify known invalid combinations do not exist."""
|
||||
invalid_combos = [
|
||||
(1992, "Chevrolet", "Corvette", "Z06"), # Z06 started 2001
|
||||
(2000, "Chevrolet", "Corvette", "35th Anniversary Edition"), # Was 1988
|
||||
(2000, "Chevrolet", "Corvette", "Stingray"), # Stingray started 2014
|
||||
(1995, "Ford", "Mustang", "Mach-E"), # Mach-E is 2021+
|
||||
(2020, "Tesla", "Cybertruck", "Base"), # Not in production until later
|
||||
]
|
||||
|
||||
issues = []
|
||||
for year, make, model, trim in invalid_combos:
|
||||
query = f"""
|
||||
SELECT COUNT(*) FROM vehicle_options
|
||||
WHERE year = {year}
|
||||
AND make = '{make}'
|
||||
AND model = '{model}'
|
||||
AND trim = '{trim}'
|
||||
"""
|
||||
count = int(run_psql(query).strip())
|
||||
if count > 0:
|
||||
issues.append(f"Invalid combo found: {year} {make} {model} {trim}")
|
||||
|
||||
return issues
|
||||
|
||||
def check_trim_coverage():
|
||||
"""Report on trim coverage statistics."""
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(DISTINCT (year, make, model)) as total_models,
|
||||
COUNT(DISTINCT (year, make, model)) FILTER (WHERE trim = 'Base') as base_only,
|
||||
COUNT(DISTINCT (year, make, model)) FILTER (WHERE trim != 'Base') as has_specific_trims
|
||||
FROM vehicle_options
|
||||
"""
|
||||
result = run_psql(query).strip()
|
||||
print(f"Trim coverage (total/base_only/has_specific_trims): {result}")
|
||||
|
||||
|
||||
def main():
|
||||
check_container()
|
||||
|
||||
print("🔍 Running QA checks...\n")
|
||||
|
||||
queries = {
|
||||
"engine_duplicate_names": """
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT LOWER(name) as n, COUNT(*) c
|
||||
FROM engines
|
||||
GROUP BY 1 HAVING COUNT(*) > 1
|
||||
) t;
|
||||
""",
|
||||
"transmission_duplicate_types": """
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT LOWER(type) as t, COUNT(*) c
|
||||
FROM transmissions
|
||||
GROUP BY 1 HAVING COUNT(*) > 1
|
||||
) t;
|
||||
""",
|
||||
"vehicle_option_duplicates": """
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT year, make, model, trim, engine_id, transmission_id, COUNT(*) c
|
||||
FROM vehicle_options
|
||||
GROUP BY 1,2,3,4,5,6 HAVING COUNT(*) > 1
|
||||
) t;
|
||||
""",
|
||||
"year_range": """
|
||||
SELECT MIN(year) || ' - ' || MAX(year) FROM vehicle_options;
|
||||
""",
|
||||
"year_range_valid": """
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM vehicle_options WHERE year < 2015 OR year > 2022 LIMIT 1
|
||||
) t;
|
||||
""",
|
||||
"counts": """
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM engines) AS engines,
|
||||
(SELECT COUNT(*) FROM transmissions) AS transmissions,
|
||||
(SELECT COUNT(*) FROM vehicle_options) AS vehicle_options;
|
||||
""",
|
||||
"cross_join_gaps": """
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT base.year, base.make, base.model, base.trim, e.engine_id, t.transmission_id
|
||||
FROM (
|
||||
SELECT DISTINCT year, make, model, trim FROM vehicle_options
|
||||
) base
|
||||
JOIN (
|
||||
SELECT DISTINCT year, make, model, trim, engine_id FROM vehicle_options
|
||||
) e ON base.year = e.year AND base.make = e.make AND base.model = e.model AND base.trim = e.trim
|
||||
JOIN (
|
||||
SELECT DISTINCT year, make, model, trim, transmission_id FROM vehicle_options
|
||||
) t ON base.year = t.year AND base.make = t.make AND base.model = t.model AND base.trim = t.trim
|
||||
EXCEPT
|
||||
SELECT year, make, model, trim, engine_id, transmission_id FROM vehicle_options
|
||||
) gap;
|
||||
""",
|
||||
}
|
||||
|
||||
results = {}
|
||||
for key, query in queries.items():
|
||||
try:
|
||||
results[key] = run_psql(query).strip()
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"❌ Query failed ({key}): {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
issues_found = False
|
||||
|
||||
print(f"Engine duplicate names: {results['engine_duplicate_names']}")
|
||||
print(f"Transmission duplicate types: {results['transmission_duplicate_types']}")
|
||||
print(f"Vehicle option duplicates: {results['vehicle_option_duplicates']}")
|
||||
print(f"Year range: {results['year_range']}")
|
||||
print(f"Out-of-range years (should be 0): {results['year_range_valid']}")
|
||||
print(f"Counts (engines, transmissions, vehicle_options): {results['counts']}")
|
||||
print(f"Cross-join gaps (should be 0 to avoid impossible pairs): {results['cross_join_gaps']}")
|
||||
|
||||
if (
|
||||
results["engine_duplicate_names"] != "0"
|
||||
or results["transmission_duplicate_types"] != "0"
|
||||
or results["vehicle_option_duplicates"] != "0"
|
||||
or results["year_range_valid"] != "0"
|
||||
or results["cross_join_gaps"] != "0"
|
||||
):
|
||||
issues_found = True
|
||||
|
||||
invalids = check_invalid_combinations()
|
||||
if invalids:
|
||||
issues_found = True
|
||||
print("\n❌ Invalid combinations detected:")
|
||||
for issue in invalids:
|
||||
print(f" - {issue}")
|
||||
else:
|
||||
print("\n✅ No known invalid year/make/model/trim combos found.")
|
||||
|
||||
check_trim_coverage()
|
||||
|
||||
if not issues_found:
|
||||
print("\n✅ QA checks passed.")
|
||||
else:
|
||||
print("\n❌ QA checks found issues.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Reset vehicle database tables before a fresh import.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Vehicle Database Reset"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Check if postgres container is running
|
||||
if ! docker ps --filter "name=mvp-postgres" --format "{{.Names}}" | grep -q "mvp-postgres"; then
|
||||
echo "Error: mvp-postgres container is not running"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Current data (before reset):"
|
||||
docker exec mvp-postgres psql -U postgres -d motovaultpro -c \
|
||||
"SELECT
|
||||
(SELECT COUNT(*) FROM engines) as engines,
|
||||
(SELECT COUNT(*) FROM transmissions) as transmissions,
|
||||
(SELECT COUNT(*) FROM vehicle_options) as vehicle_options;" 2>/dev/null || echo " Tables may not exist yet"
|
||||
echo ""
|
||||
|
||||
# Confirm reset
|
||||
read -p "Are you sure you want to reset all vehicle data? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Reset cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Truncating tables..."
|
||||
docker exec -i mvp-postgres psql -U postgres -d motovaultpro <<'EOF'
|
||||
TRUNCATE TABLE vehicle_options RESTART IDENTITY CASCADE;
|
||||
TRUNCATE TABLE engines RESTART IDENTITY CASCADE;
|
||||
TRUNCATE TABLE transmissions RESTART IDENTITY CASCADE;
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Reset complete"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Verification (should all be 0):"
|
||||
docker exec mvp-postgres psql -U postgres -d motovaultpro -c \
|
||||
"SELECT
|
||||
(SELECT COUNT(*) FROM engines) as engines,
|
||||
(SELECT COUNT(*) FROM transmissions) as transmissions,
|
||||
(SELECT COUNT(*) FROM vehicle_options) as vehicle_options;"
|
||||
echo ""
|
||||
echo "Ready for fresh import with: ./import_data.sh"
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Compare database counts with exported SQL file counts
|
||||
# Usage: ./validate_export.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "Validating exported SQL files against database..."
|
||||
echo ""
|
||||
|
||||
# Get counts from database
|
||||
DB_ENGINES=$(docker exec mvp-postgres psql -U postgres -d motovaultpro -t -A -c "SELECT COUNT(*) FROM engines;")
|
||||
DB_TRANS=$(docker exec mvp-postgres psql -U postgres -d motovaultpro -t -A -c "SELECT COUNT(*) FROM transmissions;")
|
||||
DB_VEHICLES=$(docker exec mvp-postgres psql -U postgres -d motovaultpro -t -A -c "SELECT COUNT(*) FROM vehicle_options;")
|
||||
|
||||
# Count records in SQL files (count lines starting with '(' which are data rows)
|
||||
SQL_ENGINES=$(grep -c '^(' output/01_engines.sql)
|
||||
SQL_TRANS=$(grep -c '^(' output/02_transmissions.sql)
|
||||
SQL_VEHICLES=$(grep -c '^(' output/03_vehicle_options.sql)
|
||||
|
||||
# Display comparison
|
||||
echo "Database vs SQL File Counts:"
|
||||
echo " Engines: $DB_ENGINES (DB) vs $SQL_ENGINES (SQL)"
|
||||
echo " Transmissions: $DB_TRANS (DB) vs $SQL_TRANS (SQL)"
|
||||
echo " Vehicle Options: $DB_VEHICLES (DB) vs $SQL_VEHICLES (SQL)"
|
||||
echo ""
|
||||
|
||||
# Validate counts match
|
||||
if [ "$DB_ENGINES" -eq "$SQL_ENGINES" ] && [ "$DB_TRANS" -eq "$SQL_TRANS" ] && [ "$DB_VEHICLES" -eq "$SQL_VEHICLES" ]; then
|
||||
echo "Validation PASSED - All counts match!"
|
||||
exit 0
|
||||
else
|
||||
echo "Validation FAILED - Counts do not match!"
|
||||
exit 1
|
||||
fi
|
||||
196
docker-compose.blue-green.yml
Normal file
196
docker-compose.blue-green.yml
Normal file
@@ -0,0 +1,196 @@
|
||||
# Blue-Green Deployment Overlay for MotoVaultPro
|
||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.blue-green.yml up -d
|
||||
#
|
||||
# This overlay defines blue and green stacks that share the same database layer.
|
||||
# Traffic routing is handled by Traefik's weighted load balancer.
|
||||
#
|
||||
# Stack naming:
|
||||
# BLUE: mvp-frontend-blue, mvp-backend-blue
|
||||
# GREEN: mvp-frontend-green, mvp-backend-green
|
||||
#
|
||||
# Shared services (from base compose):
|
||||
# mvp-traefik, mvp-postgres, mvp-redis
|
||||
|
||||
services:
|
||||
# ========================================
|
||||
# BLUE Stack - Frontend
|
||||
# ========================================
|
||||
mvp-frontend-blue:
|
||||
image: ${FRONTEND_IMAGE:-registry.motovaultpro.com/motovaultpro/frontend:latest}
|
||||
container_name: mvp-frontend-blue
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
VITE_API_BASE_URL: /api
|
||||
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
|
||||
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
|
||||
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
|
||||
SECRETS_DIR: /run/secrets
|
||||
volumes:
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- mvp-backend-blue
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=motovaultpro_frontend"
|
||||
- "com.motovaultpro.stack=blue"
|
||||
- "com.motovaultpro.service=frontend"
|
||||
|
||||
# ========================================
|
||||
# BLUE Stack - Backend
|
||||
# ========================================
|
||||
mvp-backend-blue:
|
||||
image: ${BACKEND_IMAGE:-registry.motovaultpro.com/motovaultpro/backend:latest}
|
||||
container_name: mvp-backend-blue
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
CONFIG_PATH: /app/config/production.yml
|
||||
SECRETS_DIR: /run/secrets
|
||||
DATABASE_HOST: mvp-postgres
|
||||
REDIS_HOST: mvp-redis
|
||||
volumes:
|
||||
- ./config/app/production.yml:/app/config/production.yml:ro
|
||||
- ./config/shared/production.yml:/app/config/shared.yml:ro
|
||||
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
|
||||
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
|
||||
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
|
||||
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
|
||||
- ./data/documents:/app/data/documents
|
||||
- ./data/backups:/app/data/backups
|
||||
networks:
|
||||
- backend
|
||||
- database
|
||||
depends_on:
|
||||
- mvp-postgres
|
||||
- mvp-redis
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=motovaultpro_backend"
|
||||
- "com.motovaultpro.stack=blue"
|
||||
- "com.motovaultpro.service=backend"
|
||||
|
||||
# ========================================
|
||||
# GREEN Stack - Frontend
|
||||
# ========================================
|
||||
mvp-frontend-green:
|
||||
image: ${FRONTEND_IMAGE:-registry.motovaultpro.com/motovaultpro/frontend:latest}
|
||||
container_name: mvp-frontend-green
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
VITE_API_BASE_URL: /api
|
||||
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
|
||||
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
|
||||
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
|
||||
SECRETS_DIR: /run/secrets
|
||||
volumes:
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- mvp-backend-green
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=motovaultpro_frontend"
|
||||
- "com.motovaultpro.stack=green"
|
||||
- "com.motovaultpro.service=frontend"
|
||||
|
||||
# ========================================
|
||||
# GREEN Stack - Backend
|
||||
# ========================================
|
||||
mvp-backend-green:
|
||||
image: ${BACKEND_IMAGE:-registry.motovaultpro.com/motovaultpro/backend:latest}
|
||||
container_name: mvp-backend-green
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
CONFIG_PATH: /app/config/production.yml
|
||||
SECRETS_DIR: /run/secrets
|
||||
DATABASE_HOST: mvp-postgres
|
||||
REDIS_HOST: mvp-redis
|
||||
volumes:
|
||||
- ./config/app/production.yml:/app/config/production.yml:ro
|
||||
- ./config/shared/production.yml:/app/config/shared.yml:ro
|
||||
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
|
||||
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
|
||||
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
|
||||
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
|
||||
- ./data/documents:/app/data/documents
|
||||
- ./data/backups:/app/data/backups
|
||||
networks:
|
||||
- backend
|
||||
- database
|
||||
depends_on:
|
||||
- mvp-postgres
|
||||
- mvp-redis
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=motovaultpro_backend"
|
||||
- "com.motovaultpro.stack=green"
|
||||
- "com.motovaultpro.service=backend"
|
||||
|
||||
# ========================================
|
||||
# Override Traefik to add dynamic config
|
||||
# ========================================
|
||||
mvp-traefik:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./config/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||
- ./config/traefik/middleware.yml:/etc/traefik/middleware.yml:ro
|
||||
- ./config/traefik/dynamic:/etc/traefik/dynamic:ro
|
||||
- ./certs:/certs:ro
|
||||
- traefik_data:/data
|
||||
- ./secrets/app/cloudflare-dns-token.txt:/run/secrets/cloudflare-dns-token:ro
|
||||
@@ -1,7 +1,11 @@
|
||||
# Base registry for mirrored images (override with environment variable)
|
||||
x-registry: ®istry
|
||||
REGISTRY_MIRRORS: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}
|
||||
|
||||
services:
|
||||
# Traefik - Service Discovery and Load Balancing
|
||||
mvp-traefik:
|
||||
image: traefik:v3.6
|
||||
image: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}/traefik:v3.6
|
||||
container_name: mvp-traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -15,7 +19,7 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./config/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||
- ./config/traefik/middleware.yml:/etc/traefik/middleware.yml:ro
|
||||
- ./config/traefik/dynamic:/etc/traefik/dynamic:ro
|
||||
- ./certs:/certs:ro
|
||||
- traefik_data:/data
|
||||
- ./secrets/app/cloudflare-dns-token.txt:/run/secrets/cloudflare-dns-token:ro
|
||||
@@ -86,8 +90,8 @@ services:
|
||||
# Application Services - Backend API
|
||||
mvp-backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
cache_from:
|
||||
- node:lts-alpine
|
||||
container_name: mvp-backend
|
||||
@@ -154,7 +158,7 @@ services:
|
||||
|
||||
# Database Services - Application PostgreSQL
|
||||
mvp-postgres:
|
||||
image: postgres:18-alpine
|
||||
image: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}/postgres:18-alpine
|
||||
container_name: mvp-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -179,7 +183,7 @@ services:
|
||||
|
||||
# Database Services - Application Redis
|
||||
mvp-redis:
|
||||
image: redis:8.4-alpine
|
||||
image: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}/redis:8.4-alpine
|
||||
container_name: mvp-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
|
||||
315
docs/BUILD-SERVER-SETUP.md
Normal file
315
docs/BUILD-SERVER-SETUP.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Build Server Setup Guide
|
||||
|
||||
Complete guide for setting up a dedicated build VPS for MotoVaultPro CI/CD pipeline.
|
||||
|
||||
## Overview
|
||||
|
||||
The build server isolates resource-intensive Docker builds from the production server, ensuring deployments don't impact application performance.
|
||||
|
||||
```
|
||||
+-------------------+ +--------------------+
|
||||
| GitLab Server | | Production Server |
|
||||
| (CI/CD + Registry)| | (Shell Runner) |
|
||||
+--------+----------+ +----------+---------+
|
||||
| |
|
||||
v v
|
||||
+--------+----------+ +----------+---------+
|
||||
| Build VPS | | Blue-Green Stacks |
|
||||
| (Docker Runner) |---->| + Shared Data |
|
||||
+-------------------+ +--------------------+
|
||||
```
|
||||
|
||||
## Server Requirements
|
||||
|
||||
### Minimum Specifications
|
||||
|
||||
| Resource | Requirement |
|
||||
|----------|-------------|
|
||||
| CPU | 2 cores |
|
||||
| RAM | 4GB |
|
||||
| Storage | 50GB SSD |
|
||||
| Network | 100Mbps+ |
|
||||
| OS | Ubuntu 22.04 LTS / Debian 12 |
|
||||
|
||||
### Network Requirements
|
||||
|
||||
- Outbound HTTPS to GitLab instance
|
||||
- Outbound HTTPS to Docker registries (for fallback)
|
||||
- SSH access for administration
|
||||
|
||||
---
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Update System
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
sudo apt install -y curl git ca-certificates gnupg
|
||||
```
|
||||
|
||||
### 2. Install Docker Engine
|
||||
|
||||
```bash
|
||||
# Add Docker's official GPG key
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
# Add the repository to Apt sources
|
||||
echo \
|
||||
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# Install Docker
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
# Verify installation
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
### 3. Install GitLab Runner
|
||||
|
||||
```bash
|
||||
# Add GitLab Runner repository
|
||||
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
|
||||
|
||||
# Install GitLab Runner
|
||||
sudo apt install gitlab-runner
|
||||
|
||||
# Verify installation
|
||||
gitlab-runner --version
|
||||
```
|
||||
|
||||
### 4. Register Runner with Shell Executor
|
||||
|
||||
```bash
|
||||
sudo gitlab-runner register \
|
||||
--non-interactive \
|
||||
--url "https://git.motovaultpro.com" \
|
||||
--registration-token "YOUR_REGISTRATION_TOKEN" \
|
||||
--executor "shell" \
|
||||
--description "Build Server - Shell Executor" \
|
||||
--tag-list "build" \
|
||||
--run-untagged="false" \
|
||||
--locked="true"
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Replace `YOUR_REGISTRATION_TOKEN` with the token from GitLab Admin > CI/CD > Runners
|
||||
- Shell executor runs jobs directly on the host with access to Docker
|
||||
- Tag `build` is used in `.gitlab-ci.yml` to route build jobs to this server
|
||||
|
||||
### 5. Add gitlab-runner to Docker Group
|
||||
|
||||
The gitlab-runner user needs access to Docker:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker gitlab-runner
|
||||
|
||||
# Verify access
|
||||
sudo -u gitlab-runner docker info
|
||||
sudo -u gitlab-runner docker compose version
|
||||
```
|
||||
|
||||
### 6. Configure Docker Registry Authentication
|
||||
|
||||
Create credentials file for GitLab Container Registry:
|
||||
|
||||
```bash
|
||||
# Login to GitLab Container Registry (creates ~/.docker/config.json)
|
||||
docker login registry.motovaultpro.com -u <deploy-token-username> -p <deploy-token>
|
||||
```
|
||||
|
||||
**Creating Deploy Token:**
|
||||
1. Go to GitLab Project > Settings > Repository > Deploy Tokens
|
||||
2. Create token with `read_registry` and `write_registry` scopes
|
||||
3. Use the token username/password for Docker login
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test Runner Registration
|
||||
|
||||
```bash
|
||||
sudo gitlab-runner verify
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Verifying runner... is alive runner=XXXXXX
|
||||
```
|
||||
|
||||
### Test Docker Access
|
||||
|
||||
```bash
|
||||
sudo gitlab-runner exec docker --docker-privileged test-job
|
||||
```
|
||||
|
||||
### Test Registry Push
|
||||
|
||||
```bash
|
||||
# Build and push a test image
|
||||
docker build -t registry.motovaultpro.com/motovaultpro/test:latest -f- . <<EOF
|
||||
FROM alpine:latest
|
||||
RUN echo "test"
|
||||
EOF
|
||||
|
||||
docker push registry.motovaultpro.com/motovaultpro/test:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Disk Cleanup
|
||||
|
||||
Docker builds accumulate disk space. Set up automated cleanup:
|
||||
|
||||
```bash
|
||||
# Create cleanup script
|
||||
sudo tee /usr/local/bin/docker-cleanup.sh > /dev/null <<'EOF'
|
||||
#!/bin/bash
|
||||
# Remove unused Docker resources older than 7 days
|
||||
docker system prune -af --filter "until=168h"
|
||||
docker volume prune -f
|
||||
EOF
|
||||
|
||||
sudo chmod +x /usr/local/bin/docker-cleanup.sh
|
||||
|
||||
# Add to crontab (run daily at 3 AM)
|
||||
echo "0 3 * * * /usr/local/bin/docker-cleanup.sh >> /var/log/docker-cleanup.log 2>&1" | sudo crontab -
|
||||
```
|
||||
|
||||
### Log Rotation
|
||||
|
||||
Configure log rotation for GitLab Runner:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/logrotate.d/gitlab-runner > /dev/null <<EOF
|
||||
/var/log/gitlab-runner/*.log {
|
||||
daily
|
||||
rotate 7
|
||||
compress
|
||||
missingok
|
||||
notifempty
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
### Update Runner
|
||||
|
||||
```bash
|
||||
# Update GitLab Runner
|
||||
sudo apt update
|
||||
sudo apt upgrade gitlab-runner
|
||||
|
||||
# Restart runner
|
||||
sudo gitlab-runner restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Firewall Configuration
|
||||
|
||||
```bash
|
||||
# Allow only necessary outbound traffic
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
sudo ufw allow ssh
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
### Runner Security
|
||||
|
||||
- **Locked runner**: Only accepts jobs from the specific project
|
||||
- **Protected tags**: Only runs on protected branches (main)
|
||||
- **Docker socket**: Mounted read-only where possible
|
||||
|
||||
### Secrets Management
|
||||
|
||||
The build server does NOT store application secrets. All secrets are:
|
||||
- Stored in GitLab CI/CD Variables
|
||||
- Injected at runtime on the production server
|
||||
- Never cached in Docker layers
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Runner Not Picking Up Jobs
|
||||
|
||||
```bash
|
||||
# Check runner status
|
||||
sudo gitlab-runner status
|
||||
|
||||
# View runner logs
|
||||
sudo journalctl -u gitlab-runner -f
|
||||
|
||||
# Re-register runner if needed
|
||||
sudo gitlab-runner unregister --all-runners
|
||||
sudo gitlab-runner register
|
||||
```
|
||||
|
||||
### Docker Build Failures
|
||||
|
||||
```bash
|
||||
# Check Docker daemon
|
||||
sudo systemctl status docker
|
||||
|
||||
# Check available disk space
|
||||
df -h
|
||||
|
||||
# Clear Docker cache
|
||||
docker system prune -af
|
||||
```
|
||||
|
||||
### Registry Push Failures
|
||||
|
||||
```bash
|
||||
# Verify registry login
|
||||
docker login registry.motovaultpro.com
|
||||
|
||||
# Check network connectivity
|
||||
curl -v https://registry.motovaultpro.com/v2/
|
||||
|
||||
# Verify image exists
|
||||
docker images | grep motovaultpro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Important Paths
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/etc/gitlab-runner/config.toml` | Runner configuration |
|
||||
| `/var/log/gitlab-runner/` | Runner logs |
|
||||
| `~/.docker/config.json` | Docker registry credentials |
|
||||
| `/var/lib/docker/` | Docker data |
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
# Runner management
|
||||
sudo gitlab-runner status
|
||||
sudo gitlab-runner restart
|
||||
sudo gitlab-runner verify
|
||||
|
||||
# Docker management
|
||||
docker system df # Check disk usage
|
||||
docker system prune -af # Clean all unused resources
|
||||
docker images # List images
|
||||
docker ps -a # List containers
|
||||
|
||||
# View build logs
|
||||
sudo journalctl -u gitlab-runner --since "1 hour ago"
|
||||
```
|
||||
@@ -1,17 +1,72 @@
|
||||
# MotoVaultPro GitLab CI/CD Deployment Guide
|
||||
|
||||
Complete guide for deploying MotoVaultPro using GitLab CI/CD with shell executor runners.
|
||||
Complete guide for deploying MotoVaultPro using GitLab CI/CD with blue-green deployment and auto-rollback.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [GitLab Runner Setup](#gitlab-runner-setup)
|
||||
3. [CI/CD Variables Configuration](#cicd-variables-configuration)
|
||||
4. [Secrets Architecture](#secrets-architecture)
|
||||
5. [Pipeline Overview](#pipeline-overview)
|
||||
6. [Deployment Process](#deployment-process)
|
||||
7. [Rollback Procedure](#rollback-procedure)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
1. [Architecture Overview](#architecture-overview)
|
||||
2. [Prerequisites](#prerequisites)
|
||||
3. [Pipeline Stages](#pipeline-stages)
|
||||
4. [Blue-Green Deployment](#blue-green-deployment)
|
||||
5. [CI/CD Variables Configuration](#cicd-variables-configuration)
|
||||
6. [Container Registry](#container-registry)
|
||||
7. [Deployment Process](#deployment-process)
|
||||
8. [Rollback Procedures](#rollback-procedures)
|
||||
9. [Maintenance Migrations](#maintenance-migrations)
|
||||
10. [Notifications](#notifications)
|
||||
11. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
MotoVaultPro uses a blue-green deployment strategy with automatic rollback:
|
||||
|
||||
```
|
||||
+---------------------------------------------------+
|
||||
| GitLab (CI/CD + Registry) |
|
||||
+---------------------------------------------------+
|
||||
| |
|
||||
v v
|
||||
+------------------+ +-----------------------+
|
||||
| Build VPS | | Production Server |
|
||||
| (Docker Runner) | | (Shell Runner) |
|
||||
| Tags: build | | Tags: production |
|
||||
+------------------+ +-----------+-----------+
|
||||
| |
|
||||
| Push images | Pull + Deploy
|
||||
v v
|
||||
+---------------------------------------------------+
|
||||
| GitLab Container Registry |
|
||||
| registry.motovaultpro.com/motovaultpro/ |
|
||||
+---------------------------------------------------+
|
||||
|
|
||||
+---------------+---------------+
|
||||
| |
|
||||
+--------v--------+ +--------v--------+
|
||||
| BLUE Stack | | GREEN Stack |
|
||||
| mvp-frontend | | mvp-frontend |
|
||||
| mvp-backend | | mvp-backend |
|
||||
+-----------------+ +-----------------+
|
||||
| |
|
||||
+----------- Traefik -----------+
|
||||
(weighted LB)
|
||||
|
|
||||
+---------------+---------------+
|
||||
| |
|
||||
+--------v--------+ +--------v--------+
|
||||
| PostgreSQL | | Redis |
|
||||
| (shared) | | (shared) |
|
||||
+-----------------+ +-----------------+
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Zero-downtime deployments**: Traffic switches in under 5 seconds
|
||||
- **Instant rollback**: Previous version remains running
|
||||
- **Automatic rollback**: On health check failure
|
||||
- **Email notifications**: Via Resend API
|
||||
- **Container registry**: Self-hosted on GitLab (no Docker Hub)
|
||||
|
||||
---
|
||||
|
||||
@@ -19,55 +74,115 @@ Complete guide for deploying MotoVaultPro using GitLab CI/CD with shell executor
|
||||
|
||||
### Server Requirements
|
||||
|
||||
- Linux server with Docker Engine installed
|
||||
- Docker Compose v2 (plugin version)
|
||||
- GitLab Runner installed and registered
|
||||
- Git installed
|
||||
- curl installed (for health checks)
|
||||
| Server | Purpose | Specs | Runner Tags |
|
||||
|--------|---------|-------|-------------|
|
||||
| Build VPS | Docker image builds | 2 CPU, 4GB RAM | `build` |
|
||||
| Prod Server | Application hosting | 8GB+ RAM | `production` |
|
||||
|
||||
### GitLab Requirements
|
||||
See [BUILD-SERVER-SETUP.md](BUILD-SERVER-SETUP.md) for build server setup.
|
||||
|
||||
- GitLab 18.6+ (tested with 18.6.2)
|
||||
- Project with CI/CD enabled
|
||||
- Protected `main` branch
|
||||
- Maintainer access for CI/CD variable configuration
|
||||
### Software Requirements
|
||||
|
||||
- GitLab 18.6+
|
||||
- Docker Engine 24.0+
|
||||
- Docker Compose v2
|
||||
- GitLab Runner (shell executor on both servers)
|
||||
- `jq` for JSON processing
|
||||
|
||||
---
|
||||
|
||||
## GitLab Runner Setup
|
||||
## Pipeline Stages
|
||||
|
||||
### 1. Verify Runner Registration
|
||||
The CI/CD pipeline consists of 7 stages:
|
||||
|
||||
```bash
|
||||
sudo gitlab-runner verify
|
||||
```
|
||||
validate -> build -> deploy-prepare -> deploy-switch -> verify -> [rollback] -> notify
|
||||
```
|
||||
|
||||
Expected output should show your runner as active with shell executor.
|
||||
| Stage | Runner | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `validate` | prod | Check prerequisites, determine target stack |
|
||||
| `build` | build | Build and push images to GitLab registry |
|
||||
| `deploy-prepare` | prod | Pull images, start inactive stack, health check |
|
||||
| `deploy-switch` | prod | Switch Traefik traffic weights |
|
||||
| `verify` | prod | Production health verification |
|
||||
| `rollback` | prod | Auto-triggered on verify failure |
|
||||
| `notify` | prod | Email success/failure notifications |
|
||||
|
||||
### 2. Verify Docker Permissions
|
||||
### Pipeline Flow
|
||||
|
||||
The `gitlab-runner` user must have Docker access:
|
||||
|
||||
```bash
|
||||
# Add gitlab-runner to docker group (if not already done)
|
||||
sudo usermod -aG docker gitlab-runner
|
||||
|
||||
# Verify access
|
||||
sudo -u gitlab-runner docker info
|
||||
sudo -u gitlab-runner docker compose version
|
||||
```
|
||||
[Push to main]
|
||||
|
|
||||
v
|
||||
[validate] - Checks Docker, paths, registry
|
||||
|
|
||||
v
|
||||
[build] - Builds backend + frontend images
|
||||
| Pushes to registry.motovaultpro.com
|
||||
v
|
||||
[deploy-prepare] - Pulls new images
|
||||
| Starts inactive stack (blue or green)
|
||||
| Runs health checks
|
||||
v
|
||||
[deploy-switch] - Updates Traefik weights
|
||||
| Switches traffic instantly
|
||||
v
|
||||
[verify] - External health check
|
||||
| Container status verification
|
||||
|
|
||||
+--[SUCCESS]--> [notify-success] - Sends success email
|
||||
|
|
||||
+--[FAILURE]--> [rollback] - Switches back to previous stack
|
||||
|
|
||||
v
|
||||
[notify-failure] - Sends failure email
|
||||
```
|
||||
|
||||
### 3. Verify Deployment Directory
|
||||
---
|
||||
|
||||
Ensure the deployment directory exists and is accessible:
|
||||
## Blue-Green Deployment
|
||||
|
||||
```bash
|
||||
# Create deployment directory
|
||||
sudo mkdir -p /opt/motovaultpro
|
||||
sudo chown gitlab-runner:gitlab-runner /opt/motovaultpro
|
||||
### Stack Configuration
|
||||
|
||||
# Clone repository (first time only)
|
||||
sudo -u gitlab-runner git clone <repository-url> /opt/motovaultpro
|
||||
Both stacks share the same database layer:
|
||||
|
||||
| Component | Blue Stack | Green Stack | Shared |
|
||||
|-----------|------------|-------------|--------|
|
||||
| Frontend | `mvp-frontend-blue` | `mvp-frontend-green` | - |
|
||||
| Backend | `mvp-backend-blue` | `mvp-backend-green` | - |
|
||||
| PostgreSQL | - | - | `mvp-postgres` |
|
||||
| Redis | - | - | `mvp-redis` |
|
||||
| Traefik | - | - | `mvp-traefik` |
|
||||
|
||||
### Traffic Routing
|
||||
|
||||
Traefik uses weighted services for traffic distribution:
|
||||
|
||||
```yaml
|
||||
# config/traefik/dynamic/blue-green.yml
|
||||
services:
|
||||
mvp-frontend-weighted:
|
||||
weighted:
|
||||
services:
|
||||
- name: mvp-frontend-blue-svc
|
||||
weight: 100 # Active
|
||||
- name: mvp-frontend-green-svc
|
||||
weight: 0 # Standby
|
||||
```
|
||||
|
||||
### Deployment State
|
||||
|
||||
State is tracked in `config/deployment/state.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"active_stack": "blue",
|
||||
"inactive_stack": "green",
|
||||
"last_deployment": "2024-01-15T10:30:00Z",
|
||||
"last_deployment_commit": "abc123",
|
||||
"rollback_available": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -76,410 +191,316 @@ sudo -u gitlab-runner git clone <repository-url> /opt/motovaultpro
|
||||
|
||||
Navigate to **Settings > CI/CD > Variables** in your GitLab project.
|
||||
|
||||
### Secrets (File Type Variables)
|
||||
### Required Variables
|
||||
|
||||
These variables use GitLab's **File** type, which writes the value to a temporary file and provides the path as the environment variable. This replicates the Kubernetes secrets pattern used by the application.
|
||||
|
||||
| Variable Name | Type | Protected | Masked | Description |
|
||||
|--------------|------|-----------|--------|-------------|
|
||||
| `POSTGRES_PASSWORD` | File | Yes | Yes | PostgreSQL database password |
|
||||
| `AUTH0_CLIENT_SECRET` | File | Yes | Yes | Auth0 client secret for backend |
|
||||
| `GOOGLE_MAPS_API_KEY` | File | Yes | Yes | Google Maps API key |
|
||||
| `GOOGLE_MAPS_MAP_ID` | File | Yes | No | Google Maps Map ID |
|
||||
| `CF_DNS_API_TOKEN` | File | Yes | Yes | Cloudflare API token for Let's Encrypt DNS challenge |
|
||||
| `RESEND_API_KEY` | File | Yes | Yes | Resend API key for email notifications |
|
||||
|
||||
### Configuration Variables
|
||||
|
||||
| Variable Name | Type | Protected | Masked | Value |
|
||||
|--------------|------|-----------|--------|-------|
|
||||
| `VITE_AUTH0_DOMAIN` | Variable | No | No | `motovaultpro.us.auth0.com` |
|
||||
| `VITE_AUTH0_CLIENT_ID` | Variable | No | No | Your Auth0 client ID |
|
||||
| `VITE_AUTH0_AUDIENCE` | Variable | No | No | `https://api.motovaultpro.com` |
|
||||
|
||||
Note: `DEPLOY_PATH` is automatically set in `.gitlab-ci.yml` using `GIT_CLONE_PATH` for a stable path.
|
||||
|
||||
### Creating Cloudflare API Token
|
||||
|
||||
The `CF_DNS_API_TOKEN` is required for automatic SSL certificate generation via Let's Encrypt DNS-01 challenge.
|
||||
|
||||
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/profile/api-tokens)
|
||||
2. Click **Create Token**
|
||||
3. Use template: **Edit zone DNS**
|
||||
4. Configure permissions:
|
||||
- **Permissions**: Zone > DNS > Edit
|
||||
- **Zone Resources**: Include > Specific zone > `motovaultpro.com`
|
||||
5. Click **Continue to summary** then **Create Token**
|
||||
6. Copy the token value immediately (it won't be shown again)
|
||||
7. Add as `CF_DNS_API_TOKEN` File variable in GitLab
|
||||
|
||||
### Setting Up a File Type Variable
|
||||
|
||||
1. Go to **Settings > CI/CD > Variables**
|
||||
2. Click **Add variable**
|
||||
3. Enter the variable key (e.g., `POSTGRES_PASSWORD`)
|
||||
4. Enter the secret value in the **Value** field
|
||||
5. Set **Type** to **File**
|
||||
6. Enable **Protect variable** (recommended)
|
||||
7. Enable **Mask variable** (for sensitive data)
|
||||
8. Click **Add variable**
|
||||
|
||||
---
|
||||
|
||||
## Secrets Architecture
|
||||
|
||||
MotoVaultPro uses a Kubernetes-style secrets pattern where secrets are mounted as files at `/run/secrets/` inside containers.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **GitLab stores secrets** as File type CI/CD variables
|
||||
2. **During pipeline execution**, GitLab writes each secret to a temporary file
|
||||
3. **The `inject-secrets.sh` script** copies these files to `secrets/app/` directory
|
||||
4. **Docker Compose** mounts these files to `/run/secrets/` in containers
|
||||
5. **Application code** reads secrets from the filesystem (not environment variables)
|
||||
| Variable | Type | Protected | Purpose |
|
||||
|----------|------|-----------|---------|
|
||||
| `DEPLOY_NOTIFY_EMAIL` | Variable | Yes | Notification recipient |
|
||||
| `VITE_AUTH0_DOMAIN` | Variable | No | Auth0 domain |
|
||||
| `VITE_AUTH0_CLIENT_ID` | Variable | No | Auth0 client ID |
|
||||
| `VITE_AUTH0_AUDIENCE` | Variable | No | Auth0 audience |
|
||||
|
||||
### Secret Files
|
||||
|
||||
```
|
||||
secrets/app/
|
||||
postgres-password.txt -> /run/secrets/postgres-password
|
||||
auth0-client-secret.txt -> /run/secrets/auth0-client-secret
|
||||
google-maps-api-key.txt -> /run/secrets/google-maps-api-key
|
||||
google-maps-map-id.txt -> /run/secrets/google-maps-map-id
|
||||
cloudflare-dns-token.txt -> /run/secrets/cloudflare-dns-token
|
||||
resend-api-key.txt -> /run/secrets/resend-api-key
|
||||
```
|
||||
These use GitLab's **File** type and are injected via `scripts/inject-secrets.sh`:
|
||||
|
||||
### Security Benefits
|
||||
| Variable | Type | Protected | Masked |
|
||||
|----------|------|-----------|--------|
|
||||
| `POSTGRES_PASSWORD` | File | Yes | Yes |
|
||||
| `AUTH0_CLIENT_SECRET` | File | Yes | Yes |
|
||||
| `GOOGLE_MAPS_API_KEY` | File | Yes | Yes |
|
||||
| `GOOGLE_MAPS_MAP_ID` | File | Yes | No |
|
||||
| `CF_DNS_API_TOKEN` | File | Yes | Yes |
|
||||
| `RESEND_API_KEY` | File | Yes | Yes |
|
||||
|
||||
- Secrets never appear as environment variables (not visible in `env` or `printenv`)
|
||||
- File permissions (600) restrict access
|
||||
- Masked variables prevent accidental log exposure
|
||||
- Protected variables only available on protected branches
|
||||
### Registry Authentication
|
||||
|
||||
GitLab provides these automatically:
|
||||
- `CI_REGISTRY_USER` - Registry username
|
||||
- `CI_REGISTRY_PASSWORD` - Registry token
|
||||
- `CI_REGISTRY` - Registry URL
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Overview
|
||||
## Container Registry
|
||||
|
||||
The CI/CD pipeline consists of four stages:
|
||||
All images are hosted on the GitLab Container Registry to avoid Docker Hub rate limits.
|
||||
|
||||
### Stage 1: Validate
|
||||
|
||||
Verifies deployment prerequisites:
|
||||
- Docker is accessible
|
||||
- Docker Compose is available
|
||||
- Deployment directory exists
|
||||
|
||||
### Stage 2: Build
|
||||
|
||||
Builds Docker images:
|
||||
- Pulls latest code from repository
|
||||
- Builds all service images with `--no-cache`
|
||||
|
||||
### Stage 3: Deploy
|
||||
|
||||
Deploys the application:
|
||||
1. Injects secrets from GitLab variables
|
||||
2. Stops existing services gracefully
|
||||
3. Pulls base images
|
||||
4. Starts database services (PostgreSQL, Redis)
|
||||
5. Runs database migrations
|
||||
6. Starts all services
|
||||
|
||||
### Stage 4: Verify
|
||||
|
||||
Validates deployment health:
|
||||
- Checks all containers are running
|
||||
- Tests backend health endpoint
|
||||
- Reports deployment status
|
||||
|
||||
### Pipeline Diagram
|
||||
### Registry URL
|
||||
|
||||
```
|
||||
[Validate] -> [Build] -> [Deploy] -> [Verify]
|
||||
| | | |
|
||||
Check Build Inject Health
|
||||
prereqs images secrets checks
|
||||
|
|
||||
Migrate
|
||||
|
|
||||
Start
|
||||
services
|
||||
registry.motovaultpro.com
|
||||
```
|
||||
|
||||
### Image Paths
|
||||
|
||||
| Image | Path |
|
||||
|-------|------|
|
||||
| Backend | `registry.motovaultpro.com/motovaultpro/backend:$TAG` |
|
||||
| Frontend | `registry.motovaultpro.com/motovaultpro/frontend:$TAG` |
|
||||
| Mirrors | `registry.motovaultpro.com/mirrors/` |
|
||||
|
||||
### Base Image Mirrors
|
||||
|
||||
Mirror upstream images to avoid rate limits:
|
||||
|
||||
```bash
|
||||
# Run manually or via scheduled pipeline
|
||||
./scripts/ci/mirror-base-images.sh
|
||||
```
|
||||
|
||||
Mirrored images:
|
||||
- `node:20-alpine`
|
||||
- `nginx:alpine`
|
||||
- `postgres:18-alpine`
|
||||
- `redis:8.4-alpine`
|
||||
- `traefik:v3.6`
|
||||
- `docker:24.0`
|
||||
- `docker:24.0-dind`
|
||||
|
||||
---
|
||||
|
||||
## Deployment Process
|
||||
|
||||
### Automatic Deployment
|
||||
|
||||
Deployments are triggered automatically when:
|
||||
- Code is pushed to the `main` branch
|
||||
- A merge request is merged into `main`
|
||||
Deployments trigger automatically on push to `main`:
|
||||
|
||||
1. **Validate**: Check prerequisites, determine target stack
|
||||
2. **Build**: Build images on dedicated build server
|
||||
3. **Prepare**: Start inactive stack, run health checks
|
||||
4. **Switch**: Update Traefik weights (instant)
|
||||
5. **Verify**: External health check
|
||||
6. **Notify**: Send email notification
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
To trigger a manual deployment:
|
||||
|
||||
1. Go to **CI/CD > Pipelines**
|
||||
2. Click **Run pipeline**
|
||||
3. Select the `main` branch
|
||||
3. Select `main` branch
|
||||
4. Click **Run pipeline**
|
||||
|
||||
### Deployment Steps (What Happens)
|
||||
### Deployment Timeline
|
||||
|
||||
1. **Secrets Injection**
|
||||
- `inject-secrets.sh` copies GitLab File variables to `secrets/app/`
|
||||
- Permissions are set to 600 for security
|
||||
|
||||
2. **Service Shutdown**
|
||||
- Existing containers are stopped gracefully (30s timeout)
|
||||
- Volumes are preserved
|
||||
|
||||
3. **Database Startup**
|
||||
- PostgreSQL and Redis start first
|
||||
- 15-second wait for database readiness
|
||||
|
||||
4. **Migrations**
|
||||
- Backend container runs database migrations
|
||||
- Ensures schema is up-to-date
|
||||
|
||||
5. **Full Service Startup**
|
||||
- All services start via `docker compose up -d`
|
||||
- Traefik routes traffic automatically
|
||||
|
||||
6. **Health Verification**
|
||||
- Container status checks
|
||||
- Backend health endpoint validation
|
||||
| Phase | Duration |
|
||||
|-------|----------|
|
||||
| Validate | ~5s |
|
||||
| Build | ~2 min |
|
||||
| Deploy-prepare | ~30s |
|
||||
| Deploy-switch | ~3s |
|
||||
| Verify | ~30s |
|
||||
| **Total** | ~3 min |
|
||||
|
||||
---
|
||||
|
||||
## Rollback Procedure
|
||||
## Rollback Procedures
|
||||
|
||||
### Automatic Rollback
|
||||
|
||||
If the verify stage fails, the pipeline will report failure but services remain running. Manual intervention is required.
|
||||
Triggers automatically when:
|
||||
- Health check fails in `deploy-prepare`
|
||||
- `verify` stage fails after switch
|
||||
- Container becomes unhealthy within verification period
|
||||
|
||||
The pipeline runs `scripts/ci/auto-rollback.sh` which:
|
||||
1. Verifies previous stack is healthy
|
||||
2. Switches traffic back
|
||||
3. Sends notification
|
||||
|
||||
### Manual Rollback
|
||||
|
||||
Use the rollback script:
|
||||
SSH to production server:
|
||||
|
||||
```bash
|
||||
# SSH to server
|
||||
ssh user@server
|
||||
|
||||
# Run rollback to previous commit
|
||||
cd /opt/motovaultpro
|
||||
./scripts/rollback.sh HEAD~1
|
||||
|
||||
# Or rollback to specific tag/commit
|
||||
./scripts/rollback.sh v1.0.0
|
||||
# Check current state
|
||||
cat config/deployment/state.json | jq .
|
||||
|
||||
# Switch to other stack
|
||||
./scripts/ci/switch-traffic.sh blue # or green
|
||||
```
|
||||
|
||||
### Rollback Script Details
|
||||
|
||||
The script performs:
|
||||
1. Stops all current services
|
||||
2. Checks out the specified version
|
||||
3. Rebuilds images
|
||||
4. Starts services
|
||||
|
||||
### Emergency Recovery
|
||||
|
||||
If rollback fails:
|
||||
If both stacks are unhealthy:
|
||||
|
||||
```bash
|
||||
# Stop everything
|
||||
docker compose -f docker-compose.yml -f docker-compose.blue-green.yml down
|
||||
|
||||
# Restart shared services
|
||||
docker compose up -d mvp-postgres mvp-redis mvp-traefik
|
||||
|
||||
# Wait for database
|
||||
sleep 15
|
||||
|
||||
# Start one stack
|
||||
export BACKEND_IMAGE=registry.motovaultpro.com/motovaultpro/backend:latest
|
||||
export FRONTEND_IMAGE=registry.motovaultpro.com/motovaultpro/frontend:latest
|
||||
docker compose -f docker-compose.yml -f docker-compose.blue-green.yml up -d \
|
||||
mvp-frontend-blue mvp-backend-blue
|
||||
|
||||
# Switch traffic
|
||||
./scripts/ci/switch-traffic.sh blue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Maintenance Migrations
|
||||
|
||||
For breaking database changes requiring downtime:
|
||||
|
||||
### Via Pipeline (Recommended)
|
||||
|
||||
1. Go to **CI/CD > Pipelines**
|
||||
2. Find the `maintenance-migration` job
|
||||
3. Click **Play** to trigger manually
|
||||
|
||||
### Via Script
|
||||
|
||||
```bash
|
||||
cd /opt/motovaultpro
|
||||
|
||||
# Stop everything
|
||||
docker compose down
|
||||
# With backup
|
||||
./scripts/ci/maintenance-migrate.sh backup
|
||||
|
||||
# Check git history
|
||||
git log --oneline -10
|
||||
# Without backup
|
||||
./scripts/ci/maintenance-migrate.sh
|
||||
```
|
||||
|
||||
# Checkout known working version
|
||||
git checkout <commit-hash>
|
||||
### What Happens
|
||||
|
||||
# Rebuild and start
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
1. Sends maintenance notification
|
||||
2. Enables maintenance mode (stops traffic)
|
||||
3. Creates database backup (if requested)
|
||||
4. Runs migrations
|
||||
5. Restarts backends
|
||||
6. Restores traffic
|
||||
7. Sends completion notification
|
||||
|
||||
# Verify
|
||||
docker compose ps
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
Email notifications via Resend API for:
|
||||
|
||||
| Event | Subject |
|
||||
|-------|---------|
|
||||
| `success` | Deployment Successful |
|
||||
| `failure` | Deployment Failed |
|
||||
| `rollback` | Auto-Rollback Executed |
|
||||
| `rollback_failed` | CRITICAL: Rollback Failed |
|
||||
| `maintenance_start` | Maintenance Mode Started |
|
||||
| `maintenance_end` | Maintenance Complete |
|
||||
|
||||
Configure recipient in GitLab CI/CD variables:
|
||||
```
|
||||
DEPLOY_NOTIFY_EMAIL = admin@example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pipeline Fails at Validate Stage
|
||||
|
||||
**Symptom**: `DEPLOY_PATH not found`
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Create directory on runner server
|
||||
sudo mkdir -p /opt/motovaultpro
|
||||
sudo chown gitlab-runner:gitlab-runner /opt/motovaultpro
|
||||
```
|
||||
|
||||
### Pipeline Fails at Build Stage
|
||||
|
||||
**Symptom**: Docker build errors
|
||||
|
||||
**Solutions**:
|
||||
1. Check Dockerfile syntax
|
||||
2. Verify network connectivity for npm/package downloads
|
||||
3. Check disk space: `df -h`
|
||||
4. Clear Docker cache: `docker system prune -a`
|
||||
|
||||
### Pipeline Fails at Deploy Stage
|
||||
|
||||
**Symptom**: Secrets injection fails
|
||||
|
||||
**Solutions**:
|
||||
1. Verify CI/CD variables are configured correctly
|
||||
2. Check variable types are set to **File** for secrets
|
||||
3. Ensure variables are not restricted to specific environments
|
||||
|
||||
**Symptom**: Migration fails
|
||||
|
||||
**Solutions**:
|
||||
1. Check database connectivity
|
||||
2. Verify PostgreSQL is healthy: `docker logs mvp-postgres`
|
||||
3. Run migrations manually:
|
||||
```bash
|
||||
docker compose exec mvp-backend npm run migrate
|
||||
```
|
||||
|
||||
### Pipeline Fails at Verify Stage
|
||||
|
||||
**Symptom**: Container not running
|
||||
|
||||
**Solutions**:
|
||||
1. Check container logs: `docker logs <container-name>`
|
||||
2. Verify secrets are correctly mounted
|
||||
3. Check for port conflicts
|
||||
|
||||
**Symptom**: Health check fails
|
||||
|
||||
**Solutions**:
|
||||
1. Wait longer (service might be starting)
|
||||
2. Check backend logs: `docker logs mvp-backend`
|
||||
3. Verify database connection
|
||||
|
||||
### Services Start But Application Doesn't Work
|
||||
|
||||
**Check secrets are mounted**:
|
||||
**Check build server connectivity:**
|
||||
```bash
|
||||
docker compose exec mvp-backend ls -la /run/secrets/
|
||||
# On build server
|
||||
sudo gitlab-runner verify
|
||||
docker login registry.motovaultpro.com
|
||||
```
|
||||
|
||||
**Check configuration**:
|
||||
**Check disk space:**
|
||||
```bash
|
||||
docker compose exec mvp-backend cat /app/config/production.yml
|
||||
df -h
|
||||
docker system prune -af
|
||||
```
|
||||
|
||||
**Check network connectivity**:
|
||||
### Pipeline Fails at Deploy-Prepare
|
||||
|
||||
**Container won't start:**
|
||||
```bash
|
||||
docker network ls
|
||||
docker network inspect motovaultpro_backend
|
||||
docker logs mvp-backend-blue --tail 100
|
||||
docker logs mvp-frontend-blue --tail 100
|
||||
```
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
**Health check timeout:**
|
||||
```bash
|
||||
# All services
|
||||
docker compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose logs -f mvp-backend
|
||||
|
||||
# Last 100 lines
|
||||
docker compose logs --tail 100 mvp-backend
|
||||
# Increase timeout in .gitlab-ci.yml
|
||||
HEALTH_CHECK_TIMEOUT: "90"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Updating Secrets
|
||||
|
||||
1. Update the CI/CD variable in GitLab
|
||||
2. Trigger a new pipeline (push or manual)
|
||||
3. The new secrets will be injected during deployment
|
||||
|
||||
### Database Backups
|
||||
|
||||
Backups should be configured separately. Recommended approach:
|
||||
### Traffic Not Switching
|
||||
|
||||
**Check Traefik config:**
|
||||
```bash
|
||||
# Manual backup
|
||||
docker compose exec mvp-postgres pg_dump -U postgres motovaultpro > backup.sql
|
||||
|
||||
# Automated backup (add to cron)
|
||||
0 2 * * * cd /opt/motovaultpro && docker compose exec -T mvp-postgres pg_dump -U postgres motovaultpro > /backups/mvp-$(date +\%Y\%m\%d).sql
|
||||
cat config/traefik/dynamic/blue-green.yml
|
||||
docker exec mvp-traefik traefik healthcheck
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
**Check routing:**
|
||||
```bash
|
||||
curl -I https://motovaultpro.com/api/health
|
||||
```
|
||||
|
||||
Consider adding:
|
||||
- Prometheus metrics (Traefik already configured)
|
||||
- Health check alerts
|
||||
- Log aggregation
|
||||
### Verify Stage Fails
|
||||
|
||||
**Check external connectivity:**
|
||||
```bash
|
||||
curl -sf https://motovaultpro.com/api/health
|
||||
```
|
||||
|
||||
**Check container health:**
|
||||
```bash
|
||||
docker inspect --format='{{.State.Health.Status}}' mvp-backend-blue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
# View pipeline status
|
||||
# GitLab UI: CI/CD > Pipelines
|
||||
|
||||
# SSH to server
|
||||
ssh user@your-server
|
||||
|
||||
# Navigate to project
|
||||
cd /opt/motovaultpro
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Restart a service
|
||||
docker compose restart mvp-backend
|
||||
|
||||
# Run migrations manually
|
||||
docker compose exec mvp-backend npm run migrate
|
||||
|
||||
# Access database
|
||||
docker compose exec mvp-postgres psql -U postgres motovaultpro
|
||||
|
||||
# Health check
|
||||
curl http://localhost:3001/health
|
||||
```
|
||||
|
||||
### Important Paths
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `$CI_BUILDS_DIR/motovaultpro` | Application root (stable clone path) |
|
||||
| `$CI_BUILDS_DIR/motovaultpro/secrets/app/` | Secrets directory |
|
||||
| `$CI_BUILDS_DIR/motovaultpro/data/documents/` | Document storage |
|
||||
| `$CI_BUILDS_DIR/motovaultpro/config/` | Configuration files |
|
||||
| `config/deployment/state.json` | Deployment state |
|
||||
| `config/traefik/dynamic/blue-green.yml` | Traffic routing |
|
||||
| `scripts/ci/` | Deployment scripts |
|
||||
|
||||
Note: `CI_BUILDS_DIR` is typically `/opt/gitlab-runner/builds` for shell executors.
|
||||
### Common Commands
|
||||
|
||||
### Container Names
|
||||
```bash
|
||||
# View current state
|
||||
cat config/deployment/state.json | jq .
|
||||
|
||||
| Container | Purpose |
|
||||
|-----------|---------|
|
||||
| `mvp-traefik` | Reverse proxy, TLS termination |
|
||||
| `mvp-frontend` | React SPA |
|
||||
| `mvp-backend` | Node.js API |
|
||||
| `mvp-postgres` | PostgreSQL database |
|
||||
| `mvp-redis` | Redis cache |
|
||||
# Check container status
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Health}}"
|
||||
|
||||
# View logs
|
||||
docker logs mvp-backend-blue -f
|
||||
|
||||
# Manual traffic switch
|
||||
./scripts/ci/switch-traffic.sh green
|
||||
|
||||
# Run health check
|
||||
./scripts/ci/health-check.sh blue
|
||||
|
||||
# Send test notification
|
||||
./scripts/ci/notify.sh success "Test message"
|
||||
```
|
||||
|
||||
### Memory Budget (8GB Server)
|
||||
|
||||
| Component | RAM |
|
||||
|-----------|-----|
|
||||
| Blue frontend | 512MB |
|
||||
| Blue backend | 1GB |
|
||||
| Green frontend | 512MB |
|
||||
| Green backend | 1GB |
|
||||
| PostgreSQL | 2GB |
|
||||
| Redis | 512MB |
|
||||
| Traefik | 128MB |
|
||||
| System | 1.3GB |
|
||||
| **Total** | ~7GB |
|
||||
|
||||
@@ -22,13 +22,16 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
|
||||
- Make no assumptions.
|
||||
- Ask clarifying questions.
|
||||
- Ultrathink
|
||||
- You will be fixing a bug with the application backup function.
|
||||
- You will be fixing a bug the system backup and restore function.
|
||||
|
||||
*** CONTEXT ***
|
||||
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
|
||||
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
||||
- There is an error when you try and create a backup.
|
||||
- Start with this file. /Users/egullickson/Documents/Technology/coding/motovaultpro/backend/src/features/admin/backup/api/backup.controller.ts
|
||||
- There are permission errors with the backup files.
|
||||
- The backup directory is mapped from the filesystem of the host
|
||||
- The app is deployed as the gitlab-runner user and group which is a different UID then the nodejs user
|
||||
- Start with the files in this directory /Users/egullickson/Documents/Technology/coding/motovaultpro/backend/src/features/backup/api
|
||||
- The docker file is located at /Users/egullickson/Documents/Technology/coding/motovaultpro/backend/Dockerfile
|
||||
|
||||
*** CHANGES TO IMPLEMENT ***
|
||||
- Research this code base and ask iterative questions to compile a complete plan.
|
||||
@@ -45,16 +48,19 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
|
||||
- Make no assumptions.
|
||||
- Ask clarifying questions.
|
||||
- Ultrathink
|
||||
- The initial data load for this applicaiton during the CI/CD process in gitlab needs to be updated
|
||||
- This application is ready to go into production.
|
||||
- Analysis needs to be done on the CI/CD pipeline
|
||||
|
||||
*** CONTEXT ***
|
||||
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
||||
- The current deployment database load needs to be thoroughly critiqued and scrutenized.
|
||||
- The current deployment does not take into account no downtime or miniimal downtime updates.
|
||||
- The same runner's build the software that run the software
|
||||
- There needs to be a balance of uptime and complexity
|
||||
- production will run on a single server to start
|
||||
|
||||
*** ACTION - CHANGES TO IMPLEMENT ***
|
||||
- The vehicle catalog currently loaded into the local mvp-postres container needs to be exported into a SQL file and saved as a source of truth
|
||||
- Whatever process is running today only goes up to model year 2022.
|
||||
- All the existing SQL files setup for import can be replaced with new ones created from the running mvp-postres data.
|
||||
- Research this code base and ask iterative questions to compile a complete plan.
|
||||
- We will pair plan this. Ask me for options for various levels of redundancy and automation
|
||||
|
||||
|
||||
|
||||
@@ -97,14 +103,17 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
|
||||
- Make no assumptions.
|
||||
- Ask clarifying questions.
|
||||
- Ultrathink
|
||||
- You will be making changes to the color theme of this application.
|
||||
- You will be making changes to email templates of this application.
|
||||
|
||||
*** CONTEXT ***
|
||||
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
|
||||
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
||||
- Currently the onboarding drop downs are washed out when using the light theme. See image.
|
||||
- The colors need to change to have more contrast but retain the MUI theme for drop down.
|
||||
- Start your research at this route https://motovaultpro.com/garage/settings/admin/email-templates
|
||||
- The email templates are currently plain text.
|
||||
- The templates need to be improved with colors and the company logo
|
||||
- The company log should be base64 encoded in the email so end users don't need to download anything.
|
||||
- The theme should match the website light theme
|
||||
- A screenshot showing the colors is attached
|
||||
|
||||
*** CHANGES TO IMPLEMENT ***
|
||||
- Research this code base and ask iterative questions to compile a complete plan.
|
||||
- The URL is here. https://motovaultpro.com/onboarding
|
||||
@@ -1,7 +1,11 @@
|
||||
# Production Dockerfile for MotoVaultPro Frontend
|
||||
# Uses mirrored base images from GitLab Container Registry
|
||||
|
||||
# Build argument for registry (defaults to GitLab mirrors, falls back to Docker Hub)
|
||||
ARG REGISTRY_MIRRORS=registry.motovaultpro.com/mirrors
|
||||
|
||||
# Stage 1: Base with dependencies
|
||||
FROM node:lts-alpine AS base
|
||||
FROM ${REGISTRY_MIRRORS}/node:20-alpine AS base
|
||||
RUN apk add --no-cache dumb-init curl
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
@@ -29,7 +33,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 4: Production stage with nginx
|
||||
FROM nginx:alpine AS production
|
||||
FROM ${REGISTRY_MIRRORS}/nginx:alpine AS production
|
||||
|
||||
# Add curl for healthchecks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
@@ -4,7 +4,36 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>MotoVaultPro</title>
|
||||
|
||||
<!-- Dark mode initialization - MUST run before any other scripts -->
|
||||
<!-- This prevents iOS 26 Safari from overriding our dark mode preference -->
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
const stored = localStorage.getItem('motovaultpro-mobile-settings');
|
||||
const settings = stored ? JSON.parse(stored) : {};
|
||||
|
||||
// Check user preference, fall back to system preference
|
||||
const prefersDark = settings.darkMode !== undefined
|
||||
? settings.darkMode
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to system preference on error
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Runtime config MUST load synchronously before any module scripts -->
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import React from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Container, Paper, Typography, Box, IconButton, Avatar, useTheme } from '@mui/material';
|
||||
import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material';
|
||||
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
|
||||
@@ -28,7 +28,6 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
const { user, logout } = useAuth0();
|
||||
const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore();
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
|
||||
// Sync theme preference with backend
|
||||
useThemeSync();
|
||||
@@ -128,14 +127,17 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: theme.palette.mode === 'light' ? 'primary.main' : 'transparent',
|
||||
sx={(theme) => ({
|
||||
backgroundColor: 'primary.main',
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: 'transparent',
|
||||
}),
|
||||
borderRadius: 1,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src="/images/logos/motovaultpro-title-slogan.png"
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
CascadeDeleteResult,
|
||||
EmailTemplate,
|
||||
UpdateEmailTemplateRequest,
|
||||
PreviewTemplateResponse,
|
||||
// User management types
|
||||
ManagedUser,
|
||||
ListUsersResponse,
|
||||
@@ -278,15 +279,15 @@ export const adminApi = {
|
||||
const response = await apiClient.put<EmailTemplate>(`/admin/email-templates/${key}`, data);
|
||||
return response.data;
|
||||
},
|
||||
preview: async (key: string, variables: Record<string, string>): Promise<{ subject: string; body: string }> => {
|
||||
const response = await apiClient.post<{ subject: string; body: string }>(
|
||||
preview: async (key: string, variables: Record<string, string>): Promise<PreviewTemplateResponse> => {
|
||||
const response = await apiClient.post<PreviewTemplateResponse>(
|
||||
`/admin/email-templates/${key}/preview`,
|
||||
{ variables }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
sendTest: async (key: string): Promise<{ message?: string; error?: string; subject: string; body: string }> => {
|
||||
const response = await apiClient.post<{ message?: string; error?: string; subject: string; body: string }>(
|
||||
sendTest: async (key: string): Promise<{ message?: string; error?: string }> => {
|
||||
const response = await apiClient.post<{ message?: string; error?: string }>(
|
||||
`/admin/email-templates/${key}/test`
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -347,11 +347,10 @@ export const useImportApply = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (previewId: string) => adminApi.importApply(previewId),
|
||||
onSuccess: (result) => {
|
||||
onSuccess: () => {
|
||||
// Invalidate cache to refresh catalog data
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogSearch'] });
|
||||
toast.success(
|
||||
`Import completed: ${result.created} created, ${result.updated} updated`
|
||||
);
|
||||
// Note: Toast and dialog behavior now handled by parent components
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to apply import');
|
||||
|
||||
@@ -610,7 +610,7 @@ export const AdminBackupMobileScreen: React.FC = () => {
|
||||
|
||||
{showRestoreConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
|
||||
<div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-sm w-full">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Confirm Restore</h3>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-xs text-red-800">
|
||||
@@ -988,7 +988,7 @@ export const AdminBackupMobileScreen: React.FC = () => {
|
||||
{/* Delete Backup Confirmation Modal */}
|
||||
{showDeleteBackupConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
|
||||
<div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-sm w-full">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Delete Backup</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Are you sure you want to delete "{selectedBackup?.filename}"? This action cannot be
|
||||
@@ -1016,7 +1016,7 @@ export const AdminBackupMobileScreen: React.FC = () => {
|
||||
{/* Delete Schedule Confirmation Modal */}
|
||||
{showDeleteScheduleConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
|
||||
<div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-sm w-full">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Delete Schedule</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
MoreVert,
|
||||
Close,
|
||||
History,
|
||||
ExpandMore,
|
||||
ExpandLess,
|
||||
} from '@mui/icons-material';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
@@ -29,6 +31,7 @@ import { adminApi } from '../api/admin.api';
|
||||
import {
|
||||
CatalogSearchResult,
|
||||
ImportPreviewResult,
|
||||
ImportApplyResult,
|
||||
} from '../types/admin.types';
|
||||
|
||||
export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
@@ -54,6 +57,8 @@ export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
// Import state
|
||||
const [importSheet, setImportSheet] = useState(false);
|
||||
const [importPreview, setImportPreview] = useState<ImportPreviewResult | null>(null);
|
||||
const [importResult, setImportResult] = useState<ImportApplyResult | null>(null);
|
||||
const [errorsExpanded, setErrorsExpanded] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Hooks
|
||||
@@ -144,15 +149,38 @@ export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
if (!importPreview?.previewId) return;
|
||||
|
||||
try {
|
||||
await importApplyMutation.mutateAsync(importPreview.previewId);
|
||||
setImportSheet(false);
|
||||
setImportPreview(null);
|
||||
const result = await importApplyMutation.mutateAsync(importPreview.previewId);
|
||||
setImportResult(result);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(
|
||||
`Import completed with ${result.errors.length} error(s): ${result.created} created, ${result.updated} updated`
|
||||
);
|
||||
// Keep sheet open for error review
|
||||
} else {
|
||||
toast.success(
|
||||
`Import completed successfully: ${result.created} created, ${result.updated} updated`
|
||||
);
|
||||
// Auto-close on complete success
|
||||
setImportSheet(false);
|
||||
setImportPreview(null);
|
||||
setImportResult(null);
|
||||
}
|
||||
|
||||
refetch();
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
// Error handled by mutation's onError
|
||||
}
|
||||
}, [importPreview, importApplyMutation, refetch]);
|
||||
|
||||
const handleImportSheetClose = useCallback(() => {
|
||||
if (importApplyMutation.isPending) return;
|
||||
setImportSheet(false);
|
||||
setImportPreview(null);
|
||||
setImportResult(null);
|
||||
setErrorsExpanded(false);
|
||||
}, [importApplyMutation.isPending]);
|
||||
|
||||
// Export handler
|
||||
const handleExport = useCallback(() => {
|
||||
setMenuOpen(false);
|
||||
@@ -351,14 +379,14 @@ export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
{menuOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center">
|
||||
<div
|
||||
className="bg-white rounded-t-2xl w-full max-w-lg p-4 space-y-2 animate-slide-up"
|
||||
className="bg-white dark:bg-scuro rounded-t-2xl w-full max-w-lg p-4 space-y-2 animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Options</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus">Options</h2>
|
||||
<button
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="p-2 text-slate-500 hover:text-slate-700"
|
||||
className="p-2 text-slate-500 dark:text-titanio hover:text-slate-700 dark:hover:text-avus"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
<Close />
|
||||
@@ -368,7 +396,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
<button
|
||||
onClick={handleImportClick}
|
||||
disabled={importPreviewMutation.isPending}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left text-slate-700 hover:bg-slate-50 rounded-lg transition"
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left text-slate-700 dark:text-avus hover:bg-slate-50 dark:hover:bg-gray-800 rounded-lg transition"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<FileUpload />
|
||||
@@ -378,7 +406,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exportMutation.isPending}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left text-slate-700 hover:bg-slate-50 rounded-lg transition"
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left text-slate-700 dark:text-avus hover:bg-slate-50 dark:hover:bg-gray-800 rounded-lg transition"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<FileDownload />
|
||||
@@ -387,7 +415,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
|
||||
<button
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="w-full bg-slate-100 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-200 transition mt-4"
|
||||
className="w-full bg-slate-100 dark:bg-gray-700 text-slate-700 dark:text-gray-200 py-3 rounded-lg font-medium hover:bg-slate-200 dark:hover:bg-gray-600 transition mt-4"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
Cancel
|
||||
@@ -399,9 +427,9 @@ export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
{/* Delete Confirmation Sheet */}
|
||||
{deleteSheet.open && deleteSheet.item && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center">
|
||||
<div className="bg-white rounded-t-2xl w-full max-w-lg p-6 space-y-4 animate-slide-up">
|
||||
<h2 className="text-xl font-bold text-slate-800">Delete Configuration?</h2>
|
||||
<p className="text-slate-600">
|
||||
<div className="bg-white dark:bg-scuro rounded-t-2xl w-full max-w-lg p-6 space-y-4 animate-slide-up">
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-avus">Delete Configuration?</h2>
|
||||
<p className="text-slate-600 dark:text-titanio">
|
||||
Are you sure you want to delete{' '}
|
||||
<strong>
|
||||
{deleteSheet.item.year} {deleteSheet.item.make} {deleteSheet.item.model}{' '}
|
||||
@@ -413,7 +441,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
<button
|
||||
onClick={() => setDeleteSheet({ open: false, item: null })}
|
||||
disabled={deleting}
|
||||
className="flex-1 bg-slate-200 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-300 transition disabled:opacity-50"
|
||||
className="flex-1 bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-200 py-3 rounded-lg font-medium hover:bg-slate-300 dark:hover:bg-gray-600 transition disabled:opacity-50"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
Cancel
|
||||
@@ -435,17 +463,16 @@ export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Preview Sheet */}
|
||||
{importSheet && importPreview && (
|
||||
{/* Import Preview/Results Sheet */}
|
||||
{importSheet && (importPreview || importResult) && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center">
|
||||
<div className="bg-white rounded-t-2xl w-full max-w-lg p-6 space-y-4 animate-slide-up max-h-[80vh] overflow-y-auto">
|
||||
<div className="bg-white dark:bg-scuro rounded-t-2xl w-full max-w-lg p-6 space-y-4 animate-slide-up max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-slate-800">Import Preview</h2>
|
||||
<h2 className="text-xl font-bold text-slate-800">
|
||||
{importResult ? 'Import Results' : 'Import Preview'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setImportSheet(false);
|
||||
setImportPreview(null);
|
||||
}}
|
||||
onClick={handleImportSheetClose}
|
||||
disabled={importApplyMutation.isPending}
|
||||
className="p-2 text-slate-500 hover:text-slate-700"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
@@ -454,74 +481,127 @@ export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg">
|
||||
<strong>{importPreview.toCreate.length}</strong> to create
|
||||
</div>
|
||||
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg">
|
||||
<strong>{importPreview.toUpdate.length}</strong> to update
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview Mode */}
|
||||
{importPreview && !importResult && (
|
||||
<>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg">
|
||||
<strong>{importPreview.toCreate.length}</strong> to create
|
||||
</div>
|
||||
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg">
|
||||
<strong>{importPreview.toUpdate.length}</strong> to update
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{importPreview.errors.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<p className="text-red-800 font-semibold mb-2">
|
||||
{importPreview.errors.length} Error(s) Found:
|
||||
</p>
|
||||
<ul className="text-red-700 text-sm space-y-1">
|
||||
{importPreview.errors.slice(0, 5).map((err, idx) => (
|
||||
<li key={idx}>
|
||||
Row {err.row}: {err.error}
|
||||
</li>
|
||||
))}
|
||||
{importPreview.errors.length > 5 && (
|
||||
<li>...and {importPreview.errors.length - 5} more errors</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{importPreview.errors.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<p className="text-red-800 font-semibold mb-2">
|
||||
{importPreview.errors.length} Error(s) Found:
|
||||
</p>
|
||||
<ul className="text-red-700 text-sm space-y-1">
|
||||
{importPreview.errors.slice(0, 5).map((err, idx) => (
|
||||
<li key={idx}>
|
||||
Row {err.row}: {err.error}
|
||||
</li>
|
||||
))}
|
||||
{importPreview.errors.length > 5 && (
|
||||
<li>...and {importPreview.errors.length - 5} more errors</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importPreview.valid ? (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<p className="text-green-800">
|
||||
The import file is valid and ready to be applied.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-amber-800">
|
||||
Please fix the errors above before importing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{importPreview.valid ? (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<p className="text-green-800">
|
||||
The import file is valid and ready to be applied.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-amber-800">
|
||||
Please fix the errors above before importing.
|
||||
</p>
|
||||
</div>
|
||||
{/* Results Mode */}
|
||||
{importResult && (
|
||||
<>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg">
|
||||
<strong>{importResult.created}</strong> created
|
||||
</div>
|
||||
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg">
|
||||
<strong>{importResult.updated}</strong> updated
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="border border-red-500 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setErrorsExpanded(!errorsExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 bg-red-100 hover:bg-red-200 transition"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<span className="text-red-900 font-semibold">
|
||||
{importResult.errors.length} Error(s) Occurred
|
||||
</span>
|
||||
<span className="text-red-900">
|
||||
{errorsExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{errorsExpanded && (
|
||||
<div className="max-h-96 overflow-y-auto p-4 bg-white">
|
||||
<ul className="space-y-2">
|
||||
{importResult.errors.map((err, idx) => (
|
||||
<li key={idx} className="text-sm font-mono text-slate-700">
|
||||
<strong>Row {err.row}:</strong> {err.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult.errors.length === 0 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<p className="text-green-800">
|
||||
Import completed successfully with no errors.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setImportSheet(false);
|
||||
setImportPreview(null);
|
||||
}}
|
||||
onClick={handleImportSheetClose}
|
||||
disabled={importApplyMutation.isPending}
|
||||
className="flex-1 bg-slate-200 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-300 transition disabled:opacity-50"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={!importPreview.valid || importApplyMutation.isPending}
|
||||
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition disabled:opacity-50"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{importApplyMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mx-auto" />
|
||||
) : (
|
||||
'Apply Import'
|
||||
)}
|
||||
{importResult ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{!importResult && (
|
||||
<button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={!importPreview?.valid || importApplyMutation.isPending}
|
||||
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition disabled:opacity-50"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{importApplyMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mx-auto" />
|
||||
) : (
|
||||
'Apply Import'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
||||
const [editIsActive, setEditIsActive] = useState(true);
|
||||
const [previewSubject, setPreviewSubject] = useState('');
|
||||
const [previewBody, setPreviewBody] = useState('');
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const [showHtmlPreview, setShowHtmlPreview] = useState(false);
|
||||
|
||||
// Queries
|
||||
const { data: templates, isLoading } = useQuery({
|
||||
@@ -66,6 +68,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
||||
onSuccess: (data) => {
|
||||
setPreviewSubject(data.subject);
|
||||
setPreviewBody(data.body);
|
||||
setPreviewHtml(data.html || '');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to generate preview');
|
||||
@@ -117,6 +120,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
||||
setPreviewTemplate(null);
|
||||
setPreviewSubject('');
|
||||
setPreviewBody('');
|
||||
setPreviewHtml('');
|
||||
setShowHtmlPreview(false);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -238,7 +243,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
||||
{editingTemplate.variables.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
className="inline-block px-2 py-1 bg-white border border-blue-300 rounded text-xs font-mono text-blue-700"
|
||||
className="inline-block px-2 py-1 bg-white dark:bg-gray-800 border border-blue-300 dark:border-blue-600 rounded text-xs font-mono text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
{`{{${variable}}}`}
|
||||
</span>
|
||||
@@ -250,7 +255,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleCloseEdit}
|
||||
className="flex-1 px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
||||
className="flex-1 px-4 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -304,6 +309,23 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Toggle HTML/Text Preview */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Show HTML Preview</span>
|
||||
<button
|
||||
onClick={() => setShowHtmlPreview(!showHtmlPreview)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors min-h-[44px] min-w-[44px] ${
|
||||
showHtmlPreview ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
showHtmlPreview ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Subject
|
||||
@@ -313,14 +335,29 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Body
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
|
||||
{previewBody}
|
||||
{showHtmlPreview ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
HTML Preview
|
||||
</label>
|
||||
<div className="border border-gray-300 rounded-lg overflow-hidden bg-gray-50">
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
style={{ width: '100%', height: '400px', border: 'none' }}
|
||||
title="Email HTML Preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Body (Plain Text)
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
|
||||
{previewBody}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-sm w-full shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3>
|
||||
<div className="bg-white dark:bg-scuro rounded-xl p-6 max-w-sm w-full shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">{title}</h3>
|
||||
{children}
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
{actions || (
|
||||
@@ -337,7 +337,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full px-4 py-3 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]"
|
||||
className="w-full px-4 py-3 rounded-lg border border-slate-200 dark:border-silverstone dark:bg-scuro dark:text-avus focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
@@ -378,7 +378,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
<select
|
||||
value={params.tier || ''}
|
||||
onChange={(e) => handleTierFilterChange(e.target.value as SubscriptionTier | '')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-silverstone dark:bg-scuro dark:text-avus min-h-[44px]"
|
||||
>
|
||||
<option value="">All Tiers</option>
|
||||
<option value="free">Free</option>
|
||||
@@ -393,7 +393,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
<select
|
||||
value={params.status || 'all'}
|
||||
onChange={(e) => handleStatusFilterChange(e.target.value as 'active' | 'deactivated' | 'all')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-silverstone dark:bg-scuro dark:text-avus min-h-[44px]"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="active">Active</option>
|
||||
|
||||
@@ -210,6 +210,12 @@ export interface UpdateEmailTemplateRequest {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface PreviewTemplateResponse {
|
||||
subject: string;
|
||||
body: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Management types (subscription tiers)
|
||||
// ============================================
|
||||
|
||||
@@ -114,7 +114,7 @@ export const VerifyEmailPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 dark:from-paper dark:via-nero dark:to-paper flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<div className="bg-white dark:bg-scuro rounded-lg shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
@@ -131,8 +131,8 @@ export const VerifyEmailPage: React.FC = () => {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">Check Your Email</h1>
|
||||
<p className="text-gray-600">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-avus mb-2">Check Your Email</h1>
|
||||
<p className="text-gray-600 dark:text-titanio">
|
||||
We've sent a verification link to
|
||||
</p>
|
||||
{email && (
|
||||
@@ -143,7 +143,7 @@ export const VerifyEmailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-sm text-gray-700">
|
||||
<div className="bg-slate-50 dark:bg-gray-800 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
<p className="mb-2">Click the link in the email to verify your account.</p>
|
||||
<p>Once verified, you can log in to complete your profile setup.</p>
|
||||
</div>
|
||||
|
||||
@@ -56,8 +56,8 @@ export const DocumentsMobileScreen: React.FC = () => {
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-2">Documents</h2>
|
||||
<div className="text-slate-500 py-6 text-center">Loading...</div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">Documents</h2>
|
||||
<div className="text-slate-500 dark:text-titanio py-6 text-center">Loading...</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
@@ -77,8 +77,8 @@ export const DocumentsMobileScreen: React.FC = () => {
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-2">Login Required</h3>
|
||||
<p className="text-slate-600 text-sm mb-4">Please log in to view your documents</p>
|
||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">Login Required</h3>
|
||||
<p className="text-slate-600 dark:text-titanio text-sm mb-4">Please log in to view your documents</p>
|
||||
<button
|
||||
onClick={() => loginWithRedirect()}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
@@ -106,8 +106,8 @@ export const DocumentsMobileScreen: React.FC = () => {
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-2">Session Expired</h3>
|
||||
<p className="text-slate-600 text-sm mb-4">Your session has expired. Please log in again.</p>
|
||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">Session Expired</h3>
|
||||
<p className="text-slate-600 dark:text-titanio text-sm mb-4">Your session has expired. Please log in again.</p>
|
||||
<button
|
||||
onClick={() => loginWithRedirect()}
|
||||
className="w-full px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors"
|
||||
@@ -126,13 +126,13 @@ export const DocumentsMobileScreen: React.FC = () => {
|
||||
<AddDocumentDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-2">Documents</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">Documents</h2>
|
||||
|
||||
<div className="flex justify-end mb-2">
|
||||
<Button onClick={() => setIsAddOpen(true)} className="min-h-[44px]">Add Document</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-slate-500 py-6 text-center">Loading...</div>}
|
||||
{isLoading && <div className="text-slate-500 dark:text-titanio py-6 text-center">Loading...</div>}
|
||||
|
||||
{hasError && !isAuthError && (
|
||||
<div className="py-6 text-center">
|
||||
@@ -162,8 +162,8 @@ export const DocumentsMobileScreen: React.FC = () => {
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-600 text-sm mb-3">No documents yet</p>
|
||||
<p className="text-slate-500 text-xs">Documents will appear here once you create them</p>
|
||||
<p className="text-slate-600 dark:text-titanio text-sm mb-3">No documents yet</p>
|
||||
<p className="text-slate-500 dark:text-titanio text-xs">Documents will appear here once you create them</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -174,8 +174,8 @@ export const DocumentsMobileScreen: React.FC = () => {
|
||||
return (
|
||||
<div key={doc.id} className="flex items-center justify-between border rounded-xl p-3">
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{doc.title}</div>
|
||||
<div className="text-xs text-slate-500">{doc.documentType} • {vehicleLabel}</div>
|
||||
<div className="font-medium text-slate-800 dark:text-avus">{doc.title}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-titanio">{doc.documentType} • {vehicleLabel}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
|
||||
|
||||
@@ -188,10 +188,16 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
sx: (theme) => ({
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#4C4E4D' : 'grey.50',
|
||||
backgroundColor: 'grey.50',
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: '#4C4E4D',
|
||||
}),
|
||||
'& .MuiOutlinedInput-input': {
|
||||
cursor: 'default',
|
||||
color: theme.palette.mode === 'dark' ? '#F2F3F6' : 'inherit',
|
||||
color: 'inherit',
|
||||
...theme.applyStyles('dark', {
|
||||
color: '#F2F3F6',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
|
||||
@@ -333,7 +333,10 @@ export const StationPicker: React.FC<StationPickerProps> = ({
|
||||
sx={(theme) => ({
|
||||
'& .MuiAutocomplete-groupLabel': {
|
||||
fontWeight: 600,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#4C4E4D' : 'grey.100',
|
||||
backgroundColor: 'grey.100',
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: '#4C4E4D',
|
||||
}),
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
|
||||
@@ -36,13 +36,13 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h3 className="text-xl font-semibold text-slate-800 mb-4">Delete Account</h3>
|
||||
<div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-md w-full">
|
||||
<h3 className="text-xl font-semibold text-slate-800 dark:text-avus mb-4">Delete Account</h3>
|
||||
|
||||
{/* Warning Alert */}
|
||||
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="font-semibold text-amber-900 mb-2">30-Day Grace Period</p>
|
||||
<p className="text-sm text-amber-800">
|
||||
<div className="mb-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="font-semibold text-amber-900 dark:text-amber-200 mb-2">30-Day Grace Period</p>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
Your account will be scheduled for deletion in 30 days. You can cancel this request at any time during
|
||||
the grace period by logging back in.
|
||||
</p>
|
||||
@@ -50,7 +50,7 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
|
||||
|
||||
{/* Confirmation Input */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
|
||||
Type DELETE to confirm
|
||||
</label>
|
||||
<input
|
||||
@@ -58,14 +58,14 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
placeholder="DELETE"
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 ${
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 dark:bg-scuro dark:text-avus ${
|
||||
confirmationText.length > 0 && confirmationText !== 'DELETE'
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-slate-300 focus:ring-red-500 focus:border-red-500'
|
||||
? 'border-red-300 dark:border-red-700 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-slate-300 dark:border-silverstone focus:ring-red-500 focus:border-red-500'
|
||||
}`}
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Type the word "DELETE" (all caps) to confirm</p>
|
||||
<p className="text-xs text-slate-500 dark:text-titanio mt-1">Type the word "DELETE" (all caps) to confirm</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
@@ -73,7 +73,7 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={requestDeletionMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors disabled:opacity-50"
|
||||
className="flex-1 py-2.5 px-4 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -25,9 +25,9 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
}) => (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{label}</p>
|
||||
<p className="font-medium text-slate-800 dark:text-avus">{label}</p>
|
||||
{description && (
|
||||
<p className="text-sm text-slate-500">{description}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-titanio">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -56,14 +56,14 @@ const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 dark:bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-sm w-full">
|
||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">{title}</h3>
|
||||
{children}
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -184,8 +184,8 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Settings</h1>
|
||||
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-avus">Settings</h1>
|
||||
<p className="text-slate-500 dark:text-titanio mt-2">Manage your account and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Pending Deletion Banner */}
|
||||
@@ -195,7 +195,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Profile</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus">Profile</h2>
|
||||
{!isEditingProfile && !profileLoading && (
|
||||
<button
|
||||
onClick={handleEditProfile}
|
||||
@@ -214,21 +214,21 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
) : isEditingProfile ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profile?.email || ''}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-slate-100 text-slate-500"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-silverstone rounded-lg bg-slate-100 dark:bg-gray-800 text-slate-500 dark:text-gray-400"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Email is managed by your account provider</p>
|
||||
<p className="text-xs text-slate-500 dark:text-titanio mt-1">Email is managed by your account provider</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
@@ -236,13 +236,13 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
value={editedDisplayName}
|
||||
onChange={(e) => setEditedDisplayName(e.target.value)}
|
||||
placeholder="Enter your display name"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:border-silverstone dark:text-avus"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-silverstone rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:text-avus"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
|
||||
Notification Email
|
||||
</label>
|
||||
<input
|
||||
@@ -250,17 +250,17 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
value={editedNotificationEmail}
|
||||
onChange={(e) => setEditedNotificationEmail(e.target.value)}
|
||||
placeholder="Leave blank to use your primary email"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:border-silverstone dark:text-avus"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-silverstone rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:text-avus"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Optional: Use a different email for notifications</p>
|
||||
<p className="text-xs text-slate-500 dark:text-titanio mt-1">Optional: Use a different email for notifications</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-2">
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors disabled:opacity-50"
|
||||
className="flex-1 py-2.5 px-4 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
Cancel
|
||||
@@ -294,21 +294,21 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-slate-800 truncate">
|
||||
<p className="font-medium text-slate-800 dark:text-avus truncate">
|
||||
{profile?.displayName || user?.name || 'User'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 truncate">{profile?.email || user?.email}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-titanio truncate">{profile?.email || user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-3 border-t border-slate-200">
|
||||
<div className="space-y-2 pt-3 border-t border-slate-200 dark:border-silverstone">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Display Name</p>
|
||||
<p className="text-sm text-slate-800">{profile?.displayName || 'Not set'}</p>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-canna uppercase">Display Name</p>
|
||||
<p className="text-sm text-slate-800 dark:text-avus">{profile?.displayName || 'Not set'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Notification Email</p>
|
||||
<p className="text-sm text-slate-800">{profile?.notificationEmail || 'Using primary email'}</p>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-canna uppercase">Notification Email</p>
|
||||
<p className="text-sm text-slate-800 dark:text-avus">{profile?.notificationEmail || 'Using primary email'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,7 +319,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
{/* Notifications Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Notifications</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Notifications</h2>
|
||||
<div className="space-y-3">
|
||||
<ToggleSwitch
|
||||
enabled={settings.notifications.email}
|
||||
@@ -355,7 +355,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
{/* Appearance & Units Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Appearance & Units</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Appearance & Units</h2>
|
||||
<div className="space-y-4">
|
||||
<ToggleSwitch
|
||||
enabled={settings.darkMode}
|
||||
@@ -366,8 +366,8 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">Unit System</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
<p className="font-medium text-slate-800 dark:text-avus">Unit System</p>
|
||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
||||
Currently using {settings.unitSystem === 'imperial' ? 'Miles, Gallons, MPG, USD' : 'Km, Liters, L/100km, EUR'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -385,7 +385,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
{/* Data Management Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Data Management</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Data Management</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowDataExport(true)}
|
||||
@@ -393,7 +393,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
>
|
||||
Export My Data
|
||||
</button>
|
||||
<p className="text-sm text-slate-500">
|
||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
||||
Download a copy of all your vehicle and fuel data
|
||||
</p>
|
||||
</div>
|
||||
@@ -421,7 +421,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
{!adminLoading && isAdmin && (
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary-500 mb-4">Admin Console</h2>
|
||||
<h2 className="text-lg font-semibold text-primary-500 dark:text-primary-400 mb-4">Admin Console</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => navigateToScreen('AdminUsers')}
|
||||
@@ -463,11 +463,11 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
{/* Account Actions Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account Actions</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Account Actions</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full py-3 px-4 bg-gray-100 text-gray-700 rounded-lg text-left font-medium hover:bg-gray-200 transition-colors"
|
||||
className="w-full py-3 px-4 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg text-left font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
@@ -487,7 +487,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
onClose={() => setShowDataExport(false)}
|
||||
title="Export Data"
|
||||
>
|
||||
<p className="text-slate-600 mb-4">
|
||||
<p className="text-slate-600 dark:text-titanio mb-4">
|
||||
This will create a downloadable file containing all your vehicle data, fuel logs, and preferences.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
|
||||
@@ -14,3 +14,24 @@ body {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Tell browser we support both color schemes for native UI elements (scrollbars, form controls) */
|
||||
/* This enables iOS Safari to properly handle dark mode */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
-webkit-color-scheme: light dark; /* iOS Safari fallback */
|
||||
}
|
||||
|
||||
/* Base styles on html element */
|
||||
html {
|
||||
background-color: #ffffff;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Dark mode overrides when .dark class is present */
|
||||
html.dark {
|
||||
background-color: #231F1C;
|
||||
color: #F2F3F6;
|
||||
color-scheme: dark;
|
||||
-webkit-color-scheme: dark;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
Alert,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search,
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
FileDownload,
|
||||
FileUpload,
|
||||
Clear,
|
||||
ExpandMore,
|
||||
ExpandLess,
|
||||
} from '@mui/icons-material';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
||||
@@ -52,6 +55,7 @@ import {
|
||||
import {
|
||||
CatalogSearchResult,
|
||||
ImportPreviewResult,
|
||||
ImportApplyResult,
|
||||
} from '../../features/admin/types/admin.types';
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
||||
@@ -76,6 +80,8 @@ export const AdminCatalogPage: React.FC = () => {
|
||||
// Import state
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [importPreview, setImportPreview] = useState<ImportPreviewResult | null>(null);
|
||||
const [importResult, setImportResult] = useState<ImportApplyResult | null>(null);
|
||||
const [errorsExpanded, setErrorsExpanded] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Hooks
|
||||
@@ -217,15 +223,38 @@ export const AdminCatalogPage: React.FC = () => {
|
||||
if (!importPreview?.previewId) return;
|
||||
|
||||
try {
|
||||
await importApplyMutation.mutateAsync(importPreview.previewId);
|
||||
setImportDialogOpen(false);
|
||||
setImportPreview(null);
|
||||
const result = await importApplyMutation.mutateAsync(importPreview.previewId);
|
||||
setImportResult(result);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(
|
||||
`Import completed with ${result.errors.length} error(s): ${result.created} created, ${result.updated} updated`
|
||||
);
|
||||
// Keep dialog open for error review
|
||||
} else {
|
||||
toast.success(
|
||||
`Import completed successfully: ${result.created} created, ${result.updated} updated`
|
||||
);
|
||||
// Auto-close on complete success
|
||||
setImportDialogOpen(false);
|
||||
setImportPreview(null);
|
||||
setImportResult(null);
|
||||
}
|
||||
|
||||
refetch();
|
||||
} catch (error) {
|
||||
// Error is handled by mutation
|
||||
// Error is handled by mutation's onError
|
||||
}
|
||||
}, [importPreview, importApplyMutation, refetch]);
|
||||
|
||||
const handleImportDialogClose = useCallback(() => {
|
||||
if (importApplyMutation.isPending) return;
|
||||
setImportDialogOpen(false);
|
||||
setImportPreview(null);
|
||||
setImportResult(null);
|
||||
setErrorsExpanded(false);
|
||||
}, [importApplyMutation.isPending]);
|
||||
|
||||
// Export handler
|
||||
const handleExport = useCallback(() => {
|
||||
exportMutation.mutate();
|
||||
@@ -506,18 +535,20 @@ export const AdminCatalogPage: React.FC = () => {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Import Preview Dialog */}
|
||||
{/* Import Preview/Results Dialog */}
|
||||
<Dialog
|
||||
open={importDialogOpen}
|
||||
onClose={() => !importApplyMutation.isPending && setImportDialogOpen(false)}
|
||||
onClose={handleImportDialogClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Import Preview</DialogTitle>
|
||||
<DialogTitle>
|
||||
{importResult ? 'Import Results' : 'Import Preview'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{importPreview && (
|
||||
{/* Preview Mode */}
|
||||
{importPreview && !importResult && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
{/* Summary */}
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Typography>
|
||||
<strong>To Create:</strong> {importPreview.toCreate.length}
|
||||
@@ -527,7 +558,6 @@ export const AdminCatalogPage: React.FC = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Errors */}
|
||||
{importPreview.errors.length > 0 && (
|
||||
<Alert severity="error">
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
@@ -546,7 +576,6 @@ export const AdminCatalogPage: React.FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Valid status */}
|
||||
{importPreview.valid ? (
|
||||
<Alert severity="success">
|
||||
The import file is valid and ready to be applied.
|
||||
@@ -558,23 +587,86 @@ export const AdminCatalogPage: React.FC = () => {
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Results Mode */}
|
||||
{importResult && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Typography>
|
||||
<strong>Created:</strong> {importResult.created}
|
||||
</Typography>
|
||||
<Typography>
|
||||
<strong>Updated:</strong> {importResult.updated}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{importResult.errors.length > 0 && (
|
||||
<Box sx={{ border: 1, borderColor: 'error.main', borderRadius: 1 }}>
|
||||
<Box
|
||||
onClick={() => setErrorsExpanded(!errorsExpanded)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: 2,
|
||||
bgcolor: 'error.light',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'error.main', color: 'white' },
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{importResult.errors.length} Error(s) Occurred
|
||||
</Typography>
|
||||
<IconButton size="small" sx={{ color: 'inherit' }}>
|
||||
{errorsExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={errorsExpanded}>
|
||||
<Box sx={{ maxHeight: 400, overflow: 'auto', p: 2, bgcolor: 'background.paper' }}>
|
||||
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
||||
{importResult.errors.map((err, idx) => (
|
||||
<Typography
|
||||
component="li"
|
||||
key={idx}
|
||||
variant="body2"
|
||||
sx={{ mb: 1, fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
>
|
||||
<strong>Row {err.row}:</strong> {err.error}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{importResult.errors.length === 0 && (
|
||||
<Alert severity="success">
|
||||
Import completed successfully with no errors.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => setImportDialogOpen(false)}
|
||||
onClick={handleImportDialogClose}
|
||||
disabled={importApplyMutation.isPending}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={!importPreview?.valid || importApplyMutation.isPending}
|
||||
variant="contained"
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
{importApplyMutation.isPending ? <CircularProgress size={20} /> : 'Apply Import'}
|
||||
{importResult ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
{!importResult && (
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={!importPreview?.valid || importApplyMutation.isPending}
|
||||
variant="contained"
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
{importApplyMutation.isPending ? <CircularProgress size={20} /> : 'Apply Import'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
||||
@@ -59,6 +59,8 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
||||
const [editIsActive, setEditIsActive] = useState(true);
|
||||
const [previewSubject, setPreviewSubject] = useState('');
|
||||
const [previewBody, setPreviewBody] = useState('');
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const [showHtmlPreview, setShowHtmlPreview] = useState(false);
|
||||
|
||||
// Queries
|
||||
const { data: templates, isLoading } = useQuery({
|
||||
@@ -87,6 +89,7 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
||||
onSuccess: (data) => {
|
||||
setPreviewSubject(data.subject);
|
||||
setPreviewBody(data.body);
|
||||
setPreviewHtml(data.html || '');
|
||||
setPreviewDialogOpen(true);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -141,6 +144,8 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
||||
setPreviewDialogOpen(false);
|
||||
setPreviewSubject('');
|
||||
setPreviewBody('');
|
||||
setPreviewHtml('');
|
||||
setShowHtmlPreview(false);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -362,6 +367,16 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
||||
This preview uses sample data to show how the template will appear.
|
||||
</Alert>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={showHtmlPreview}
|
||||
onChange={(e) => setShowHtmlPreview(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Show HTML Preview"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Subject"
|
||||
fullWidth
|
||||
@@ -371,19 +386,41 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Body"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={12}
|
||||
value={previewBody}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
inputProps={{
|
||||
style: { fontFamily: 'monospace' },
|
||||
}}
|
||||
/>
|
||||
{showHtmlPreview ? (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
HTML Preview
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#f8f9fa',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
style={{ width: '100%', height: '500px', border: 'none' }}
|
||||
title="Email HTML Preview"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<TextField
|
||||
label="Body (Plain Text)"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={12}
|
||||
value={previewBody}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
inputProps={{
|
||||
style: { fontFamily: 'monospace' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const GlassCard: React.FC<GlassCardProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-3xl border border-slate-200/70 bg-white/80 shadow-sm backdrop-blur',
|
||||
'rounded-3xl border border-slate-200/70 dark:border-slate-700/70 bg-white/80 dark:bg-nero/80 shadow-sm backdrop-blur',
|
||||
paddings[padding],
|
||||
onClick && 'cursor-pointer hover:shadow-xl hover:-translate-y-0.5 transition',
|
||||
className
|
||||
|
||||
@@ -15,7 +15,7 @@ export const MobileContainer: React.FC<MobileContainerProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 dark:from-paper dark:via-nero dark:to-paper flex items-start justify-center p-4 md:py-6">
|
||||
<div className={`w-full max-w-[380px] min-h-screen md:min-h-[600px] md:rounded-[32px] shadow-2xl flex flex-col border-0 md:border border-slate-200/70 bg-white/90 md:bg-white/70 backdrop-blur-xl ${className}`}>
|
||||
<div className={`w-full max-w-[380px] min-h-screen md:min-h-[600px] md:rounded-[32px] shadow-2xl flex flex-col border-0 md:border border-slate-200/70 dark:border-slate-700/70 bg-white/90 dark:bg-nero/90 md:bg-white/70 md:dark:bg-nero/70 backdrop-blur-xl ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const MobilePill: React.FC<MobilePillProps> = ({
|
||||
"group h-11 rounded-2xl text-sm font-medium border transition flex items-center justify-center gap-2 backdrop-blur",
|
||||
active
|
||||
? "text-white border-transparent shadow-lg bg-gradient-moto"
|
||||
: "bg-white/80 text-slate-800 border-slate-200 hover:bg-slate-50",
|
||||
: "bg-white/80 dark:bg-nero/80 text-slate-800 dark:text-avus border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-inactive",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
152
scripts/ci/auto-rollback.sh
Executable file
152
scripts/ci/auto-rollback.sh
Executable file
@@ -0,0 +1,152 @@
|
||||
#!/bin/bash
|
||||
# Auto-rollback script for blue-green deployment
|
||||
# Reverts traffic to the previous healthy stack
|
||||
#
|
||||
# Usage: ./auto-rollback.sh [reason]
|
||||
# reason: Optional description of why rollback is happening
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - Rollback successful
|
||||
# 1 - Rollback failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
REASON="${1:-Automatic rollback triggered}"
|
||||
|
||||
STATE_FILE="$PROJECT_ROOT/config/deployment/state.json"
|
||||
SWITCH_SCRIPT="$SCRIPT_DIR/switch-traffic.sh"
|
||||
HEALTH_SCRIPT="$SCRIPT_DIR/health-check.sh"
|
||||
NOTIFY_SCRIPT="$SCRIPT_DIR/notify.sh"
|
||||
|
||||
echo "========================================"
|
||||
echo "AUTO-ROLLBACK INITIATED"
|
||||
echo "Reason: $REASON"
|
||||
echo "Time: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
echo "========================================"
|
||||
|
||||
# Determine current and rollback stacks
|
||||
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
|
||||
CURRENT_STACK=$(jq -r '.active_stack // "unknown"' "$STATE_FILE")
|
||||
ROLLBACK_STACK=$(jq -r '.inactive_stack // "unknown"' "$STATE_FILE")
|
||||
else
|
||||
echo "ERROR: Cannot determine current stack state"
|
||||
echo "State file: $STATE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$CURRENT_STACK" == "unknown" ]] || [[ "$ROLLBACK_STACK" == "unknown" ]]; then
|
||||
echo "ERROR: Invalid stack state"
|
||||
echo " Current: $CURRENT_STACK"
|
||||
echo " Rollback target: $ROLLBACK_STACK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Stack Status:"
|
||||
echo " Currently active: $CURRENT_STACK"
|
||||
echo " Rollback target: $ROLLBACK_STACK"
|
||||
echo ""
|
||||
|
||||
# Verify rollback stack is healthy before switching
|
||||
echo "Step 1/3: Verifying rollback stack health..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
if [[ -x "$HEALTH_SCRIPT" ]]; then
|
||||
if ! "$HEALTH_SCRIPT" "$ROLLBACK_STACK" 30; then
|
||||
echo ""
|
||||
echo "CRITICAL: Rollback stack ($ROLLBACK_STACK) is NOT healthy!"
|
||||
echo "Manual intervention required."
|
||||
echo ""
|
||||
echo "Troubleshooting steps:"
|
||||
echo " 1. Check container logs: docker logs mvp-backend-$ROLLBACK_STACK"
|
||||
echo " 2. Check container status: docker ps -a"
|
||||
echo " 3. Consider restarting rollback stack"
|
||||
echo ""
|
||||
|
||||
# Send critical notification
|
||||
if [[ -x "$NOTIFY_SCRIPT" ]]; then
|
||||
"$NOTIFY_SCRIPT" "rollback_failed" \
|
||||
"Rollback to $ROLLBACK_STACK failed - stack unhealthy. Manual intervention required. Reason: $REASON" \
|
||||
|| true
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
echo " OK: Rollback stack is healthy"
|
||||
else
|
||||
echo " WARNING: Health check script not found, proceeding anyway"
|
||||
fi
|
||||
|
||||
# Switch traffic to rollback stack
|
||||
echo ""
|
||||
echo "Step 2/3: Switching traffic to $ROLLBACK_STACK..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
if [[ -x "$SWITCH_SCRIPT" ]]; then
|
||||
if ! "$SWITCH_SCRIPT" "$ROLLBACK_STACK" instant; then
|
||||
echo "ERROR: Traffic switch failed"
|
||||
|
||||
if [[ -x "$NOTIFY_SCRIPT" ]]; then
|
||||
"$NOTIFY_SCRIPT" "rollback_failed" \
|
||||
"Rollback traffic switch failed. Manual intervention required. Reason: $REASON" \
|
||||
|| true
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Traffic switch script not found: $SWITCH_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update state file with rollback info
|
||||
echo ""
|
||||
echo "Step 3/3: Updating deployment state..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
jq --arg stack "$ROLLBACK_STACK" \
|
||||
--arg reason "$REASON" \
|
||||
--arg ts "$TIMESTAMP" \
|
||||
--arg failed "$CURRENT_STACK" \
|
||||
'.active_stack = $stack |
|
||||
.inactive_stack = $failed |
|
||||
.last_rollback = $ts |
|
||||
.last_rollback_reason = $reason |
|
||||
.rollback_available = false' \
|
||||
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
|
||||
echo " State updated"
|
||||
fi
|
||||
|
||||
# Send notification
|
||||
if [[ -x "$NOTIFY_SCRIPT" ]]; then
|
||||
echo ""
|
||||
echo "Sending rollback notification..."
|
||||
"$NOTIFY_SCRIPT" "rollback" \
|
||||
"Rollback executed. Traffic switched from $CURRENT_STACK to $ROLLBACK_STACK. Reason: $REASON" \
|
||||
|| echo " WARNING: Notification failed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "ROLLBACK COMPLETE"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
echo " Previous stack: $CURRENT_STACK (now inactive)"
|
||||
echo " Current stack: $ROLLBACK_STACK (now active)"
|
||||
echo " Reason: $REASON"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Investigate why $CURRENT_STACK failed"
|
||||
echo " 2. Check logs: docker logs mvp-backend-$CURRENT_STACK"
|
||||
echo " 3. Fix issues before next deployment"
|
||||
echo ""
|
||||
|
||||
exit 0
|
||||
185
scripts/ci/health-check.sh
Executable file
185
scripts/ci/health-check.sh
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/bin/bash
|
||||
# Health check script for blue-green deployment
|
||||
# Verifies container health and HTTP endpoints
|
||||
#
|
||||
# Usage: ./health-check.sh <stack> [timeout_seconds]
|
||||
# stack: blue or green
|
||||
# timeout_seconds: max wait time (default: 60)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - All health checks passed
|
||||
# 1 - Health check failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
STACK="${1:-}"
|
||||
TIMEOUT="${2:-60}"
|
||||
|
||||
if [[ -z "$STACK" ]] || [[ ! "$STACK" =~ ^(blue|green)$ ]]; then
|
||||
echo "Usage: $0 <blue|green> [timeout_seconds]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FRONTEND_CONTAINER="mvp-frontend-$STACK"
|
||||
BACKEND_CONTAINER="mvp-backend-$STACK"
|
||||
|
||||
echo "========================================"
|
||||
echo "Health Check - $STACK Stack"
|
||||
echo "Timeout: ${TIMEOUT}s"
|
||||
echo "========================================"
|
||||
|
||||
# Function to check Docker container health
|
||||
check_container_health() {
|
||||
local container="$1"
|
||||
local status
|
||||
|
||||
status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "not found")
|
||||
|
||||
case "$status" in
|
||||
"healthy")
|
||||
return 0
|
||||
;;
|
||||
"starting")
|
||||
return 2 # Still starting
|
||||
;;
|
||||
"unhealthy"|"not found"|"")
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Function to check HTTP endpoint
|
||||
check_http_endpoint() {
|
||||
local container="$1"
|
||||
local port="$2"
|
||||
local path="$3"
|
||||
|
||||
if docker exec "$container" curl -sf "http://localhost:${port}${path}" > /dev/null 2>&1; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check database connectivity via backend
|
||||
check_database_connectivity() {
|
||||
local container="$1"
|
||||
|
||||
# The /health endpoint should verify database connectivity
|
||||
if docker exec "$container" curl -sf "http://localhost:3001/health" 2>/dev/null | grep -q '"database"'; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Wait for containers to be healthy
|
||||
wait_for_health() {
|
||||
local container="$1"
|
||||
local elapsed=0
|
||||
|
||||
while [[ $elapsed -lt $TIMEOUT ]]; do
|
||||
check_container_health "$container"
|
||||
local status=$?
|
||||
|
||||
if [[ $status -eq 0 ]]; then
|
||||
return 0
|
||||
elif [[ $status -eq 1 ]]; then
|
||||
echo " ERROR: Container $container is unhealthy"
|
||||
docker logs "$container" --tail 20 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Still starting, wait
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
echo " Waiting for $container... (${elapsed}s/${TIMEOUT}s)"
|
||||
done
|
||||
|
||||
echo " ERROR: Timeout waiting for $container"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Main health check sequence
|
||||
echo ""
|
||||
echo "Step 1/4: Checking container status..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
for container in "$FRONTEND_CONTAINER" "$BACKEND_CONTAINER"; do
|
||||
running=$(docker inspect --format='{{.State.Running}}' "$container" 2>/dev/null || echo "false")
|
||||
if [[ "$running" != "true" ]]; then
|
||||
echo " ERROR: Container $container is not running"
|
||||
docker ps -a --filter "name=$container" --format "table {{.Names}}\t{{.Status}}"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK: $container is running"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Step 2/4: Waiting for Docker health checks..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
for container in "$FRONTEND_CONTAINER" "$BACKEND_CONTAINER"; do
|
||||
echo " Checking $container..."
|
||||
if ! wait_for_health "$container"; then
|
||||
echo " FAILED: $container health check"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK: $container is healthy"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Step 3/4: Verifying HTTP endpoints..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Check frontend
|
||||
echo " Checking frontend HTTP..."
|
||||
if ! check_http_endpoint "$FRONTEND_CONTAINER" 3000 "/"; then
|
||||
echo " FAILED: Frontend HTTP check"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK: Frontend responds on port 3000"
|
||||
|
||||
# Check backend health endpoint
|
||||
echo " Checking backend HTTP..."
|
||||
if ! check_http_endpoint "$BACKEND_CONTAINER" 3001 "/health"; then
|
||||
echo " FAILED: Backend HTTP check"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK: Backend responds on port 3001"
|
||||
|
||||
echo ""
|
||||
echo "Step 4/4: Verifying database connectivity..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
if ! check_database_connectivity "$BACKEND_CONTAINER"; then
|
||||
echo " WARNING: Could not verify database connectivity"
|
||||
echo " (Backend may not expose database status in /health)"
|
||||
else
|
||||
echo " OK: Database connectivity verified"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Health Check PASSED - $STACK Stack"
|
||||
echo "========================================"
|
||||
|
||||
# Update state file
|
||||
STATE_FILE="$PROJECT_ROOT/config/deployment/state.json"
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
# Update stack health status using jq if available
|
||||
if command -v jq &> /dev/null; then
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
jq --arg stack "$STACK" --arg ts "$TIMESTAMP" \
|
||||
'.[$stack].healthy = true | .[$stack].last_health_check = $ts' \
|
||||
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
220
scripts/ci/maintenance-migrate.sh
Executable file
220
scripts/ci/maintenance-migrate.sh
Executable file
@@ -0,0 +1,220 @@
|
||||
#!/bin/bash
|
||||
# Maintenance mode migration script
|
||||
# Enables maintenance mode, runs migrations, then restores service
|
||||
#
|
||||
# Usage: ./maintenance-migrate.sh [backup]
|
||||
# backup: If set, creates a database backup before migration
|
||||
#
|
||||
# This script is for BREAKING migrations that require downtime.
|
||||
# Non-breaking migrations run automatically on container start.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - Migration successful
|
||||
# 1 - Migration failed (maintenance mode will remain active)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
CREATE_BACKUP="${1:-}"
|
||||
|
||||
COMPOSE_FILE="$PROJECT_ROOT/docker-compose.yml"
|
||||
COMPOSE_BLUE_GREEN="$PROJECT_ROOT/docker-compose.blue-green.yml"
|
||||
STATE_FILE="$PROJECT_ROOT/config/deployment/state.json"
|
||||
NOTIFY_SCRIPT="$SCRIPT_DIR/notify.sh"
|
||||
TRAEFIK_CONFIG="$PROJECT_ROOT/config/traefik/dynamic/blue-green.yml"
|
||||
BACKUP_DIR="$PROJECT_ROOT/data/backups"
|
||||
|
||||
echo "========================================"
|
||||
echo "MAINTENANCE MODE MIGRATION"
|
||||
echo "Time: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
echo "========================================"
|
||||
|
||||
# Determine active stack
|
||||
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
|
||||
ACTIVE_STACK=$(jq -r '.active_stack // "blue"' "$STATE_FILE")
|
||||
else
|
||||
ACTIVE_STACK="blue"
|
||||
fi
|
||||
|
||||
BACKEND_CONTAINER="mvp-backend-$ACTIVE_STACK"
|
||||
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " Active stack: $ACTIVE_STACK"
|
||||
echo " Backend container: $BACKEND_CONTAINER"
|
||||
echo " Create backup: ${CREATE_BACKUP:-no}"
|
||||
echo ""
|
||||
|
||||
# Step 1: Send maintenance notification
|
||||
echo "Step 1/6: Sending maintenance notification..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
if [[ -x "$NOTIFY_SCRIPT" ]]; then
|
||||
"$NOTIFY_SCRIPT" "maintenance_start" "Starting maintenance window for database migration" || true
|
||||
fi
|
||||
echo " OK"
|
||||
|
||||
# Step 2: Enable maintenance mode
|
||||
echo ""
|
||||
echo "Step 2/6: Enabling maintenance mode..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Update state file
|
||||
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
|
||||
jq '.maintenance_mode = true' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
fi
|
||||
|
||||
# Stop traffic to both stacks (weight 0)
|
||||
echo " Stopping traffic to application stacks..."
|
||||
|
||||
# Set both stacks to weight 0 - Traefik will return 503
|
||||
sed -i.bak -E "s/(name: mvp-frontend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 0/" "$TRAEFIK_CONFIG"
|
||||
sed -i.bak -E "s/(name: mvp-frontend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 0/" "$TRAEFIK_CONFIG"
|
||||
sed -i.bak -E "s/(name: mvp-backend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 0/" "$TRAEFIK_CONFIG"
|
||||
sed -i.bak -E "s/(name: mvp-backend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 0/" "$TRAEFIK_CONFIG"
|
||||
rm -f "${TRAEFIK_CONFIG}.bak"
|
||||
|
||||
# Wait for Traefik to pick up changes
|
||||
sleep 3
|
||||
echo " OK: Maintenance mode active"
|
||||
|
||||
# Step 3: Create database backup (if requested)
|
||||
if [[ -n "$CREATE_BACKUP" ]]; then
|
||||
echo ""
|
||||
echo "Step 3/6: Creating database backup..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
BACKUP_FILE="$BACKUP_DIR/pre-migration-$(date +%Y%m%d-%H%M%S).sql"
|
||||
|
||||
if docker exec mvp-postgres pg_dump -U postgres motovaultpro > "$BACKUP_FILE"; then
|
||||
echo " OK: Backup created: $BACKUP_FILE"
|
||||
ls -lh "$BACKUP_FILE"
|
||||
else
|
||||
echo " ERROR: Backup failed"
|
||||
echo ""
|
||||
echo "Aborting migration. Restoring traffic..."
|
||||
|
||||
# Restore traffic
|
||||
if [[ "$ACTIVE_STACK" == "blue" ]]; then
|
||||
sed -i -E "s/(name: mvp-frontend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
|
||||
sed -i -E "s/(name: mvp-backend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
|
||||
else
|
||||
sed -i -E "s/(name: mvp-frontend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
|
||||
sed -i -E "s/(name: mvp-backend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
|
||||
fi
|
||||
|
||||
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
|
||||
jq '.maintenance_mode = false' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "Step 3/6: Skipping backup (not requested)..."
|
||||
echo "----------------------------------------"
|
||||
echo " OK"
|
||||
fi
|
||||
|
||||
# Step 4: Run migrations
|
||||
echo ""
|
||||
echo "Step 4/6: Running database migrations..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Flush Redis cache before migration
|
||||
echo " Flushing Redis cache..."
|
||||
docker exec mvp-redis redis-cli FLUSHALL > /dev/null 2>&1 || true
|
||||
|
||||
# Run migrations
|
||||
echo " Running migrations..."
|
||||
if docker exec "$BACKEND_CONTAINER" npm run migrate; then
|
||||
echo " OK: Migrations completed"
|
||||
else
|
||||
echo " ERROR: Migration failed"
|
||||
echo ""
|
||||
echo "Migration failed. Maintenance mode remains active."
|
||||
echo "To restore:"
|
||||
echo " 1. Fix migration issues"
|
||||
echo " 2. Re-run migrations: docker exec $BACKEND_CONTAINER npm run migrate"
|
||||
echo " 3. Run: $0 to retry, or manually restore traffic"
|
||||
|
||||
if [[ -x "$NOTIFY_SCRIPT" ]]; then
|
||||
"$NOTIFY_SCRIPT" "failure" "Database migration failed during maintenance window. Manual intervention required." || true
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 5: Restart backend containers
|
||||
echo ""
|
||||
echo "Step 5/6: Restarting backend containers..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Restart both backends to pick up any schema changes
|
||||
docker restart mvp-backend-blue mvp-backend-green 2>/dev/null || true
|
||||
|
||||
# Wait for backends to be healthy
|
||||
echo " Waiting for backends to be healthy..."
|
||||
sleep 10
|
||||
|
||||
for container in mvp-backend-blue mvp-backend-green; do
|
||||
for i in {1..12}; do
|
||||
health=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "unknown")
|
||||
if [[ "$health" == "healthy" ]]; then
|
||||
echo " OK: $container is healthy"
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 12 ]]; then
|
||||
echo " WARNING: $container health check timeout"
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
done
|
||||
|
||||
# Step 6: Disable maintenance mode
|
||||
echo ""
|
||||
echo "Step 6/6: Restoring traffic..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Restore traffic to active stack
|
||||
if [[ "$ACTIVE_STACK" == "blue" ]]; then
|
||||
sed -i -E "s/(name: mvp-frontend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
|
||||
sed -i -E "s/(name: mvp-backend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
|
||||
else
|
||||
sed -i -E "s/(name: mvp-frontend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
|
||||
sed -i -E "s/(name: mvp-backend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
|
||||
fi
|
||||
|
||||
# Update state file
|
||||
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
jq --arg ts "$TIMESTAMP" '.maintenance_mode = false | .last_migration = $ts' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
fi
|
||||
|
||||
# Wait for Traefik to pick up changes
|
||||
sleep 3
|
||||
echo " OK: Traffic restored"
|
||||
|
||||
# Send completion notification
|
||||
if [[ -x "$NOTIFY_SCRIPT" ]]; then
|
||||
"$NOTIFY_SCRIPT" "maintenance_end" "Maintenance window complete. Database migration successful." || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MAINTENANCE MIGRATION COMPLETE"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
echo " - Migrations: Successful"
|
||||
echo " - Active stack: $ACTIVE_STACK"
|
||||
echo " - Maintenance mode: Disabled"
|
||||
if [[ -n "$CREATE_BACKUP" ]]; then
|
||||
echo " - Backup: $BACKUP_FILE"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
exit 0
|
||||
84
scripts/ci/mirror-base-images.sh
Executable file
84
scripts/ci/mirror-base-images.sh
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# Mirror upstream Docker images to GitLab Container Registry
|
||||
# Run manually or via scheduled GitLab pipeline
|
||||
# This avoids Docker Hub rate limits and ensures build reliability
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="${REGISTRY:-registry.motovaultpro.com/mirrors}"
|
||||
|
||||
# Base images required by MotoVaultPro
|
||||
IMAGES=(
|
||||
"node:20-alpine"
|
||||
"nginx:alpine"
|
||||
"postgres:18-alpine"
|
||||
"redis:8.4-alpine"
|
||||
"traefik:v3.6"
|
||||
"docker:24.0"
|
||||
"docker:24.0-dind"
|
||||
)
|
||||
|
||||
echo "========================================"
|
||||
echo "Base Image Mirroring Script"
|
||||
echo "Registry: $REGISTRY"
|
||||
echo "========================================"
|
||||
|
||||
# Check if logged into registry
|
||||
if ! docker info 2>/dev/null | grep -q "Username"; then
|
||||
echo "WARNING: Not logged into Docker registry"
|
||||
echo "Run: docker login registry.motovaultpro.com"
|
||||
fi
|
||||
|
||||
FAILED=()
|
||||
SUCCESS=()
|
||||
|
||||
for img in "${IMAGES[@]}"; do
|
||||
echo ""
|
||||
echo "Processing: $img"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Pull from upstream
|
||||
echo " Pulling from upstream..."
|
||||
if ! docker pull "$img"; then
|
||||
echo " ERROR: Failed to pull $img"
|
||||
FAILED+=("$img")
|
||||
continue
|
||||
fi
|
||||
|
||||
# Tag for local registry
|
||||
local_tag="$REGISTRY/$img"
|
||||
echo " Tagging as: $local_tag"
|
||||
docker tag "$img" "$local_tag"
|
||||
|
||||
# Push to local registry
|
||||
echo " Pushing to registry..."
|
||||
if ! docker push "$local_tag"; then
|
||||
echo " ERROR: Failed to push $local_tag"
|
||||
FAILED+=("$img")
|
||||
continue
|
||||
fi
|
||||
|
||||
SUCCESS+=("$img")
|
||||
echo " OK: $img mirrored successfully"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Summary"
|
||||
echo "========================================"
|
||||
echo "Successful: ${#SUCCESS[@]}"
|
||||
for img in "${SUCCESS[@]}"; do
|
||||
echo " - $img"
|
||||
done
|
||||
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Failed: ${#FAILED[@]}"
|
||||
for img in "${FAILED[@]}"; do
|
||||
echo " - $img"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "All images mirrored successfully"
|
||||
195
scripts/ci/notify.sh
Executable file
195
scripts/ci/notify.sh
Executable file
@@ -0,0 +1,195 @@
|
||||
#!/bin/bash
|
||||
# Deployment notification script using Resend API
|
||||
# Sends email notifications for deployment events
|
||||
#
|
||||
# Usage: ./notify.sh <event_type> [message] [commit_sha]
|
||||
# event_type: success, failure, rollback, rollback_failed, maintenance_start, maintenance_end
|
||||
# message: Optional custom message
|
||||
# commit_sha: Optional commit SHA for context
|
||||
#
|
||||
# Required environment variables:
|
||||
# DEPLOY_NOTIFY_EMAIL - Recipient email address
|
||||
#
|
||||
# Reads Resend API key from:
|
||||
# /run/secrets/resend-api-key (container)
|
||||
# ./secrets/app/resend-api-key.txt (local)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - Notification sent (or skipped if not configured)
|
||||
# 1 - Notification failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
EVENT_TYPE="${1:-}"
|
||||
MESSAGE="${2:-}"
|
||||
COMMIT_SHA="${3:-${CI_COMMIT_SHORT_SHA:-unknown}}"
|
||||
|
||||
if [[ -z "$EVENT_TYPE" ]]; then
|
||||
echo "Usage: $0 <success|failure|rollback|rollback_failed|maintenance_start|maintenance_end> [message] [commit_sha]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get recipient email
|
||||
NOTIFY_EMAIL="${DEPLOY_NOTIFY_EMAIL:-}"
|
||||
if [[ -z "$NOTIFY_EMAIL" ]]; then
|
||||
echo "DEPLOY_NOTIFY_EMAIL not set, skipping notification"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get Resend API key
|
||||
RESEND_API_KEY=""
|
||||
if [[ -f "/run/secrets/resend-api-key" ]]; then
|
||||
RESEND_API_KEY=$(cat /run/secrets/resend-api-key)
|
||||
elif [[ -f "$PROJECT_ROOT/secrets/app/resend-api-key.txt" ]]; then
|
||||
RESEND_API_KEY=$(cat "$PROJECT_ROOT/secrets/app/resend-api-key.txt")
|
||||
fi
|
||||
|
||||
if [[ -z "$RESEND_API_KEY" ]]; then
|
||||
echo "WARNING: Resend API key not found, skipping notification"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Determine subject and styling based on event type
|
||||
case "$EVENT_TYPE" in
|
||||
"success")
|
||||
SUBJECT="Deployment Successful - MotoVaultPro"
|
||||
STATUS_COLOR="#22c55e"
|
||||
STATUS_EMOJI="[OK]"
|
||||
STATUS_TEXT="Deployment Successful"
|
||||
DEFAULT_MESSAGE="Version $COMMIT_SHA is now live."
|
||||
;;
|
||||
"failure")
|
||||
SUBJECT="Deployment Failed - MotoVaultPro"
|
||||
STATUS_COLOR="#ef4444"
|
||||
STATUS_EMOJI="[FAILED]"
|
||||
STATUS_TEXT="Deployment Failed"
|
||||
DEFAULT_MESSAGE="Deployment of $COMMIT_SHA failed. Check pipeline logs."
|
||||
;;
|
||||
"rollback")
|
||||
SUBJECT="Auto-Rollback Executed - MotoVaultPro"
|
||||
STATUS_COLOR="#f59e0b"
|
||||
STATUS_EMOJI="[ROLLBACK]"
|
||||
STATUS_TEXT="Rollback Executed"
|
||||
DEFAULT_MESSAGE="Automatic rollback was triggered. Previous version restored."
|
||||
;;
|
||||
"rollback_failed")
|
||||
SUBJECT="CRITICAL: Rollback Failed - MotoVaultPro"
|
||||
STATUS_COLOR="#dc2626"
|
||||
STATUS_EMOJI="[CRITICAL]"
|
||||
STATUS_TEXT="Rollback Failed"
|
||||
DEFAULT_MESSAGE="Rollback attempt failed. Manual intervention required immediately."
|
||||
;;
|
||||
"maintenance_start")
|
||||
SUBJECT="Maintenance Mode Started - MotoVaultPro"
|
||||
STATUS_COLOR="#6366f1"
|
||||
STATUS_EMOJI="[MAINTENANCE]"
|
||||
STATUS_TEXT="Maintenance Mode Active"
|
||||
DEFAULT_MESSAGE="Application is in maintenance mode for database migration."
|
||||
;;
|
||||
"maintenance_end")
|
||||
SUBJECT="Maintenance Mode Ended - MotoVaultPro"
|
||||
STATUS_COLOR="#22c55e"
|
||||
STATUS_EMOJI="[ONLINE]"
|
||||
STATUS_TEXT="Maintenance Complete"
|
||||
DEFAULT_MESSAGE="Maintenance window complete. Application is online."
|
||||
;;
|
||||
*)
|
||||
echo "Unknown event type: $EVENT_TYPE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Use custom message or default
|
||||
FINAL_MESSAGE="${MESSAGE:-$DEFAULT_MESSAGE}"
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
# Build HTML email
|
||||
HTML_BODY=$(cat <<EOF
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f3f4f6; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.header { background: ${STATUS_COLOR}; color: white; padding: 20px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.content { padding: 30px; }
|
||||
.status { font-size: 18px; font-weight: 600; margin-bottom: 15px; }
|
||||
.message { color: #374151; line-height: 1.6; margin-bottom: 20px; }
|
||||
.details { background: #f9fafb; border-radius: 6px; padding: 15px; font-size: 14px; }
|
||||
.details-row { display: flex; justify-content: space-between; margin-bottom: 8px; }
|
||||
.details-label { color: #6b7280; }
|
||||
.details-value { color: #111827; font-weight: 500; }
|
||||
.footer { padding: 20px; text-align: center; color: #6b7280; font-size: 12px; border-top: 1px solid #e5e7eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>MotoVaultPro</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="status">${STATUS_EMOJI} ${STATUS_TEXT}</div>
|
||||
<div class="message">${FINAL_MESSAGE}</div>
|
||||
<div class="details">
|
||||
<div class="details-row">
|
||||
<span class="details-label">Environment:</span>
|
||||
<span class="details-value">Production</span>
|
||||
</div>
|
||||
<div class="details-row">
|
||||
<span class="details-label">Commit:</span>
|
||||
<span class="details-value">${COMMIT_SHA}</span>
|
||||
</div>
|
||||
<div class="details-row">
|
||||
<span class="details-label">Time:</span>
|
||||
<span class="details-value">${TIMESTAMP}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
MotoVaultPro CI/CD Notification System
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
)
|
||||
|
||||
# Send email via Resend API
|
||||
echo "Sending notification: $EVENT_TYPE"
|
||||
echo " To: $NOTIFY_EMAIL"
|
||||
echo " Subject: $SUBJECT"
|
||||
|
||||
# Build JSON payload
|
||||
JSON_PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"from": "MotoVaultPro <deploy@motovaultpro.com>",
|
||||
"to": ["$NOTIFY_EMAIL"],
|
||||
"subject": "$SUBJECT",
|
||||
"html": $(echo "$HTML_BODY" | jq -Rs .)
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Send via Resend API
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer $RESEND_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_PAYLOAD" \
|
||||
"https://api.resend.com/emails")
|
||||
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
if [[ "$HTTP_CODE" == "200" ]] || [[ "$HTTP_CODE" == "201" ]]; then
|
||||
echo " OK: Notification sent successfully"
|
||||
exit 0
|
||||
else
|
||||
echo " ERROR: Failed to send notification (HTTP $HTTP_CODE)"
|
||||
echo " Response: $BODY"
|
||||
exit 1
|
||||
fi
|
||||
157
scripts/ci/switch-traffic.sh
Executable file
157
scripts/ci/switch-traffic.sh
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
# Traffic switching script for blue-green deployment
|
||||
# Updates Traefik weighted routing configuration
|
||||
#
|
||||
# Usage: ./switch-traffic.sh <target_stack> [mode]
|
||||
# target_stack: blue or green (the stack to switch TO)
|
||||
# mode: instant (default) or gradual
|
||||
#
|
||||
# Gradual mode: 25% -> 50% -> 75% -> 100% with 3s intervals
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - Traffic switch successful
|
||||
# 1 - Switch failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
TARGET_STACK="${1:-}"
|
||||
MODE="${2:-instant}"
|
||||
|
||||
if [[ -z "$TARGET_STACK" ]] || [[ ! "$TARGET_STACK" =~ ^(blue|green)$ ]]; then
|
||||
echo "Usage: $0 <blue|green> [instant|gradual]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TRAEFIK_CONFIG="$PROJECT_ROOT/config/traefik/dynamic/blue-green.yml"
|
||||
STATE_FILE="$PROJECT_ROOT/config/deployment/state.json"
|
||||
|
||||
if [[ ! -f "$TRAEFIK_CONFIG" ]]; then
|
||||
echo "ERROR: Traefik config not found: $TRAEFIK_CONFIG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo "Traffic Switch"
|
||||
echo "Target Stack: $TARGET_STACK"
|
||||
echo "Mode: $MODE"
|
||||
echo "========================================"
|
||||
|
||||
# Determine source and target weights
|
||||
if [[ "$TARGET_STACK" == "blue" ]]; then
|
||||
SOURCE_STACK="green"
|
||||
else
|
||||
SOURCE_STACK="blue"
|
||||
fi
|
||||
|
||||
# Function to update weights in Traefik config
|
||||
update_weights() {
|
||||
local blue_weight="$1"
|
||||
local green_weight="$2"
|
||||
|
||||
echo " Setting weights: blue=$blue_weight, green=$green_weight"
|
||||
|
||||
# Use sed to update weights in the YAML file
|
||||
# Frontend blue weight
|
||||
sed -i.bak -E "s/(name: mvp-frontend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 $blue_weight/" "$TRAEFIK_CONFIG"
|
||||
# Frontend green weight
|
||||
sed -i.bak -E "s/(name: mvp-frontend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 $green_weight/" "$TRAEFIK_CONFIG"
|
||||
# Backend blue weight
|
||||
sed -i.bak -E "s/(name: mvp-backend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 $blue_weight/" "$TRAEFIK_CONFIG"
|
||||
# Backend green weight
|
||||
sed -i.bak -E "s/(name: mvp-backend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 $green_weight/" "$TRAEFIK_CONFIG"
|
||||
|
||||
# Clean up backup files
|
||||
rm -f "${TRAEFIK_CONFIG}.bak"
|
||||
|
||||
# Traefik watches the file and reloads automatically
|
||||
# Give it a moment to pick up changes
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# Verify Traefik has picked up the changes
|
||||
verify_traefik_reload() {
|
||||
# Give Traefik time to reload config
|
||||
sleep 2
|
||||
|
||||
# Check if Traefik is still healthy
|
||||
if docker exec mvp-traefik traefik healthcheck > /dev/null 2>&1; then
|
||||
echo " OK: Traefik config reloaded"
|
||||
return 0
|
||||
else
|
||||
echo " WARNING: Could not verify Traefik health"
|
||||
return 0 # Don't fail on this, file watcher is reliable
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "gradual" ]]; then
|
||||
echo ""
|
||||
echo "Gradual traffic switch starting..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
if [[ "$TARGET_STACK" == "blue" ]]; then
|
||||
STEPS=("25 75" "50 50" "75 25" "100 0")
|
||||
else
|
||||
STEPS=("75 25" "50 50" "25 75" "0 100")
|
||||
fi
|
||||
|
||||
step=1
|
||||
for weights in "${STEPS[@]}"; do
|
||||
blue_w=$(echo "$weights" | cut -d' ' -f1)
|
||||
green_w=$(echo "$weights" | cut -d' ' -f2)
|
||||
|
||||
echo ""
|
||||
echo "Step $step/4: blue=$blue_w%, green=$green_w%"
|
||||
update_weights "$blue_w" "$green_w"
|
||||
verify_traefik_reload
|
||||
|
||||
if [[ $step -lt 4 ]]; then
|
||||
echo " Waiting 3 seconds before next step..."
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
step=$((step + 1))
|
||||
done
|
||||
|
||||
else
|
||||
# Instant switch
|
||||
echo ""
|
||||
echo "Instant traffic switch..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
if [[ "$TARGET_STACK" == "blue" ]]; then
|
||||
update_weights 100 0
|
||||
else
|
||||
update_weights 0 100
|
||||
fi
|
||||
|
||||
verify_traefik_reload
|
||||
fi
|
||||
|
||||
# Update state file
|
||||
echo ""
|
||||
echo "Updating deployment state..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
jq --arg active "$TARGET_STACK" \
|
||||
--arg inactive "$SOURCE_STACK" \
|
||||
--arg ts "$TIMESTAMP" \
|
||||
'.active_stack = $active | .inactive_stack = $inactive | .last_switch = $ts' \
|
||||
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
|
||||
echo " Active stack: $TARGET_STACK"
|
||||
echo " Inactive stack: $SOURCE_STACK"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Traffic Switch COMPLETE"
|
||||
echo "All traffic now routed to: $TARGET_STACK"
|
||||
echo "========================================"
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user