Wire up visitor metrics from webserver DB and fix uptime monitoring
- Read visitors from /server_storage/visitors.db (webserver's DB) instead of admin dash's own table; geoip lookups at query time for globe markers - Globe card now shows 24h, 7d, and all-time unique visitor counts - Uptime monitor: Nextcloud via host.docker.internal for Docker networking, Website and Gitea monitored on public domains - UptimeCard uses real hourly history bars instead of Math.random() mock - docker-compose: mount /server_storage:ro, add extra_hosts for Linux compat Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
106
ADMIN_DASH_INTEGRATION.md
Normal file
106
ADMIN_DASH_INTEGRATION.md
Normal file
@@ -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:
|
||||||
|
```
|
||||||
@@ -9,31 +9,53 @@ export async function GET() {
|
|||||||
|
|
||||||
// Get live status (most recent log per service)
|
// Get live status (most recent log per service)
|
||||||
const live = await db.all(`
|
const live = await db.all(`
|
||||||
SELECT service_name as name, url, status, latency
|
SELECT service_name as name, url, status, latency
|
||||||
FROM uptime_logs
|
FROM uptime_logs
|
||||||
WHERE id IN (SELECT MAX(id) FROM uptime_logs GROUP BY service_name)
|
WHERE id IN (SELECT MAX(id) FROM uptime_logs GROUP BY service_name)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get 24h stats
|
// Get 24h stats
|
||||||
const stats24h = await db.all(`
|
const stats24h = await db.all(`
|
||||||
SELECT service_name,
|
SELECT service_name,
|
||||||
count(*) as total,
|
count(*) as total,
|
||||||
sum(case when status = 'up' then 1 else 0 end) as up_count
|
sum(case when status = 'up' then 1 else 0 end) as up_count
|
||||||
FROM uptime_logs
|
FROM uptime_logs
|
||||||
WHERE timestamp > datetime('now', '-24 hours')
|
WHERE timestamp > datetime('now', '-24 hours')
|
||||||
GROUP BY service_name
|
GROUP BY service_name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get 7d stats
|
// Get 7d stats
|
||||||
const stats7d = await db.all(`
|
const stats7d = await db.all(`
|
||||||
SELECT service_name,
|
SELECT service_name,
|
||||||
count(*) as total,
|
count(*) as total,
|
||||||
sum(case when status = 'up' then 1 else 0 end) as up_count
|
sum(case when status = 'up' then 1 else 0 end) as up_count
|
||||||
FROM uptime_logs
|
FROM uptime_logs
|
||||||
WHERE timestamp > datetime('now', '-7 days')
|
WHERE timestamp > datetime('now', '-7 days')
|
||||||
GROUP BY service_name
|
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<string, Array<{ hour: string; up: boolean }>> = {};
|
||||||
|
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
|
// Merge data
|
||||||
const results = live.map(l => {
|
const results = live.map(l => {
|
||||||
const s24 = stats24h.find(s => s.service_name === l.name);
|
const s24 = stats24h.find(s => s.service_name === l.name);
|
||||||
@@ -43,13 +65,13 @@ export async function GET() {
|
|||||||
...l,
|
...l,
|
||||||
uptime24h: s24 ? Math.round((s24.up_count / s24.total) * 100) : 100,
|
uptime24h: s24 ? Math.round((s24.up_count / s24.total) * 100) : 100,
|
||||||
uptime7d: s7d ? Math.round((s7d.up_count / s7d.total) * 100) : 100,
|
uptime7d: s7d ? Math.round((s7d.up_count / s7d.total) * 100) : 100,
|
||||||
|
history: historyMap[l.name] || [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(results);
|
return NextResponse.json(results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Uptime stats error:', error);
|
console.error('Uptime stats error:', error);
|
||||||
// Fallback to simple check if DB fails
|
|
||||||
return NextResponse.json([]);
|
return NextResponse.json([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,76 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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 const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const db = await getDb();
|
const db = await getVisitorsDb();
|
||||||
|
|
||||||
// Get active/recent visitors (last 24h)
|
if (!db) {
|
||||||
const recent = await db.all(`
|
return NextResponse.json({
|
||||||
SELECT lat, lon, city, country, count(*) as count
|
locations: [],
|
||||||
FROM visitors
|
totalVisitors: 0,
|
||||||
WHERE timestamp > datetime('now', '-24 hours')
|
active24h: 0,
|
||||||
GROUP BY lat, lon
|
active7d: 0,
|
||||||
`);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Get total count
|
// Count unique visitors across time windows
|
||||||
const total = await db.get(`SELECT count(*) as count FROM visitors`);
|
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<string, { lat: number; lon: number; city: string; country: string; count: number }>();
|
||||||
|
|
||||||
|
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({
|
return NextResponse.json({
|
||||||
locations: recent.map(r => ({
|
locations,
|
||||||
location: [r.lat, r.lon],
|
totalVisitors: total?.count || 0,
|
||||||
size: Math.min(0.1, 0.05 + (r.count * 0.005)), // Scale size by visitor count
|
active24h: last24h?.count || 0,
|
||||||
city: r.city,
|
active7d: last7d?.count || 0,
|
||||||
country: r.country
|
|
||||||
})),
|
|
||||||
totalVisitors: total.count,
|
|
||||||
active24h: recent.reduce((sum, r) => sum + r.count, 0)
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Visitor fetch error:', 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { Globe } from 'lucide-react';
|
|||||||
|
|
||||||
export function GlobeCard() {
|
export function GlobeCard() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [visitorStats, setVisitorStats] = useState({ total: 0, active24h: 0, locations: [] });
|
const [visitorStats, setVisitorStats] = useState({
|
||||||
|
total: 0,
|
||||||
|
active24h: 0,
|
||||||
|
active7d: 0,
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchVisitors() {
|
async function fetchVisitors() {
|
||||||
@@ -16,14 +21,15 @@ export function GlobeCard() {
|
|||||||
setVisitorStats({
|
setVisitorStats({
|
||||||
total: data.totalVisitors || 0,
|
total: data.totalVisitors || 0,
|
||||||
active24h: data.active24h || 0,
|
active24h: data.active24h || 0,
|
||||||
locations: data.locations || []
|
active7d: data.active7d || 0,
|
||||||
|
locations: data.locations || [],
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load visitors', e);
|
console.error('Failed to load visitors', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchVisitors();
|
fetchVisitors();
|
||||||
const interval = setInterval(fetchVisitors, 30000); // 30s poll
|
const interval = setInterval(fetchVisitors, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -64,18 +70,22 @@ export function GlobeCard() {
|
|||||||
<Globe size={18} />
|
<Globe size={18} />
|
||||||
<span className="text-sm font-medium">Visitor Map</span>
|
<span className="text-sm font-medium">Visitor Map</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-2xl font-bold text-white tracking-tight block">{visitorStats.active24h}</span>
|
<span className="text-2xl font-bold text-white tracking-tight block">{visitorStats.active24h}</span>
|
||||||
<span className="text-xs text-neutral-500 font-mono">LAST 24H</span>
|
<span className="text-xs text-neutral-500 font-mono">LAST 24H</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-lg font-bold text-neutral-300 block">{visitorStats.active7d}</span>
|
||||||
|
<span className="text-xs text-neutral-500 font-mono">LAST 7D</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute top-6 right-6 z-10 text-right pointer-events-none">
|
<div className="absolute top-6 right-6 z-10 text-right pointer-events-none">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xl font-bold text-neutral-300 block">{visitorStats.total}</span>
|
<span className="text-xl font-bold text-neutral-300 block">{visitorStats.total}</span>
|
||||||
<span className="text-xs text-neutral-500 font-mono">TOTAL</span>
|
<span className="text-xs text-neutral-500 font-mono">ALL TIME</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,9 @@ interface ServiceStatus {
|
|||||||
latency: number;
|
latency: number;
|
||||||
uptime24h?: number;
|
uptime24h?: number;
|
||||||
uptime7d?: 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() {
|
export function UptimeCard() {
|
||||||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -72,11 +67,20 @@ export function UptimeCard() {
|
|||||||
<span className="text-xs text-neutral-500 font-mono w-10 text-right">{service.latency}ms</span>
|
<span className="text-xs text-neutral-500 font-mono w-10 text-right">{service.latency}ms</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Mini bars visualization for history - Mocked visual for now until API is ready */}
|
|
||||||
<div className="flex gap-[2px] h-1.5 opacity-50">
|
<div className="flex gap-[2px] h-1.5 opacity-50">
|
||||||
{[...Array(20)].map((_, i) => (
|
{service.history && service.history.length > 0 ? (
|
||||||
<div key={i} className={`flex-1 rounded-full ${Math.random() > 0.95 ? 'bg-red-500' : 'bg-emerald-500'}`} />
|
service.history.slice(-24).map((h, i) => (
|
||||||
))}
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex-1 rounded-full ${h.up ? 'bg-emerald-500' : 'bg-red-500'}`}
|
||||||
|
title={h.hour}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
[...Array(24)].map((_, i) => (
|
||||||
|
<div key={i} className="flex-1 rounded-full bg-neutral-700" />
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-[10px] text-neutral-600 font-mono">
|
<div className="flex justify-between text-[10px] text-neutral-600 font-mono">
|
||||||
<span>24h: {service.uptime24h ?? 100}%</span>
|
<span>24h: {service.uptime24h ?? 100}%</span>
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "3333:3000"
|
- "3333:3000"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DB_PATH=/app/data/dashboard.db
|
- DB_PATH=/app/data/dashboard.db
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- /server_storage:/server_storage:ro
|
||||||
|
|||||||
25
lib/visitors-db.ts
Normal file
25
lib/visitors-db.ts
Normal file
@@ -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<Database | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ const fetch = require('node-fetch'); // Native fetch in Node 18, but let's be sa
|
|||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
{ name: 'Website', url: 'https://www.akkolli.net' },
|
{ name: 'Website', url: 'https://www.akkolli.net' },
|
||||||
{ name: 'Gitea', url: 'https://code.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() {
|
async function monitor() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const nextConfig = {
|
|||||||
// This is needed to copy the geoip-lite data files to the standalone build
|
// This is needed to copy the geoip-lite data files to the standalone build
|
||||||
outputFileTracingIncludes: {
|
outputFileTracingIncludes: {
|
||||||
'/api/track': ['./node_modules/geoip-lite/data/**/*'],
|
'/api/track': ['./node_modules/geoip-lite/data/**/*'],
|
||||||
|
'/api/visitors': ['./node_modules/geoip-lite/data/**/*'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user