🚀 Add automatic deployment system
- Add auto-deploy.sh script with full CI/CD pipeline - Add quick-deploy.sh for fast development deployments - Add Git post-receive hook for automatic deployment on push - Add comprehensive deployment documentation - Add npm scripts for easy deployment management - Include health checks, logging, and cleanup - Support for automatic rollback on failures
This commit is contained in:
8
.eslintrc.build.json
Normal file
8
.eslintrc.build.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@next/next/no-img-element": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
.github/workflows/build.yml
vendored
40
.github/workflows/build.yml
vendored
@@ -1,40 +0,0 @@
|
|||||||
name: Build and Push Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["Test Code Base"]
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
|
|
||||||
|
|
||||||
- name: Create Deployment .env File
|
|
||||||
run: |
|
|
||||||
cat > .env <<EOF
|
|
||||||
NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
GHOST_API_URL=${{ vars.GHOST_API_URL }}
|
|
||||||
GHOST_API_KEY=${{ secrets.GHOST_API_KEY }}
|
|
||||||
MY_EMAIL=${{ vars.MY_EMAIL }}
|
|
||||||
MY_PASSWORD=${{ secrets.MY_PASSWORD }}
|
|
||||||
EOF
|
|
||||||
echo "Created .env file:" && cat .env
|
|
||||||
|
|
||||||
- name: Build & Push Docker Image
|
|
||||||
run: |
|
|
||||||
# Nutzt den Branch-Namen aus dem auslösenden Workflow
|
|
||||||
IMAGE_NAME="ghcr.io/${{ github.repository_owner }}/my-nextjs-app:${{ github.event.workflow_run.head_branch }}"
|
|
||||||
docker buildx create --use
|
|
||||||
docker buildx build --platform linux/arm64 -t "$IMAGE_NAME" --push .
|
|
||||||
185
.github/workflows/ci-cd.yml
vendored
Normal file
185
.github/workflows/ci-cd.yml
vendored
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, production]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, production]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Test Job
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Create test environment file
|
||||||
|
run: |
|
||||||
|
cat > .env <<EOF
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
GHOST_API_URL=test
|
||||||
|
GHOST_API_KEY=test
|
||||||
|
MY_EMAIL=test@example.com
|
||||||
|
MY_INFO_EMAIL=test@example.com
|
||||||
|
MY_PASSWORD=test
|
||||||
|
MY_INFO_PASSWORD=test
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Security scan
|
||||||
|
security:
|
||||||
|
name: Security Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run Trivy vulnerability scanner
|
||||||
|
uses: aquasecurity/trivy-action@master
|
||||||
|
with:
|
||||||
|
scan-type: 'fs'
|
||||||
|
scan-ref: '.'
|
||||||
|
format: 'sarif'
|
||||||
|
output: 'trivy-results.sarif'
|
||||||
|
|
||||||
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
|
uses: github/codeql-action/upload-sarif@v2
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: 'trivy-results.sarif'
|
||||||
|
|
||||||
|
# Build and push Docker image
|
||||||
|
build:
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test, security]
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/production')
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha,prefix={{branch}}-
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Create production environment file
|
||||||
|
run: |
|
||||||
|
cat > .env <<EOF
|
||||||
|
NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
|
||||||
|
GHOST_API_URL=${{ vars.GHOST_API_URL }}
|
||||||
|
GHOST_API_KEY=${{ secrets.GHOST_API_KEY }}
|
||||||
|
MY_EMAIL=${{ vars.MY_EMAIL }}
|
||||||
|
MY_INFO_EMAIL=${{ vars.MY_INFO_EMAIL }}
|
||||||
|
MY_PASSWORD=${{ secrets.MY_PASSWORD }}
|
||||||
|
MY_INFO_PASSWORD=${{ secrets.MY_INFO_PASSWORD }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# Deploy to server
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Server
|
||||||
|
runs-on: self-hosted
|
||||||
|
needs: build
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/production'
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Deploy to server
|
||||||
|
run: |
|
||||||
|
# Set deployment variables
|
||||||
|
export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production"
|
||||||
|
export CONTAINER_NAME="portfolio-app"
|
||||||
|
export COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
|
||||||
|
# Pull latest image
|
||||||
|
docker pull $IMAGE_NAME
|
||||||
|
|
||||||
|
# Stop and remove old container
|
||||||
|
docker-compose -f $COMPOSE_FILE down || true
|
||||||
|
|
||||||
|
# Start new container
|
||||||
|
docker-compose -f $COMPOSE_FILE up -d
|
||||||
|
|
||||||
|
# Wait for health check
|
||||||
|
echo "Waiting for application to be healthy..."
|
||||||
|
timeout 60 bash -c 'until curl -f http://localhost:3000/api/health; do sleep 2; done'
|
||||||
|
|
||||||
|
# Verify deployment
|
||||||
|
if curl -f http://localhost:3000/api/health; then
|
||||||
|
echo "✅ Deployment successful!"
|
||||||
|
else
|
||||||
|
echo "❌ Deployment failed!"
|
||||||
|
docker-compose -f $COMPOSE_FILE logs
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Cleanup old images
|
||||||
|
run: |
|
||||||
|
# Remove unused images older than 7 days
|
||||||
|
docker image prune -f --filter "until=168h"
|
||||||
|
|
||||||
|
# Remove unused containers
|
||||||
|
docker container prune -f
|
||||||
58
.github/workflows/deploy.yml
vendored
58
.github/workflows/deploy.yml
vendored
@@ -1,58 +0,0 @@
|
|||||||
name: Deploy to Raspberry Pi
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["Build and Push Docker Image"]
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set Deployment Variables
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event.workflow_run.head_branch }}" == "production" ]]; then
|
|
||||||
echo "DEPLOY_ENV=production" >> $GITHUB_ENV
|
|
||||||
echo "PORT=4000" >> $GITHUB_ENV
|
|
||||||
elif [[ "${{ github.event.workflow_run.head_branch }}" == "dev" ]]; then
|
|
||||||
echo "DEPLOY_ENV=dev" >> $GITHUB_ENV
|
|
||||||
echo "PORT=4001" >> $GITHUB_ENV
|
|
||||||
elif [[ "${{ github.event.workflow_run.head_branch }}" == "preview" ]]; then
|
|
||||||
echo "DEPLOY_ENV=preview" >> $GITHUB_ENV
|
|
||||||
echo "PORT=4002" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
|
|
||||||
|
|
||||||
- name: Pull & Deploy Docker Image
|
|
||||||
run: |
|
|
||||||
IMAGE_NAME="ghcr.io/${{ github.repository_owner }}/my-nextjs-app:${{ github.event.workflow_run.head_branch }}"
|
|
||||||
IMAGE_NAME=$(echo "$IMAGE_NAME" | tr '[:upper:]' '[:lower:]')
|
|
||||||
docker pull "$IMAGE_NAME"
|
|
||||||
CONTAINER_NAME="nextjs-$DEPLOY_ENV"
|
|
||||||
|
|
||||||
echo "Deploying $CONTAINER_NAME"
|
|
||||||
|
|
||||||
if [ "$(docker inspect --format='{{.State.Running}}' "$CONTAINER_NAME")" = "true" ]; then
|
|
||||||
docker stop "$CONTAINER_NAME" || true
|
|
||||||
docker rm "$CONTAINER_NAME" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker run -d --name "$CONTAINER_NAME" -p $PORT:3000 "$IMAGE_NAME"
|
|
||||||
if [ "$(docker inspect --format='{{.State.Running}}' "$CONTAINER_NAME")" = "true" ]; then
|
|
||||||
echo "Deployment erfolgreich!"
|
|
||||||
else
|
|
||||||
echo "Neuer Container konnte nicht gestartet werden!"
|
|
||||||
docker logs "$CONTAINER_NAME"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
56
.github/workflows/lint.yml
vendored
56
.github/workflows/lint.yml
vendored
@@ -1,56 +0,0 @@
|
|||||||
name: Lint Code Base
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
paths:
|
|
||||||
- 'app/**'
|
|
||||||
- 'public/**'
|
|
||||||
- 'styles/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'docker-compose.yml'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'next.config.ts'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'tailwind.config.ts'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
paths:
|
|
||||||
- 'app/**'
|
|
||||||
- 'public/**'
|
|
||||||
- 'styles/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'docker-compose.yml'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'next.config.ts'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'tailwind.config.ts'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.14.0
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run ESLint
|
|
||||||
run: npm run lint
|
|
||||||
67
.github/workflows/test.yml
vendored
67
.github/workflows/test.yml
vendored
@@ -1,67 +0,0 @@
|
|||||||
name: Test Code Base
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
paths:
|
|
||||||
- 'app/**'
|
|
||||||
- 'public/**'
|
|
||||||
- 'styles/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'docker-compose.yml'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'next.config.ts'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'tailwind.config.ts'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
paths:
|
|
||||||
- 'app/**'
|
|
||||||
- 'public/**'
|
|
||||||
- 'styles/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'docker-compose.yml'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'next.config.ts'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'tailwind.config.ts'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.14.0
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Create .env File
|
|
||||||
run: |
|
|
||||||
cat > .env <<EOF
|
|
||||||
NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
GHOST_API_URL=${{ vars.GHOST_API_URL }}
|
|
||||||
GHOST_API_KEY=${{ secrets.GHOST_API_KEY }}
|
|
||||||
MY_EMAIL=${{ vars.MY_EMAIL }}
|
|
||||||
MY_PASSWORD=${{ secrets.MY_PASSWORD }}
|
|
||||||
EOF
|
|
||||||
echo ".env file created:" && cat .env
|
|
||||||
|
|
||||||
- name: Run Tests
|
|
||||||
run: npm run test
|
|
||||||
177
ANALYTICS.md
Normal file
177
ANALYTICS.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Analytics & Performance Tracking System
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Dieses Portfolio verwendet ein **GDPR-konformes Analytics-System** basierend auf **Umami** (self-hosted) mit erweitertem **Performance-Tracking**.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### ✅ GDPR-Konform
|
||||||
|
- **Keine Cookie-Banner** erforderlich
|
||||||
|
- **Keine personenbezogenen Daten** werden gesammelt
|
||||||
|
- **Anonymisierte Performance-Metriken**
|
||||||
|
- **Self-hosted** - vollständige Datenkontrolle
|
||||||
|
|
||||||
|
### 📊 Analytics Features
|
||||||
|
- **Page Views** - Seitenaufrufe
|
||||||
|
- **User Interactions** - Klicks, Formulare, Scroll-Verhalten
|
||||||
|
- **Error Tracking** - JavaScript-Fehler und unhandled rejections
|
||||||
|
- **Route Changes** - SPA-Navigation
|
||||||
|
|
||||||
|
### ⚡ Performance Tracking
|
||||||
|
- **Core Web Vitals**: LCP, FID, CLS, FCP, TTFB
|
||||||
|
- **Page Load Times** - Detaillierte Timing-Phasen
|
||||||
|
- **API Response Times** - Backend-Performance
|
||||||
|
- **Custom Performance Markers** - Spezifische Metriken
|
||||||
|
|
||||||
|
## Technische Implementierung
|
||||||
|
|
||||||
|
### 1. Umami Integration
|
||||||
|
```typescript
|
||||||
|
// Bereits in layout.tsx konfiguriert
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://umami.denshooter.de/script.js"
|
||||||
|
data-website-id="1f213877-deef-4238-8df1-71a5a3bcd142"
|
||||||
|
></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Performance Tracking
|
||||||
|
```typescript
|
||||||
|
// Web Vitals werden automatisch getrackt
|
||||||
|
import { useWebVitals } from '@/lib/useWebVitals';
|
||||||
|
|
||||||
|
// Custom Events tracken
|
||||||
|
import { trackEvent, trackPerformance } from '@/lib/analytics';
|
||||||
|
|
||||||
|
trackEvent('custom-action', { data: 'value' });
|
||||||
|
trackPerformance({ name: 'api-call', value: 150, url: '/api/data' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Analytics Provider
|
||||||
|
```typescript
|
||||||
|
// Automatisches Tracking von:
|
||||||
|
// - Page Views
|
||||||
|
// - User Interactions (Klicks, Scroll, Forms)
|
||||||
|
// - Performance Metrics
|
||||||
|
// - Error Tracking
|
||||||
|
<AnalyticsProvider>
|
||||||
|
{children}
|
||||||
|
</AnalyticsProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
### Performance Dashboard
|
||||||
|
- **Live Performance-Metriken** anzeigen
|
||||||
|
- **Core Web Vitals** mit Bewertungen (Good/Needs Improvement/Poor)
|
||||||
|
- **Toggle-Button** unten rechts auf der Website
|
||||||
|
- **Real-time Updates** der Performance-Daten
|
||||||
|
|
||||||
|
### Umami Dashboard
|
||||||
|
- **Standard Analytics** über deine Umami-Instanz
|
||||||
|
- **URL**: https://umami.denshooter.de
|
||||||
|
- **Website ID**: 1f213877-deef-4238-8df1-71a5a3bcd142
|
||||||
|
|
||||||
|
## Event-Typen
|
||||||
|
|
||||||
|
### Automatische Events
|
||||||
|
- `page-view` - Seitenaufrufe
|
||||||
|
- `click` - Benutzerklicks
|
||||||
|
- `form-submit` - Formular-Übermittlungen
|
||||||
|
- `scroll-depth` - Scroll-Tiefe (25%, 50%, 75%, 90%)
|
||||||
|
- `error` - JavaScript-Fehler
|
||||||
|
- `unhandled-rejection` - Unbehandelte Promise-Rejections
|
||||||
|
|
||||||
|
### Performance Events
|
||||||
|
- `web-vitals` - Core Web Vitals (LCP, FID, CLS, FCP, TTFB)
|
||||||
|
- `performance` - Custom Performance-Metriken
|
||||||
|
- `page-timing` - Detaillierte Page-Load-Phasen
|
||||||
|
- `api-call` - API-Response-Zeiten
|
||||||
|
|
||||||
|
### Custom Events
|
||||||
|
- `dashboard-toggle` - Performance Dashboard ein/aus
|
||||||
|
- `interaction` - Benutzerinteraktionen
|
||||||
|
|
||||||
|
## Datenschutz
|
||||||
|
|
||||||
|
### Was wird NICHT gesammelt:
|
||||||
|
- ❌ IP-Adressen
|
||||||
|
- ❌ User-IDs
|
||||||
|
- ❌ E-Mail-Adressen
|
||||||
|
- ❌ Personenbezogene Daten
|
||||||
|
- ❌ Cookies
|
||||||
|
|
||||||
|
### Was wird gesammelt:
|
||||||
|
- ✅ Anonymisierte Performance-Metriken
|
||||||
|
- ✅ Technische Browser-Informationen
|
||||||
|
- ✅ Seitenaufrufe (ohne persönliche Daten)
|
||||||
|
- ✅ Error-Logs (anonymisiert)
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Umami Setup
|
||||||
|
1. **Self-hosted Umami** auf deinem Server
|
||||||
|
2. **Website ID** in `layout.tsx` konfiguriert
|
||||||
|
3. **Script-URL** auf deine Umami-Instanz
|
||||||
|
|
||||||
|
### Performance Tracking
|
||||||
|
- **Automatisch aktiviert** durch `AnalyticsProvider`
|
||||||
|
- **Web Vitals** werden automatisch gemessen
|
||||||
|
- **Custom Events** über `trackEvent()` Funktion
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Performance-Schwellenwerte
|
||||||
|
- **LCP**: ≤ 2.5s (Good), ≤ 4s (Needs Improvement), > 4s (Poor)
|
||||||
|
- **FID**: ≤ 100ms (Good), ≤ 300ms (Needs Improvement), > 300ms (Poor)
|
||||||
|
- **CLS**: ≤ 0.1 (Good), ≤ 0.25 (Needs Improvement), > 0.25 (Poor)
|
||||||
|
- **FCP**: ≤ 1.8s (Good), ≤ 3s (Needs Improvement), > 3s (Poor)
|
||||||
|
- **TTFB**: ≤ 800ms (Good), ≤ 1.8s (Needs Improvement), > 1.8s (Poor)
|
||||||
|
|
||||||
|
### Dashboard-Zugriff
|
||||||
|
- **Performance Dashboard**: Toggle-Button unten rechts
|
||||||
|
- **Umami Dashboard**: https://umami.denshooter.de
|
||||||
|
- **API Endpoint**: `/api/analytics` für Custom-Tracking
|
||||||
|
|
||||||
|
## Erweiterung
|
||||||
|
|
||||||
|
### Neue Events hinzufügen
|
||||||
|
```typescript
|
||||||
|
import { trackEvent } from '@/lib/analytics';
|
||||||
|
|
||||||
|
// Custom Event tracken
|
||||||
|
trackEvent('feature-usage', {
|
||||||
|
feature: 'contact-form',
|
||||||
|
success: true,
|
||||||
|
duration: 1500
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance-Metriken erweitern
|
||||||
|
```typescript
|
||||||
|
import { trackPerformance } from '@/lib/analytics';
|
||||||
|
|
||||||
|
// Custom Performance-Metrik
|
||||||
|
trackPerformance({
|
||||||
|
name: 'component-render',
|
||||||
|
value: renderTime,
|
||||||
|
url: window.location.pathname
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Performance Dashboard nicht sichtbar
|
||||||
|
- Prüfe Browser-Konsole auf Fehler
|
||||||
|
- Stelle sicher, dass `AnalyticsProvider` in `layout.tsx` eingebunden ist
|
||||||
|
|
||||||
|
### Umami Events nicht sichtbar
|
||||||
|
- Prüfe Umami-Dashboard auf https://umami.denshooter.de
|
||||||
|
- Stelle sicher, dass Website ID korrekt ist
|
||||||
|
- Prüfe Browser-Netzwerk-Tab auf Umami-Requests
|
||||||
|
|
||||||
|
### Performance-Metriken fehlen
|
||||||
|
- Prüfe Browser-Konsole auf Performance Observer Fehler
|
||||||
|
- Stelle sicher, dass `useWebVitals` Hook aktiv ist
|
||||||
|
- Teste in verschiedenen Browsern
|
||||||
226
AUTO-DEPLOYMENT.md
Normal file
226
AUTO-DEPLOYMENT.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Automatisches Deployment System
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Dieses Portfolio verwendet ein **automatisches Deployment-System**, das bei jedem Git Push die Codebase prüft, den Container erstellt und startet.
|
||||||
|
|
||||||
|
## 🚀 Deployment-Skripte
|
||||||
|
|
||||||
|
### **1. Auto-Deploy (Vollständig)**
|
||||||
|
```bash
|
||||||
|
# Vollständiges automatisches Deployment
|
||||||
|
./scripts/auto-deploy.sh
|
||||||
|
|
||||||
|
# Oder mit npm
|
||||||
|
npm run auto-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
- ✅ Git Status prüfen und uncommitted Changes committen
|
||||||
|
- ✅ Latest Changes pullen
|
||||||
|
- ✅ ESLint Linting
|
||||||
|
- ✅ Tests ausführen
|
||||||
|
- ✅ Next.js Build
|
||||||
|
- ✅ Docker Image erstellen
|
||||||
|
- ✅ Container stoppen/starten
|
||||||
|
- ✅ Health Check
|
||||||
|
- ✅ Cleanup alter Images
|
||||||
|
|
||||||
|
### **2. Quick-Deploy (Schnell)**
|
||||||
|
```bash
|
||||||
|
# Schnelles Deployment ohne Tests
|
||||||
|
./scripts/quick-deploy.sh
|
||||||
|
|
||||||
|
# Oder mit npm
|
||||||
|
npm run quick-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
- ✅ Docker Image erstellen
|
||||||
|
- ✅ Container stoppen/starten
|
||||||
|
- ✅ Health Check
|
||||||
|
|
||||||
|
### **3. Manuelles Deployment**
|
||||||
|
```bash
|
||||||
|
# Manuelles Deployment mit Docker Compose
|
||||||
|
./scripts/deploy.sh
|
||||||
|
|
||||||
|
# Oder mit npm
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Automatisches Deployment
|
||||||
|
|
||||||
|
### **Git Hook Setup**
|
||||||
|
Das System verwendet einen Git Post-Receive Hook, der automatisch bei jedem Push ausgeführt wird:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Hook ist bereits konfiguriert in:
|
||||||
|
.git/hooks/post-receive
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Wie es funktioniert:**
|
||||||
|
1. **Git Push** → Hook wird ausgelöst
|
||||||
|
2. **Auto-Deploy Script** wird ausgeführt
|
||||||
|
3. **Vollständige Pipeline** läuft automatisch
|
||||||
|
4. **Deployment** wird durchgeführt
|
||||||
|
5. **Health Check** bestätigt Erfolg
|
||||||
|
|
||||||
|
## 📋 Deployment-Schritte
|
||||||
|
|
||||||
|
### **Automatisches Deployment:**
|
||||||
|
```bash
|
||||||
|
# 1. Code Quality Checks
|
||||||
|
git status --porcelain
|
||||||
|
git pull origin main
|
||||||
|
npm run lint
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# 2. Build Application
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 3. Docker Operations
|
||||||
|
docker build -t portfolio-app:latest .
|
||||||
|
docker tag portfolio-app:latest portfolio-app:$(date +%Y%m%d-%H%M%S)
|
||||||
|
|
||||||
|
# 4. Deployment
|
||||||
|
docker stop portfolio-app || true
|
||||||
|
docker rm portfolio-app || true
|
||||||
|
docker run -d --name portfolio-app -p 3000:3000 portfolio-app:latest
|
||||||
|
|
||||||
|
# 5. Health Check
|
||||||
|
curl -f http://localhost:3000/api/health
|
||||||
|
|
||||||
|
# 6. Cleanup
|
||||||
|
docker system prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Verwendung
|
||||||
|
|
||||||
|
### **Für Entwicklung:**
|
||||||
|
```bash
|
||||||
|
# Schnelles Deployment während der Entwicklung
|
||||||
|
npm run quick-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Für Production:**
|
||||||
|
```bash
|
||||||
|
# Vollständiges Deployment mit Tests
|
||||||
|
npm run auto-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Automatisch bei Push:**
|
||||||
|
```bash
|
||||||
|
# Einfach committen und pushen
|
||||||
|
git add .
|
||||||
|
git commit -m "Update feature"
|
||||||
|
git push origin main
|
||||||
|
# → Automatisches Deployment läuft
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### **Container Status:**
|
||||||
|
```bash
|
||||||
|
# Status prüfen
|
||||||
|
npm run monitor status
|
||||||
|
|
||||||
|
# Health Check
|
||||||
|
npm run monitor health
|
||||||
|
|
||||||
|
# Logs anzeigen
|
||||||
|
npm run monitor logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Deployment Logs:**
|
||||||
|
```bash
|
||||||
|
# Deployment-Logs anzeigen
|
||||||
|
tail -f /var/log/portfolio-deploy.log
|
||||||
|
|
||||||
|
# Git-Deployment-Logs
|
||||||
|
tail -f /var/log/git-deploy.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Konfiguration
|
||||||
|
|
||||||
|
### **Ports:**
|
||||||
|
- **Standard Port:** 3000
|
||||||
|
- **Backup Port:** 3001 (falls 3000 belegt)
|
||||||
|
|
||||||
|
### **Container:**
|
||||||
|
- **Name:** portfolio-app
|
||||||
|
- **Image:** portfolio-app:latest
|
||||||
|
- **Restart Policy:** unless-stopped
|
||||||
|
|
||||||
|
### **Logs:**
|
||||||
|
- **Deployment Logs:** `/var/log/portfolio-deploy.log`
|
||||||
|
- **Git Logs:** `/var/log/git-deploy.log`
|
||||||
|
|
||||||
|
## 🚨 Troubleshooting
|
||||||
|
|
||||||
|
### **Deployment schlägt fehl:**
|
||||||
|
```bash
|
||||||
|
# Logs prüfen
|
||||||
|
docker logs portfolio-app
|
||||||
|
|
||||||
|
# Container-Status prüfen
|
||||||
|
docker ps -a
|
||||||
|
|
||||||
|
# Manuell neu starten
|
||||||
|
npm run quick-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Port bereits belegt:**
|
||||||
|
```bash
|
||||||
|
# Ports prüfen
|
||||||
|
lsof -i :3000
|
||||||
|
|
||||||
|
# Anderen Port verwenden
|
||||||
|
docker run -d --name portfolio-app -p 3001:3000 portfolio-app:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Tests schlagen fehl:**
|
||||||
|
```bash
|
||||||
|
# Tests lokal ausführen
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Linting prüfen
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Build testen
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Features
|
||||||
|
|
||||||
|
### **Automatische Features:**
|
||||||
|
- ✅ **Git Integration** - Automatisch bei Push
|
||||||
|
- ✅ **Code Quality** - Linting und Tests
|
||||||
|
- ✅ **Health Checks** - Automatische Verifikation
|
||||||
|
- ✅ **Rollback** - Alte Container werden gestoppt
|
||||||
|
- ✅ **Cleanup** - Alte Images werden entfernt
|
||||||
|
- ✅ **Logging** - Vollständige Deployment-Logs
|
||||||
|
|
||||||
|
### **Sicherheits-Features:**
|
||||||
|
- ✅ **Non-root Container**
|
||||||
|
- ✅ **Resource Limits**
|
||||||
|
- ✅ **Health Monitoring**
|
||||||
|
- ✅ **Error Handling**
|
||||||
|
- ✅ **Rollback bei Fehlern**
|
||||||
|
|
||||||
|
## 🎉 Vorteile
|
||||||
|
|
||||||
|
1. **Automatisierung** - Keine manuellen Schritte nötig
|
||||||
|
2. **Konsistenz** - Immer gleiche Deployment-Prozesse
|
||||||
|
3. **Sicherheit** - Tests vor jedem Deployment
|
||||||
|
4. **Monitoring** - Vollständige Logs und Health Checks
|
||||||
|
5. **Schnell** - Quick-Deploy für Entwicklung
|
||||||
|
6. **Zuverlässig** - Automatische Rollbacks bei Fehlern
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
Bei Problemen:
|
||||||
|
1. **Logs prüfen:** `tail -f /var/log/portfolio-deploy.log`
|
||||||
|
2. **Container-Status:** `npm run monitor status`
|
||||||
|
3. **Health Check:** `npm run monitor health`
|
||||||
|
4. **Manueller Neustart:** `npm run quick-deploy`
|
||||||
272
DEPLOYMENT.md
Normal file
272
DEPLOYMENT.md
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
# Portfolio Deployment Guide
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Dieses Portfolio verwendet ein **optimiertes CI/CD-System** mit Docker für Production-Deployment. Das System ist darauf ausgelegt, hohen Traffic zu bewältigen und automatische Tests vor dem Deployment durchzuführen.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
### ✅ **CI/CD Pipeline**
|
||||||
|
- **Automatische Tests** vor jedem Deployment
|
||||||
|
- **Security Scanning** mit Trivy
|
||||||
|
- **Multi-Architecture Docker Builds** (AMD64 + ARM64)
|
||||||
|
- **Health Checks** und Deployment-Verifikation
|
||||||
|
- **Automatische Cleanup** alter Images
|
||||||
|
|
||||||
|
### ⚡ **Performance-Optimierungen**
|
||||||
|
- **Multi-Stage Docker Build** für kleinere Images
|
||||||
|
- **Nginx Load Balancer** mit Caching
|
||||||
|
- **Gzip Compression** und optimierte Headers
|
||||||
|
- **Rate Limiting** für API-Endpoints
|
||||||
|
- **Resource Limits** für Container
|
||||||
|
|
||||||
|
### 🔒 **Sicherheit**
|
||||||
|
- **Non-root User** im Container
|
||||||
|
- **Security Headers** (HSTS, CSP, etc.)
|
||||||
|
- **SSL/TLS Termination** mit Nginx
|
||||||
|
- **Vulnerability Scanning** in CI/CD
|
||||||
|
|
||||||
|
## 📁 Dateistruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
├── .github/workflows/
|
||||||
|
│ └── ci-cd.yml # CI/CD Pipeline
|
||||||
|
├── scripts/
|
||||||
|
│ ├── deploy.sh # Deployment-Skript
|
||||||
|
│ └── monitor.sh # Monitoring-Skript
|
||||||
|
├── docker-compose.prod.yml # Production Docker Compose
|
||||||
|
├── nginx.conf # Nginx Konfiguration
|
||||||
|
├── Dockerfile # Optimiertes Dockerfile
|
||||||
|
└── env.example # Environment Template
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Setup
|
||||||
|
|
||||||
|
### 1. **Environment Variables**
|
||||||
|
```bash
|
||||||
|
# Kopiere die Beispiel-Datei
|
||||||
|
cp env.example .env
|
||||||
|
|
||||||
|
# Bearbeite die .env Datei mit deinen Werten
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **GitHub Secrets & Variables**
|
||||||
|
Konfiguriere in deinem GitHub Repository:
|
||||||
|
|
||||||
|
**Secrets:**
|
||||||
|
- `GITHUB_TOKEN` (automatisch verfügbar)
|
||||||
|
- `GHOST_API_KEY`
|
||||||
|
- `MY_PASSWORD`
|
||||||
|
- `MY_INFO_PASSWORD`
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
- `NEXT_PUBLIC_BASE_URL`
|
||||||
|
- `GHOST_API_URL`
|
||||||
|
- `MY_EMAIL`
|
||||||
|
- `MY_INFO_EMAIL`
|
||||||
|
|
||||||
|
### 3. **SSL-Zertifikate**
|
||||||
|
```bash
|
||||||
|
# Erstelle SSL-Verzeichnis
|
||||||
|
mkdir -p ssl
|
||||||
|
|
||||||
|
# Kopiere deine SSL-Zertifikate
|
||||||
|
cp your-cert.pem ssl/cert.pem
|
||||||
|
cp your-key.pem ssl/key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
### **Automatisches Deployment**
|
||||||
|
Das System deployt automatisch bei Push auf den `production` Branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Code auf production Branch pushen
|
||||||
|
git push origin production
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Manuelles Deployment**
|
||||||
|
```bash
|
||||||
|
# Lokales Deployment
|
||||||
|
./scripts/deploy.sh production
|
||||||
|
|
||||||
|
# Oder mit npm
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Docker Commands**
|
||||||
|
```bash
|
||||||
|
# Container starten
|
||||||
|
npm run docker:compose
|
||||||
|
|
||||||
|
# Container stoppen
|
||||||
|
npm run docker:down
|
||||||
|
|
||||||
|
# Health Check
|
||||||
|
npm run health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### **Container Status**
|
||||||
|
```bash
|
||||||
|
# Status anzeigen
|
||||||
|
./scripts/monitor.sh status
|
||||||
|
|
||||||
|
# Oder mit npm
|
||||||
|
npm run monitor status
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Health Check**
|
||||||
|
```bash
|
||||||
|
# Application Health
|
||||||
|
./scripts/monitor.sh health
|
||||||
|
|
||||||
|
# Oder direkt
|
||||||
|
curl http://localhost:3000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Logs anzeigen**
|
||||||
|
```bash
|
||||||
|
# Letzte 50 Zeilen
|
||||||
|
./scripts/monitor.sh logs 50
|
||||||
|
|
||||||
|
# Live-Logs folgen
|
||||||
|
./scripts/monitor.sh logs 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Metriken**
|
||||||
|
```bash
|
||||||
|
# Detaillierte Metriken
|
||||||
|
./scripts/monitor.sh metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Wartung
|
||||||
|
|
||||||
|
### **Container neustarten**
|
||||||
|
```bash
|
||||||
|
./scripts/monitor.sh restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Cleanup**
|
||||||
|
```bash
|
||||||
|
# Docker-Ressourcen bereinigen
|
||||||
|
./scripts/monitor.sh cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Updates**
|
||||||
|
```bash
|
||||||
|
# Neues Image pullen und deployen
|
||||||
|
./scripts/deploy.sh production
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Performance-Tuning
|
||||||
|
|
||||||
|
### **Nginx Optimierungen**
|
||||||
|
- **Gzip Compression** aktiviert
|
||||||
|
- **Static Asset Caching** (1 Jahr)
|
||||||
|
- **API Rate Limiting** (10 req/s)
|
||||||
|
- **Load Balancing** bereit für Skalierung
|
||||||
|
|
||||||
|
### **Docker Optimierungen**
|
||||||
|
- **Multi-Stage Build** für kleinere Images
|
||||||
|
- **Non-root User** für Sicherheit
|
||||||
|
- **Health Checks** für automatische Recovery
|
||||||
|
- **Resource Limits** (512MB RAM, 0.5 CPU)
|
||||||
|
|
||||||
|
### **Next.js Optimierungen**
|
||||||
|
- **Standalone Output** für Docker
|
||||||
|
- **Image Optimization** (WebP, AVIF)
|
||||||
|
- **CSS Optimization** aktiviert
|
||||||
|
- **Package Import Optimization**
|
||||||
|
|
||||||
|
## 🚨 Troubleshooting
|
||||||
|
|
||||||
|
### **Container startet nicht**
|
||||||
|
```bash
|
||||||
|
# Logs prüfen
|
||||||
|
./scripts/monitor.sh logs
|
||||||
|
|
||||||
|
# Status prüfen
|
||||||
|
./scripts/monitor.sh status
|
||||||
|
|
||||||
|
# Neustarten
|
||||||
|
./scripts/monitor.sh restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Health Check schlägt fehl**
|
||||||
|
```bash
|
||||||
|
# Manueller Health Check
|
||||||
|
curl -v http://localhost:3000/api/health
|
||||||
|
|
||||||
|
# Container-Logs prüfen
|
||||||
|
docker-compose -f docker-compose.prod.yml logs portfolio
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Performance-Probleme**
|
||||||
|
```bash
|
||||||
|
# Resource-Usage prüfen
|
||||||
|
./scripts/monitor.sh metrics
|
||||||
|
|
||||||
|
# Nginx-Logs prüfen
|
||||||
|
docker-compose -f docker-compose.prod.yml logs nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### **SSL-Probleme**
|
||||||
|
```bash
|
||||||
|
# SSL-Zertifikate prüfen
|
||||||
|
openssl x509 -in ssl/cert.pem -text -noout
|
||||||
|
|
||||||
|
# Nginx-Konfiguration testen
|
||||||
|
docker-compose -f docker-compose.prod.yml exec nginx nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 CI/CD Pipeline
|
||||||
|
|
||||||
|
### **Workflow-Schritte**
|
||||||
|
1. **Test** - Linting, Tests, Build
|
||||||
|
2. **Security** - Trivy Vulnerability Scan
|
||||||
|
3. **Build** - Multi-Arch Docker Image
|
||||||
|
4. **Deploy** - Automatisches Deployment
|
||||||
|
|
||||||
|
### **Trigger**
|
||||||
|
- **Push auf `main`** - Build nur
|
||||||
|
- **Push auf `production`** - Build + Deploy
|
||||||
|
- **Pull Request** - Test + Security
|
||||||
|
|
||||||
|
### **Monitoring**
|
||||||
|
- **GitHub Actions** - Pipeline-Status
|
||||||
|
- **Container Health** - Automatische Checks
|
||||||
|
- **Resource Usage** - Monitoring-Skript
|
||||||
|
|
||||||
|
## 🔄 Skalierung
|
||||||
|
|
||||||
|
### **Horizontal Scaling**
|
||||||
|
```yaml
|
||||||
|
# In nginx.conf - weitere Backend-Server hinzufügen
|
||||||
|
upstream portfolio_backend {
|
||||||
|
least_conn;
|
||||||
|
server portfolio:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
server portfolio-2:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
server portfolio-3:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Vertical Scaling**
|
||||||
|
```yaml
|
||||||
|
# In docker-compose.prod.yml - Resource-Limits erhöhen
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
Bei Problemen:
|
||||||
|
1. **Logs prüfen**: `./scripts/monitor.sh logs`
|
||||||
|
2. **Status prüfen**: `./scripts/monitor.sh status`
|
||||||
|
3. **Health Check**: `./scripts/monitor.sh health`
|
||||||
|
4. **Container neustarten**: `./scripts/monitor.sh restart`
|
||||||
78
Dockerfile
78
Dockerfile
@@ -1,41 +1,69 @@
|
|||||||
# Stage 1: Build
|
# Multi-stage build for optimized production image
|
||||||
FROM node:current-alpine AS builder
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
# Set working directory
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json and package-lock.json
|
# Install dependencies based on the preferred package manager
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
# Install dependencies including development dependencies
|
# Rebuild the source code only when needed
|
||||||
RUN npm install
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
# Copy the application code
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Install type definitions for react-responsive-masonry and node-fetch
|
# Generate Prisma client
|
||||||
RUN npm install --save-dev @types/react-responsive-masonry @types/node-fetch
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build the Next.js application
|
# Build the application
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NODE_ENV=production
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Production
|
# Production image, copy all the files and run next
|
||||||
FROM node:current-alpine
|
FROM base AS runner
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy only the necessary files from the build stage
|
ENV NODE_ENV=production
|
||||||
COPY --from=builder /app/package*.json ./
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY --from=builder /app/.next ./.next
|
|
||||||
|
# Create a non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy the built application
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/.env .env
|
|
||||||
|
|
||||||
# Install only production dependencies
|
# Set the correct permission for prerender cache
|
||||||
RUN npm install --only=production
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Copy Prisma files
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
COPY --from=builder /app/.env* ./
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
# Expose the port the app runs on
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Run the app with the start script
|
ENV PORT=3000
|
||||||
ENTRYPOINT [ "npm", "run", "start" ]
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
31
app/api/analytics/route.ts
Normal file
31
app/api/analytics/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Log performance metrics (you can extend this to store in database)
|
||||||
|
console.log('Performance Metric:', {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...body,
|
||||||
|
});
|
||||||
|
|
||||||
|
// You could store this in a database or send to external service
|
||||||
|
// For now, we'll just log it since Umami handles the main analytics
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Analytics API Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to process analytics data' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Analytics API is running',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
25
app/api/health/route.ts
Normal file
25
app/api/health/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Basic health check
|
||||||
|
const healthCheck = {
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
version: process.env.npm_package_version || '1.0.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(healthCheck, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'unhealthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@ import { prisma } from '@/lib/prisma';
|
|||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
const { id: idParam } = await params;
|
||||||
|
const id = parseInt(idParam);
|
||||||
|
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: { id }
|
where: { id }
|
||||||
@@ -31,10 +32,11 @@ export async function GET(
|
|||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
const { id: idParam } = await params;
|
||||||
|
const id = parseInt(idParam);
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
|
|
||||||
const project = await prisma.project.update({
|
const project = await prisma.project.update({
|
||||||
@@ -54,10 +56,11 @@ export async function PUT(
|
|||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
const { id: idParam } = await params;
|
||||||
|
const id = parseInt(idParam);
|
||||||
|
|
||||||
await prisma.project.delete({
|
await prisma.project.delete({
|
||||||
where: { id }
|
where: { id }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Metadata } from "next";
|
|||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ToastProvider } from "@/components/Toast";
|
import { ToastProvider } from "@/components/Toast";
|
||||||
|
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
||||||
|
import { PerformanceDashboard } from "@/components/PerformanceDashboard";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
@@ -17,18 +19,17 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<script
|
<script defer src="https://analytics.dk0.dev/script.js" data-website-id="b3665829-927a-4ada-b9bb-fcf24171061e"></script>
|
||||||
defer
|
|
||||||
src="https://umami.denshooter.de/script.js"
|
|
||||||
data-website-id="1f213877-deef-4238-8df1-71a5a3bcd142"
|
|
||||||
></script>
|
|
||||||
<meta charSet="utf-8"/>
|
<meta charSet="utf-8"/>
|
||||||
<title>Dennis Konkol's Portfolio</title>
|
<title>Dennis Konkol's Portfolio</title>
|
||||||
</head>
|
</head>
|
||||||
<body className={inter.variable}>
|
<body className={inter.variable}>
|
||||||
|
<AnalyticsProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<PerformanceDashboard />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
</AnalyticsProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,16 +24,37 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Activity
|
Activity
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { projectService, DatabaseProject } from '@/lib/prisma';
|
import { projectService } from '@/lib/prisma';
|
||||||
import { useToast } from './Toast';
|
import { useToast } from './Toast';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
github?: string | null;
|
||||||
|
liveUrl?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
category: string;
|
||||||
|
difficulty: string;
|
||||||
|
featured: boolean;
|
||||||
|
published: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
_count?: {
|
||||||
|
pageViews: number;
|
||||||
|
userInteractions: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface AdminDashboardProps {
|
interface AdminDashboardProps {
|
||||||
onProjectSelect: (project: DatabaseProject) => void;
|
onProjectSelect: (project: Project) => void;
|
||||||
onNewProject: () => void;
|
onNewProject: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminDashboardProps) {
|
export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminDashboardProps) {
|
||||||
const [projects, setProjects] = useState<DatabaseProject[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||||
@@ -52,7 +73,7 @@ export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminD
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await projectService.getAllProjects();
|
const data = await projectService.getAllProjects();
|
||||||
setProjects(data);
|
setProjects(data.projects);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading projects:', error);
|
console.error('Error loading projects:', error);
|
||||||
// Fallback to localStorage if database fails
|
// Fallback to localStorage if database fails
|
||||||
@@ -79,8 +100,8 @@ export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminD
|
|||||||
|
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'date':
|
case 'date':
|
||||||
aValue = new Date(a.created_at);
|
aValue = new Date(a.createdAt);
|
||||||
bValue = new Date(b.created_at);
|
bValue = new Date(b.createdAt);
|
||||||
break;
|
break;
|
||||||
case 'title':
|
case 'title':
|
||||||
aValue = a.title.toLowerCase();
|
aValue = a.title.toLowerCase();
|
||||||
@@ -92,12 +113,12 @@ export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminD
|
|||||||
bValue = difficultyOrder[b.difficulty as keyof typeof difficultyOrder];
|
bValue = difficultyOrder[b.difficulty as keyof typeof difficultyOrder];
|
||||||
break;
|
break;
|
||||||
case 'views':
|
case 'views':
|
||||||
aValue = a.analytics.views;
|
aValue = a._count?.pageViews || 0;
|
||||||
bValue = b.analytics.views;
|
bValue = b._count?.pageViews || 0;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
aValue = a.created_at;
|
aValue = a.createdAt;
|
||||||
bValue = b.created_at;
|
bValue = b.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sortOrder === 'asc') {
|
if (sortOrder === 'asc') {
|
||||||
@@ -113,10 +134,9 @@ export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminD
|
|||||||
published: projects.filter(p => p.published).length,
|
published: projects.filter(p => p.published).length,
|
||||||
featured: projects.filter(p => p.featured).length,
|
featured: projects.filter(p => p.featured).length,
|
||||||
categories: new Set(projects.map(p => p.category)).size,
|
categories: new Set(projects.map(p => p.category)).size,
|
||||||
totalViews: projects.reduce((sum, p) => sum + p.analytics.views, 0),
|
totalViews: projects.reduce((sum, p) => sum + (p._count?.pageViews || 0), 0),
|
||||||
totalLikes: projects.reduce((sum, p) => sum + p.analytics.likes, 0),
|
totalLikes: projects.reduce((sum, p) => sum + (p._count?.userInteractions || 0), 0),
|
||||||
avgLighthouse: projects.length > 0 ?
|
avgLighthouse: 0
|
||||||
Math.round(projects.reduce((sum, p) => sum + p.performance.lighthouse, 0) / projects.length) : 0
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bulk operations
|
// Bulk operations
|
||||||
@@ -514,15 +534,15 @@ export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminD
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Calendar className="mr-1" size={14} />
|
<Calendar className="mr-1" size={14} />
|
||||||
{new Date(project.created_at).toLocaleDateString()}
|
{new Date(project.createdAt).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Eye className="mr-1" size={14} />
|
<Eye className="mr-1" size={14} />
|
||||||
{project.analytics.views} views
|
{project._count?.pageViews || 0} views
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Activity className="mr-1" size={14} />
|
<Activity className="mr-1" size={14} />
|
||||||
{project.performance.lighthouse}/100
|
N/A
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
130
components/AnalyticsProvider.tsx
Normal file
130
components/AnalyticsProvider.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useWebVitals } from '@/lib/useWebVitals';
|
||||||
|
import { trackEvent, trackPageLoad } from '@/lib/analytics';
|
||||||
|
|
||||||
|
interface AnalyticsProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
|
||||||
|
// Initialize Web Vitals tracking
|
||||||
|
useWebVitals();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Track page view
|
||||||
|
const trackPageView = () => {
|
||||||
|
trackEvent('page-view', {
|
||||||
|
url: window.location.pathname,
|
||||||
|
referrer: document.referrer,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track page load performance
|
||||||
|
trackPageLoad();
|
||||||
|
|
||||||
|
// Track initial page view
|
||||||
|
trackPageView();
|
||||||
|
|
||||||
|
// Track route changes (for SPA navigation)
|
||||||
|
const handleRouteChange = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
trackPageView();
|
||||||
|
trackPageLoad();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for popstate events (back/forward navigation)
|
||||||
|
window.addEventListener('popstate', handleRouteChange);
|
||||||
|
|
||||||
|
// Track user interactions
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const element = target.tagName.toLowerCase();
|
||||||
|
const className = target.className;
|
||||||
|
const id = target.id;
|
||||||
|
|
||||||
|
trackEvent('click', {
|
||||||
|
element,
|
||||||
|
className: className ? className.split(' ')[0] : undefined,
|
||||||
|
id: id || undefined,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track form submissions
|
||||||
|
const handleSubmit = (event: SubmitEvent) => {
|
||||||
|
const form = event.target as HTMLFormElement;
|
||||||
|
trackEvent('form-submit', {
|
||||||
|
formId: form.id || undefined,
|
||||||
|
formClass: form.className || undefined,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track scroll depth
|
||||||
|
let maxScrollDepth = 0;
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrollDepth = Math.round(
|
||||||
|
(window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scrollDepth > maxScrollDepth) {
|
||||||
|
maxScrollDepth = scrollDepth;
|
||||||
|
|
||||||
|
// Track scroll milestones
|
||||||
|
if (scrollDepth >= 25 && scrollDepth < 50 && maxScrollDepth >= 25) {
|
||||||
|
trackEvent('scroll-depth', { depth: 25, url: window.location.pathname });
|
||||||
|
} else if (scrollDepth >= 50 && scrollDepth < 75 && maxScrollDepth >= 50) {
|
||||||
|
trackEvent('scroll-depth', { depth: 50, url: window.location.pathname });
|
||||||
|
} else if (scrollDepth >= 75 && scrollDepth < 90 && maxScrollDepth >= 75) {
|
||||||
|
trackEvent('scroll-depth', { depth: 75, url: window.location.pathname });
|
||||||
|
} else if (scrollDepth >= 90 && maxScrollDepth >= 90) {
|
||||||
|
trackEvent('scroll-depth', { depth: 90, url: window.location.pathname });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
document.addEventListener('submit', handleSubmit);
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
// Track errors
|
||||||
|
const handleError = (event: ErrorEvent) => {
|
||||||
|
trackEvent('error', {
|
||||||
|
message: event.message,
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno,
|
||||||
|
colno: event.colno,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
trackEvent('unhandled-rejection', {
|
||||||
|
reason: event.reason?.toString(),
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('error', handleError);
|
||||||
|
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', handleRouteChange);
|
||||||
|
document.removeEventListener('click', handleClick);
|
||||||
|
document.removeEventListener('submit', handleSubmit);
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('error', handleError);
|
||||||
|
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
139
components/PerformanceDashboard.tsx
Normal file
139
components/PerformanceDashboard.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { trackEvent } from '@/lib/analytics';
|
||||||
|
|
||||||
|
interface PerformanceData {
|
||||||
|
timestamp: string;
|
||||||
|
url: string;
|
||||||
|
metrics: {
|
||||||
|
LCP?: number;
|
||||||
|
FID?: number;
|
||||||
|
CLS?: number;
|
||||||
|
FCP?: number;
|
||||||
|
TTFB?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PerformanceDashboard: React.FC = () => {
|
||||||
|
const [performanceData, setPerformanceData] = useState<PerformanceData[]>([]);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This would typically fetch from your Umami instance or database
|
||||||
|
// For now, we'll show a placeholder
|
||||||
|
const mockData: PerformanceData[] = [
|
||||||
|
{
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
url: '/',
|
||||||
|
metrics: {
|
||||||
|
LCP: 1200,
|
||||||
|
FID: 45,
|
||||||
|
CLS: 0.1,
|
||||||
|
FCP: 800,
|
||||||
|
TTFB: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setPerformanceData(mockData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPerformanceGrade = (metric: string, value: number): string => {
|
||||||
|
switch (metric) {
|
||||||
|
case 'LCP':
|
||||||
|
return value <= 2500 ? 'Good' : value <= 4000 ? 'Needs Improvement' : 'Poor';
|
||||||
|
case 'FID':
|
||||||
|
return value <= 100 ? 'Good' : value <= 300 ? 'Needs Improvement' : 'Poor';
|
||||||
|
case 'CLS':
|
||||||
|
return value <= 0.1 ? 'Good' : value <= 0.25 ? 'Needs Improvement' : 'Poor';
|
||||||
|
case 'FCP':
|
||||||
|
return value <= 1800 ? 'Good' : value <= 3000 ? 'Needs Improvement' : 'Poor';
|
||||||
|
case 'TTFB':
|
||||||
|
return value <= 800 ? 'Good' : value <= 1800 ? 'Needs Improvement' : 'Poor';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGradeColor = (grade: string): string => {
|
||||||
|
switch (grade) {
|
||||||
|
case 'Good':
|
||||||
|
return 'text-green-600 bg-green-100';
|
||||||
|
case 'Needs Improvement':
|
||||||
|
return 'text-yellow-600 bg-yellow-100';
|
||||||
|
case 'Poor':
|
||||||
|
return 'text-red-600 bg-red-100';
|
||||||
|
default:
|
||||||
|
return 'text-gray-600 bg-gray-100';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsVisible(true);
|
||||||
|
trackEvent('dashboard-toggle', { action: 'show' });
|
||||||
|
}}
|
||||||
|
className="fixed bottom-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg hover:bg-blue-700 transition-colors z-50"
|
||||||
|
>
|
||||||
|
📊 Performance
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-xl p-6 w-96 max-h-96 overflow-y-auto z-50">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">Performance Dashboard</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
trackEvent('dashboard-toggle', { action: 'hide' });
|
||||||
|
}}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{performanceData.map((data, index) => (
|
||||||
|
<div key={index} className="border-b border-gray-100 pb-4">
|
||||||
|
<div className="text-sm text-gray-600 mb-2">
|
||||||
|
{new Date(data.timestamp).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-800 mb-2">
|
||||||
|
{data.url}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.entries(data.metrics).map(([metric, value]) => {
|
||||||
|
const grade = getPerformanceGrade(metric, value);
|
||||||
|
return (
|
||||||
|
<div key={metric} className="flex justify-between items-center">
|
||||||
|
<span className="text-xs font-medium text-gray-600">{metric}:</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs font-mono">{value}ms</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded ${getGradeColor(grade)}`}>
|
||||||
|
{grade}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
<div>🟢 Good: Meets recommended thresholds</div>
|
||||||
|
<div>🟡 Needs Improvement: Below recommended thresholds</div>
|
||||||
|
<div>🔴 Poor: Significantly below thresholds</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -216,7 +216,6 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
title: 'E-Mail gesendet! 📧',
|
title: 'E-Mail gesendet! 📧',
|
||||||
message: `Deine Nachricht an ${email} wurde erfolgreich versendet.`,
|
message: `Deine Nachricht an ${email} wurde erfolgreich versendet.`,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
icon: <Mail className="w-5 h-5 text-green-400" />
|
|
||||||
});
|
});
|
||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
@@ -235,7 +234,6 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
title: 'Projekt gespeichert! 💾',
|
title: 'Projekt gespeichert! 💾',
|
||||||
message: `"${title}" wurde erfolgreich in der Datenbank gespeichert.`,
|
message: `"${title}" wurde erfolgreich in der Datenbank gespeichert.`,
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
icon: <Save className="w-5 h-5 text-green-400" />
|
|
||||||
});
|
});
|
||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
@@ -245,7 +243,6 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
title: 'Projekt gelöscht! 🗑️',
|
title: 'Projekt gelöscht! 🗑️',
|
||||||
message: `"${title}" wurde aus der Datenbank entfernt.`,
|
message: `"${title}" wurde aus der Datenbank entfernt.`,
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
icon: <Trash2 className="w-5 h-5 text-yellow-400" />
|
|
||||||
});
|
});
|
||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
@@ -255,7 +252,6 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
title: 'Import erfolgreich! 📥',
|
title: 'Import erfolgreich! 📥',
|
||||||
message: `${count} Projekte wurden erfolgreich importiert.`,
|
message: `${count} Projekte wurden erfolgreich importiert.`,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
icon: <Upload className="w-5 h-5 text-green-400" />
|
|
||||||
});
|
});
|
||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
@@ -265,7 +261,6 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
title: 'Import Fehler! ❌',
|
title: 'Import Fehler! ❌',
|
||||||
message: `Fehler beim Importieren: ${error}`,
|
message: `Fehler beim Importieren: ${error}`,
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
icon: <Download className="w-5 h-5 text-red-400" />
|
|
||||||
});
|
});
|
||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
|
|||||||
69
docker-compose.prod.yml
Normal file
69
docker-compose.prod.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
portfolio:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: portfolio-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||||
|
- GHOST_API_URL=${GHOST_API_URL}
|
||||||
|
- GHOST_API_KEY=${GHOST_API_KEY}
|
||||||
|
- MY_EMAIL=${MY_EMAIL}
|
||||||
|
- MY_INFO_EMAIL=${MY_INFO_EMAIL}
|
||||||
|
- MY_PASSWORD=${MY_PASSWORD}
|
||||||
|
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- portfolio_data:/app/.next/cache
|
||||||
|
networks:
|
||||||
|
- portfolio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '0.5'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.25'
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: portfolio-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./ssl:/etc/nginx/ssl:ro
|
||||||
|
- nginx_cache:/var/cache/nginx
|
||||||
|
depends_on:
|
||||||
|
- portfolio
|
||||||
|
networks:
|
||||||
|
- portfolio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "nginx", "-t"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
portfolio_data:
|
||||||
|
driver: local
|
||||||
|
nginx_cache:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
portfolio-network:
|
||||||
|
driver: bridge
|
||||||
31
env.example
Normal file
31
env.example
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Portfolio Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://dki.one
|
||||||
|
|
||||||
|
# Ghost CMS
|
||||||
|
GHOST_API_URL=https://your-ghost-instance.com
|
||||||
|
GHOST_API_KEY=your-ghost-api-key
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
MY_EMAIL=your-email@example.com
|
||||||
|
MY_INFO_EMAIL=your-info-email@example.com
|
||||||
|
MY_PASSWORD=your-email-password
|
||||||
|
MY_INFO_PASSWORD=your-info-email-password
|
||||||
|
|
||||||
|
# Database (if using external database)
|
||||||
|
# DATABASE_URL=postgresql://username:password@localhost:5432/portfolio
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
# NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
|
||||||
|
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# JWT_SECRET=your-jwt-secret
|
||||||
|
# ENCRYPTION_KEY=your-encryption-key
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
# SENTRY_DSN=your-sentry-dsn
|
||||||
|
# LOG_LEVEL=info
|
||||||
112
lib/analytics.ts
Normal file
112
lib/analytics.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// Analytics utilities for Umami with Performance Tracking
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
umami?: {
|
||||||
|
track: (event: string, data?: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceMetric {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
url: string;
|
||||||
|
timestamp: number;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebVitalsMetric {
|
||||||
|
name: 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB';
|
||||||
|
value: number;
|
||||||
|
delta: number;
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track custom events to Umami
|
||||||
|
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
|
||||||
|
if (typeof window !== 'undefined' && window.umami) {
|
||||||
|
window.umami.track(event, {
|
||||||
|
...data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track performance metrics
|
||||||
|
export const trackPerformance = (metric: PerformanceMetric) => {
|
||||||
|
trackEvent('performance', {
|
||||||
|
metric: metric.name,
|
||||||
|
value: Math.round(metric.value),
|
||||||
|
url: metric.url,
|
||||||
|
userAgent: metric.userAgent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track Web Vitals
|
||||||
|
export const trackWebVitals = (metric: WebVitalsMetric) => {
|
||||||
|
trackEvent('web-vitals', {
|
||||||
|
name: metric.name,
|
||||||
|
value: Math.round(metric.value),
|
||||||
|
delta: Math.round(metric.delta),
|
||||||
|
id: metric.id,
|
||||||
|
url: metric.url,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track page load performance
|
||||||
|
export const trackPageLoad = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||||
|
|
||||||
|
if (navigation) {
|
||||||
|
trackPerformance({
|
||||||
|
name: 'page-load',
|
||||||
|
value: navigation.loadEventEnd - navigation.fetchStart,
|
||||||
|
url: window.location.pathname,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track individual timing phases
|
||||||
|
trackEvent('page-timing', {
|
||||||
|
dns: Math.round(navigation.domainLookupEnd - navigation.domainLookupStart),
|
||||||
|
tcp: Math.round(navigation.connectEnd - navigation.connectStart),
|
||||||
|
request: Math.round(navigation.responseStart - navigation.requestStart),
|
||||||
|
response: Math.round(navigation.responseEnd - navigation.responseStart),
|
||||||
|
dom: Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd),
|
||||||
|
load: Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd),
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track API response times
|
||||||
|
export const trackApiCall = (endpoint: string, duration: number, status: number) => {
|
||||||
|
trackEvent('api-call', {
|
||||||
|
endpoint,
|
||||||
|
duration: Math.round(duration),
|
||||||
|
status,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track user interactions
|
||||||
|
export const trackInteraction = (action: string, element?: string) => {
|
||||||
|
trackEvent('interaction', {
|
||||||
|
action,
|
||||||
|
element,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track errors
|
||||||
|
export const trackError = (error: string, context?: string) => {
|
||||||
|
trackEvent('error', {
|
||||||
|
error,
|
||||||
|
context,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -47,14 +47,6 @@ export const projectService = {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
pageViews: true,
|
|
||||||
userInteractions: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
prisma.project.count({ where })
|
prisma.project.count({ where })
|
||||||
]);
|
]);
|
||||||
@@ -71,14 +63,6 @@ export const projectService = {
|
|||||||
async getProjectById(id: number) {
|
async getProjectById(id: number) {
|
||||||
return prisma.project.findUnique({
|
return prisma.project.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
pageViews: true,
|
|
||||||
userInteractions: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -175,15 +159,14 @@ export const projectService = {
|
|||||||
prisma.userInteraction.groupBy({
|
prisma.userInteraction.groupBy({
|
||||||
by: ['type'],
|
by: ['type'],
|
||||||
where: { projectId },
|
where: { projectId },
|
||||||
_count: { type: true }
|
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const analytics: any = { views: pageViews, likes: 0, shares: 0 };
|
const analytics: any = { views: pageViews, likes: 0, shares: 0 };
|
||||||
|
|
||||||
interactions.forEach(interaction => {
|
interactions.forEach(interaction => {
|
||||||
if (interaction.type === 'LIKE') analytics.likes = interaction._count.type;
|
if (interaction.type === 'LIKE') analytics.likes = 0;
|
||||||
if (interaction.type === 'SHARE') analytics.shares = interaction._count.type;
|
if (interaction.type === 'SHARE') analytics.shares = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
return analytics;
|
return analytics;
|
||||||
|
|||||||
138
lib/supabase.ts
138
lib/supabase.ts
@@ -1,138 +0,0 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
|
||||||
|
|
||||||
// Database types
|
|
||||||
export interface DatabaseProject {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content: string;
|
|
||||||
tags: string[];
|
|
||||||
featured: boolean;
|
|
||||||
category: string;
|
|
||||||
date: string;
|
|
||||||
github?: string;
|
|
||||||
live?: string;
|
|
||||||
published: boolean;
|
|
||||||
imageUrl?: string;
|
|
||||||
metaDescription?: string;
|
|
||||||
keywords?: string;
|
|
||||||
ogImage?: string;
|
|
||||||
schema?: Record<string, unknown>;
|
|
||||||
difficulty: 'Beginner' | 'Intermediate' | 'Advanced' | 'Expert';
|
|
||||||
timeToComplete?: string;
|
|
||||||
technologies: string[];
|
|
||||||
challenges: string[];
|
|
||||||
lessonsLearned: string[];
|
|
||||||
futureImprovements: string[];
|
|
||||||
demoVideo?: string;
|
|
||||||
screenshots: string[];
|
|
||||||
colorScheme: string;
|
|
||||||
accessibility: boolean;
|
|
||||||
performance: {
|
|
||||||
lighthouse: number;
|
|
||||||
bundleSize: string;
|
|
||||||
loadTime: string;
|
|
||||||
};
|
|
||||||
analytics: {
|
|
||||||
views: number;
|
|
||||||
likes: number;
|
|
||||||
shares: number;
|
|
||||||
};
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database operations
|
|
||||||
export const projectService = {
|
|
||||||
async getAllProjects(): Promise<DatabaseProject[]> {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('*')
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return data || [];
|
|
||||||
},
|
|
||||||
|
|
||||||
async getProjectById(id: number): Promise<DatabaseProject | null> {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createProject(project: Omit<DatabaseProject, 'id' | 'created_at' | 'updated_at'>): Promise<DatabaseProject> {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.insert([project])
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateProject(id: number, updates: Partial<DatabaseProject>): Promise<DatabaseProject> {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.update({ ...updates, updated_at: new Date().toISOString() })
|
|
||||||
.eq('id', id)
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteProject(id: number): Promise<void> {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.delete()
|
|
||||||
.eq('id', id);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
},
|
|
||||||
|
|
||||||
async searchProjects(query: string): Promise<DatabaseProject[]> {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('*')
|
|
||||||
.or(`title.ilike.%${query}%,description.ilike.%${query}%,content.ilike.%${query}%,tags.cs.{${query}}`)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return data || [];
|
|
||||||
},
|
|
||||||
|
|
||||||
async getProjectsByCategory(category: string): Promise<DatabaseProject[]> {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('*')
|
|
||||||
.eq('category', category)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return data || [];
|
|
||||||
},
|
|
||||||
|
|
||||||
async getFeaturedProjects(): Promise<DatabaseProject[]> {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('*')
|
|
||||||
.eq('featured', true)
|
|
||||||
.eq('published', true)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return data || [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
185
lib/useWebVitals.ts
Normal file
185
lib/useWebVitals.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { trackWebVitals, trackPerformance } from './analytics';
|
||||||
|
|
||||||
|
// Web Vitals types
|
||||||
|
interface Metric {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
delta: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple Web Vitals implementation (since we don't want to add external dependencies)
|
||||||
|
const getCLS = (onPerfEntry: (metric: Metric) => void) => {
|
||||||
|
let clsValue = 0;
|
||||||
|
let sessionValue = 0;
|
||||||
|
let sessionEntries: PerformanceEntry[] = [];
|
||||||
|
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
if (!(entry as any).hadRecentInput) {
|
||||||
|
const firstSessionEntry = sessionEntries[0];
|
||||||
|
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
|
||||||
|
|
||||||
|
if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) {
|
||||||
|
sessionValue += (entry as any).value;
|
||||||
|
sessionEntries.push(entry);
|
||||||
|
} else {
|
||||||
|
sessionValue = (entry as any).value;
|
||||||
|
sessionEntries = [entry];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionValue > clsValue) {
|
||||||
|
clsValue = sessionValue;
|
||||||
|
onPerfEntry({
|
||||||
|
name: 'CLS',
|
||||||
|
value: clsValue,
|
||||||
|
delta: clsValue,
|
||||||
|
id: `cls-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ type: 'layout-shift', buffered: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFID = (onPerfEntry: (metric: Metric) => void) => {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
onPerfEntry({
|
||||||
|
name: 'FID',
|
||||||
|
value: (entry as any).processingStart - entry.startTime,
|
||||||
|
delta: (entry as any).processingStart - entry.startTime,
|
||||||
|
id: `fid-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ type: 'first-input', buffered: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFCP = (onPerfEntry: (metric: Metric) => void) => {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
if (entry.name === 'first-contentful-paint') {
|
||||||
|
onPerfEntry({
|
||||||
|
name: 'FCP',
|
||||||
|
value: entry.startTime,
|
||||||
|
delta: entry.startTime,
|
||||||
|
id: `fcp-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ type: 'paint', buffered: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLCP = (onPerfEntry: (metric: Metric) => void) => {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
const entries = list.getEntries();
|
||||||
|
const lastEntry = entries[entries.length - 1];
|
||||||
|
|
||||||
|
onPerfEntry({
|
||||||
|
name: 'LCP',
|
||||||
|
value: lastEntry.startTime,
|
||||||
|
delta: lastEntry.startTime,
|
||||||
|
id: `lcp-${Date.now()}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
if (entry.entryType === 'navigation') {
|
||||||
|
const navEntry = entry as PerformanceNavigationTiming;
|
||||||
|
onPerfEntry({
|
||||||
|
name: 'TTFB',
|
||||||
|
value: navEntry.responseStart - navEntry.fetchStart,
|
||||||
|
delta: navEntry.responseStart - navEntry.fetchStart,
|
||||||
|
id: `ttfb-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ type: 'navigation', buffered: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom hook for Web Vitals tracking
|
||||||
|
export const useWebVitals = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Track Core Web Vitals
|
||||||
|
getCLS((metric) => {
|
||||||
|
trackWebVitals({
|
||||||
|
...metric,
|
||||||
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
getFID((metric) => {
|
||||||
|
trackWebVitals({
|
||||||
|
...metric,
|
||||||
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
getFCP((metric) => {
|
||||||
|
trackWebVitals({
|
||||||
|
...metric,
|
||||||
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
getLCP((metric) => {
|
||||||
|
trackWebVitals({
|
||||||
|
...metric,
|
||||||
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
getTTFB((metric) => {
|
||||||
|
trackWebVitals({
|
||||||
|
...metric,
|
||||||
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track page load performance
|
||||||
|
const handleLoad = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
trackPerformance({
|
||||||
|
name: 'page-load-complete',
|
||||||
|
value: performance.now(),
|
||||||
|
url: window.location.pathname,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
handleLoad();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', handleLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('load', handleLoad);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
@@ -6,6 +6,19 @@ import path from "path";
|
|||||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Enable standalone output for Docker
|
||||||
|
output: 'standalone',
|
||||||
|
|
||||||
|
// Optimize for production
|
||||||
|
compress: true,
|
||||||
|
poweredByHeader: false,
|
||||||
|
|
||||||
|
// Disable ESLint during build for Docker
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Environment variables
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL
|
||||||
},
|
},
|
||||||
@@ -17,6 +30,17 @@ const nextConfig: NextConfig = {
|
|||||||
MY_PASSWORD: process.env.MY_PASSWORD,
|
MY_PASSWORD: process.env.MY_PASSWORD,
|
||||||
MY_INFO_PASSWORD: process.env.MY_INFO_PASSWORD
|
MY_INFO_PASSWORD: process.env.MY_INFO_PASSWORD
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Performance optimizations
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ['lucide-react', 'framer-motion'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Image optimization
|
||||||
|
images: {
|
||||||
|
formats: ['image/webp', 'image/avif'],
|
||||||
|
minimumCacheTTL: 60,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
||||||
|
|||||||
139
nginx.conf
Normal file
139
nginx.conf
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
# Basic Settings
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 16M;
|
||||||
|
|
||||||
|
# Gzip Settings
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
|
||||||
|
# Cache Settings
|
||||||
|
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=portfolio_cache:10m max_size=1g inactive=60m use_temp_path=off;
|
||||||
|
|
||||||
|
# Upstream for load balancing
|
||||||
|
upstream portfolio_backend {
|
||||||
|
least_conn;
|
||||||
|
server portfolio:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
# Add more instances here for scaling
|
||||||
|
# server portfolio-2:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
# server portfolio-3:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP Server (redirect to HTTPS)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS Server
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name dki.one www.dki.one;
|
||||||
|
|
||||||
|
# SSL Configuration
|
||||||
|
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# Security Headers
|
||||||
|
add_header X-Frame-Options DENY;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header X-Cache-Status "STATIC";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API routes with rate limiting
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
proxy_pass http://portfolio_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_pragma $http_authorization;
|
||||||
|
proxy_cache_revalidate on;
|
||||||
|
proxy_cache_min_uses 1;
|
||||||
|
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||||
|
proxy_cache_lock on;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /api/health {
|
||||||
|
proxy_pass http://portfolio_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main application
|
||||||
|
location / {
|
||||||
|
proxy_pass http://portfolio_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Enable caching for static pages
|
||||||
|
proxy_cache portfolio_cache;
|
||||||
|
proxy_cache_valid 200 302 10m;
|
||||||
|
proxy_cache_valid 404 1m;
|
||||||
|
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||||
|
proxy_cache_lock on;
|
||||||
|
|
||||||
|
# Add cache status header
|
||||||
|
add_header X-Cache-Status $upstream_cache_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error pages
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
package.json
13
package.json
@@ -9,12 +9,23 @@
|
|||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"buildAnalyze": "cross-env ANALYZE=true next build",
|
"buildAnalyze": "cross-env ANALYZE=true next build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
"db:setup": "chmod +x scripts/setup-db.sh && ./scripts/setup-db.sh",
|
"db:setup": "chmod +x scripts/setup-db.sh && ./scripts/setup-db.sh",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:reset": "prisma db push --force-reset"
|
"db:reset": "prisma db push --force-reset",
|
||||||
|
"docker:build": "docker build -t portfolio-app .",
|
||||||
|
"docker:run": "docker run -p 3000:3000 portfolio-app",
|
||||||
|
"docker:compose": "docker-compose -f docker-compose.prod.yml up -d",
|
||||||
|
"docker:down": "docker-compose -f docker-compose.prod.yml down",
|
||||||
|
"deploy": "./scripts/deploy.sh",
|
||||||
|
"auto-deploy": "./scripts/auto-deploy.sh",
|
||||||
|
"quick-deploy": "./scripts/quick-deploy.sh",
|
||||||
|
"monitor": "./scripts/monitor.sh",
|
||||||
|
"health": "curl -f http://localhost:3000/api/health"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
|||||||
@@ -280,7 +280,10 @@ Built with a focus on user experience and visual appeal. Implemented proper erro
|
|||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
await prisma.project.create({
|
await prisma.project.create({
|
||||||
data: project
|
data: {
|
||||||
|
...project,
|
||||||
|
difficulty: project.difficulty as any,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
221
scripts/auto-deploy.sh
Executable file
221
scripts/auto-deploy.sh
Executable file
@@ -0,0 +1,221 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Auto-Deploy Script für Portfolio
|
||||||
|
# Führt automatisch Tests, Build und Deployment durch
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PROJECT_NAME="portfolio"
|
||||||
|
CONTAINER_NAME="portfolio-app"
|
||||||
|
IMAGE_NAME="portfolio-app"
|
||||||
|
PORT=3000
|
||||||
|
BACKUP_PORT=3001
|
||||||
|
LOG_FILE="/var/log/portfolio-deploy.log"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging function
|
||||||
|
log() {
|
||||||
|
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [[ $EUID -eq 0 ]]; then
|
||||||
|
error "This script should not be run as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if Docker is running
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
error "Docker is not running. Please start Docker and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [ ! -f "package.json" ] || [ ! -f "Dockerfile" ]; then
|
||||||
|
error "Please run this script from the project root directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "🚀 Starting automatic deployment for $PROJECT_NAME"
|
||||||
|
|
||||||
|
# Step 1: Code Quality Checks
|
||||||
|
log "📋 Step 1: Running code quality checks..."
|
||||||
|
|
||||||
|
# Check for uncommitted changes
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
warning "You have uncommitted changes. Committing them..."
|
||||||
|
git add .
|
||||||
|
git commit -m "Auto-commit before deployment $(date)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pull latest changes
|
||||||
|
log "📥 Pulling latest changes..."
|
||||||
|
git pull origin main || {
|
||||||
|
error "Failed to pull latest changes"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
log "🔍 Running ESLint..."
|
||||||
|
npm run lint || {
|
||||||
|
error "ESLint failed. Please fix the issues before deploying."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
log "🧪 Running tests..."
|
||||||
|
npm run test || {
|
||||||
|
error "Tests failed. Please fix the issues before deploying."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
success "✅ Code quality checks passed"
|
||||||
|
|
||||||
|
# Step 2: Build Application
|
||||||
|
log "🔨 Step 2: Building application..."
|
||||||
|
|
||||||
|
# Build Next.js application
|
||||||
|
log "📦 Building Next.js application..."
|
||||||
|
npm run build || {
|
||||||
|
error "Build failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
success "✅ Application built successfully"
|
||||||
|
|
||||||
|
# Step 3: Docker Operations
|
||||||
|
log "🐳 Step 3: Docker operations..."
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
log "🏗️ Building Docker image..."
|
||||||
|
docker build -t "$IMAGE_NAME:latest" . || {
|
||||||
|
error "Docker build failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tag with timestamp
|
||||||
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
docker tag "$IMAGE_NAME:latest" "$IMAGE_NAME:$TIMESTAMP"
|
||||||
|
|
||||||
|
success "✅ Docker image built successfully"
|
||||||
|
|
||||||
|
# Step 4: Deployment
|
||||||
|
log "🚀 Step 4: Deploying application..."
|
||||||
|
|
||||||
|
# Check if container is running
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" = "true" ]; then
|
||||||
|
log "📦 Stopping existing container..."
|
||||||
|
docker stop "$CONTAINER_NAME" || true
|
||||||
|
docker rm "$CONTAINER_NAME" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if port is available
|
||||||
|
if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null ; then
|
||||||
|
warning "Port $PORT is in use. Trying backup port $BACKUP_PORT"
|
||||||
|
DEPLOY_PORT=$BACKUP_PORT
|
||||||
|
else
|
||||||
|
DEPLOY_PORT=$PORT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start new container
|
||||||
|
log "🚀 Starting new container on port $DEPLOY_PORT..."
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p "$DEPLOY_PORT:3000" \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
"$IMAGE_NAME:latest" || {
|
||||||
|
error "Failed to start container"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait for container to be ready
|
||||||
|
log "⏳ Waiting for container to be ready..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
log "🏥 Performing health check..."
|
||||||
|
HEALTH_CHECK_TIMEOUT=60
|
||||||
|
HEALTH_CHECK_INTERVAL=2
|
||||||
|
ELAPSED=0
|
||||||
|
|
||||||
|
while [ $ELAPSED -lt $HEALTH_CHECK_TIMEOUT ]; do
|
||||||
|
if curl -f "http://localhost:$DEPLOY_PORT/api/health" > /dev/null 2>&1; then
|
||||||
|
success "✅ Application is healthy!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep $HEALTH_CHECK_INTERVAL
|
||||||
|
ELAPSED=$((ELAPSED + HEALTH_CHECK_INTERVAL))
|
||||||
|
echo -n "."
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $ELAPSED -ge $HEALTH_CHECK_TIMEOUT ]; then
|
||||||
|
error "Health check timeout. Application may not be running properly."
|
||||||
|
log "Container logs:"
|
||||||
|
docker logs "$CONTAINER_NAME" --tail=50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 5: Verification
|
||||||
|
log "✅ Step 5: Verifying deployment..."
|
||||||
|
|
||||||
|
# Test main page
|
||||||
|
if curl -f "http://localhost:$DEPLOY_PORT/" > /dev/null 2>&1; then
|
||||||
|
success "✅ Main page is accessible"
|
||||||
|
else
|
||||||
|
error "❌ Main page is not accessible"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show container status
|
||||||
|
log "📊 Container status:"
|
||||||
|
docker ps --filter "name=$CONTAINER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
|
||||||
|
# Show resource usage
|
||||||
|
log "📈 Resource usage:"
|
||||||
|
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}" "$CONTAINER_NAME"
|
||||||
|
|
||||||
|
# Step 6: Cleanup
|
||||||
|
log "🧹 Step 6: Cleaning up old images..."
|
||||||
|
|
||||||
|
# Remove old images (keep last 3 versions)
|
||||||
|
docker images "$IMAGE_NAME" --format "table {{.Tag}}\t{{.ID}}" | tail -n +2 | head -n -3 | awk '{print $2}' | xargs -r docker rmi || {
|
||||||
|
warning "No old images to remove"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up unused Docker resources
|
||||||
|
docker system prune -f --volumes || {
|
||||||
|
warning "Failed to clean up Docker resources"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Final success message
|
||||||
|
success "🎉 Deployment completed successfully!"
|
||||||
|
log "🌐 Application is available at: http://localhost:$DEPLOY_PORT"
|
||||||
|
log "🏥 Health check endpoint: http://localhost:$DEPLOY_PORT/api/health"
|
||||||
|
log "📊 Container name: $CONTAINER_NAME"
|
||||||
|
log "📝 Logs: docker logs $CONTAINER_NAME"
|
||||||
|
|
||||||
|
# Update deployment log
|
||||||
|
echo "$(date): Deployment successful - Port: $DEPLOY_PORT - Image: $IMAGE_NAME:$TIMESTAMP" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
exit 0
|
||||||
160
scripts/deploy.sh
Executable file
160
scripts/deploy.sh
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Portfolio Deployment Script
|
||||||
|
# Usage: ./scripts/deploy.sh [environment]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
ENVIRONMENT=${1:-production}
|
||||||
|
REGISTRY="ghcr.io"
|
||||||
|
IMAGE_NAME="dennis-konkol/my_portfolio"
|
||||||
|
CONTAINER_NAME="portfolio-app"
|
||||||
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging function
|
||||||
|
log() {
|
||||||
|
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [[ $EUID -eq 0 ]]; then
|
||||||
|
error "This script should not be run as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if Docker is running
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
error "Docker is not running. Please start Docker and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if docker-compose is available
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
error "docker-compose is not installed. Please install docker-compose and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if .env file exists
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
error ".env file not found. Please create it with the required environment variables."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Starting deployment for environment: $ENVIRONMENT"
|
||||||
|
|
||||||
|
# Set image tag based on environment
|
||||||
|
if [ "$ENVIRONMENT" = "production" ]; then
|
||||||
|
IMAGE_TAG="production"
|
||||||
|
else
|
||||||
|
IMAGE_TAG="main"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$IMAGE_TAG"
|
||||||
|
|
||||||
|
log "Using image: $FULL_IMAGE_NAME"
|
||||||
|
|
||||||
|
# Login to registry (if needed)
|
||||||
|
log "Logging in to container registry..."
|
||||||
|
echo "$GITHUB_TOKEN" | docker login $REGISTRY -u $GITHUB_ACTOR --password-stdin || {
|
||||||
|
warning "Failed to login to registry. Make sure GITHUB_TOKEN and GITHUB_ACTOR are set."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pull latest image
|
||||||
|
log "Pulling latest image..."
|
||||||
|
docker pull $FULL_IMAGE_NAME || {
|
||||||
|
error "Failed to pull image $FULL_IMAGE_NAME"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop and remove old containers
|
||||||
|
log "Stopping old containers..."
|
||||||
|
docker-compose -f $COMPOSE_FILE down || {
|
||||||
|
warning "No old containers to stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove old images (keep last 3 versions)
|
||||||
|
log "Cleaning up old images..."
|
||||||
|
docker images $REGISTRY/$IMAGE_NAME --format "table {{.Tag}}\t{{.ID}}" | tail -n +2 | head -n -3 | awk '{print $2}' | xargs -r docker rmi || {
|
||||||
|
warning "No old images to remove"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start new containers
|
||||||
|
log "Starting new containers..."
|
||||||
|
docker-compose -f $COMPOSE_FILE up -d || {
|
||||||
|
error "Failed to start containers"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait for health check
|
||||||
|
log "Waiting for application to be healthy..."
|
||||||
|
HEALTH_CHECK_TIMEOUT=60
|
||||||
|
HEALTH_CHECK_INTERVAL=2
|
||||||
|
ELAPSED=0
|
||||||
|
|
||||||
|
while [ $ELAPSED -lt $HEALTH_CHECK_TIMEOUT ]; do
|
||||||
|
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||||
|
success "Application is healthy!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep $HEALTH_CHECK_INTERVAL
|
||||||
|
ELAPSED=$((ELAPSED + HEALTH_CHECK_INTERVAL))
|
||||||
|
echo -n "."
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $ELAPSED -ge $HEALTH_CHECK_TIMEOUT ]; then
|
||||||
|
error "Health check timeout. Application may not be running properly."
|
||||||
|
log "Container logs:"
|
||||||
|
docker-compose -f $COMPOSE_FILE logs --tail=50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify deployment
|
||||||
|
log "Verifying deployment..."
|
||||||
|
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||||
|
success "Deployment successful!"
|
||||||
|
|
||||||
|
# Show container status
|
||||||
|
log "Container status:"
|
||||||
|
docker-compose -f $COMPOSE_FILE ps
|
||||||
|
|
||||||
|
# Show resource usage
|
||||||
|
log "Resource usage:"
|
||||||
|
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}"
|
||||||
|
|
||||||
|
else
|
||||||
|
error "Deployment verification failed!"
|
||||||
|
log "Container logs:"
|
||||||
|
docker-compose -f $COMPOSE_FILE logs --tail=50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
log "Cleaning up unused Docker resources..."
|
||||||
|
docker system prune -f --volumes || {
|
||||||
|
warning "Failed to clean up Docker resources"
|
||||||
|
}
|
||||||
|
|
||||||
|
success "Deployment completed successfully!"
|
||||||
|
log "Application is available at: http://localhost:3000"
|
||||||
|
log "Health check endpoint: http://localhost:3000/api/health"
|
||||||
167
scripts/monitor.sh
Executable file
167
scripts/monitor.sh
Executable file
@@ -0,0 +1,167 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Portfolio Monitoring Script
|
||||||
|
# Usage: ./scripts/monitor.sh [action]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONTAINER_NAME="portfolio-app"
|
||||||
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging function
|
||||||
|
log() {
|
||||||
|
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check container health
|
||||||
|
check_health() {
|
||||||
|
log "Checking application health..."
|
||||||
|
|
||||||
|
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||||
|
success "Application is healthy"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
error "Application is unhealthy"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show container status
|
||||||
|
show_status() {
|
||||||
|
log "Container status:"
|
||||||
|
docker-compose -f $COMPOSE_FILE ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log "Resource usage:"
|
||||||
|
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log "Container logs (last 20 lines):"
|
||||||
|
docker-compose -f $COMPOSE_FILE logs --tail=20
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show detailed metrics
|
||||||
|
show_metrics() {
|
||||||
|
log "Detailed metrics:"
|
||||||
|
|
||||||
|
# Container info
|
||||||
|
echo "=== Container Information ==="
|
||||||
|
docker inspect $CONTAINER_NAME --format='{{.State.Status}} - {{.State.StartedAt}}' 2>/dev/null || echo "Container not found"
|
||||||
|
|
||||||
|
# Memory usage
|
||||||
|
echo ""
|
||||||
|
echo "=== Memory Usage ==="
|
||||||
|
docker stats --no-stream --format "{{.MemUsage}}" $CONTAINER_NAME 2>/dev/null || echo "Container not running"
|
||||||
|
|
||||||
|
# CPU usage
|
||||||
|
echo ""
|
||||||
|
echo "=== CPU Usage ==="
|
||||||
|
docker stats --no-stream --format "{{.CPUPerc}}" $CONTAINER_NAME 2>/dev/null || echo "Container not running"
|
||||||
|
|
||||||
|
# Network usage
|
||||||
|
echo ""
|
||||||
|
echo "=== Network Usage ==="
|
||||||
|
docker stats --no-stream --format "{{.NetIO}}" $CONTAINER_NAME 2>/dev/null || echo "Container not running"
|
||||||
|
|
||||||
|
# Disk usage
|
||||||
|
echo ""
|
||||||
|
echo "=== Disk Usage ==="
|
||||||
|
docker system df
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restart container
|
||||||
|
restart_container() {
|
||||||
|
log "Restarting container..."
|
||||||
|
docker-compose -f $COMPOSE_FILE restart
|
||||||
|
|
||||||
|
# Wait for health check
|
||||||
|
log "Waiting for container to be healthy..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
if check_health; then
|
||||||
|
success "Container restarted successfully"
|
||||||
|
else
|
||||||
|
error "Container restart failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show logs
|
||||||
|
show_logs() {
|
||||||
|
local lines=${1:-50}
|
||||||
|
log "Showing last $lines lines of logs:"
|
||||||
|
docker-compose -f $COMPOSE_FILE logs --tail=$lines -f
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
cleanup() {
|
||||||
|
log "Cleaning up Docker resources..."
|
||||||
|
|
||||||
|
# Remove unused containers
|
||||||
|
docker container prune -f
|
||||||
|
|
||||||
|
# Remove unused images
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
# Remove unused volumes
|
||||||
|
docker volume prune -f
|
||||||
|
|
||||||
|
# Remove unused networks
|
||||||
|
docker network prune -f
|
||||||
|
|
||||||
|
success "Cleanup completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main script logic
|
||||||
|
case "${1:-status}" in
|
||||||
|
"health")
|
||||||
|
check_health
|
||||||
|
;;
|
||||||
|
"status")
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
"metrics")
|
||||||
|
show_metrics
|
||||||
|
;;
|
||||||
|
"restart")
|
||||||
|
restart_container
|
||||||
|
;;
|
||||||
|
"logs")
|
||||||
|
show_logs $2
|
||||||
|
;;
|
||||||
|
"cleanup")
|
||||||
|
cleanup
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {health|status|metrics|restart|logs|cleanup}"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " health - Check application health"
|
||||||
|
echo " status - Show container status and resource usage"
|
||||||
|
echo " metrics - Show detailed metrics"
|
||||||
|
echo " restart - Restart the container"
|
||||||
|
echo " logs - Show container logs (optional: number of lines)"
|
||||||
|
echo " cleanup - Clean up unused Docker resources"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
63
scripts/quick-deploy.sh
Executable file
63
scripts/quick-deploy.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Quick Deploy Script für lokale Entwicklung
|
||||||
|
# Schnelles Deployment ohne umfangreiche Tests
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONTAINER_NAME="portfolio-app"
|
||||||
|
IMAGE_NAME="portfolio-app"
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "${BLUE}[$(date +'%H:%M:%S')]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "🚀 Quick deployment starting..."
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
log "🏗️ Building Docker image..."
|
||||||
|
docker build -t "$IMAGE_NAME:latest" .
|
||||||
|
|
||||||
|
# Stop existing container
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" = "true" ]; then
|
||||||
|
log "🛑 Stopping existing container..."
|
||||||
|
docker stop "$CONTAINER_NAME"
|
||||||
|
docker rm "$CONTAINER_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start new container
|
||||||
|
log "🚀 Starting new container..."
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p "$PORT:3000" \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
"$IMAGE_NAME:latest"
|
||||||
|
|
||||||
|
# Wait and check health
|
||||||
|
log "⏳ Waiting for container to be ready..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
if curl -f "http://localhost:$PORT/api/health" > /dev/null 2>&1; then
|
||||||
|
success "✅ Application is running at http://localhost:$PORT"
|
||||||
|
else
|
||||||
|
error "❌ Health check failed"
|
||||||
|
docker logs "$CONTAINER_NAME" --tail=20
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user