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
|
# database
|
||||||
/data/*.db
|
/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.
|
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
|
||||||
|
|
@ -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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue