feat: add analytics dashboard with traffic monitoring

- Parse Caddy access logs every 30s into traffic_events SQLite table
- GeoIP country lookup via maxmind (GeoLite2-Country.mmdb)
- 90-day retention with automatic purge
- Analytics page with interval (24h/7d/30d) and per-host filtering:
  - Stats cards: total requests, unique IPs, blocked count, block rate
  - Requests-over-time area chart (ApexCharts)
  - SVG world choropleth map (d3-geo + topojson-client, React 19 compatible)
  - Top countries table with flag emojis
  - HTTP protocol donut chart
  - Top user agents horizontal bar chart
  - Recent blocked requests table with pagination
- Traffic (24h) summary card on Overview page linking to analytics
- 7 authenticated API routes under /api/analytics/
- Share caddy-logs volume with web container (read-only)
- group_add caddy GID to web container for log file read access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-02-26 20:43:23 +01:00
parent 674e06e3c9
commit 8be69d2774
23 changed files with 2584 additions and 16 deletions

View File

@@ -185,3 +185,30 @@ export const linkingTokens = sqliteTable("linking_tokens", {
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull()
});
export const trafficEvents = sqliteTable(
'traffic_events',
{
id: integer('id').primaryKey({ autoIncrement: true }),
ts: integer('ts').notNull(),
clientIp: text('client_ip').notNull(),
countryCode: text('country_code'),
host: text('host').notNull().default(''),
method: text('method').notNull().default(''),
uri: text('uri').notNull().default(''),
status: integer('status').notNull().default(0),
proto: text('proto').notNull().default(''),
bytesSent: integer('bytes_sent').notNull().default(0),
userAgent: text('user_agent').notNull().default(''),
isBlocked: integer('is_blocked', { mode: 'boolean' }).notNull().default(false),
},
(table) => ({
tsIdx: index('idx_traffic_events_ts').on(table.ts),
hostTsIdx: index('idx_traffic_events_host_ts').on(table.host, table.ts),
})
);
export const logParseState = sqliteTable('log_parse_state', {
key: text('key').primaryKey(),
value: text('value').notNull(),
});