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:
parent
43141d19f5
commit
eeb3e58814
|
|
@ -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
|
||||
|
|
@ -42,3 +42,4 @@ next-env.d.ts
|
|||
|
||||
# database
|
||||
/data/*.db
|
||||
/data_docker/*.db
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
31
README.md
31
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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -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 trimmedName = name.trim();
|
||||
|
||||
// Try to insert
|
||||
try {
|
||||
const result = await db
|
||||
.insert(schema.brands)
|
||||
.values({ name: name.trim() })
|
||||
.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 });
|
||||
|
|
|
|||
|
|
@ -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 trimmedName = name.trim();
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.insert(schema.chemistries)
|
||||
.values({ name: name.trim(), isCustom: true })
|
||||
.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 });
|
||||
|
|
|
|||
|
|
@ -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 trimmedName = name.trim();
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.insert(schema.batteryTypes)
|
||||
.values({ name: name.trim(), isCustom: true })
|
||||
.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 });
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
let _db: BetterSQLite3Database<typeof schema> | null = null;
|
||||
let _sqlite: Database.Database | null = null;
|
||||
|
||||
function initDb(): BetterSQLite3Database<typeof schema> {
|
||||
if (_db) return _db;
|
||||
|
||||
const dbPath = path.join(process.cwd(), 'data', 'battery_tracker.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
|
||||
// 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');
|
||||
_sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
_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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue