From eeb3e5881488d00dfacfc9b5d1a726a699c9882b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 20 Jan 2026 05:49:57 +0000 Subject: [PATCH] feat: add Docker support and fix page refresh issues - Add Dockerfile with root user for simplified deployment - Add docker-compose.yml for local development - Add docker-compose.prod.yml for production deployment - Add start.sh script for container initialization - Fix page caching: add dynamic rendering to all pages - Update database initialization to handle permissions - Update README with deployment instructions This is the first fully functional version with Docker support. No manual permission setup required on deployment. --- .dockerignore | 27 ++++++++ .gitignore | 1 + Dockerfile | 57 +++++++++++++++ README.md | 31 +++++++++ docker-compose.prod.yml | 10 +++ docker-compose.yml | 15 ++++ next.config.ts | 2 +- src/app/api/brands/route.ts | 29 ++++++-- src/app/api/chemistries/route.ts | 27 ++++++-- src/app/api/types/route.ts | 27 ++++++-- src/app/batteries/page.tsx | 3 + src/app/devices/page.tsx | 3 + src/app/page.tsx | 3 + src/lib/db/index.ts | 115 +++++++++++++++++++++++++++++-- start.sh | 12 ++++ 15 files changed, 340 insertions(+), 22 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..05f19c0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Dependencies +node_modules +npm-debug.log* + +# Next.js build output +.next + +# Database +data/ + +# Git +.git +.gitignore + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Misc +*.md +!README.md diff --git a/.gitignore b/.gitignore index 031ed9c..8180329 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ next-env.d.ts # database /data/*.db +/data_docker/*.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f19d86 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat python3 make g++ +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Create data directory for build process +RUN mkdir -p /app/data + +# Generate database migrations and build +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production + +# Copy public assets +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next + +# Copy standalone output +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +# Copy drizzle migrations for runtime +COPY --from=builder /app/drizzle ./drizzle + +# Copy and setup startup script +COPY start.sh /app/start.sh +RUN chmod +x /app/start.sh + +# Create data directory for SQLite +RUN mkdir -p /app/data + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["/app/start.sh"] diff --git a/README.md b/README.md index e215bc4..2cf3997 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,34 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. + +## Deploy locally with docker compose, and push to remote + +### Build and push new image +```bash +# Build the image +docker compose build + +# Tag for registry +docker tag battery_tracker-battery-tracker:latest 192.168.2.212:3000/tigeren/batterytracker:1.0 + +# Push to registry +docker push 192.168.2.212:3000/tigeren/batterytracker:1.0 +``` + +### Deploy on a new server + +**Simple deployment - no manual permission setup needed!** + +```bash +# 1. Copy docker-compose.prod.yml to your server + +# 2. Pull and start (the container runs as root and handles permissions automatically) +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d +``` + +The database file will be automatically created at `./data/battery_tracker.db` + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..c04e489 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,10 @@ +services: + battery-tracker: + image: 192.168.2.212:3000/tigeren/batterytracker:1.0 + ports: + - "3020:3000" + volumes: + - ./data:/app/data + restart: unless-stopped + environment: + - NODE_ENV=production diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7d6fee2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + battery-tracker: + build: + context: . + dockerfile: Dockerfile + # image: 192.168.2.212:3000/tigeren/batterytracker:1.0 + ports: + - "3020:3000" + volumes: + - ./data_docker:/app/data + restart: unless-stopped + environment: + - NODE_ENV=production + + diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/src/app/api/brands/route.ts b/src/app/api/brands/route.ts index c32f56c..17112b5 100644 --- a/src/app/api/brands/route.ts +++ b/src/app/api/brands/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { db, schema } from '@/lib/db'; +import { eq } from 'drizzle-orm'; export async function GET() { try { @@ -20,12 +21,30 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Name is required' }, { status: 400 }); } - const result = await db - .insert(schema.brands) - .values({ name: name.trim() }) - .returning(); + const trimmedName = name.trim(); - return NextResponse.json(result[0], { status: 201 }); + // Try to insert + try { + const result = await db + .insert(schema.brands) + .values({ name: trimmedName }) + .returning(); + return NextResponse.json(result[0], { status: 201 }); + } catch (error: any) { + // If unique constraint fails, find the existing brand + if (error.code === 'SQLITE_CONSTRAINT_UNIQUE' || error.message?.includes('UNIQUE constraint failed')) { + const existing = await db + .select() + .from(schema.brands) + .where(eq(schema.brands.name, trimmedName)) + .get(); + + if (existing) { + return NextResponse.json(existing, { status: 200 }); + } + } + throw error; + } } catch (error) { console.error('Failed to create brand:', error); return NextResponse.json({ error: 'Failed to create brand' }, { status: 500 }); diff --git a/src/app/api/chemistries/route.ts b/src/app/api/chemistries/route.ts index 0e406b9..f0de321 100644 --- a/src/app/api/chemistries/route.ts +++ b/src/app/api/chemistries/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { db, schema } from '@/lib/db'; +import { eq } from 'drizzle-orm'; export async function GET() { try { @@ -20,12 +21,28 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Name is required' }, { status: 400 }); } - const result = await db - .insert(schema.chemistries) - .values({ name: name.trim(), isCustom: true }) - .returning(); + const trimmedName = name.trim(); - return NextResponse.json(result[0], { status: 201 }); + try { + const result = await db + .insert(schema.chemistries) + .values({ name: trimmedName, isCustom: true }) + .returning(); + + return NextResponse.json(result[0], { status: 201 }); + } catch (error: any) { + if (error.code === 'SQLITE_CONSTRAINT_UNIQUE' || error.message?.includes('UNIQUE constraint failed')) { + const existing = await db + .select() + .from(schema.chemistries) + .where(eq(schema.chemistries.name, trimmedName)) + .get(); + if (existing) { + return NextResponse.json(existing, { status: 200 }); + } + } + throw error; + } } catch (error) { console.error('Failed to create chemistry:', error); return NextResponse.json({ error: 'Failed to create chemistry' }, { status: 500 }); diff --git a/src/app/api/types/route.ts b/src/app/api/types/route.ts index 898f713..ab4bfa6 100644 --- a/src/app/api/types/route.ts +++ b/src/app/api/types/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { db, schema } from '@/lib/db'; +import { eq } from 'drizzle-orm'; export async function GET() { try { @@ -20,12 +21,28 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Name is required' }, { status: 400 }); } - const result = await db - .insert(schema.batteryTypes) - .values({ name: name.trim(), isCustom: true }) - .returning(); + const trimmedName = name.trim(); - return NextResponse.json(result[0], { status: 201 }); + try { + const result = await db + .insert(schema.batteryTypes) + .values({ name: trimmedName, isCustom: true }) + .returning(); + + return NextResponse.json(result[0], { status: 201 }); + } catch (error: any) { + if (error.code === 'SQLITE_CONSTRAINT_UNIQUE' || error.message?.includes('UNIQUE constraint failed')) { + const existing = await db + .select() + .from(schema.batteryTypes) + .where(eq(schema.batteryTypes.name, trimmedName)) + .get(); + if (existing) { + return NextResponse.json(existing, { status: 200 }); + } + } + throw error; + } } catch (error) { console.error('Failed to create battery type:', error); return NextResponse.json({ error: 'Failed to create battery type' }, { status: 500 }); diff --git a/src/app/batteries/page.tsx b/src/app/batteries/page.tsx index fbe176e..beae6ce 100644 --- a/src/app/batteries/page.tsx +++ b/src/app/batteries/page.tsx @@ -2,6 +2,9 @@ import { db, schema } from '@/lib/db'; import { eq, sql } from 'drizzle-orm'; import { BatteryListClient } from '@/components/battery/BatteryListClient'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + async function getBatteries() { const groups = await db .select({ diff --git a/src/app/devices/page.tsx b/src/app/devices/page.tsx index 1eed277..30dba35 100644 --- a/src/app/devices/page.tsx +++ b/src/app/devices/page.tsx @@ -2,6 +2,9 @@ import { db, schema } from '@/lib/db'; import { eq } from 'drizzle-orm'; import { DeviceListClient } from '@/components/device/DeviceListClient'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + async function getDevices() { const devices = await db .select() diff --git a/src/app/page.tsx b/src/app/page.tsx index 3d42b3a..8521be2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,9 @@ import { Badge } from '@/components/ui/Badge'; import { Battery, BatteryCharging, Package, Monitor } from 'lucide-react'; import Link from 'next/link'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + async function getStats() { // Get total available and charging counts const batteryCounts = await db diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 1902942..f3eda50 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -1,14 +1,117 @@ import Database from 'better-sqlite3'; -import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { drizzle, BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import * as schema from './schema'; import path from 'path'; +import fs from 'fs'; -const dbPath = path.join(process.cwd(), 'data', 'battery_tracker.db'); -const sqlite = new Database(dbPath); +let _db: BetterSQLite3Database | null = null; +let _sqlite: Database.Database | null = null; -// Enable foreign keys -sqlite.pragma('foreign_keys = ON'); +function initDb(): BetterSQLite3Database { + if (_db) return _db; -export const db = drizzle(sqlite, { schema }); + const dbPath = path.join(process.cwd(), 'data', 'battery_tracker.db'); + + // Ensure data directory exists + const dataDir = path.dirname(dbPath); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + + _sqlite = new Database(dbPath); + + // Enable foreign keys + _sqlite.pragma('foreign_keys = ON'); + + _db = drizzle(_sqlite, { schema }); + + // Check if we need migrations or seeding + const tablesExist = _sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='battery_groups'").get(); + + // Run migrations + try { + const migrationsPath = path.join(process.cwd(), 'drizzle'); + if (fs.existsSync(migrationsPath)) { + migrate(_db, { migrationsFolder: migrationsPath }); + } + } catch (error: any) { + // If it fails because tables exist, we might be out of sync with migration metadata + if (error.message?.includes('already exists')) { + console.warn('Migration warning: Some tables already exist. Attempting to continue...'); + } else { + console.error('Migration failed:', error); + } + } + + // Ensure chemistry_id exists (manual fix for out-of-sync migrations) + try { + const hasChemistryId = _sqlite.prepare("PRAGMA table_info(battery_groups)").all() + .some((col: any) => col.name === 'chemistry_id'); + + if (!hasChemistryId && tablesExist) { + console.log('Manually adding chemistry_id column...'); + _sqlite.prepare("ALTER TABLE battery_groups ADD COLUMN chemistry_id INTEGER REFERENCES chemistries(id)").run(); + } + + // Fix unique index if it's the old one (brand_id, type_id only) + if (tablesExist) { + const indexes = _sqlite.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='battery_groups'").all() as any[]; + const hasOldIndex = indexes.some(idx => idx.name === 'brand_type_idx'); + const hasNewIndex = indexes.some(idx => idx.name === 'brand_type_chemistry_idx'); + + if (hasOldIndex && !hasNewIndex) { + console.log('Updating battery_groups unique index...'); + _sqlite.prepare("DROP INDEX brand_type_idx").run(); + _sqlite.prepare("CREATE UNIQUE INDEX brand_type_chemistry_idx ON battery_groups (brand_id, type_id, chemistry_id)").run(); + } + } + } catch (e) { + console.error('Failed to verify/fix schema consistency:', e); + } + + // Seed if tables were just created or are empty + if (!tablesExist) { + seedDefaults(_db); + } + + return _db; +} + +function seedDefaults(db: BetterSQLite3Database) { + console.log('Seeding default data...'); + + const defaultTypes = ['AA', 'AAA', '18650', 'CR2032', '9V', 'C', 'D', '14500', '16340', '21700']; + for (const typeName of defaultTypes) { + try { + db.insert(schema.batteryTypes) + .values({ name: typeName, isCustom: false }) + .onConflictDoNothing() + .run(); + } catch (e) { + console.error(`Failed to seed battery type ${typeName}:`, e); + } + } + + const defaultChemistries = ['NiMH', 'Li-ion', 'LiFePO4', 'NiCd', 'Li-Po', 'Alkaline']; + for (const chemName of defaultChemistries) { + try { + db.insert(schema.chemistries) + .values({ name: chemName, isCustom: false }) + .onConflictDoNothing() + .run(); + } catch (e) { + console.error(`Failed to seed chemistry ${chemName}:`, e); + } + } + console.log('Seed complete!'); +} + +export const db = new Proxy({} as BetterSQLite3Database, { + get(target, prop) { + const database = initDb(); + return (database as any)[prop]; + } +}); export { schema }; diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..9bf990f --- /dev/null +++ b/start.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# Ensure /app/data directory exists +echo "Ensuring data directory exists..." +mkdir -p /app/data + +echo "Starting Battery Tracker..." +echo "Data directory: /app/data" + +# Start the application +exec node server.js