From 8a2ffae3edd95ca5d039e8df50f84265bb3a1f56 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 7 Apr 2026 02:10:42 +0000 Subject: [PATCH] Initial commit: Phil Dashboard --- .env | 1 + Dockerfile | 40 +++++++ docker-compose.yml | 27 +++++ package.json | 15 +++ public/styles.css | 288 +++++++++++++++++++++++++++++++++++++++++++++ src/server.js | 249 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 620 insertions(+) create mode 100644 .env create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/styles.css create mode 100644 src/server.js diff --git a/.env b/.env new file mode 100644 index 0000000..c2edd19 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +API_KEY=phil-8f3k2m9x4p7q1w6n diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..940bd13 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM node:22-slim + +# Install dependencies for Puppeteer +RUN apt-get update && apt-get install -y \ + chromium \ + fonts-liberation \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + xdg-utils \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# Set Puppeteer to use installed Chromium +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --production + +COPY . . + +RUN mkdir -p /data + +EXPOSE 3000 + +CMD ["node", "src/server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ebadff --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + app: + build: . + container_name: phil-dashboard + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3000 + - DB_PATH=/data/phil.db + - API_KEY=phil-8f3k2m9x4p7q1w6n + volumes: + - phil-data:/data + labels: + - "traefik.enable=true" + - "traefik.http.routers.phil-dashboard.rule=Host(\`phil.m32advisory.com\`)" + - "traefik.http.routers.phil-dashboard.entrypoints=websecure" + - "traefik.http.routers.phil-dashboard.tls.certresolver=letsencrypt" + - "traefik.http.services.phil-dashboard.loadbalancer.server.port=3000" + networks: + - traefik-public + +volumes: + phil-data: + +networks: + traefik-public: + external: true diff --git a/package.json b/package.json new file mode 100644 index 0000000..c13cb26 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "crystal-dashboard", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "dependencies": { + "better-sqlite3": "^11.0.0", + "express": "^4.18.2", + "puppeteer": "^22.0.0", + "uuid": "^9.0.0" + } +} diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..bc67c49 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,288 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: #f8fafc; + color: #1e293b; + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +/* Header */ +header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px 0; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +header .container { + display: flex; + justify-content: space-between; + align-items: center; +} + +header h1 { + font-size: 1.5rem; + font-weight: 600; +} + +header nav a { + color: white; + text-decoration: none; + opacity: 0.9; +} + +header nav a:hover { + opacity: 1; +} + +/* Main */ +main { + flex: 1; + padding: 40px 0; +} + +main h2 { + margin-bottom: 30px; + font-size: 1.75rem; + color: #334155; +} + +/* Reports Grid */ +.reports-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 24px; +} + +.report-card { + background: white; + border-radius: 12px; + padding: 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: transform 0.2s, box-shadow 0.2s; +} + +.report-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1); +} + +.report-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.report-type { + background: #e0e7ff; + color: #4338ca; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.report-date { + color: #94a3b8; + font-size: 0.875rem; +} + +.report-card h3 { + margin-bottom: 8px; +} + +.report-card h3 a { + color: #1e293b; + text-decoration: none; +} + +.report-card h3 a:hover { + color: #667eea; +} + +.summary { + color: #64748b; + font-size: 0.875rem; + margin-bottom: 8px; +} + +.period { + color: #94a3b8; + font-size: 0.8rem; +} + +.report-actions { + margin-top: 16px; + display: flex; + gap: 8px; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 8px 16px; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + border: none; + cursor: pointer; + transition: background 0.2s; + background: #667eea; + color: white; +} + +.btn:hover { + background: #5a67d8; +} + +.btn-secondary { + background: #e2e8f0; + color: #475569; +} + +.btn-secondary:hover { + background: #cbd5e1; +} + +.btn-danger { + background: #fee2e2; + color: #dc2626; +} + +.btn-danger:hover { + background: #fecaca; +} + +/* Report View */ +.report-view { + background: white; + border-radius: 12px; + padding: 32px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.report-meta { + display: flex; + gap: 16px; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.report-toolbar { + display: flex; + gap: 12px; + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 1px solid #e2e8f0; +} + +.report-content { + line-height: 1.7; +} + +.report-content h1, .report-content h2, .report-content h3 { + color: #334155; + margin: 24px 0 16px; +} + +.report-content h1:first-child, .report-content h2:first-child { + margin-top: 0; +} + +.report-content table { + width: 100%; + border-collapse: collapse; + margin: 20px 0; +} + +.report-content th, .report-content td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid #e2e8f0; +} + +.report-content th { + background: #f8fafc; + font-weight: 600; + color: #475569; +} + +.report-content tr:hover { + background: #f8fafc; +} + +.report-content .amount { + text-align: right; + font-family: 'SF Mono', 'Consolas', monospace; +} + +.report-content .total { + font-weight: 700; + background: #f0f9ff; +} + +.report-content .negative { + color: #dc2626; +} + +.report-content .positive { + color: #16a34a; +} + +.report-content .section-total { + font-weight: 600; + border-top: 2px solid #cbd5e1; +} + +/* Empty State */ +.empty { + text-align: center; + color: #94a3b8; + padding: 60px 20px; + background: white; + border-radius: 12px; + font-size: 1.125rem; +} + +/* Footer */ +footer { + background: #1e293b; + color: #94a3b8; + padding: 20px 0; + text-align: center; + font-size: 0.875rem; +} + +/* Responsive */ +@media (max-width: 640px) { + .reports-grid { + grid-template-columns: 1fr; + } + + .report-toolbar { + flex-wrap: wrap; + } + + .report-meta { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..2d6c01f --- /dev/null +++ b/src/server.js @@ -0,0 +1,249 @@ +import express from 'express'; +import Database from 'better-sqlite3'; +import { v4 as uuidv4 } from 'uuid'; +import puppeteer from 'puppeteer'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const app = express(); +const PORT = process.env.PORT || 3000; +const API_KEY = process.env.API_KEY || 'crystal-secret-key'; + +// Initialize database +const dbPath = process.env.DB_PATH || '/data/crystal.db'; +const db = new Database(dbPath); + +// Create tables +db.exec(` + CREATE TABLE IF NOT EXISTS reports ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + report_type TEXT NOT NULL, + html_content TEXT NOT NULL, + summary TEXT, + created_at TEXT DEFAULT (datetime('now')), + period_start TEXT, + period_end TEXT + ) +`); + +app.use(express.json({ limit: '10mb' })); +app.use(express.static(path.join(__dirname, '../public'))); + +// Helper: Wrap content in layout +function renderPage(title, content, extra = '') { + return ` + + + + + ${title} - Phil Dashboard + + ${extra} + + +
+
+

📋 Phil Dashboard

+ +
+
+
+ ${content} +
+ + +`; +} + +// Dashboard - List all reports +app.get('/', (req, res) => { + const reports = db.prepare(` + SELECT id, title, report_type, summary, created_at, period_start, period_end + FROM reports ORDER BY created_at DESC + `).all(); + + const reportsList = reports.length === 0 + ? '

No reports yet. Ask Phil to generate one!

' + : reports.map(r => ` +
+
+ ${r.report_type} + ${new Date(r.created_at).toLocaleDateString()} +
+

${r.title}

+ ${r.summary ? `

${r.summary}

` : ''} + ${r.period_start ? `

${r.period_start} to ${r.period_end || 'Present'}

` : ''} +
+ View + PDF + +
+
+ `).join(''); + + const content = ` +

Regulatory Reports

+
+ ${reportsList} +
+ + `; + + res.send(renderPage('Dashboard', content)); +}); + +// View single report +app.get('/reports/:id', (req, res) => { + const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(req.params.id); + if (!report) return res.status(404).send(renderPage('Not Found', '

Report not found

')); + + const content = ` +
+
+ ${report.report_type} + Generated: ${new Date(report.created_at).toLocaleString()} + ${report.period_start ? `Period: ${report.period_start} to ${report.period_end || 'Present'}` : ''} +
+
+ ← Back + Download PDF + +
+
+ ${report.html_content} +
+
+ + `; + + res.send(renderPage(report.title, content)); +}); + +// Generate PDF +app.get('/reports/:id/pdf', async (req, res) => { + const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(req.params.id); + if (!report) return res.status(404).send('Report not found'); + + try { + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + const page = await browser.newPage(); + + const html = ` + + + + ${report.title} + + + +

${report.title}

+
+ Generated: ${new Date(report.created_at).toLocaleString()} + ${report.period_start ? ` • Period: ${report.period_start} to ${report.period_end || 'Present'}` : ''} +
+ ${report.html_content} + + +`; + + await page.setContent(html, { waitUntil: 'networkidle0' }); + const pdf = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' } + }); + + await browser.close(); + + const filename = `${report.title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}.pdf`; + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(pdf); + } catch (err) { + console.error('PDF generation error:', err); + res.status(500).send('Failed to generate PDF'); + } +}); + +// Delete report +app.delete('/reports/:id', (req, res) => { + const result = db.prepare('DELETE FROM reports WHERE id = ?').run(req.params.id); + if (result.changes === 0) return res.status(404).json({ error: 'Not found' }); + res.json({ success: true }); +}); + +// API: Create report (for Phil) +app.post('/api/reports', (req, res) => { + const authHeader = req.headers.authorization; + if (authHeader !== `Bearer ${API_KEY}`) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { title, report_type, html_content, summary, period_start, period_end } = req.body; + + if (!title || !report_type || !html_content) { + return res.status(400).json({ error: 'Missing required fields: title, report_type, html_content' }); + } + + const id = uuidv4(); + db.prepare(` + INSERT INTO reports (id, title, report_type, html_content, summary, period_start, period_end) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(id, title, report_type, html_content, summary || null, period_start || null, period_end || null); + + const url = `${req.protocol}://${req.get('host')}/reports/${id}`; + res.json({ success: true, id, url }); +}); + +// API: List reports (for Phil) +app.get('/api/reports', (req, res) => { + const reports = db.prepare(` + SELECT id, title, report_type, summary, created_at, period_start, period_end + FROM reports ORDER BY created_at DESC LIMIT 50 + `).all(); + res.json(reports); +}); + +app.listen(PORT, () => { + console.log(`Phil Dashboard running on port ${PORT}`); +});