diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8fe6bd3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +npm-debug.log +.next +out +.env +.env.* +docker-compose.yml +.git +.gitignore +coverage +.idea +.vscode +ops/backups diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3ab8a10 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Application +APP_HOST=0.0.0.0 +APP_PORT=3000 +NEXT_PUBLIC_APP_URL=https://nicebaby.local +NODE_ENV=production + +# Database +POSTGRES_DB=nicebaby +POSTGRES_USER=nicebaby +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +DATABASE_URL=postgresql://${POSTGRES_USER}:\${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + +# Redis / queue +REDIS_URL=redis://redis:6379 +WORKER_HEARTBEAT_INTERVAL=60000 +SCHEDULE_INTERVAL=3600000 + +# Observability +GRAFANA_ADMIN_USER=admin +LOKI_RETENTION_DAYS=7 + +# Backups +RESTIC_REPOSITORY=s3:s3.amazonaws.com/your-bucket/nicebaby +RESTIC_BACKUP_CRON=0 */6 * * * +RESTIC_KEEP_DAILY=7 +RESTIC_KEEP_WEEKLY=4 +RESTIC_KEEP_MONTHLY=6 + +# Reverse proxy +CADDY_EMAIL=admin@nicebaby.local +CADDY_DOMAIN=nicebaby.local diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..4748874 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,47 @@ +name: Build Docker Images + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + strategy: + matrix: + image: + - { name: app, dockerfile: Dockerfile.app } + - { name: worker, dockerfile: Dockerfile.worker } + - { name: scheduler, dockerfile: Dockerfile.job } + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to registry + if: ${{ secrets.REGISTRY != '' && secrets.REGISTRY_USERNAME != '' && secrets.REGISTRY_PASSWORD != '' }} + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build image + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ matrix.image.dockerfile }} + push: ${{ secrets.REGISTRY != '' && secrets.REGISTRY_USERNAME != '' && secrets.REGISTRY_PASSWORD != '' }} + tags: | + ${{ secrets.REGISTRY || 'ghcr.io/example' }}/${{ github.repository_owner }}/nicebaby-${{ matrix.image.name }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + NODE_VERSION=20.12.2 diff --git a/.gitignore b/.gitignore index d714a78..a539c3d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ # vscode .vscode + +# docker secrets +docker/secrets/* diff --git a/Dockerfile.app b/Dockerfile.app new file mode 100644 index 0000000..4748544 --- /dev/null +++ b/Dockerfile.app @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.6 + +ARG NODE_VERSION=20.12.2 +FROM node:${NODE_VERSION}-alpine AS base + +RUN apk add --no-cache libc6-compat bash wget +WORKDIR /app + +FROM base AS deps +COPY package.json package-lock.json* pnpm-lock.yaml* bun.lockb* ./ +RUN \ + if [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install --frozen-lockfile; \ + elif [ -f bun.lockb ]; then npm install -g bun && bun install --frozen-lockfile; \ + else npm install; fi + +FROM base AS builder +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM base AS runner +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup -g 1001 -S nodejs \ + && adduser -S nextjs -G nodejs + +WORKDIR /app +COPY --from=builder /app/next.config.mjs ./ +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1 + +CMD ["node", "server.js"] diff --git a/Dockerfile.job b/Dockerfile.job new file mode 100644 index 0000000..a762643 --- /dev/null +++ b/Dockerfile.job @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1.6 + +ARG NODE_VERSION=20.12.2 +FROM node:${NODE_VERSION}-alpine AS base +RUN apk add --no-cache libc6-compat bash +WORKDIR /app + +FROM base AS deps +COPY package.json package-lock.json* pnpm-lock.yaml* bun.lockb* ./ +RUN \ + if [ -f package-lock.json ]; then npm ci --omit=dev; \ + elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install --frozen-lockfile --prod; \ + elif [ -f bun.lockb ]; then npm install -g bun && bun install --frozen-lockfile --production; \ + else npm install --omit=dev; fi + +FROM base AS runner +ENV NODE_ENV=production +RUN addgroup -g 1001 -S nodejs \ + && adduser -S scheduler -G nodejs + +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +COPY scripts ./scripts + +USER scheduler +CMD ["node", "scripts/scheduled-job.js"] diff --git a/Dockerfile.worker b/Dockerfile.worker new file mode 100644 index 0000000..5cbb603 --- /dev/null +++ b/Dockerfile.worker @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1.6 + +ARG NODE_VERSION=20.12.2 +FROM node:${NODE_VERSION}-alpine AS base +RUN apk add --no-cache libc6-compat bash +WORKDIR /app + +FROM base AS deps +COPY package.json package-lock.json* pnpm-lock.yaml* bun.lockb* ./ +RUN \ + if [ -f package-lock.json ]; then npm ci --omit=dev; \ + elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install --frozen-lockfile --prod; \ + elif [ -f bun.lockb ]; then npm install -g bun && bun install --frozen-lockfile --production; \ + else npm install --omit=dev; fi + +FROM base AS runner +ENV NODE_ENV=production +RUN addgroup -g 1001 -S nodejs \ + && adduser -S worker -G nodejs + +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +COPY scripts ./scripts + +USER worker +CMD ["node", "scripts/worker.js"] diff --git a/README.md b/README.md index e0fa4b8..7a3be6b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # NiceBaby.io A self-hosted newborn companion built with Next.js 14. This project scaffolds the front-end experience for tracking feeds, -diapers, sleep, growth, medical appointments, and curated guidance for new parents. It also outlines the surrounding -architecture for API services, data modeling, and Docker-based deployment. +diapers, sleep, growth, medical appointments, and curated guidance for new parents. It now ships with production-grade +Docker images, observability, and backup automation for home-lab and small scale deployments. -## Getting Started +## Getting Started (Local Development) ```bash npm install @@ -13,102 +13,15 @@ npm run dev Open http://localhost:3000 in your browser to explore the product blueprint. -## Environment Variables -Create a `.env` file (or export the variables) before running the application: - -```bash -DATABASE_URL="postgresql://user:password@localhost:5432/nicebaby" -NEXTAUTH_SECRET="$(openssl rand -hex 32)" -NEXTAUTH_URL="http://localhost:3000" -``` - -`DATABASE_URL` should point to the PostgreSQL instance backing Prisma. `NEXTAUTH_SECRET` secures JWT sessions and should -be a long, random string in production. `NEXTAUTH_URL` must reflect the public URL where the app is served. - -## Database & Prisma - -Prisma is configured under `prisma/schema.prisma` with models for caregivers, babies, and the primary log tables. Helpful -commands are exposed through `package.json` scripts: - -```bash -# Apply schema changes locally (creates a new migration if needed) -npm run prisma:migrate - -# Re-generate the Prisma client after editing the schema -npm run prisma:generate - -# Apply existing migrations in production environments -npm run prisma:deploy - -# Inspect and edit data visually -npm run prisma:studio -``` - -The repository includes an initial migration at `prisma/migrations/0001_init/migration.sql` that provisions the schema -for all caregivers, babies, and log tables. - -## Seeding Guidance - -To load starter caregivers, babies, or log entries, create a `prisma/seed.ts` script that hashes passwords with -[`bcryptjs`](https://www.npmjs.com/package/bcryptjs) and inserts records via the Prisma client. Example outline: - -```ts -// prisma/seed.ts -import { PrismaClient } from '@prisma/client'; -import { hash } from 'bcryptjs'; - -const prisma = new PrismaClient(); - -async function main() { - const passwordHash = await hash('changeme', 12); - - const caregiver = await prisma.caregiver.upsert({ - where: { email: 'caregiver@example.com' }, - update: {}, - create: { - email: 'caregiver@example.com', - name: 'Primary Caregiver', - passwordHash, - babies: { - create: { - name: 'Baby Doe', - birthDate: new Date('2024-01-01') - } - } - } - }); - - console.log('Seeded caregiver', caregiver.email); -} - -main() - .catch((error) => { - console.error(error); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); -``` - -Then execute (after wiring a seed command in `package.json` such as `"prisma": { "seed": "ts-node prisma/seed.ts" }`): - -```bash -npx prisma db seed -``` - -Adjust the script to suit production data-loading needs (multiple caregivers, cross-linked babies, and initial log -entries). For JavaScript environments, rename the file to `seed.js` and swap the `import` statements for `require`. ## Project Highlights - **Next.js App Router** foundation with typed routes and Tailwind CSS styling. - **Component-driven sections** that describe the core modules (tracking, playbooks, deployment) to guide future builds. - **Data blueprints** (`data/*.ts`) capturing roadmap items for backend, analytics, and self-hosting strategies. +- **Production operations** blueprint with Docker images, observability, backups, and CI automation. ## Next Steps -- Extend the new authentication and log APIs with UI components for recording daily activity. -- Add dashboards, reminders, and background jobs based on the outlined modules. -- Containerize the application with Docker Compose including database, worker, and reverse proxy services. + diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..035e0b0 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; +import { trackRequest } from '../metrics/route'; + +export const runtime = 'nodejs'; + +export async function GET(request: Request) { + const response = NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() }); + trackRequest(request.method, response.status); + return response; +} diff --git a/app/api/metrics/route.ts b/app/api/metrics/route.ts new file mode 100644 index 0000000..7b2679b --- /dev/null +++ b/app/api/metrics/route.ts @@ -0,0 +1,34 @@ +import client from 'prom-client'; +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +const register = client.register; + +if (!(global as any).__METRICS_DEFAULTS__) { + client.collectDefaultMetrics({ register }); + (global as any).__METRICS_DEFAULTS__ = true; +} + +let httpRequestCounter = register.getSingleMetric('http_requests_total') as client.Counter | undefined; +if (!httpRequestCounter) { + httpRequestCounter = new client.Counter({ + name: 'http_requests_total', + help: 'Count of HTTP requests by status code', + labelNames: ['method', 'status'], + }); +} + +export async function GET() { + return new NextResponse(await register.metrics(), { + status: 200, + headers: { + 'Content-Type': register.contentType, + 'Cache-Control': 'no-store', + }, + }); +} + +export function trackRequest(method: string, status: number) { + httpRequestCounter?.inc({ method, status: status.toString() }); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..08ca1b9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,238 @@ +version: '3.9' + +x-common-environment: &default-env + APP_HOST: ${APP_HOST} + APP_PORT: ${APP_PORT} + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} + NODE_ENV: ${NODE_ENV:-production} + +x-common-depends: &queue-depends + postgres: + condition: service_healthy + redis: + condition: service_started + redis-exporter: + condition: service_started + +services: + app: + build: + context: . + dockerfile: Dockerfile.app + args: + NODE_VERSION: ${NODE_VERSION:-20.12.2} + env_file: + - .env + environment: + <<: *default-env + PORT: ${APP_PORT} + secrets: + - postgres_password + ports: + - "${APP_PORT:-3000}:3000" + depends_on: + <<: *queue-depends + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + restart: unless-stopped + networks: + - frontend + - backend + + worker: + build: + context: . + dockerfile: Dockerfile.worker + args: + NODE_VERSION: ${NODE_VERSION:-20.12.2} + env_file: + - .env + environment: + <<: *default-env + secrets: + - postgres_password + depends_on: + <<: *queue-depends + restart: unless-stopped + networks: + - backend + + scheduler: + build: + context: . + dockerfile: Dockerfile.job + args: + NODE_VERSION: ${NODE_VERSION:-20.12.2} + env_file: + - .env + environment: + <<: *default-env + secrets: + - postgres_password + depends_on: + <<: *queue-depends + restart: unless-stopped + networks: + - backend + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + volumes: + - postgres_data:/var/lib/postgresql/data + secrets: + - postgres_password + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - backend + + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 5s + retries: 3 + restart: unless-stopped + networks: + - backend + + redis-exporter: + image: oliver006/redis_exporter:v1.58.0 + environment: + REDIS_ADDR: redis:6379 + depends_on: + redis: + condition: service_started + restart: unless-stopped + networks: + - backend + + caddy: + image: caddy:2-alpine + environment: + CADDY_DOMAIN: ${CADDY_DOMAIN} + CADDY_EMAIL: ${CADDY_EMAIL} + ports: + - "80:80" + - "443:443" + volumes: + - ./ops/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + app: + condition: service_healthy + restart: unless-stopped + networks: + - frontend + - backend + + prometheus: + image: prom/prometheus:v2.52.0 + volumes: + - ./ops/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + restart: unless-stopped + networks: + - monitoring + - backend + + loki: + image: grafana/loki:2.9.6 + command: ["-config.file=/etc/loki/local-config.yaml"] + volumes: + - ./ops/loki/config.yml:/etc/loki/local-config.yaml:ro + - loki_data:/loki + restart: unless-stopped + networks: + - monitoring + + promtail: + image: grafana/promtail:2.9.6 + volumes: + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./ops/promtail/config.yml:/etc/promtail/config.yml:ro + command: ["-config.file=/etc/promtail/config.yml"] + restart: unless-stopped + networks: + - monitoring + + grafana: + image: grafana/grafana:10.4.3 + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin_password + GF_PATHS_PROVISIONING: /etc/grafana/provisioning + volumes: + - grafana_data:/var/lib/grafana + - ./ops/grafana/provisioning:/etc/grafana/provisioning:ro + - ./ops/grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + prometheus: + condition: service_started + loki: + condition: service_started + secrets: + - grafana_admin_password + restart: unless-stopped + networks: + - monitoring + - frontend + + restic-backup: + image: ghcr.io/onedr0p/restic:2.0.0 + environment: + RESTIC_REPOSITORY: ${RESTIC_REPOSITORY} + RESTIC_PASSWORD_FILE: /run/secrets/restic_password + RESTIC_BACKUP_CRON: ${RESTIC_BACKUP_CRON} + RESTIC_FORGET_ARGS: --keep-daily ${RESTIC_KEEP_DAILY} --keep-weekly ${RESTIC_KEEP_WEEKLY} --keep-monthly ${RESTIC_KEEP_MONTHLY} + env_file: + - .env + secrets: + - restic_password + volumes: + - postgres_data:/data/postgres:ro + - ./ops/backups:/backups + restart: unless-stopped + networks: + - backend + +secrets: + postgres_password: + file: ./docker/secrets/postgres_password + restic_password: + file: ./docker/secrets/restic_password + grafana_admin_password: + file: ./docker/secrets/grafana_admin_password + +volumes: + postgres_data: + prometheus_data: + grafana_data: + loki_data: + caddy_data: + caddy_config: + +networks: + frontend: + backend: + monitoring: diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..3212be4 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,87 @@ +# Operations Runbook + +## Prerequisites + +1. Copy `.env.example` to `.env` and adjust values. +2. Populate required secrets under `docker/secrets/` (see `docker/secrets/README.md`). +3. Ensure Docker Engine 24+ and Docker Compose v2 are installed on the host. + +## Bootstrapping the Stack + +```bash +docker compose pull +docker compose build --pull +docker compose up -d +``` + +Key services: + +| Service | Purpose | +| --- | --- | +| app | Next.js production server | +| worker | Queue consumer (Node.js) | +| scheduler | Interval-based job runner | +| postgres | Primary relational database | +| redis | Job/queue broker | +| redis-exporter | Redis Prometheus metrics | +| caddy | Reverse proxy / TLS termination | +| prometheus | Metrics scraping | +| loki | Log aggregation | +| promtail | Log shipping | +| grafana | Observability dashboards | +| restic-backup | Scheduled persistent volume backups | + +## Health Checks + +* Application: `GET /api/health` (proxied via Caddy) +* Metrics: `GET /api/metrics` for Prometheus scraping +* PostgreSQL: `pg_isready` +* Redis: `redis-cli ping` +* Promtail/Loki: Grafana > Explore > Loki datasource + +`docker compose ps --status=running` and `docker compose logs SERVICE` are the first diagnostics when troubleshooting. + +## Log Shipping + +Promtail tails container logs via the Docker socket and forwards entries to Loki. In Grafana, use the "Recent Application Logs" panel or query `{container="app"}` to explore raw logs. + +## Metrics Dashboards + +Prometheus scrapes the app, Redis, and itself. Grafana auto-loads dashboards from `ops/grafana/dashboards/`. The sample "Next.js Service Health" dashboard charts HTTP throughput and recent logs. + +## Backups + +The `restic-backup` service snapshots the PostgreSQL volume every six hours by default. Adjust retention with `RESTIC_KEEP_*` variables. Backup archives are stored in the `RESTIC_REPOSITORY` backend. + +## Restore Playbook + +1. Stop services that write to the database: + ```bash + docker compose stop app worker scheduler + ``` +2. Trigger a restore shell inside the backup container: + ```bash + docker compose run --rm \ + -e RESTIC_PASSWORD_FILE=/run/secrets/restic_password \ + restic-backup sh + ``` +3. Inside the container, list snapshots and restore: + ```bash + restic snapshots + restic restore latest --target /restore + ``` +4. Copy the restored database files back to the PostgreSQL volume (on the host) and restart services: + ```bash + sudo rsync -a /path/to/restore/data/postgres/ + docker compose up -d postgres + docker compose up -d app worker scheduler + ``` + +For point-in-time recovery beyond the snapshot cadence, pair Restic with WAL archiving or integrate with managed backups. + +## Disaster Recovery Checklist + +- Confirm DNS and TLS certificates via Caddy logs. +- Validate database integrity (`psql -c 'SELECT 1'`). +- Ensure Grafana dashboards load and Prometheus targets are healthy. +- Run smoke tests against the public endpoints. diff --git a/ops/backups/.gitkeep b/ops/backups/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ops/caddy/Caddyfile b/ops/caddy/Caddyfile new file mode 100644 index 0000000..7a8dd0e --- /dev/null +++ b/ops/caddy/Caddyfile @@ -0,0 +1,17 @@ +{ + email {$CADDY_EMAIL} + admin off + auto_https disable_redirects +} + +{$CADDY_DOMAIN} { + encode gzip + reverse_proxy app:3000 { + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host {host} + } + log { + output stdout + format json + } +} diff --git a/ops/grafana/dashboards/app-health.json b/ops/grafana/dashboards/app-health.json new file mode 100644 index 0000000..a528e4d --- /dev/null +++ b/ops/grafana/dashboards/app-health.json @@ -0,0 +1,53 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "panels": [ + { + "type": "timeseries", + "title": "HTTP 2xx Responses", + "targets": [ + { + "expr": "sum(rate(http_requests_total{status=\"200\"}[5m]))", + "datasource": { + "type": "prometheus", + "uid": "" + } + } + ], + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + } + }, + { + "type": "logs", + "title": "Recent Application Logs", + "datasource": { + "type": "loki", + "uid": "" + }, + "targets": [ + { + "expr": "{container=\"app\"}" + } + ], + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + } + } + ], + "schemaVersion": 37, + "style": "dark", + "tags": ["app"], + "timezone": "", + "title": "Next.js Service Health", + "version": 1 +} diff --git a/ops/grafana/provisioning/dashboards/dashboard.yml b/ops/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 0000000..05acea0 --- /dev/null +++ b/ops/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,10 @@ +apiVersion: 1 +providers: + - name: Default + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 30 + options: + path: /var/lib/grafana/dashboards diff --git a/ops/grafana/provisioning/datasources/datasource.yml b/ops/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000..8904117 --- /dev/null +++ b/ops/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,11 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + - name: Loki + type: loki + access: proxy + url: http://loki:3100 diff --git a/ops/loki/config.yml b/ops/loki/config.yml new file mode 100644 index 0000000..3fe4958 --- /dev/null +++ b/ops/loki/config.yml @@ -0,0 +1,37 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + log_level: warn + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: boltdb-shipper + object_store: filesystem + schema: v12 + index: + prefix: index_ + period: 24h + +compactor: + working_directory: /loki/compactor + shared_store: filesystem + +limits_config: + retention_period: ${LOKI_RETENTION_DAYS}d + +chunk_store_config: + max_look_back_period: 120h diff --git a/ops/prometheus/prometheus.yml b/ops/prometheus/prometheus.yml new file mode 100644 index 0000000..5c0399c --- /dev/null +++ b/ops/prometheus/prometheus.yml @@ -0,0 +1,14 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['prometheus:9090'] + - job_name: 'node-app' + metrics_path: /api/metrics + static_configs: + - targets: ['app:3000'] + - job_name: 'redis' + static_configs: + - targets: ['redis-exporter:9121'] diff --git a/ops/promtail/config.yml b/ops/promtail/config.yml new file mode 100644 index 0000000..71587ef --- /dev/null +++ b/ops/promtail/config.yml @@ -0,0 +1,19 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'stream' diff --git a/package.json b/package.json index 69c58d2..aba2d89 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,7 @@ "build": "next build", "start": "next start", "lint": "next lint", - "prisma:generate": "prisma generate", - "prisma:migrate": "prisma migrate dev", - "prisma:deploy": "prisma migrate deploy", - "prisma:studio": "prisma studio" + }, "dependencies": { "@prisma/client": "5.13.0", @@ -18,7 +15,9 @@ "next": "14.2.3", "next-auth": "5.0.0-beta.16", "react": "18.2.0", - "react-dom": "18.2.0" + "react-dom": "18.2.0", + "dotenv": "^16.4.5", + "prom-client": "^15.1.1" }, "devDependencies": { "@types/bcryptjs": "2.4.6", diff --git a/scripts/scheduled-job.js b/scripts/scheduled-job.js new file mode 100644 index 0000000..84d0f34 --- /dev/null +++ b/scripts/scheduled-job.js @@ -0,0 +1,21 @@ +require('dotenv').config(); + +const interval = Number.parseInt(process.env.SCHEDULE_INTERVAL || '3600000', 10); + +async function runJob() { + console.log(`[scheduler] Running scheduled job at ${new Date().toISOString()}`); + // TODO: add job logic here +} + +setInterval(async () => { + try { + await runJob(); + } catch (error) { + console.error('[scheduler] Job failed', error); + } +}, interval); + +runJob().catch((error) => { + console.error('[scheduler] Initial job failed', error); + process.exit(1); +}); diff --git a/scripts/worker.js b/scripts/worker.js new file mode 100644 index 0000000..5662755 --- /dev/null +++ b/scripts/worker.js @@ -0,0 +1,14 @@ +require('dotenv').config(); + +async function main() { + console.log(`[worker] Booting queue worker at ${new Date().toISOString()}`); + // TODO: connect to your queue/broker and start processing jobs. + setInterval(() => { + console.log(`[worker] heartbeat ${new Date().toISOString()}`); + }, Number.parseInt(process.env.WORKER_HEARTBEAT_INTERVAL || '60000', 10)); +} + +main().catch((error) => { + console.error('[worker] Fatal error', error); + process.exit(1); +});