diff --git a/ADMIN_DASH_INTEGRATION.md b/ADMIN_DASH_INTEGRATION.md new file mode 100644 index 0000000..b679364 --- /dev/null +++ b/ADMIN_DASH_INTEGRATION.md @@ -0,0 +1,106 @@ +# Admin Dashboard Integration + +How to integrate with the website container from an external admin dashboard. + +## 1. Uptime Check + +**GET** `http://website-container:3000/api/health` + +Returns: +```json +{ "status": "ok", "timestamp": "2026-02-09T05:19:53.468Z" } +``` + +- 200 = up, anything else (timeout, connection refused, non-200) = down +- Both containers must be on the same Docker network for hostname resolution: + +```bash +docker network create app-net +docker network connect app-net website-container +docker network connect app-net admin-dash-container +``` + +Alternatively, use `http://host.docker.internal:8080/api/health` to go through the host port mapping (no shared network needed, but adds overhead). + +## 2. Visitors Database + +The website writes visitor data to a SQLite DB at `/server_storage/visitors.db`. Mount the same host volume in the admin dashboard container (read-only): + +```bash +docker run -d \ + --name admin-dash-container \ + -v /server_storage:/server_storage:ro \ + admin-dash:latest +``` + +### Schema + +```sql +CREATE TABLE visits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT NOT NULL, -- e.g. "2600:6c65:6740:..." or "18.199.106.183" + path TEXT NOT NULL, -- e.g. "/", "/blog", "/resume" + visited_at TEXT NOT NULL -- UTC datetime, e.g. "2026-02-09 05:19:53" +); +``` + +### Reading from the DB + +The DB uses WAL mode, so reads won't block the website's writes. Open in **read-only** mode to avoid conflicts: + +```typescript +// Node.js with better-sqlite3 +import Database from 'better-sqlite3'; +const db = new Database('/server_storage/visitors.db', { readonly: true }); +const visits = db.prepare('SELECT * FROM visits ORDER BY visited_at DESC LIMIT 100').all(); +``` + +```python +# Python with sqlite3 +import sqlite3 +conn = sqlite3.connect('file:///server_storage/visitors.db?mode=ro', uri=True) +visits = conn.execute('SELECT * FROM visits ORDER BY visited_at DESC LIMIT 100').fetchall() +``` + +### Useful Queries + +```sql +-- Unique visitors today +SELECT COUNT(DISTINCT ip_address) FROM visits +WHERE visited_at >= date('now'); + +-- Page view counts +SELECT path, COUNT(*) as views FROM visits +GROUP BY path ORDER BY views DESC; + +-- Visits per hour (last 24h) +SELECT strftime('%Y-%m-%d %H:00', visited_at) as hour, COUNT(*) as views +FROM visits WHERE visited_at >= datetime('now', '-1 day') +GROUP BY hour ORDER BY hour; +``` + +## Docker Compose Example + +```yaml +services: + website: + image: my-website:latest + ports: + - "8080:3000" + volumes: + - /server_storage:/server_storage + networks: + - app-net + + admin-dash: + image: admin-dash:latest + ports: + - "3333:3000" + volumes: + - /server_storage:/server_storage:ro + networks: + - app-net + +networks: + app-net: +``` diff --git a/app/api/status/route.ts b/app/api/status/route.ts index 975ef78..622b762 100644 --- a/app/api/status/route.ts +++ b/app/api/status/route.ts @@ -9,31 +9,53 @@ export async function GET() { // Get live status (most recent log per service) const live = await db.all(` - SELECT service_name as name, url, status, latency - FROM uptime_logs + SELECT service_name as name, url, status, latency + FROM uptime_logs WHERE id IN (SELECT MAX(id) FROM uptime_logs GROUP BY service_name) `); // Get 24h stats const stats24h = await db.all(` - SELECT service_name, - count(*) as total, - sum(case when status = 'up' then 1 else 0 end) as up_count - FROM uptime_logs + SELECT service_name, + count(*) as total, + sum(case when status = 'up' then 1 else 0 end) as up_count + FROM uptime_logs WHERE timestamp > datetime('now', '-24 hours') GROUP BY service_name `); // Get 7d stats const stats7d = await db.all(` - SELECT service_name, - count(*) as total, - sum(case when status = 'up' then 1 else 0 end) as up_count - FROM uptime_logs + SELECT service_name, + count(*) as total, + sum(case when status = 'up' then 1 else 0 end) as up_count + FROM uptime_logs WHERE timestamp > datetime('now', '-7 days') GROUP BY service_name `); + // Get hourly history for last 24h (24 buckets per service) + const history = await db.all(` + SELECT service_name, + strftime('%Y-%m-%d %H:00', timestamp) as hour, + count(*) as total, + sum(case when status = 'up' then 1 else 0 end) as up_count + FROM uptime_logs + WHERE timestamp > datetime('now', '-24 hours') + GROUP BY service_name, hour + ORDER BY hour ASC + `); + + // Group history by service + const historyMap: Record> = {}; + for (const row of history) { + if (!historyMap[row.service_name]) historyMap[row.service_name] = []; + historyMap[row.service_name].push({ + hour: row.hour, + up: row.up_count === row.total, + }); + } + // Merge data const results = live.map(l => { const s24 = stats24h.find(s => s.service_name === l.name); @@ -43,13 +65,13 @@ export async function GET() { ...l, uptime24h: s24 ? Math.round((s24.up_count / s24.total) * 100) : 100, uptime7d: s7d ? Math.round((s7d.up_count / s7d.total) * 100) : 100, + history: historyMap[l.name] || [], }; }); return NextResponse.json(results); } catch (error) { console.error('Uptime stats error:', error); - // Fallback to simple check if DB fails return NextResponse.json([]); } } diff --git a/app/api/visitors/route.ts b/app/api/visitors/route.ts index b761290..38d5ef7 100644 --- a/app/api/visitors/route.ts +++ b/app/api/visitors/route.ts @@ -1,35 +1,76 @@ import { NextResponse } from 'next/server'; -import { getDb } from '@/lib/db'; +import { getVisitorsDb } from '@/lib/visitors-db'; +import geoip from 'geoip-lite'; export const dynamic = 'force-dynamic'; export async function GET() { try { - const db = await getDb(); + const db = await getVisitorsDb(); - // Get active/recent visitors (last 24h) - const recent = await db.all(` - SELECT lat, lon, city, country, count(*) as count - FROM visitors - WHERE timestamp > datetime('now', '-24 hours') - GROUP BY lat, lon - `); + if (!db) { + return NextResponse.json({ + locations: [], + totalVisitors: 0, + active24h: 0, + active7d: 0, + }); + } - // Get total count - const total = await db.get(`SELECT count(*) as count FROM visitors`); + // Count unique visitors across time windows + const [total, last24h, last7d] = await Promise.all([ + db.get(`SELECT COUNT(DISTINCT ip_address) as count FROM visits`), + db.get(`SELECT COUNT(DISTINCT ip_address) as count FROM visits WHERE visited_at >= datetime('now', '-24 hours')`), + db.get(`SELECT COUNT(DISTINCT ip_address) as count FROM visits WHERE visited_at >= datetime('now', '-7 days')`), + ]); + + // Get unique IPs from last 7 days for globe markers + const recentIps = await db.all( + `SELECT ip_address, COUNT(*) as hits FROM visits WHERE visited_at >= datetime('now', '-7 days') GROUP BY ip_address` + ); + + // Geo-locate IPs and group by location + const locationMap = new Map(); + + for (const row of recentIps) { + const geo = geoip.lookup(row.ip_address); + if (geo && geo.ll) { + const key = `${geo.ll[0]},${geo.ll[1]}`; + const existing = locationMap.get(key); + if (existing) { + existing.count += row.hits; + } else { + locationMap.set(key, { + lat: geo.ll[0], + lon: geo.ll[1], + city: geo.city || 'Unknown', + country: geo.country || 'Unknown', + count: row.hits, + }); + } + } + } + + const locations = Array.from(locationMap.values()).map(loc => ({ + location: [loc.lat, loc.lon], + size: Math.min(0.1, 0.05 + loc.count * 0.005), + city: loc.city, + country: loc.country, + })); return NextResponse.json({ - locations: recent.map(r => ({ - location: [r.lat, r.lon], - size: Math.min(0.1, 0.05 + (r.count * 0.005)), // Scale size by visitor count - city: r.city, - country: r.country - })), - totalVisitors: total.count, - active24h: recent.reduce((sum, r) => sum + r.count, 0) + locations, + totalVisitors: total?.count || 0, + active24h: last24h?.count || 0, + active7d: last7d?.count || 0, }); } catch (error) { console.error('Visitor fetch error:', error); - return NextResponse.json({ locations: [], totalVisitors: 0, active24h: 0 }); + return NextResponse.json({ + locations: [], + totalVisitors: 0, + active24h: 0, + active7d: 0, + }); } } diff --git a/components/widgets/GlobeCard.tsx b/components/widgets/GlobeCard.tsx index 48af299..9abf475 100644 --- a/components/widgets/GlobeCard.tsx +++ b/components/widgets/GlobeCard.tsx @@ -6,7 +6,12 @@ import { Globe } from 'lucide-react'; export function GlobeCard() { const canvasRef = useRef(null); - const [visitorStats, setVisitorStats] = useState({ total: 0, active24h: 0, locations: [] }); + const [visitorStats, setVisitorStats] = useState({ + total: 0, + active24h: 0, + active7d: 0, + locations: [], + }); useEffect(() => { async function fetchVisitors() { @@ -16,14 +21,15 @@ export function GlobeCard() { setVisitorStats({ total: data.totalVisitors || 0, active24h: data.active24h || 0, - locations: data.locations || [] + active7d: data.active7d || 0, + locations: data.locations || [], }); } catch (e) { console.error('Failed to load visitors', e); } } fetchVisitors(); - const interval = setInterval(fetchVisitors, 30000); // 30s poll + const interval = setInterval(fetchVisitors, 30000); return () => clearInterval(interval); }, []); @@ -64,18 +70,22 @@ export function GlobeCard() { Visitor Map -
+
{visitorStats.active24h} LAST 24H
+
+ {visitorStats.active7d} + LAST 7D +
{visitorStats.total} - TOTAL + ALL TIME
diff --git a/components/widgets/UptimeCard.tsx b/components/widgets/UptimeCard.tsx index 86c8f28..5814687 100644 --- a/components/widgets/UptimeCard.tsx +++ b/components/widgets/UptimeCard.tsx @@ -10,14 +10,9 @@ interface ServiceStatus { latency: number; uptime24h?: number; uptime7d?: number; + history?: Array<{ hour: string; up: boolean }>; } -// We'd ideally fetch stats from API, but for now we calculate from live data or mock -// To do this properly, we need an API endpoint returning stats. -// Let's update `api/status` to also return stats or create `api/status/stats`. -// For this step, I'll update the visual to SHOW where stats would be, -// and we'll implement the backend stats aggregation in the next step. - export function UptimeCard() { const [services, setServices] = useState([]); const [loading, setLoading] = useState(true); @@ -72,11 +67,20 @@ export function UptimeCard() { {service.latency}ms - {/* Mini bars visualization for history - Mocked visual for now until API is ready */}
- {[...Array(20)].map((_, i) => ( -
0.95 ? 'bg-red-500' : 'bg-emerald-500'}`} /> - ))} + {service.history && service.history.length > 0 ? ( + service.history.slice(-24).map((h, i) => ( +
+ )) + ) : ( + [...Array(24)].map((_, i) => ( +
+ )) + )}
24h: {service.uptime24h ?? 100}% diff --git a/docker-compose.yml b/docker-compose.yml index 3ea0dc8..830c112 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,11 @@ services: restart: always ports: - "3333:3000" + extra_hosts: + - "host.docker.internal:host-gateway" environment: - NODE_ENV=production - DB_PATH=/app/data/dashboard.db volumes: - ./data:/app/data + - /server_storage:/server_storage:ro diff --git a/lib/visitors-db.ts b/lib/visitors-db.ts new file mode 100644 index 0000000..f3be837 --- /dev/null +++ b/lib/visitors-db.ts @@ -0,0 +1,25 @@ +import sqlite3 from 'sqlite3'; +import { open, Database } from 'sqlite'; + +let db: Database | null = null; + +/** + * Opens a read-only connection to the webserver's visitors.db. + * Returns null if the DB doesn't exist yet (webserver hasn't created it). + * Retries on each call until the DB becomes available. + */ +export async function getVisitorsDb(): Promise { + if (db) return db; + + try { + db = await open({ + filename: '/server_storage/visitors.db', + driver: sqlite3.Database, + mode: sqlite3.OPEN_READONLY, + }); + return db; + } catch { + // DB doesn't exist yet — don't cache the failure so we retry next time + return null; + } +} diff --git a/monitor.js b/monitor.js index 54590ee..00adedc 100644 --- a/monitor.js +++ b/monitor.js @@ -6,7 +6,7 @@ const fetch = require('node-fetch'); // Native fetch in Node 18, but let's be sa const SERVICES = [ { name: 'Website', url: 'https://www.akkolli.net' }, { name: 'Gitea', url: 'https://code.akkolli.net' }, - { name: 'Nextcloud', url: 'http://localhost:6060' }, + { name: 'Nextcloud', url: 'http://host.docker.internal:6060' }, ]; async function monitor() { diff --git a/next.config.mjs b/next.config.mjs index 724478d..6d36c21 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -9,6 +9,7 @@ const nextConfig = { // This is needed to copy the geoip-lite data files to the standalone build outputFileTracingIncludes: { '/api/track': ['./node_modules/geoip-lite/data/**/*'], + '/api/visitors': ['./node_modules/geoip-lite/data/**/*'], }, }, webpack: (config) => {