diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..6817593 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,146 @@ +name: Build & Deploy Mintel Blog + +on: + push: + branches: [main] + +jobs: + build-and-deploy: + runs-on: docker + + steps: + - name: 📋 Log Workflow Start + run: | + echo "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})" + echo " • Commit: ${{ github.sha }}" + echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: 🔐 Login to private registry + run: | + echo "🔐 Authenticating with registry.infra.mintel.me..." + echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: 🏗️ Build Docker image + run: | + echo "🏗️ Building Docker image (linux/arm64)..." + docker buildx build \ + --pull \ + --platform linux/arm64 \ + --build-arg NEXT_PUBLIC_ANALYTICS_PROVIDER="${{ secrets.NEXT_PUBLIC_ANALYTICS_PROVIDER }}" \ + --build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \ + --build-arg NEXT_PUBLIC_UMAMI_HOST_URL="${{ secrets.NEXT_PUBLIC_UMAMI_HOST_URL }}" \ + --build-arg NEXT_PUBLIC_PLAUSIBLE_DOMAIN="${{ secrets.NEXT_PUBLIC_PLAUSIBLE_DOMAIN }}" \ + --build-arg NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL }}" \ + --build-arg NEXT_PUBLIC_GLITCHTIP_DSN="${{ secrets.NEXT_PUBLIC_GLITCHTIP_DSN }}" \ + -t registry.infra.mintel.me/mintel/mintel.me:latest \ + --push -f docker/Dockerfile . + + - name: 🚀 Deploy to production server + run: | + echo "🚀 Deploying to alpha.mintel.me..." + + # Setup SSH + mkdir -p ~/.ssh + echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null + + # Create .env file content + cat > /tmp/mintel.me.env << EOF + # ============================================================================ + # Mintel Blog - Production Environment Configuration + # ============================================================================ + # Auto-generated by CI/CD workflow + # ============================================================================ + + # Application + NODE_ENV=production + DOMAIN=mintel.me + ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }} + + # Analytics + NEXT_PUBLIC_ANALYTICS_PROVIDER=${{ secrets.NEXT_PUBLIC_ANALYTICS_PROVIDER }} + NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }} + NEXT_PUBLIC_UMAMI_HOST_URL=${{ secrets.NEXT_PUBLIC_UMAMI_HOST_URL }} + NEXT_PUBLIC_PLAUSIBLE_DOMAIN=${{ secrets.NEXT_PUBLIC_PLAUSIBLE_DOMAIN }} + NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL }} + + # Error Tracking (GlitchTip/Sentry) + NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.NEXT_PUBLIC_GLITCHTIP_DSN }} + + # Redis + REDIS_URL=redis://redis:6379 + + EOF + + # Upload .env and deploy + scp -o StrictHostKeyChecking=accept-new /tmp/mintel.me.env root@alpha.mintel.me:/home/deploy/sites/mintel.me/.env + + ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << EOF + set -e + cd /home/deploy/sites/mintel.me + + chmod 600 .env + chown deploy:deploy .env + + echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin + docker pull registry.infra.mintel.me/mintel/mintel.me:latest + + docker-compose down + + echo "🚀 Starting containers..." + docker-compose up -d + + echo "⏳ Giving the app a few seconds to warm up..." + sleep 10 + + echo "🔍 Checking container status..." + docker-compose ps + + if ! docker-compose ps | grep -q "Up"; then + echo "❌ Container failed to start" + docker-compose logs --tail=100 + exit 1 + fi + + echo "✅ Deployment complete!" + EOF + + rm -f /tmp/mintel.me.env + + - name: 📊 Workflow Summary + if: always() + run: | + echo "📊 Status: ${{ job.status }}" + echo "🎯 Target: alpha.mintel.me" + + - name: 🔔 Gotify Notification (Success) + if: success() + run: | + echo "Sending success notification to Gotify..." + curl -k -s -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \ + -F "title=✅ Deployment Success: ${{ github.repository }}" \ + -F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) was successful. + + Commit: ${{ github.sha }} + Actor: ${{ github.actor }} + Run ID: ${{ github.run_id }}" \ + -F "priority=5" + + - name: 🔔 Gotify Notification (Failure) + if: failure() + run: | + echo "Sending failure notification to Gotify..." + curl -k -s -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \ + -F "title=❌ Deployment Failed: ${{ github.repository }}" \ + -F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) failed! + + Commit: ${{ github.sha }} + Actor: ${{ github.actor }} + Run ID: ${{ github.run_id }} + + Please check the logs for details." \ + -F "priority=8" diff --git a/docker-compose.yml b/docker-compose.yml index b27cfac..061d642 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,35 +1,41 @@ version: '3.8' services: - # Main website - Nginx serving static Astro build + # Main website - Next.js standalone website: build: context: . dockerfile: docker/Dockerfile + args: + - NEXT_PUBLIC_ANALYTICS_PROVIDER=${NEXT_PUBLIC_ANALYTICS_PROVIDER} + - NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID} + - NEXT_PUBLIC_UMAMI_HOST_URL=${NEXT_PUBLIC_UMAMI_HOST_URL} + - NEXT_PUBLIC_PLAUSIBLE_DOMAIN=${NEXT_PUBLIC_PLAUSIBLE_DOMAIN} + - NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL=${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL} + - NEXT_PUBLIC_GLITCHTIP_DSN=${NEXT_PUBLIC_GLITCHTIP_DSN} container_name: mintel-website restart: unless-stopped ports: - - "8080:80" + - "3000:3000" environment: - - NGINX_HOST=${DOMAIN:-localhost} + - NODE_ENV=production - REDIS_URL=redis://redis:6379 + - NEXT_PUBLIC_ANALYTICS_PROVIDER=${NEXT_PUBLIC_ANALYTICS_PROVIDER} + - NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID} + - NEXT_PUBLIC_UMAMI_HOST_URL=${NEXT_PUBLIC_UMAMI_HOST_URL} + - NEXT_PUBLIC_PLAUSIBLE_DOMAIN=${NEXT_PUBLIC_PLAUSIBLE_DOMAIN} + - NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL=${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL} + - NEXT_PUBLIC_GLITCHTIP_DSN=${NEXT_PUBLIC_GLITCHTIP_DSN} depends_on: - redis networks: - app-network - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost/health"] - interval: 30s - timeout: 10s - retries: 3 # Redis cache for performance redis: image: redis:7-alpine container_name: mintel-redis restart: unless-stopped - ports: - - "6379:6379" volumes: - redis-data:/data command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru @@ -61,23 +67,6 @@ services: networks: - app-network - # Plausible Analytics (if you want to run it alongside) - # Uncomment if you need to spin up a new Plausible instance - # plausible: - # image: plausible/analytics:v2.0 - # container_name: mintel-plausible - # restart: unless-stopped - # ports: - # - "8081:8000" - # environment: - # - BASE_URL=https://analytics.${DOMAIN} - # - SECRET_KEY_BASE=${PLAUSIBLE_SECRET} - # depends_on: - # - postgres - # - clickhouse - # networks: - # - app-network - volumes: redis-data: caddy-data: @@ -85,4 +74,4 @@ volumes: networks: app-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docker/Dockerfile b/docker/Dockerfile index a91ef59..bc9d7f5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,34 +1,63 @@ -# Multi-stage build for optimized Astro static site -FROM node:22-alpine AS builder +# Multi-stage build for Next.js +FROM node:22-alpine AS base +# 1. Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat WORKDIR /app -# Copy package files -COPY package*.json ./ +# Install dependencies based on the preferred package manager +COPY package.json package-lock.json* ./ +RUN npm ci -# Install dependencies -RUN npm ci --only=production - -# Copy source code +# 2. Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules COPY . . -# Build the Astro site +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED 1 + +# Build arguments for environment variables needed at build time +ARG NEXT_PUBLIC_ANALYTICS_PROVIDER +ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID +ARG NEXT_PUBLIC_UMAMI_HOST_URL +ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN +ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL +ARG NEXT_PUBLIC_GLITCHTIP_DSN + RUN npm run build -# Production stage - Nginx for serving static files -FROM nginx:alpine +# 3. Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app -# Remove default nginx static assets -RUN rm -rf /usr/share/nginx/html/* +ENV NODE_ENV production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED 1 -# Copy built assets from builder stage -COPY --from=builder /app/dist /usr/share/nginx/html +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs -# Copy custom nginx config -COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/public ./public -# Expose port -EXPOSE 80 +# Set the correct permission for prune cache +RUN mkdir .next +RUN chown nextjs:nodejs .next -# Start nginx -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +# 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 + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +ENV HOSTNAME "0.0.0.0" + +CMD ["node", "server.js"] diff --git a/next.config.mjs b/next.config.mjs index d5456a1..b18fe65 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + output: 'standalone', }; export default nextConfig; diff --git a/src/components/Analytics.tsx b/src/components/Analytics.tsx index 16d51ff..9b41c75 100644 --- a/src/components/Analytics.tsx +++ b/src/components/Analytics.tsx @@ -1,22 +1,13 @@ 'use client'; -import React, { useEffect } from 'react'; -import { createPlausibleAnalytics } from '../utils/analytics'; +import { useEffect } from 'react'; +import { getDefaultAnalytics } from '../utils/analytics'; +import { getDefaultErrorTracking } from '../utils/error-tracking'; -interface AnalyticsProps { - domain?: string; - scriptUrl?: string; -} - -export const Analytics: React.FC = ({ - domain = 'mintel.me', - scriptUrl = 'https://plausible.yourdomain.com/js/script.js' -}) => { +export const Analytics: React.FC = () => { useEffect(() => { - const analytics = createPlausibleAnalytics({ - domain: document.documentElement.lang || domain, - scriptUrl - }); + const analytics = getDefaultAnalytics(); + const errorTracking = getDefaultErrorTracking(); // Track page load performance const trackPageLoad = () => { @@ -58,14 +49,41 @@ export const Analytics: React.FC = ({ } }; + // Global error handler for error tracking + const handleGlobalError = (event: ErrorEvent) => { + errorTracking.captureException(event.error || event.message); + }; + + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + errorTracking.captureException(event.reason); + }; + + window.addEventListener('error', handleGlobalError); + window.addEventListener('unhandledrejection', handleUnhandledRejection); + trackPageLoad(); trackOutboundLinks(); const cleanupSearch = trackSearch(); return () => { if (cleanupSearch) cleanupSearch(); + window.removeEventListener('error', handleGlobalError); + window.removeEventListener('unhandledrejection', handleUnhandledRejection); }; - }, [domain, scriptUrl]); + }, []); - return null; + const analytics = getDefaultAnalytics(); + const adapter = analytics.getAdapter(); + const scriptTag = adapter.getScriptTag ? adapter.getScriptTag() : null; + + if (!scriptTag) return null; + + // We use dangerouslySetInnerHTML to inject the script tag from the adapter + // This is safe here because the script URLs and IDs come from our own config/env + return ( +
+ ); }; diff --git a/src/utils/analytics/index.ts b/src/utils/analytics/index.ts index 0ba2197..08ad158 100644 --- a/src/utils/analytics/index.ts +++ b/src/utils/analytics/index.ts @@ -5,6 +5,7 @@ import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces'; import { PlausibleAdapter } from './plausible-adapter'; +import { UmamiAdapter, type UmamiConfig } from './umami-adapter'; export class AnalyticsService { private adapter: AnalyticsAdapter; @@ -65,20 +66,33 @@ export class AnalyticsService { } } -// Factory function +// Factory functions export function createPlausibleAnalytics(config: AnalyticsConfig): AnalyticsService { return new AnalyticsService(new PlausibleAdapter(config)); } +export function createUmamiAnalytics(config: UmamiConfig): AnalyticsService { + return new AnalyticsService(new UmamiAdapter(config)); +} + // Default singleton let defaultAnalytics: AnalyticsService | null = null; export function getDefaultAnalytics(): AnalyticsService { if (!defaultAnalytics) { - defaultAnalytics = createPlausibleAnalytics({ - domain: 'mintel.me', - scriptUrl: 'https://plausible.yourdomain.com/js/script.js' - }); + const provider = process.env.NEXT_PUBLIC_ANALYTICS_PROVIDER || 'plausible'; + + if (provider === 'umami') { + defaultAnalytics = createUmamiAnalytics({ + websiteId: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || '', + hostUrl: process.env.NEXT_PUBLIC_UMAMI_HOST_URL, + }); + } else { + defaultAnalytics = createPlausibleAnalytics({ + domain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'mintel.me', + scriptUrl: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL || 'https://plausible.yourdomain.com/js/script.js' + }); + } } return defaultAnalytics; } diff --git a/src/utils/analytics/umami-adapter.ts b/src/utils/analytics/umami-adapter.ts new file mode 100644 index 0000000..0e24473 --- /dev/null +++ b/src/utils/analytics/umami-adapter.ts @@ -0,0 +1,54 @@ +/** + * Umami Analytics Adapter + * Decoupled implementation + */ + +import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces'; + +export interface UmamiConfig extends AnalyticsConfig { + websiteId: string; + hostUrl?: string; +} + +export class UmamiAdapter implements AnalyticsAdapter { + private websiteId: string; + private hostUrl: string; + + constructor(config: UmamiConfig) { + this.websiteId = config.websiteId; + this.hostUrl = config.hostUrl || 'https://cloud.umami.is'; + } + + async track(event: AnalyticsEvent): Promise { + if (typeof window === 'undefined') return; + + const w = window as any; + if (w.umami) { + w.umami.track(event.name, event.props); + } + } + + async page(path: string, props?: Record): Promise { + // Umami tracks pageviews automatically by default, + // but we can manually trigger it if needed. + if (typeof window === 'undefined') return; + + const w = window as any; + if (w.umami) { + w.umami.track(props?.name || 'pageview', { url: path, ...props }); + } + } + + async identify(userId: string, traits?: Record): Promise { + // Umami doesn't have a direct 'identify' like Segment, + // but we can track it as an event or session property if supported by the instance. + await this.track({ + name: 'identify', + props: { userId, ...traits } + }); + } + + getScriptTag(): string { + return ``; + } +} diff --git a/src/utils/error-tracking/glitchtip-adapter.ts b/src/utils/error-tracking/glitchtip-adapter.ts new file mode 100644 index 0000000..7b5ee09 --- /dev/null +++ b/src/utils/error-tracking/glitchtip-adapter.ts @@ -0,0 +1,76 @@ +/** + * GlitchTip Error Tracking Adapter + * GlitchTip is Sentry-compatible. + */ + +import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces'; + +export class GlitchTipAdapter implements ErrorTrackingAdapter { + private dsn: string; + + constructor(config: ErrorTrackingConfig) { + this.dsn = config.dsn; + this.init(config); + } + + private init(config: ErrorTrackingConfig) { + if (typeof window === 'undefined') return; + + // In a real scenario, we would import @sentry/nextjs or @sentry/browser + // For this implementation, we assume Sentry is available globally or + // we provide the structure that would call the SDK. + const w = window as any; + if (w.Sentry) { + w.Sentry.init({ + dsn: this.dsn, + environment: config.environment || 'production', + release: config.release, + debug: config.debug || false, + }); + } + } + + captureException(error: any, context?: ErrorContext): void { + if (typeof window === 'undefined') return; + const w = window as any; + if (w.Sentry) { + w.Sentry.captureException(error, context); + } else { + console.error('[GlitchTip] Exception captured (Sentry not loaded):', error, context); + } + } + + captureMessage(message: string, context?: ErrorContext): void { + if (typeof window === 'undefined') return; + const w = window as any; + if (w.Sentry) { + w.Sentry.captureMessage(message, context); + } else { + console.log('[GlitchTip] Message captured (Sentry not loaded):', message, context); + } + } + + setUser(user: ErrorContext['user']): void { + if (typeof window === 'undefined') return; + const w = window as any; + if (w.Sentry) { + w.Sentry.setUser(user); + } + } + + setTag(key: string, value: string): void { + if (typeof window === 'undefined') return; + const w = window as any; + if (w.Sentry) { + w.Sentry.setTag(key, value); + } + } + + setExtra(key: string, value: any): void { + if (typeof window === 'undefined') return; + const w = window as any; + if (w.Sentry) { + w.Sentry.setExtra(key, value); + } + } +} diff --git a/src/utils/error-tracking/index.ts b/src/utils/error-tracking/index.ts new file mode 100644 index 0000000..128533d --- /dev/null +++ b/src/utils/error-tracking/index.ts @@ -0,0 +1,61 @@ +/** + * Error Tracking Service - Main entry point with DI + */ + +import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces'; +import { GlitchTipAdapter } from './glitchtip-adapter'; + +export class ErrorTrackingService { + private adapter: ErrorTrackingAdapter; + + constructor(adapter: ErrorTrackingAdapter) { + this.adapter = adapter; + } + + captureException(error: any, context?: ErrorContext): void { + this.adapter.captureException(error, context); + } + + captureMessage(message: string, context?: ErrorContext): void { + this.adapter.captureMessage(message, context); + } + + setUser(user: ErrorContext['user']): void { + this.adapter.setUser(user); + } + + setTag(key: string, value: string): void { + this.adapter.setTag(key, value); + } + + setExtra(key: string, value: any): void { + this.adapter.setExtra(key, value); + } +} + +// Factory function +export function createGlitchTipErrorTracking(config: ErrorTrackingConfig): ErrorTrackingService { + return new ErrorTrackingService(new GlitchTipAdapter(config)); +} + +// Default singleton +let defaultErrorTracking: ErrorTrackingService | null = null; + +export function getDefaultErrorTracking(): ErrorTrackingService { + if (!defaultErrorTracking) { + defaultErrorTracking = createGlitchTipErrorTracking({ + dsn: process.env.NEXT_PUBLIC_GLITCHTIP_DSN || '', + environment: process.env.NODE_ENV, + }); + } + return defaultErrorTracking; +} + +// Convenience functions +export function captureException(error: any, context?: ErrorContext): void { + getDefaultErrorTracking().captureException(error, context); +} + +export function captureMessage(message: string, context?: ErrorContext): void { + getDefaultErrorTracking().captureMessage(message, context); +} diff --git a/src/utils/error-tracking/interfaces.ts b/src/utils/error-tracking/interfaces.ts new file mode 100644 index 0000000..b0030e6 --- /dev/null +++ b/src/utils/error-tracking/interfaces.ts @@ -0,0 +1,29 @@ +/** + * Error Tracking interfaces - decoupled contracts + */ + +export interface ErrorContext { + extra?: Record; + tags?: Record; + user?: { + id?: string; + email?: string; + username?: string; + }; + level?: 'fatal' | 'error' | 'warning' | 'info' | 'debug'; +} + +export interface ErrorTrackingAdapter { + captureException(error: any, context?: ErrorContext): void; + captureMessage(message: string, context?: ErrorContext): void; + setUser(user: ErrorContext['user']): void; + setTag(key: string, value: string): void; + setExtra(key: string, value: any): void; +} + +export interface ErrorTrackingConfig { + dsn: string; + environment?: string; + release?: string; + debug?: boolean; +}