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.
This commit is contained in:
root 2026-01-20 05:49:57 +00:00
parent 43141d19f5
commit eeb3e58814
15 changed files with 340 additions and 22 deletions

27
.dockerignore Normal file
View File

@ -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

1
.gitignore vendored
View File

@ -42,3 +42,4 @@ next-env.d.ts
# database # database
/data/*.db /data/*.db
/data_docker/*.db

57
Dockerfile Normal file
View File

@ -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"]

View File

@ -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. 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. 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

10
docker-compose.prod.yml Normal file
View File

@ -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

15
docker-compose.yml Normal file
View File

@ -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

View File

@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db'; import { db, schema } from '@/lib/db';
import { eq } from 'drizzle-orm';
export async function GET() { export async function GET() {
try { try {
@ -20,12 +21,30 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 }); return NextResponse.json({ error: 'Name is required' }, { status: 400 });
} }
const trimmedName = name.trim();
// Try to insert
try {
const result = await db const result = await db
.insert(schema.brands) .insert(schema.brands)
.values({ name: name.trim() }) .values({ name: trimmedName })
.returning(); .returning();
return NextResponse.json(result[0], { status: 201 }); 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) { } catch (error) {
console.error('Failed to create brand:', error); console.error('Failed to create brand:', error);
return NextResponse.json({ error: 'Failed to create brand' }, { status: 500 }); return NextResponse.json({ error: 'Failed to create brand' }, { status: 500 });

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db'; import { db, schema } from '@/lib/db';
import { eq } from 'drizzle-orm';
export async function GET() { export async function GET() {
try { try {
@ -20,12 +21,28 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 }); return NextResponse.json({ error: 'Name is required' }, { status: 400 });
} }
const trimmedName = name.trim();
try {
const result = await db const result = await db
.insert(schema.chemistries) .insert(schema.chemistries)
.values({ name: name.trim(), isCustom: true }) .values({ name: trimmedName, isCustom: true })
.returning(); .returning();
return NextResponse.json(result[0], { status: 201 }); 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) { } catch (error) {
console.error('Failed to create chemistry:', error); console.error('Failed to create chemistry:', error);
return NextResponse.json({ error: 'Failed to create chemistry' }, { status: 500 }); return NextResponse.json({ error: 'Failed to create chemistry' }, { status: 500 });

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db'; import { db, schema } from '@/lib/db';
import { eq } from 'drizzle-orm';
export async function GET() { export async function GET() {
try { try {
@ -20,12 +21,28 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 }); return NextResponse.json({ error: 'Name is required' }, { status: 400 });
} }
const trimmedName = name.trim();
try {
const result = await db const result = await db
.insert(schema.batteryTypes) .insert(schema.batteryTypes)
.values({ name: name.trim(), isCustom: true }) .values({ name: trimmedName, isCustom: true })
.returning(); .returning();
return NextResponse.json(result[0], { status: 201 }); 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) { } catch (error) {
console.error('Failed to create battery type:', error); console.error('Failed to create battery type:', error);
return NextResponse.json({ error: 'Failed to create battery type' }, { status: 500 }); return NextResponse.json({ error: 'Failed to create battery type' }, { status: 500 });

View File

@ -2,6 +2,9 @@ import { db, schema } from '@/lib/db';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { BatteryListClient } from '@/components/battery/BatteryListClient'; import { BatteryListClient } from '@/components/battery/BatteryListClient';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
async function getBatteries() { async function getBatteries() {
const groups = await db const groups = await db
.select({ .select({

View File

@ -2,6 +2,9 @@ import { db, schema } from '@/lib/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { DeviceListClient } from '@/components/device/DeviceListClient'; import { DeviceListClient } from '@/components/device/DeviceListClient';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
async function getDevices() { async function getDevices() {
const devices = await db const devices = await db
.select() .select()

View File

@ -5,6 +5,9 @@ import { Badge } from '@/components/ui/Badge';
import { Battery, BatteryCharging, Package, Monitor } from 'lucide-react'; import { Battery, BatteryCharging, Package, Monitor } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
async function getStats() { async function getStats() {
// Get total available and charging counts // Get total available and charging counts
const batteryCounts = await db const batteryCounts = await db

View File

@ -1,14 +1,117 @@
import Database from 'better-sqlite3'; 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 * as schema from './schema';
import path from 'path'; import path from 'path';
import fs from 'fs';
const dbPath = path.join(process.cwd(), 'data', 'battery_tracker.db'); let _db: BetterSQLite3Database<typeof schema> | null = null;
const sqlite = new Database(dbPath); let _sqlite: Database.Database | null = null;
// Enable foreign keys function initDb(): BetterSQLite3Database<typeof schema> {
sqlite.pragma('foreign_keys = ON'); 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<typeof schema>) {
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<typeof schema>, {
get(target, prop) {
const database = initDb();
return (database as any)[prop];
}
});
export { schema }; export { schema };

12
start.sh Normal file
View File

@ -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