commit 45d785964d2b5c53b79bfa8c1402ea1ea22ba5d6 Author: Jason UNRAID Date: Fri Mar 6 11:33:32 2026 -0600 Initial commit of Docker project diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..7867acd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +client/node_modules +client/dist +*.tar.gz +*.zip +.git +.gitignore +*.md +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..5937568 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Stage 1: Builder +# Installs ALL dependencies and compiles the React frontend inside Docker. +# Nothing needs to be installed on the host machine except Docker itself. +# ───────────────────────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder + +WORKDIR /build + +# Install backend deps +COPY package.json ./ +RUN npm install + +# Install frontend deps and build React app +COPY client/package.json ./client/ +RUN cd client && npm install + +COPY client/ ./client/ +RUN cd client && npm run build + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 2: Production image +# Copies only what's needed to run — no dev tools, no node_modules for client. +# Final image is lean (~180MB) and ready to run with zero host setup. +# ───────────────────────────────────────────────────────────────────────────── +FROM node:20-alpine AS production + +# Chromium + deps for Phase 3 Puppeteer PDF generation +RUN apk add --no-cache \ + chromium \ + nss \ + freetype \ + harfbuzz \ + ca-certificates \ + ttf-freefont + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser +ENV NODE_ENV=production +ENV PORT=3001 +ENV DB_PATH=/data/cpas.db + +WORKDIR /app + +# Copy backend deps from builder +COPY --from=builder /build/node_modules ./node_modules +COPY --from=builder /build/client/dist ./client/dist + +# Copy backend source +COPY server.js ./ +COPY db/ ./db/ +COPY package.json ./ + +# Ensure data directory exists (will be overridden by volume mount) +RUN mkdir -p /data + +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:3001/api/health || exit 1 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100755 index 0000000..a448dde --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# CPAS Violation Tracker + +Single-container Dockerized web app for CPAS violation documentation. +Built with React + Vite (frontend), Node.js + Express (backend), SQLite (database). + +## The only requirement on your machine: Docker Desktop + +Everything else — Node.js, npm, React build — happens inside Docker. + +--- + +## Quickstart (Local) + +```bash +# 1. Build the image (installs all deps + compiles React inside Docker) +docker build -t cpas-tracker . + +# 2. Run it +docker run -d --name cpas-tracker \ + -p 3001:3001 \ + -v cpas-data:/data \ + cpas-tracker + +# 3. Open +# http://localhost:3001 +``` + +## Export for Unraid + +```bash +docker save cpas-tracker | gzip > cpas-tracker.tar.gz +``` + +Then follow README_UNRAID_INSTALL.md. + +## Update After Code Changes + +```bash +docker build -t cpas-tracker . +docker stop cpas-tracker && docker rm cpas-tracker +docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker +``` + +## Project Structure + +``` +cpas-violation-tracker/ +├── Dockerfile # Multi-stage: builds React + runs Express +├── .dockerignore +├── package.json # Backend (Express) deps +├── server.js # API + static file server +├── db/ +│ ├── schema.sql # Tables + 90-day score view +│ └── database.js # SQLite connection +└── client/ # React frontend (Vite) + ├── package.json + ├── vite.config.js + ├── index.html + └── src/ + ├── main.jsx + ├── App.jsx + ├── data/ + │ └── violations.js # All CPAS violation definitions + └── components/ + └── ViolationForm.jsx +``` + +## Phases +- [x] Phase 1 — Container scaffold, SQLite schema, base React form +- [ ] Phase 2 — Employee history, prior violation highlighting +- [ ] Phase 3 — Puppeteer PDF generation +- [ ] Phase 4 — Dashboard, CPAS scores, tier warnings +- [ ] Phase 5 — Recidivist point auto-suggest diff --git a/README_UNRAID_INSTALL.md b/README_UNRAID_INSTALL.md new file mode 100755 index 0000000..ee18340 --- /dev/null +++ b/README_UNRAID_INSTALL.md @@ -0,0 +1,219 @@ +# CPAS Violation Tracker — Unraid Installation Guide + +> **Applies to:** Unraid 6.12+ | Single container | Port 3001 +> **Host requirement:** Docker Desktop only — no Node.js needed + +--- + +## Overview + +The Docker image is fully self-contained. All dependencies and the compiled +React frontend are baked in during the build. You only need Docker Desktop +on your local machine to build and export the image. + +--- + +## Part 1 — Build the Docker Image Locally + +Open a terminal in the unzipped project folder: + +```bash +docker build -t cpas-tracker . +``` + +This single command: +- Installs backend (Node/Express) dependencies +- Installs frontend (React/Vite) dependencies +- Compiles the React app +- Packages everything into one lean image + +No npm, no Node.js required on your machine beyond Docker. + +--- + +## Part 2 — Export the Image + +```bash +docker save cpas-tracker | gzip > cpas-tracker.tar.gz +``` + +--- + +## Part 3 — Transfer to Unraid + +### Option A — Windows SMB (Recommended, no terminal) +1. Open File Explorer → address bar → `\\[YOUR-UNRAID-IP]` +2. Open the **appdata** share +3. Create a folder named `cpas` +4. Drag `cpas-tracker.tar.gz` into `\\[YOUR-UNRAID-IP]\appdata\cpas\` + +### Option B — SCP (Mac/Linux) +```bash +scp cpas-tracker.tar.gz root@[YOUR-UNRAID-IP]:/mnt/user/appdata/cpas/ +``` + +--- + +## Part 4 — Prepare Unraid (Terminal — one time only) + +1. In Unraid GUI → **Tools** → **Terminal** +2. Run: + +```bash +mkdir -p /mnt/user/appdata/cpas/db +docker load < /mnt/user/appdata/cpas/cpas-tracker.tar.gz +``` + +Expected output: +``` +Loaded image: cpas-tracker:latest +``` + +3. Close the terminal — no further terminal use needed for normal operation. + +--- + +## Part 5 — Add the Container in Unraid GUI + +### 5.1 Navigate to Docker tab +1. Click **Docker** in the top nav +2. Confirm Docker is **Enabled** (green toggle) +3. Scroll to bottom → click **Add Container** +4. Toggle **Advanced View ON** (top-right of the form) + +--- + +### 5.2 Basic Settings + +| Field | Value | +|---|---| +| **Name** | `cpas-tracker` | +| **Repository** | `cpas-tracker` | +| **Docker Hub URL** | *(leave blank — local image)* | +| **WebUI** | `http://[IP]:[PORT:3001]` | +| **Network Type** | `Bridge` | +| **Privileged** | `Off` | +| **Restart Policy** | `Unless Stopped` | +| **Console shell** | `bash` | + +> Setting the WebUI field enables a one-click launch icon on the Docker tab. + +--- + +### 5.3 Port Mapping + +Click **Add another Path, Port, Variable, Label or Device** + +| Setting | Value | +|---|---| +| Config Type | `Port` | +| Name | `Web UI` | +| Container Port | `3001` | +| Host Port | `3001` | +| Protocol | `TCP` | + +--- + +### 5.4 Volume Mapping (Database Persistence) + +Click **Add another Path, Port, Variable, Label or Device** + +| Setting | Value | +|---|---| +| Config Type | `Path` | +| Name | `Database` | +| Container Path | `/data` | +| Host Path | `/mnt/user/appdata/cpas/db` | +| Access Mode | `Read/Write` | + +> The SQLite database lives here and survives container restarts and image updates. + +--- + +### 5.5 Environment Variables + +Click **Add another Path, Port, Variable, Label or Device** for each: + +**Variable 1 — Port** + +| Setting | Value | +|---|---| +| Config Type | `Variable` | +| Name | `Port` | +| Key | `PORT` | +| Value | `3001` | + +**Variable 2 — Database Path** + +| Setting | Value | +|---|---| +| Config Type | `Variable` | +| Name | `Database Path` | +| Key | `DB_PATH` | +| Value | `/data/cpas.db` | + +--- + +### 5.6 Apply + +1. Click **Apply** at the bottom +2. Watch the progress log — wait for "Container started" +3. Click **Done** + +--- + +## Part 6 — Verify + +1. Docker tab → **cpas-tracker** should show a green icon +2. Click the container icon → **WebUI** + Or open: `http://[YOUR-UNRAID-IP]:3001` +3. Confirm **● API connected** appears in the header +4. Health check: `http://[YOUR-UNRAID-IP]:3001/api/health` + → `{"status":"ok","timestamp":"..."}` + +--- + +## Part 7 — Updating After Code Changes + +### Locally: +```bash +docker build -t cpas-tracker . +docker save cpas-tracker | gzip > cpas-tracker.tar.gz +``` + +### Transfer to Unraid (same as Part 3) + +### On Unraid — GUI only after first load: +1. Copy new tar.gz to Unraid (SMB drag-and-drop) +2. **Tools → Terminal** → `docker load < /mnt/user/appdata/cpas/cpas-tracker.tar.gz` +3. **Docker tab** → click `cpas-tracker` icon → **Restart** + +> Your database at `/mnt/user/appdata/cpas/db/cpas.db` is never touched during updates. + +--- + +## Troubleshooting + +| Problem | Fix | +|---|---| +| Container won't start | Docker tab → container icon → **Logs** | +| Port 3001 conflict | Change Host Port to `3002` in Edit Container | +| "API unreachable" in UI | Confirm green icon, check Logs, try Restart | +| DB permission error | Terminal: `chmod 755 /mnt/user/appdata/cpas/db` | +| Inspect DB directly | Terminal: `docker exec -it cpas-tracker sh` then `sqlite3 /data/cpas.db ".tables"` | + +--- + +## Quick Reference — Unraid Docker Tab Actions + +| Action | Steps | +|---|---| +| Open app | Container icon → WebUI | +| View logs | Container icon → Logs | +| Restart | Container icon → Restart | +| Edit settings | Container icon → Edit | +| Stop/Start | Container icon → Stop / Start | + +--- + +*CPAS Violation Tracker — Phase 1 | Message Point Media internal use* diff --git a/client/index.html b/client/index.html new file mode 100755 index 0000000..2a311d7 --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + CPAS Violation Tracker + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100755 index 0000000..36639a5 --- /dev/null +++ b/client/package.json @@ -0,0 +1,19 @@ +{ + "name": "cpas-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.8", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.2" + } +} diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100755 index 0000000..7392d7f --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import ViolationForm from './components/ViolationForm'; + +const styles = { + body: { + fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + minHeight: '100vh', + padding: '20px', + margin: 0, + }, + container: { + maxWidth: '1200px', + margin: '0 auto', + background: 'white', + borderRadius: '12px', + boxShadow: '0 20px 60px rgba(0,0,0,0.3)', + overflow: 'hidden', + }, + header: { + background: 'linear-gradient(135deg, #2c3e50 0%, #34495e 100%)', + color: 'white', + padding: '30px', + textAlign: 'center', + }, + statusBar: { + fontSize: '11px', + color: '#aaa', + marginTop: '6px', + } +}; + +export default function App() { + const [apiStatus, setApiStatus] = useState('checking...'); + + useEffect(() => { + fetch('/api/health') + .then(r => r.json()) + .then(() => setApiStatus('● API connected')) + .catch(() => setApiStatus('⚠ API unreachable')); + }, []); + + return ( +
+
+
+

CPAS Violation Documentation System

+

+ Generate Individual Violation Records with Contextual Fields +

+

{apiStatus}

+
+ +
+
+ ); +} diff --git a/client/src/components/ViolationForm.jsx b/client/src/components/ViolationForm.jsx new file mode 100755 index 0000000..c22f2dc --- /dev/null +++ b/client/src/components/ViolationForm.jsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import { violationData, violationGroups } from '../data/violations'; + +const s = { + content: { padding: '40px' }, + section: { background: '#f8f9fa', borderLeft: '4px solid #667eea', padding: '20px', marginBottom: '30px', borderRadius: '4px' }, + sectionTitle: { color: '#2c3e50', fontSize: '20px', marginBottom: '15px' }, + grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '15px', marginTop: '15px' }, + item: { display: 'flex', flexDirection: 'column' }, + label: { fontWeight: 600, color: '#555', marginBottom: '5px', fontSize: '13px' }, + input: { padding: '10px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit' }, + fullCol: { gridColumn: '1 / -1' }, + contextBox: { background: '#f1f3f5', border: '1px solid #ced4da', borderRadius: '4px', padding: '10px', fontSize: '12px', color: '#444', marginTop: '4px' }, + pointBox: { background: '#fff3cd', border: '2px solid #ffc107', padding: '15px', borderRadius: '6px', marginTop: '15px', textAlign: 'center' }, + slider: { width: '100%', marginTop: '10px' }, + pointValue: { fontSize: '24px', fontWeight: 'bold', color: '#667eea', margin: '10px 0' }, + btnRow: { display: 'flex', gap: '15px', justifyContent: 'center', marginTop: '30px', flexWrap: 'wrap' }, + btnPrimary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', textTransform: 'uppercase', letterSpacing: '0.5px' }, + btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: '#6c757d', color: 'white', textTransform: 'uppercase', letterSpacing: '0.5px' }, + note: { background: '#e7f3ff', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px' }, + statusOk: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#d4edda', color: '#155724', border: '1px solid #c3e6cb' }, + statusErr: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#f8d7da', color: '#721c24', border: '1px solid #f5c6cb' }, +}; + +const EMPTY_FORM = { + employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '', + violationType: '', incidentDate: '', incidentTime: '', + amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1, +}; + +export default function ViolationForm() { + const [employees, setEmployees] = useState([]); + const [form, setForm] = useState(EMPTY_FORM); + const [violation, setViolation] = useState(null); + const [status, setStatus] = useState(null); + + useEffect(() => { + axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {}); + }, []); + + const handleEmployeeSelect = e => { + const emp = employees.find(x => x.id === parseInt(e.target.value)); + if (!emp) return; + setForm(prev => ({ ...prev, employeeId: emp.id, employeeName: emp.name, department: emp.department || '', supervisor: emp.supervisor || '' })); + }; + + const handleViolationChange = e => { + const key = e.target.value; + const v = violationData[key] || null; + setViolation(v); + setForm(prev => ({ ...prev, violationType: key, points: v ? v.minPoints : 1 })); + }; + + const handleChange = e => setForm(prev => ({ ...prev, [e.target.name]: e.target.value })); + + const handleSubmit = async e => { + e.preventDefault(); + if (!form.violationType) return setStatus({ ok: false, msg: 'Please select a violation type.' }); + try { + const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor }); + const employeeId = empRes.data.id; + const empList = await axios.get('/api/employees'); + setEmployees(empList.data); + await axios.post('/api/violations', { + employee_id: employeeId, violation_type: form.violationType, + violation_name: violation?.name || form.violationType, + category: violation?.category || 'General', points: parseInt(form.points), + incident_date: form.incidentDate, incident_time: form.incidentTime || null, + location: form.location || null, details: form.additionalDetails || null, + witness_name: form.witnessName || null, + }); + setStatus({ ok: true, msg: '✓ Violation recorded successfully' }); + setForm(EMPTY_FORM); + setViolation(null); + } catch (err) { + setStatus({ ok: false, msg: '✗ Error: ' + (err.response?.data?.error || err.message) }); + } + }; + + const showField = f => violation?.fields?.includes(f); + + return ( +
+
+

Employee Information

+ {employees.length > 0 && ( +
+ + +
+ )} +
+ {[['employeeName','Employee Name','text','John Doe'],['department','Department','text','Engineering'],['supervisor','Supervisor Name','text','Jane Smith'],['witnessName','Witness Name (Officer)','text','Officer Name']].map(([name,label,type,ph]) => ( +
+ + +
+ ))} +
+
+ +
+
+

Violation Details

+
+
+ + + {violation && ( +
+ {violation.name} — {violation.description}
+ {violation.chapter} +
+ )} +
+
+ + +
+ {showField('time') && ( +
+ + +
+ )} + {showField('minutes') && ( +
+ + +
+ )} + {showField('amount') && ( +
+ + +
+ )} + {showField('location') && ( +
+ + +
+ )} + {showField('description') && ( +
+ +