From eee889dddde20b3afdef9a3f6bd8f08ecab22e44 Mon Sep 17 00:00:00 2001 From: 1337lean <1337lean@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:47:31 -0400 Subject: [PATCH] Add observability dashboards and host ingestion - Add HTTP diagnostics, traffic filters, and paginated log views - Surface host runtime metrics and authenticated host collector support - Update deployment docs, defaults, and tests for the new flows --- .env.example | 9 +- DEPLOYMENT.md | 33 ++- README.md | 24 +- app/(dashboard)/dashboard/page.tsx | 29 ++- app/(dashboard)/http/page.tsx | 63 +++++ app/(dashboard)/live/page.tsx | 17 +- app/(dashboard)/loading.tsx | 3 + app/(dashboard)/logs/page.tsx | 53 ++-- app/(dashboard)/security/page.tsx | 88 +++---- app/(dashboard)/server/page.tsx | 46 ++-- app/(dashboard)/settings/page.tsx | 18 +- app/(dashboard)/sites/[siteId]/page.tsx | 34 ++- app/(dashboard)/sites/page.tsx | 13 +- app/actions.ts | 9 +- app/api/ingest/host/route.ts | 70 +++++ app/api/ingest/http/route.ts | 117 +++++++++ app/api/security/ingest/route.ts | 12 +- app/globals.css | 161 +++++++++++- app/login/page.tsx | 5 +- app/tracker.js/route.ts | 84 +++++- components/Charts.tsx | 13 + components/ConfirmSubmit.tsx | 5 + components/CopyField.tsx | 10 + components/DataTable.tsx | 3 + components/DateRangeFilter.tsx | 23 ++ components/DisclosurePanel.tsx | 3 + components/EmptyState.tsx | 3 + components/FilterBar.tsx | 3 + components/InfoCallout.tsx | 3 + components/NavLinks.tsx | 12 + components/Pagination.tsx | 17 ++ components/RangeSelector.tsx | 5 +- components/Shell.tsx | 8 +- components/StateMessage.tsx | 4 +- components/StatusBadge.tsx | 7 + components/TrafficToggle.tsx | 17 ++ deploy/Caddyfile.example | 39 +++ deploy/bufferdash-agent.env.example | 4 + deploy/bufferdash-host-agent.service | 28 ++ lib/data.ts | 103 +++++--- lib/env.ts | 14 +- lib/filters.ts | 76 ++++++ lib/format.ts | 1 + lib/geo.ts | 21 +- lib/ingestion.ts | 44 ++++ lib/ip.ts | 3 +- lib/list-data.ts | 160 ++++++++++++ lib/security-events.ts | 3 +- lib/server-metrics.ts | 40 ++- lib/tracking.ts | 68 +++-- package.json | 2 +- .../migration.sql | 79 ++++++ prisma/schema.prisma | 70 +++++ scripts/background-worker.mjs | 7 +- scripts/bufferdash-host-agent.py | 240 ++++++++++++++++++ scripts/install-host-agent.sh | 24 ++ scripts/production-check.sh | 5 +- tests/data.integration.test.ts | 34 +++ tests/geo.test.ts | 22 +- tests/host_agent_test.py | 28 ++ tests/ingest.integration.test.ts | 76 ++++++ tests/ingestion.test.ts | 22 ++ tests/tracker.test.ts | 75 +++++- 63 files changed, 2057 insertions(+), 255 deletions(-) create mode 100644 app/(dashboard)/http/page.tsx create mode 100644 app/(dashboard)/loading.tsx create mode 100644 app/api/ingest/host/route.ts create mode 100644 app/api/ingest/http/route.ts create mode 100644 components/ConfirmSubmit.tsx create mode 100644 components/CopyField.tsx create mode 100644 components/DataTable.tsx create mode 100644 components/DateRangeFilter.tsx create mode 100644 components/DisclosurePanel.tsx create mode 100644 components/EmptyState.tsx create mode 100644 components/FilterBar.tsx create mode 100644 components/InfoCallout.tsx create mode 100644 components/NavLinks.tsx create mode 100644 components/Pagination.tsx create mode 100644 components/StatusBadge.tsx create mode 100644 components/TrafficToggle.tsx create mode 100644 deploy/Caddyfile.example create mode 100644 deploy/bufferdash-agent.env.example create mode 100644 deploy/bufferdash-host-agent.service create mode 100644 lib/filters.ts create mode 100644 lib/ingestion.ts create mode 100644 lib/list-data.ts create mode 100644 prisma/migrations/20260715120000_analytics_observability/migration.sql create mode 100755 scripts/bufferdash-host-agent.py create mode 100755 scripts/install-host-agent.sh create mode 100644 tests/host_agent_test.py create mode 100644 tests/ingest.integration.test.ts create mode 100644 tests/ingestion.test.ts diff --git a/.env.example b/.env.example index cc0785a..e1fffff 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ LOCAL_ONLY=false APP_URL=https://dash.buffer.lol BIND_ADDRESS=127.0.0.1 -APP_PORT=3000 +APP_PORT=3001 TZ=America/New_York # Database @@ -32,15 +32,18 @@ ENFORCE_TRACKING_ORIGIN=true IPINFO_TOKEN= IPINFO_TIER=lite -# Optional structured events from a trusted reverse proxy or host security agent. +# Authenticated host collector. Keep the three ingestion switches aligned with the installed agent. ENABLE_LOG_INGESTION=false +ENABLE_HTTP_INGESTION=false +ENABLE_HOST_INGESTION=false INGESTION_SECRET= # Security RATE_LIMIT_TRACKING_PER_MINUTE=120 RATE_LIMIT_ADMIN_PER_MINUTE=60 -# Server Monitoring Optional +# Server monitoring. host is recommended; ENABLE_SERVER_METRICS remains a temporary legacy fallback. +SERVER_METRICS_SOURCE=disabled ENABLE_SERVER_METRICS=false METRICS_INTERVAL_SECONDS=60 CLEANUP_INTERVAL_HOURS=24 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 457ae59..218a796 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -39,7 +39,9 @@ ANONYMIZE_IP=true TRUST_PROXY=true ENFORCE_TRACKING_ORIGIN=true FILTER_BOTS=false -ENABLE_SERVER_METRICS=true +SERVER_METRICS_SOURCE=host +ENABLE_HTTP_INGESTION=true +ENABLE_HOST_INGESTION=true DATA_RETENTION_DAYS=90 ``` @@ -58,7 +60,7 @@ It checks secret strength and separation, the admin hash, HTTPS and loopback set ```bash scripts/deploy-production.sh .env docker compose ps -curl -fsS http://127.0.0.1:3000/health +curl -fsS http://127.0.0.1:3001/health ``` Both `app` and `worker` should become healthy, `migrate` should exit successfully, and `postgres` should remain healthy. @@ -67,13 +69,26 @@ The deploy script automatically backs up a running database before an update, re ## Caddy -```caddy -dash.buffer.lol { - encode zstd gzip - reverse_proxy 127.0.0.1:3000 -} +Use [deploy/Caddyfile.example](deploy/Caddyfile.example) as the starting point. It enables strict trusted-proxy parsing for Cloudflare, overwrites upstream client-IP headers, and writes permission-restricted JSON access logs with 10 MiB rotation, seven rolled files, and seven-day retention. Re-check Cloudflare's published ranges before every proxy change. + +Validate the final configuration, then restart Caddy in a short maintenance window because changes to an existing file output may not take effect on reload: + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl restart caddy +``` + +Install the host collector after setting the same strong ingestion secret in `.env` and `/etc/bufferdash-agent.env`: + +```bash +sudo scripts/install-host-agent.sh +sudoedit /etc/bufferdash-agent.env +sudo scripts/install-host-agent.sh +systemctl status bufferdash-host-agent ``` +The agent posts only to `http://127.0.0.1:3001`, strips queries before transmission, retains its inode/offset checkpoint only after successful ingestion, and reads VPS metrics from `/proc`, `/sys`, and the root filesystem. + Allow only SSH, HTTP, and HTTPS through the VPS firewall. Port 3000 must remain bound to loopback, and PostgreSQL must not be exposed publicly. ## Connect buffer.lol @@ -173,5 +188,7 @@ docker compose up -d - Confirm the live `buffer.lol/bufferdash.js` loader points to `dash.buffer.lol/tracker.js`. - Confirm a page view appears in BufferDash. - Confirm query strings containing test values do not appear in events. -- Confirm PostgreSQL and port 3000 are unreachable from the public internet. +- Confirm PostgreSQL and the configured application port are unreachable from the public internet. +- Confirm Settings shows fresh `caddy` and `host` collectors, then compare Runtime against `top`, `free`, `df /`, and `/proc/uptime`. +- Generate controlled 404 and 500 responses and confirm sanitized samples appear under HTTP. - Confirm backups exist off-host and can be restored. diff --git a/README.md b/README.md index 932b54a..0852d0d 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,12 @@ BufferDash is a self-hosted, first-party web analytics dashboard with traffic-qu - Secure IP handling with optional anonymization and hashed IPs - Bot, unknown-path, failed-login, and rate-limit security signals - Protected admin dashboard with signed HTTP-only sessions and CSRF checks -- Background retention cleanup and optional runtime metric collection +- Background retention cleanup, Caddy HTTP diagnostics, and explicit VPS-host metrics - Docker Compose setup with PostgreSQL ## VPS Deployment -BufferDash should run behind Caddy or Nginx at an HTTPS hostname such as `https://dash.buffer.lol`. PostgreSQL remains inside Docker, while the app binds only to `127.0.0.1:3000`. +BufferDash should run behind Caddy at an HTTPS hostname such as `https://dash.buffer.lol`. PostgreSQL remains inside Docker, while the app binds only to loopback. ```bash git clone https://github.com/1337lean/bufferdash.git @@ -60,7 +60,7 @@ Run the production preflight, deploy, and verify: scripts/production-check.sh .env scripts/deploy-production.sh .env docker compose ps -curl -fsS http://127.0.0.1:3000/health +curl -fsS http://127.0.0.1:3001/health ``` The deploy command takes a database backup before updating an existing installation, applies migrations, waits for the app, worker, and database to become healthy, and fails if any production guardrail is missing. @@ -122,6 +122,20 @@ window.bufferdash.track("tool_used", { }); ``` +The convenience API is equivalent: + +```js +window.bufferdash.trackTool("ping-checker", { mode: "tcp" }); +``` + +For interactive elements, declarative tracking emits exactly one `tool_used` event per activation: + +```html + +``` + +BufferDash does not infer tool usage from arbitrary clicks. Each tracked application must mark its primary tool action or call `trackTool()` explicitly. + The tracker excludes form inputs, cookies, localStorage contents, passwords, URL fragments, and query strings by default. Add `data-include-query` only after auditing every tracked URL. ## GeoIP @@ -143,8 +157,8 @@ GeoIP sends visitor IPs to the configured provider. Leave the token empty if tha - `ANONYMIZE_IP` - `ENFORCE_TRACKING_ORIGIN` - `IPINFO_TOKEN` and `IPINFO_TIER` -- `ENABLE_LOG_INGESTION` and `INGESTION_SECRET` -- `ENABLE_SERVER_METRICS` +- `ENABLE_LOG_INGESTION`, `ENABLE_HTTP_INGESTION`, `ENABLE_HOST_INGESTION`, and `INGESTION_SECRET` +- `SERVER_METRICS_SOURCE=host|container|disabled` (`ENABLE_SERVER_METRICS` is a temporary compatibility fallback) - `DATA_RETENTION_DAYS` ## Security Notes diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index c3d2d30..0eeda96 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -7,11 +7,20 @@ import { getDashboardData, getRecentEvents } from "@/lib/data"; import { compactDuration, numberFormat, shortDate } from "@/lib/format"; import { maskIp } from "@/lib/ip"; import { parseRange, rangeLabel } from "@/lib/range"; +import { parseTraffic, type SearchParams } from "@/lib/filters"; +import { TrafficToggle } from "@/components/TrafficToggle"; +import { StatusBadge } from "@/components/StatusBadge"; +import { InfoCallout } from "@/components/InfoCallout"; +import { env } from "@/lib/env"; -export default async function DashboardPage({ searchParams }: { searchParams: Promise<{ range?: string }> }) { - const range = parseRange((await searchParams).range); - const [data, recentEvents] = await Promise.all([getDashboardData(undefined, range), getRecentEvents(undefined, 10)]); +export default async function DashboardPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams; + const range = parseRange(params.range); + const traffic = parseTraffic(params.traffic, "human"); + const [data, recentEvents] = await Promise.all([getDashboardData(undefined, range, traffic), getRecentEvents(undefined, 10, traffic)]); const { overview } = data; + const cloudflareNetworks = recentEvents.filter((event) => event.asn?.toUpperCase() === "AS13335" || event.isp?.toLowerCase().includes("cloudflare")).length; + const proxyWarning = recentEvents.length >= 5 && cloudflareNetworks / recentEvents.length > 0.5; return ( <> @@ -20,7 +29,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr title="Traffic, health, and security at a glance" description="A live command center for buffer.lol and any other site you add." /> - + + + {proxyWarning && Most recent client networks resolve to Cloudflare. This can indicate that the origin is storing the proxy address instead of Caddy's parsed client IP.}
@@ -49,7 +60,10 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr - + {data.cities.length ? :

Cities

+ + {overview.pageViews === 0 ? "City data will appear with new page views." : !env.ipinfoToken ? "Configure IPINFO_TOKEN and IPINFO_TIER=core for city analytics." : env.ipinfoTier === "lite" ? "IPinfo Lite supplies country and ASN; Core is needed for city and region." : "Core is configured, but recent events did not include a city."} +
}
@@ -61,7 +75,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
- + {recentEvents.map((event) => ( @@ -70,10 +84,11 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr + ))} - {recentEvents.length === 0 && } + {recentEvents.length === 0 && }
TimeSitePathVisitorBrowser
TimeSitePathVisitorClassificationBrowser
{event.site.name} {event.path || event.type} {maskIp(event.ipAddress)} {event.browser || "Unknown"}
No events yet. Add a site and install the tracker.
No events match this traffic view.
diff --git a/app/(dashboard)/http/page.tsx b/app/(dashboard)/http/page.tsx new file mode 100644 index 0000000..69ae5f6 --- /dev/null +++ b/app/(dashboard)/http/page.tsx @@ -0,0 +1,63 @@ +import { HttpStatusChart } from "@/components/Charts"; +import { DataTable } from "@/components/DataTable"; +import { DateRangeFilter } from "@/components/DateRangeFilter"; +import { FilterBar } from "@/components/FilterBar"; +import { InfoCallout } from "@/components/InfoCallout"; +import { MetricCard } from "@/components/MetricCard"; +import { PageHeader } from "@/components/PageHeader"; +import { Pagination } from "@/components/Pagination"; +import { StatusBadge } from "@/components/StatusBadge"; +import { TopList } from "@/components/TopList"; +import { TrafficToggle } from "@/components/TrafficToggle"; +import { parseDateWindow, parsePage, parsePageSize, parseTraffic, type SearchParams } from "@/lib/filters"; +import { compactDuration, numberFormat, shortDate } from "@/lib/format"; +import { getHttpPage } from "@/lib/list-data"; +import { maskIp } from "@/lib/ip"; + +const one = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; + +export default async function HttpPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams; + const window = parseDateWindow(params); const page = parsePage(params.page); const pageSize = parsePageSize(params.pageSize); + const traffic = parseTraffic(params.traffic, "all"); + const statusValue = Number(one(params.status)); + const status = Number.isInteger(statusValue) && statusValue >= 100 && statusValue <= 599 ? statusValue : undefined; + const statusCandidate = one(params.statusClass); + const statusClass = ["2xx", "3xx", "4xx", "5xx"].includes(statusCandidate || "") ? statusCandidate : undefined; + const filters = { host: one(params.host), method: one(params.method)?.toUpperCase(), statusClass, status, path: one(params.path) }; + const data = await getHttpPage({ ...window, page, pageSize, traffic, ...filters }); + const requests = data.summary.requests; + const rate4xx = requests ? data.summary.errors4xx / requests * 100 : 0; const rate5xx = requests ? data.summary.errors5xx / requests * 100 : 0; + const byTime = new Map(); + for (const row of data.timeline) { const key = new Date(row.bucket).toISOString(); const item = byTime.get(key) || { time: shortDate(new Date(row.bucket)), "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0 }; if (row.class in item) item[row.class as "2xx"] = row.count; byTime.set(key, item); } + const stale = !data.source || window.end.getTime() - data.source.lastSeenAt.getTime() > 150_000; + return <> + + + +
+ {window.from && }{window.to && } + + + + + + + +
+ {data.source ? <>Last Caddy batch {shortDate(data.source.lastSeenAt)}{data.source.hostname ? ` from ${data.source.hostname}` : ""}. : "No Caddy request batch has been received."} Cloudflare edge-only errors that never reach the VPS are outside this view. +
+ + + + +
+

Status timeline

2xx / 3xx / 4xx / 5xx
+
+

Recent 4xx/5xx samples

Sanitized; no queries, bodies, cookies, or authorization
+ TimeStatusHostMethodPathDurationVisitorClassificationProxy error + {data.samples.map((sample) => {shortDate(sample.occurredAt)}= 500 ? "error" : "warning"}`}>{sample.status >= 500 ? "Server error" : "Client error"} · {sample.status}{sample.host}{sample.method}{sample.path}{compactDuration(sample.durationMs)}{maskIp(sample.ipAddress)}{sample.proxyError || "—"})} + {!data.samples.length && No 4xx/5xx samples match these filters.} +
+ ; +} diff --git a/app/(dashboard)/live/page.tsx b/app/(dashboard)/live/page.tsx index 89b6fd8..4f29cd7 100644 --- a/app/(dashboard)/live/page.tsx +++ b/app/(dashboard)/live/page.tsx @@ -3,23 +3,30 @@ import { AutoRefresh } from "@/components/AutoRefresh"; import { getLiveVisitors } from "@/lib/data"; import { shortDate } from "@/lib/format"; import { maskIp } from "@/lib/ip"; +import { parseTraffic, type SearchParams } from "@/lib/filters"; +import { TrafficToggle } from "@/components/TrafficToggle"; +import { StatusBadge } from "@/components/StatusBadge"; -export default async function LivePage() { - const visitors = await getLiveVisitors(); +export default async function LivePage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams; + const traffic = parseTraffic(params.traffic, "human"); + const visitors = await getLiveVisitors(undefined, traffic); return ( <> - + +
- + {visitors.map((event) => ( + @@ -29,7 +36,7 @@ export default async function LivePage() { ))} - {visitors.length === 0 && } + {visitors.length === 0 && }
TimeIPLocationSitePageReferrerBrowserOSDevice
TimeIPClassLocationSitePageReferrerBrowserOSDevice
{shortDate(event.createdAt)} {maskIp(event.ipAddress)} {[event.city, event.country].filter(Boolean).join(", ") || "Unknown"} {event.site.name} {event.path || event.type}{event.device || "Unknown"}
No active visitors right now.
No active visitors match this traffic view.
diff --git a/app/(dashboard)/loading.tsx b/app/(dashboard)/loading.tsx new file mode 100644 index 0000000..cf87d5c --- /dev/null +++ b/app/(dashboard)/loading.tsx @@ -0,0 +1,3 @@ +export default function DashboardLoading() { + return

Loading dashboard data…

; +} diff --git a/app/(dashboard)/logs/page.tsx b/app/(dashboard)/logs/page.tsx index b83854e..c99c87a 100644 --- a/app/(dashboard)/logs/page.tsx +++ b/app/(dashboard)/logs/page.tsx @@ -1,23 +1,38 @@ import { PageHeader } from "@/components/PageHeader"; -import { getAppLogs } from "@/lib/data"; +import { DateRangeFilter } from "@/components/DateRangeFilter"; +import { TrafficToggle } from "@/components/TrafficToggle"; +import { Pagination } from "@/components/Pagination"; +import { FilterBar } from "@/components/FilterBar"; +import { DataTable } from "@/components/DataTable"; +import { StatusBadge } from "@/components/StatusBadge"; +import { parseDateWindow, parsePage, parsePageSize, parseTraffic, type SearchParams } from "@/lib/filters"; +import { getAppLogPage } from "@/lib/list-data"; import { shortDate } from "@/lib/format"; -export default async function LogsPage() { - const logs = await getAppLogs(); - return ( - <> - -
-
- - - - {logs.map((log) => )} - {logs.length === 0 && } - -
TimeSourceTypeMessage
{shortDate(log.createdAt)}{log.source}{log.type}{log.message}
No events have been recorded.
-
-
- - ); +const one = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; + +export default async function LogsPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams; + const window = parseDateWindow(params); const page = parsePage(params.page); const pageSize = parsePageSize(params.pageSize); + const traffic = parseTraffic(params.traffic, "all"); + const values = { kind: one(params.kind), type: one(params.type), source: one(params.source), siteId: one(params.siteId), q: one(params.q) }; + const data = await getAppLogPage({ ...window, page, pageSize, traffic, ...values }); + return <> + + + +
+ {window.from && }{window.to && } + + + + + + +
+
TimeKindSourceTypeClassificationMessage + {data.rows.map((row) => {shortDate(row.createdAt)}{row.kind}{row.source}{row.type}{row.message})} + {!data.rows.length && No events match these filters.} +
+ ; } diff --git a/app/(dashboard)/security/page.tsx b/app/(dashboard)/security/page.tsx index c6064e4..e160135 100644 --- a/app/(dashboard)/security/page.tsx +++ b/app/(dashboard)/security/page.tsx @@ -1,50 +1,52 @@ +import Link from "next/link"; import { PageHeader } from "@/components/PageHeader"; -import { TopList } from "@/components/TopList"; -import { getSecurityEventCounts, getSecurityEvents, getSuspiciousIps } from "@/lib/data"; +import { DateRangeFilter } from "@/components/DateRangeFilter"; +import { TrafficToggle } from "@/components/TrafficToggle"; +import { Pagination } from "@/components/Pagination"; +import { FilterBar } from "@/components/FilterBar"; +import { DataTable } from "@/components/DataTable"; +import { InfoCallout } from "@/components/InfoCallout"; +import { StatusBadge } from "@/components/StatusBadge"; +import { parseDateWindow, parsePage, parsePageSize, parseTraffic, queryString, type SearchParams } from "@/lib/filters"; +import { getSecurityPage } from "@/lib/list-data"; import { shortDate } from "@/lib/format"; import { maskIp } from "@/lib/ip"; -export default async function SecurityPage() { - const [events, suspiciousIps, signalCounts] = await Promise.all([getSecurityEvents(), getSuspiciousIps(), getSecurityEventCounts()]); +const one = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; - return ( - <> - -
- - -
-

Signal coverage

-
-

Known bots and crawlers

-

Unusual paths reached by JavaScript-capable clients

-

Empty or abnormal user agents

-

Tracking endpoint rate-limit enforcement

-

Failed and rate-limited admin logins

-

Optional SSH, reverse-proxy, and Fail2Ban events

-
-
+export default async function SecurityPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams; + const window = parseDateWindow(params); + const page = parsePage(params.page); + const pageSize = parsePageSize(params.pageSize); + const traffic = parseTraffic(params.traffic, "all"); + const filters = { type: one(params.type), source: one(params.source), ipHash: one(params.ipHash), q: one(params.q) }; + const data = await getSecurityPage({ ...window, page, pageSize, traffic, ...filters }); + return <> + + + +
+ {window.from && }{window.to && } + + + + + +
+ {filters.ipHash && Showing the complete privacy-preserving hash {filters.ipHash}. Clear} +
+

Repeat flagged visitors

+

A privacy-preserving fingerprint used to recognize repeated activity from the same IP without displaying the full address. A high count means repeated security signals, not proof of malicious intent.

+
{data.repeatVisitors.map((row) => {row.hash.slice(0, 12)}…{row.value})}{!data.repeatVisitors.length &&

No repeat fingerprints in this window.

}
-
-

Security events

-
- - - - {events.map((event) => ( - - - - - - - - ))} - {events.length === 0 && } - -
TimeTypeIPSourceMessage
{shortDate(event.createdAt)}{event.type}{maskIp(event.ipAddress)}{event.source}{event.message}
No tracked traffic flags recorded.
-
-
- - ); +

Signal counts

{data.signals.map((row) =>
{row.label}{row.value}
)}
+
+

Security events

Stable newest-first ordering
+ TimeTypeIPSourceClassificationMessage + {data.events.map((event) => {shortDate(event.createdAt)}{event.type}{maskIp(event.ipAddress)}{event.source}{event.message})} + {!data.events.length && No security events match these filters.} + +
+ ; } diff --git a/app/(dashboard)/server/page.tsx b/app/(dashboard)/server/page.tsx index 127ab14..65053e3 100644 --- a/app/(dashboard)/server/page.tsx +++ b/app/(dashboard)/server/page.tsx @@ -1,29 +1,31 @@ +import Link from "next/link"; import { ServerChart } from "@/components/Charts"; +import { InfoCallout } from "@/components/InfoCallout"; import { MetricCard } from "@/components/MetricCard"; import { PageHeader } from "@/components/PageHeader"; -import { getServerMetrics } from "@/lib/server-metrics"; -import { bytes } from "@/lib/format"; +import { getServerMetrics, type RuntimeRange } from "@/lib/server-metrics"; +import { bytes, shortDate } from "@/lib/format"; -export default async function ServerPage() { - const { latest, history } = await getServerMetrics(); +export default async function ServerPage({ searchParams }: { searchParams: Promise<{ range?: string | string[] }> }) { + const raw = (await searchParams).range; + const value = Array.isArray(raw) ? raw[0] : raw; + const range: RuntimeRange = ["1h", "6h", "24h", "7d"].includes(value || "") ? value as RuntimeRange : "6h"; + const { latest, history, scope, stale, rxRate, txRate } = await getServerMetrics(range); const memoryPercent = latest?.memoryTotalMb ? Math.round(((latest.memoryUsedMb || 0) / latest.memoryTotalMb) * 100) : 0; const diskPercent = latest?.diskTotalGb ? Math.round(((latest.diskUsedGb || 0) / latest.diskTotalGb) * 100) : 0; - - return ( - <> - -
- - - - - - -
-
-

Resource history

Sampled every minute by the background worker
- -
- - ); + const sourceLabel = stale ? "stale" : scope === "host" ? "VPS host" : "Docker container"; + return <> + + + {latest ? <>{latest.hostname || "Unknown hostname"} · last sample {shortDate(latest.createdAt)}. : "No runtime samples have been received."} {scope === "container" && "Values describe the Docker container-visible environment."} +
+ + + + + + +
+

Resource history

{range} · downsampled to 240 points
+ ; } diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 8c9a8e7..340164d 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -3,9 +3,17 @@ import { PageHeader } from "@/components/PageHeader"; import { ActionForm } from "@/components/StateMessage"; import { getCsrfToken } from "@/lib/auth"; import { env } from "@/lib/env"; +import { prisma } from "@/lib/prisma"; +import { shortDate } from "@/lib/format"; +import { InfoCallout } from "@/components/InfoCallout"; export default async function SettingsPage() { - const csrf = await getCsrfToken(); + const [csrf, sources, databaseTime] = await Promise.all([ + getCsrfToken(), + prisma.ingestionSource.findMany({ orderBy: { name: "asc" } }), + prisma.$queryRaw>`SELECT CURRENT_TIMESTAMP AS now` + ]); + const checkedAt = databaseTime[0]?.now.getTime() || 0; return ( <> @@ -19,9 +27,10 @@ export default async function SettingsPage() {

Site origin checks{env.enforceTrackingOrigin ? "On" : "Off"}

Bot filtering{env.filterBots ? "On" : "Off"}

Retention default{env.dataRetentionDays} days

-

Runtime metrics{env.enableServerMetrics ? "On" : "Off"}

+

Runtime metrics{env.serverMetricsSource}

GeoIP{env.ipinfoToken ? `IPinfo ${env.ipinfoTier}` : "Proxy headers only"}

-

Host log ingestion{env.enableLogIngestion ? "On" : "Off"}

+

HTTP ingestion{env.enableHttpIngestion ? "On" : "Off"}

+

Host ingestion{env.enableHostIngestion ? "On" : "Off"}

@@ -29,6 +38,9 @@ export default async function SettingsPage() {

Edit `.env` and restart BufferDash to change privacy, proxy, retention, and runtime settings. Secrets are intentionally never editable in the browser.

+

Collector freshness

Stale after 150 seconds
+ {sources.length ?
{sources.map((source) => { const stale = checkedAt - source.lastSeenAt.getTime() > 150_000; return

{source.name} · {source.hostname || "unknown host"} · agent {source.agentVersion || "unknown"}{stale ? "Stale" : "Fresh"} · {shortDate(source.lastSeenAt)}

; })}
: Install and start the host agent after enabling ingestion.} +

Data retention cleanup

diff --git a/app/(dashboard)/sites/[siteId]/page.tsx b/app/(dashboard)/sites/[siteId]/page.tsx index bf7e21f..5052ca8 100644 --- a/app/(dashboard)/sites/[siteId]/page.tsx +++ b/app/(dashboard)/sites/[siteId]/page.tsx @@ -10,23 +10,36 @@ import { compactDuration, numberFormat, shortDate } from "@/lib/format"; import { maskIp } from "@/lib/ip"; import { trackingSnippet } from "@/lib/snippet"; import { parseRange, rangeLabel } from "@/lib/range"; +import { parseTraffic, type SearchParams } from "@/lib/filters"; +import { TrafficToggle } from "@/components/TrafficToggle"; +import { CopyField } from "@/components/CopyField"; +import { DisclosurePanel } from "@/components/DisclosurePanel"; +import { StatusBadge } from "@/components/StatusBadge"; +import { InfoCallout } from "@/components/InfoCallout"; +import { env } from "@/lib/env"; -export default async function SiteDetailPage({ params, searchParams }: { params: Promise<{ siteId: string }>; searchParams: Promise<{ range?: string }> }) { +export default async function SiteDetailPage({ params, searchParams }: { params: Promise<{ siteId: string }>; searchParams: Promise }) { const { siteId } = await params; - const range = parseRange((await searchParams).range); + const query = await searchParams; + const range = parseRange(query.range); + const traffic = parseTraffic(query.traffic, "human"); const site = await getSite(siteId); if (!site) notFound(); - const [data, recentEvents] = await Promise.all([getDashboardData(site.id, range), getRecentEvents(site.id, 30)]); + const [data, recentEvents] = await Promise.all([getDashboardData(site.id, range, traffic), getRecentEvents(site.id, 30, traffic)]); const { overview } = data; return ( <> - - + + {query.created === "1" && Install the tracker below. This page will switch from waiting to active after the first event arrives.} + +
-

Tracking snippet

- +

{site._count.events ? "Tracking active" : "Waiting for first event"}

Installation
+
  1. Use the site key when an existing loader asks for `BUFFERDASH_SITE_ID`.
  2. Otherwise paste the full script before the closing body tag.
  3. Load the site once and refresh this page.
+ +
@@ -46,7 +59,7 @@ export default async function SiteDetailPage({ params, searchParams }: { params: - + {data.cities.length ? :

Cities

City data applies prospectively to new events.
}

Devices

@@ -57,20 +70,21 @@ export default async function SiteDetailPage({ params, searchParams }: { params:

Visitor log

- + {recentEvents.map((event) => ( + ))} - {recentEvents.length === 0 && } + {recentEvents.length === 0 && }
TimePathVisitorLocationReferrerBrowserOS
TimePathVisitorClassificationLocationReferrerBrowserOS
{shortDate(event.createdAt)} {event.path || event.type} {maskIp(event.ipAddress)} {[event.city, event.country].filter(Boolean).join(", ") || "Unknown"} {event.referrerDomain || "Direct"} {event.browser || "Unknown"} {event.os || "Unknown"}
No events for this site yet.
No events match this traffic view.
diff --git a/app/(dashboard)/sites/page.tsx b/app/(dashboard)/sites/page.tsx index 6c030ca..8a6babb 100644 --- a/app/(dashboard)/sites/page.tsx +++ b/app/(dashboard)/sites/page.tsx @@ -1,6 +1,9 @@ import Link from "next/link"; import { createSiteAction, deleteSiteAction } from "@/app/actions"; +import { CopyField } from "@/components/CopyField"; +import { DisclosurePanel } from "@/components/DisclosurePanel"; import { CopySnippet } from "@/components/CopySnippet"; +import { ConfirmSubmit } from "@/components/ConfirmSubmit"; import { PageHeader } from "@/components/PageHeader"; import { ActionForm } from "@/components/StateMessage"; import { getCsrfToken } from "@/lib/auth"; @@ -39,16 +42,18 @@ export default async function SitesPage() {

{site.name}

-

{site.domain} · created {shortDate(site.createdAt)}

+

{site.domain}

- {numberFormat(site._count.events)} events + {site._count.events ? "Tracking active" : "Awaiting first event"}
- +
{numberFormat(site._count.events)} eventsLast event {site.events[0] ? shortDate(site.events[0].createdAt) : "—"}
+ +
Open analytics - + Delete
))} diff --git a/app/actions.ts b/app/actions.ts index c5ebc1a..736d1b3 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -83,7 +83,7 @@ export async function createSiteAction(_state: ActionState, formData: FormData): const owner = await ensureAdminUser(); const publicKey = slugKey(parsed.data.domain); - await prisma.site.create({ + const site = await prisma.site.create({ data: { ...parsed.data, publicKey, @@ -91,7 +91,7 @@ export async function createSiteAction(_state: ActionState, formData: FormData): } }); - redirect("/sites"); + redirect(`/sites/${site.id}?created=1`); } export async function deleteSiteAction(formData: FormData) { @@ -117,11 +117,14 @@ export async function deleteOldDataAction(_state: ActionState, formData: FormDat await tx.event.deleteMany({ where: { createdAt: { lt: cutoff } } }); await tx.securityEvent.deleteMany({ where: { createdAt: { lt: cutoff } } }); await tx.serverMetric.deleteMany({ where: { createdAt: { lt: cutoff } } }); + await tx.httpErrorSample.deleteMany({ where: { occurredAt: { lt: cutoff } } }); + await tx.httpRequestBucket.deleteMany({ where: { bucketStart: { lt: cutoff } } }); + await tx.ingestBatch.deleteMany({ where: { receivedAt: { lt: new Date(Date.now() - 7 * 86_400_000) } } }); await tx.session.deleteMany({ where: { endedAt: { lt: cutoff } } }); await tx.visitor.deleteMany({ where: { events: { none: {} }, sessions: { none: {} } } }); }); - return { success: `Deleted analytics, sessions, visitors, security events, and metrics older than ${days} days.` }; + return { success: `Deleted analytics, HTTP diagnostics, sessions, visitors, security events, and metrics older than ${days} days.` }; } function normalizeDomain(value: string) { diff --git a/app/api/ingest/host/route.ts b/app/api/ingest/host/route.ts new file mode 100644 index 0000000..c37a847 --- /dev/null +++ b/app/api/ingest/host/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/lib/env"; +import { ingestionAuthorized, validObservedAt } from "@/lib/ingestion"; +import { prisma } from "@/lib/prisma"; +import { rateLimit } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +export const hostIngestSchema = z.object({ + sampleKey: z.string().min(8).max(200), + timestamp: z.string().datetime({ offset: true }), + hostname: z.string().min(1).max(255), + agentVersion: z.string().max(80).optional().nullable(), + cpuPercent: z.number().finite().min(0).max(100), + memoryUsedMb: z.number().finite().min(0).max(100_000_000), + memoryTotalMb: z.number().finite().positive().max(100_000_000), + diskUsedGb: z.number().finite().min(0).max(10_000_000), + diskTotalGb: z.number().finite().positive().max(10_000_000), + load1: z.number().finite().min(0).max(1_000_000), + load5: z.number().finite().min(0).max(1_000_000), + load15: z.number().finite().min(0).max(1_000_000), + uptimeSeconds: z.number().int().min(0).max(2_147_483_647), + networkRxBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + networkTxBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) +}).refine((value) => value.memoryUsedMb <= value.memoryTotalMb * 1.02, "memory used exceeds total") + .refine((value) => value.diskUsedGb <= value.diskTotalGb * 1.02, "disk used exceeds total"); + +export async function POST(request: NextRequest) { + if (!env.enableHostIngestion || !ingestionAuthorized(request.headers.get("authorization"))) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + if (!rateLimit(`ingest-host:${request.headers.get("authorization")?.slice(-12) || "agent"}`, 10).allowed) { + return NextResponse.json({ error: "rate_limited" }, { status: 429 }); + } + const raw = await request.text(); + if (raw.length > 32_768) return NextResponse.json({ error: "payload_too_large" }, { status: 413 }); + let json: unknown; + try { json = JSON.parse(raw); } catch { json = null; } + const parsed = hostIngestSchema.safeParse(json); + if (!parsed.success) return NextResponse.json({ error: "invalid_payload" }, { status: 400 }); + const timestamp = new Date(parsed.data.timestamp); + if (!validObservedAt(timestamp)) return NextResponse.json({ error: "invalid_timestamp" }, { status: 400 }); + await prisma.$transaction([ + prisma.serverMetric.upsert({ where: { sampleKey: parsed.data.sampleKey }, update: {}, create: { + sampleKey: parsed.data.sampleKey, + createdAt: timestamp, + hostname: parsed.data.hostname, + scope: "host", + cpuPercent: parsed.data.cpuPercent, + memoryUsedMb: parsed.data.memoryUsedMb, + memoryTotalMb: parsed.data.memoryTotalMb, + diskUsedGb: parsed.data.diskUsedGb, + diskTotalGb: parsed.data.diskTotalGb, + load1: parsed.data.load1, + load5: parsed.data.load5, + load15: parsed.data.load15, + uptimeSeconds: parsed.data.uptimeSeconds, + networkRxBytes: BigInt(parsed.data.networkRxBytes), + networkTxBytes: BigInt(parsed.data.networkTxBytes) + } + }), + prisma.ingestionSource.upsert({ + where: { name: "host" }, + update: { lastSeenAt: new Date(), hostname: parsed.data.hostname, agentVersion: parsed.data.agentVersion || undefined }, + create: { name: "host", lastSeenAt: new Date(), hostname: parsed.data.hostname, agentVersion: parsed.data.agentVersion || null } + }) + ]); + return new NextResponse(null, { status: 204 }); +} diff --git a/app/api/ingest/http/route.ts b/app/api/ingest/http/route.ts new file mode 100644 index 0000000..8b524dd --- /dev/null +++ b/app/api/ingest/http/route.ts @@ -0,0 +1,117 @@ +import crypto from "node:crypto"; +import { Prisma } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { detectBot } from "@/lib/bot"; +import { env } from "@/lib/env"; +import { anonymizeIp, hashIp } from "@/lib/ip"; +import { ingestionAuthorized, minuteBucket, sanitizeProxyError, sanitizeRequestPath, validObservedAt } from "@/lib/ingestion"; +import { prisma } from "@/lib/prisma"; +import { rateLimit } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +const recordSchema = z.object({ + requestKey: z.string().min(8).max(200), + timestamp: z.string().datetime({ offset: true }), + host: z.string().min(1).max(255), + method: z.string().min(1).max(16), + path: z.string().min(1).max(4000), + status: z.number().int().min(100).max(599), + durationMs: z.number().finite().min(0).max(3_600_000), + responseBytes: z.number().int().min(0).max(1_000_000_000_000).optional().nullable(), + clientIp: z.string().ip(), + userAgent: z.string().max(1000).optional().nullable(), + cfRay: z.string().max(120).optional().nullable(), + error: z.string().max(1000).optional().nullable() +}); + +export const httpIngestSchema = z.object({ + source: z.string().min(1).max(80), + batchKey: z.string().min(8).max(200), + hostname: z.string().max(255).optional().nullable(), + agentVersion: z.string().max(80).optional().nullable(), + records: z.array(recordSchema).min(1).max(100) +}); + +export async function POST(request: NextRequest) { + if (!env.enableHttpIngestion || !ingestionAuthorized(request.headers.get("authorization"))) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + if (!rateLimit(`ingest-http:${request.headers.get("authorization")?.slice(-12) || "agent"}`, 120).allowed) { + return NextResponse.json({ error: "rate_limited" }, { status: 429 }); + } + const raw = await request.text(); + if (raw.length > 262_144) return NextResponse.json({ error: "payload_too_large" }, { status: 413 }); + let json: unknown; + try { json = JSON.parse(raw); } catch { json = null; } + const parsed = httpIngestSchema.safeParse(json); + if (!parsed.success) return NextResponse.json({ error: "invalid_payload" }, { status: 400 }); + + const records = parsed.data.records.map((record) => ({ ...record, occurredAt: new Date(record.timestamp) })); + if (records.some((record) => !validObservedAt(record.occurredAt))) { + return NextResponse.json({ error: "invalid_timestamp" }, { status: 400 }); + } + + const existing = await prisma.ingestBatch.findUnique({ where: { source_batchKey: { source: parsed.data.source, batchKey: parsed.data.batchKey } }, select: { id: true } }); + if (existing) return new NextResponse(null, { status: 204 }); + + try { + await prisma.$transaction(async (tx) => { + await tx.ingestBatch.create({ data: { source: parsed.data.source, batchKey: parsed.data.batchKey } }); + for (const record of records) { + const path = sanitizeRequestPath(record.path); + const method = record.method.trim().toUpperCase(); + const host = record.host.trim().toLowerCase(); + const bot = detectBot(record.userAgent); + const trafficClass = bot.isBot ? "bot" : "human"; + const bytes = BigInt(record.responseBytes || 0); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "HttpRequestBucket" ( + "id", "bucketStart", "host", "method", "path", "status", "trafficClass", + "requestCount", "durationTotalMs", "durationMaxMs", "responseBytes" + ) VALUES ( + ${crypto.randomUUID()}, ${minuteBucket(record.occurredAt)}, ${host}, ${method}, ${path}, ${record.status}, ${trafficClass}, + 1, ${record.durationMs}, ${record.durationMs}, ${bytes} + ) + ON CONFLICT ("bucketStart", "host", "method", "path", "status", "trafficClass") + DO UPDATE SET + "requestCount" = "HttpRequestBucket"."requestCount" + 1, + "durationTotalMs" = "HttpRequestBucket"."durationTotalMs" + EXCLUDED."durationTotalMs", + "durationMaxMs" = GREATEST("HttpRequestBucket"."durationMaxMs", EXCLUDED."durationMaxMs"), + "responseBytes" = "HttpRequestBucket"."responseBytes" + EXCLUDED."responseBytes" + `); + if (record.status >= 400) { + await tx.httpErrorSample.createMany({ data: [{ + requestKey: record.requestKey, + occurredAt: record.occurredAt, + host, + method, + path, + status: record.status, + durationMs: record.durationMs, + responseBytes: record.responseBytes == null ? null : BigInt(record.responseBytes), + proxyError: sanitizeProxyError(record.error), + ipAddress: anonymizeIp(record.clientIp), + ipHash: hashIp(record.clientIp), + isBot: bot.isBot, + botName: bot.botName, + userAgent: record.userAgent?.slice(0, 500) || null, + cfRay: record.cfRay?.slice(0, 120) || null + }], skipDuplicates: true }); + } + } + await tx.ingestionSource.upsert({ + where: { name: parsed.data.source }, + update: { lastSeenAt: new Date(), hostname: parsed.data.hostname || undefined, agentVersion: parsed.data.agentVersion || undefined }, + create: { name: parsed.data.source, lastSeenAt: new Date(), hostname: parsed.data.hostname || null, agentVersion: parsed.data.agentVersion || null } + }); + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return new NextResponse(null, { status: 204 }); + } + throw error; + } + return new NextResponse(null, { status: 204 }); +} diff --git a/app/api/security/ingest/route.ts b/app/api/security/ingest/route.ts index 2f537f3..c250403 100644 --- a/app/api/security/ingest/route.ts +++ b/app/api/security/ingest/route.ts @@ -1,10 +1,10 @@ -import crypto from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { env } from "@/lib/env"; import { getClientIp } from "@/lib/ip"; import { rateLimit } from "@/lib/rate-limit"; import { recordSecurityEvent } from "@/lib/security-events"; +import { ingestionAuthorized } from "@/lib/ingestion"; export const ingestEventSchema = z.object({ source: z.string().min(1).max(80), @@ -18,7 +18,7 @@ export const ingestEventSchema = z.object({ const bodySchema = z.union([ingestEventSchema, z.array(ingestEventSchema).min(1).max(50)]); export async function POST(request: NextRequest) { - if (!env.enableLogIngestion || !authorized(request.headers.get("authorization"))) { + if (!env.enableLogIngestion || !ingestionAuthorized(request.headers.get("authorization"))) { return NextResponse.json({ error: "not_found" }, { status: 404 }); } const callerIp = getClientIp(request); @@ -35,11 +35,3 @@ export async function POST(request: NextRequest) { await Promise.all(events.map((event) => recordSecurityEvent({ ...event, ip: event.ip || callerIp }))); return new NextResponse(null, { status: 204 }); } - -function authorized(header: string | null) { - const supplied = header?.startsWith("Bearer ") ? header.slice(7) : ""; - const expected = env.ingestionSecret; - const left = Buffer.from(supplied); - const right = Buffer.from(expected); - return Boolean(supplied && expected && left.length === right.length && crypto.timingSafeEqual(left, right)); -} diff --git a/app/globals.css b/app/globals.css index ade17d4..fea16f2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -37,7 +37,7 @@ body { } a { color: inherit; text-decoration: none; } -button, input { font: inherit; } +button, input, select { font: inherit; } button { color: inherit; } h1, h2, p { margin: 0; } @@ -85,6 +85,7 @@ h1, h2, p { margin: 0; } display: grid; grid-template-columns: 260px minmax(0, 1fr); min-height: 100vh; + min-width: 0; } .sidebar { @@ -97,6 +98,7 @@ h1, h2, p { margin: 0; } border-right: 1px solid var(--line); background: rgba(7, 7, 10, 0.7); backdrop-filter: blur(18px); + min-width: 0; } .brand { @@ -126,6 +128,8 @@ h1, h2, p { margin: 0; } display: grid; gap: 0.35rem; margin-top: 2.2rem; + min-width: 0; + max-width: 100%; } .sidebar-nav a { @@ -138,7 +142,8 @@ h1, h2, p { margin: 0; } transition: 160ms ease; } -.sidebar-nav a:hover { +.sidebar-nav a:hover, +.sidebar-nav a.active { border-color: var(--line); color: var(--text); background: rgba(255, 255, 255, 0.04); @@ -155,6 +160,7 @@ h1, h2, p { margin: 0; } gap: 1rem; width: min(1420px, 100%); padding: 2rem; + min-width: 0; } .page-header { @@ -169,7 +175,7 @@ h1, h2, p { margin: 0; } .page-header h1 { max-width: 780px; margin-top: 0.4rem; - font-size: clamp(2rem, 4vw, 3.6rem); + font-size: clamp(1.8rem, 3vw, 2.8rem); line-height: 1.02; letter-spacing: 0; } @@ -328,6 +334,10 @@ td { } th { + position: sticky; + top: 0; + z-index: 1; + background: #111219; color: var(--faint); font-family: var(--font-mono); font-size: 0.72rem; @@ -351,7 +361,8 @@ label { gap: 0.45rem; } -input { +input, +select { min-height: 44px; width: 100%; border: 1px solid var(--line); @@ -362,11 +373,135 @@ input { outline: none; } -input:focus { +input:focus, +select:focus, +button:focus-visible, +a:focus-visible, +summary:focus-visible, +.table-wrap:focus-visible { border-color: rgba(196, 181, 253, 0.65); box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.14); + outline: none; } +select { appearance: none; padding: 0 2rem 0 0.8rem; background-color: rgba(0, 0, 0, 0.22); } + +.traffic-toggle, +.date-filter, +.filter-bar, +.info-callout { + grid-column: 1 / -1; +} + +.traffic-toggle, +.date-filter nav { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + align-items: center; +} + +.traffic-toggle a, +.date-filter nav a, +.pagination a { + padding: 0.45rem 0.75rem; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-size: 0.82rem; +} + +.traffic-toggle a.active, +.date-filter nav a.active, +.pagination a.active { + border-color: rgba(167, 139, 250, 0.55); + background: rgba(167, 139, 250, 0.13); + color: var(--text); +} + +.filter-help { margin-left: 0.4rem; color: var(--faint); font-size: 0.78rem; } + +.date-filter { + display: flex; + flex-wrap: wrap; + align-items: end; + justify-content: space-between; + gap: 0.75rem; +} + +.custom-dates, +.filter-form { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.65rem; +} + +.custom-dates input { min-width: 145px; } +.filter-bar { padding: 0.85rem; border: 1px solid var(--line); border-radius: 8px; background: rgba(17, 18, 25, 0.65); } +.filter-form label { min-width: 130px; flex: 1 1 140px; } +.filter-form .filter-search { flex: 2 1 240px; } + +.pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + margin-top: 1rem; +} +.pagination a[aria-disabled="true"] { opacity: 0.4; pointer-events: none; } +.pagination span { margin-left: auto; color: var(--faint); font-size: 0.82rem; } + +.status-badge { + display: inline-flex; + max-width: 230px; + padding: 0.28rem 0.5rem; + border: 1px solid var(--line); + border-radius: 999px; + font-size: 0.72rem; + line-height: 1.2; + white-space: nowrap; +} +.status-badge.bot, .status-badge.warning { color: #fed7aa; border-color: rgba(251,146,60,.35); background: rgba(251,146,60,.1); } +.status-badge.network { color: #ddd6fe; border-color: rgba(167,139,250,.35); background: rgba(167,139,250,.1); } +.status-badge.human { color: #a7f3d0; border-color: rgba(52,211,153,.3); background: rgba(52,211,153,.08); } +.status-badge.error { color: #fecdd3; border-color: rgba(251,113,133,.38); background: rgba(251,113,133,.11); } + +.info-callout { + padding: 0.9rem 1rem; + border: 1px solid rgba(167,139,250,.3); + border-radius: 8px; + color: var(--muted); + background: rgba(167,139,250,.08); + line-height: 1.55; +} +.info-callout strong { display: block; margin-bottom: 0.25rem; color: var(--text); } +.info-callout.warning { border-color: rgba(251,146,60,.35); background: rgba(251,146,60,.08); } +.info-callout a { color: var(--violet-strong); text-decoration: underline; } + +.copy-field { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.7rem; + padding: 0.7rem; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(0,0,0,.2); +} +.copy-field > span { color: var(--muted); font-size: .76rem; } +.copy-field code { overflow: hidden; color: var(--violet-strong); font-family: var(--font-mono); text-overflow: ellipsis; } + +.disclosure { border: 1px solid var(--line); border-radius: 8px; } +.disclosure summary { padding: .8rem; color: var(--muted); cursor: pointer; } +.disclosure > div { padding: 0 .8rem .8rem; } +.site-facts { display: flex; flex-wrap: wrap; gap: 1rem; color: var(--faint); font-size: .84rem; } +.install-steps { margin: 0 0 1rem; padding-left: 1.35rem; color: var(--muted); line-height: 1.7; } +.wrap-cell { min-width: 220px; max-width: 520px; white-space: normal; overflow-wrap: anywhere; } + +.empty-state-box { padding: 1.25rem; text-align: center; color: var(--muted); } +.empty-state-box strong { display: block; color: var(--text); } + .primary-button, .secondary-button, .ghost-button, @@ -500,10 +635,14 @@ input:focus { height: auto; border-right: 0; border-bottom: 1px solid var(--line); + width: 100%; + max-width: 100vw; + overflow: hidden; } .sidebar-nav { - grid-template-columns: repeat(4, minmax(0, 1fr)); + display: flex; + overflow-x: auto; margin-top: 1rem; } @@ -529,9 +668,12 @@ input:focus { @media (max-width: 620px) { .sidebar-nav { - grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-inline: -0.4rem; + padding: 0.4rem; } + .sidebar-nav a { flex: 0 0 auto; min-height: 38px; } + .page-header, .site-card-top, .row-actions, @@ -544,4 +686,9 @@ input:focus { .metrics-grid { gap: 0.75rem; } + + .custom-dates, + .filter-form, + .copy-field { display: grid; grid-template-columns: 1fr; width: 100%; } + .pagination span { width: 100%; margin-left: 0; } } diff --git a/app/login/page.tsx b/app/login/page.tsx index 2a4352b..50ef97e 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useFormState, useFormStatus } from "react-dom"; +import { useActionState } from "react"; +import { useFormStatus } from "react-dom"; import { loginAction } from "@/app/actions"; function LoginButton() { @@ -9,7 +10,7 @@ function LoginButton() { } export default function LoginPage() { - const [state, action] = useFormState(loginAction, {}); + const [state, action] = useActionState(loginAction, {}); return (
diff --git a/app/tracker.js/route.ts b/app/tracker.js/route.ts index 0901434..8bc8fa0 100644 --- a/app/tracker.js/route.ts +++ b/app/tracker.js/route.ts @@ -10,6 +10,8 @@ export const tracker = String.raw` var visitorKey = "bufferdash_visitor_id"; var sessionKey = "bufferdash_session_id"; var sessionTimeKey = "bufferdash_session_last_seen_at"; + var engagementKey = "bufferdash_session_engagement_ms"; + var maxEngagement = 24 * 60 * 60 * 1000; function id(prefix) { return prefix + "_" + Math.random().toString(36).slice(2) + Date.now().toString(36); @@ -38,6 +40,7 @@ export const tracker = String.raw` var next = id("s"); sessionStorage.setItem(sessionKey, next); sessionStorage.setItem(sessionTimeKey, String(Date.now())); + sessionStorage.setItem(engagementKey, "0"); return next; } catch (error) { return id("s"); @@ -46,7 +49,35 @@ export const tracker = String.raw` var visitorId = getVisitorId(); var sessionId = getSessionId(); - var startedAt = Date.now(); + var storedEngagement = 0; + try { storedEngagement = Math.min(maxEngagement, Math.max(0, Number(sessionStorage.getItem(engagementKey) || "0"))); } catch (error) {} + var activeSince = document.visibilityState === "hidden" ? null : Date.now(); + + function currentEngagement() { + var value = storedEngagement + (activeSince === null ? 0 : Date.now() - activeSince); + return Math.min(maxEngagement, Math.max(0, Math.round(value))); + } + + function persistEngagement() { + storedEngagement = currentEngagement(); + activeSince = null; + try { + sessionStorage.setItem(engagementKey, String(storedEngagement)); + sessionStorage.setItem(sessionTimeKey, String(Date.now())); + } catch (error) {} + return storedEngagement; + } + + function sanitizeMetadata(metadata) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined; + var result = {}; + Object.keys(metadata).slice(0, 20).forEach(function (key) { + var value = metadata[key]; + if (typeof value === "string") result[String(key).slice(0, 80)] = value.slice(0, 500); + else if (typeof value === "number" || typeof value === "boolean" || value === null) result[String(key).slice(0, 80)] = value; + }); + return result; + } function payload(type, metadata) { var url = new URL(window.location.href); @@ -75,19 +106,19 @@ export const tracker = String.raw` timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, visitorId: visitorId, sessionId: sessionId, - metadata: metadata || undefined + metadata: sanitizeMetadata(metadata) }; } function send(data, keepalive) { var body = JSON.stringify(data); if (navigator.sendBeacon && keepalive) { - navigator.sendBeacon(endpoint, new Blob([body], { type: "application/json" })); + navigator.sendBeacon(endpoint, body); return; } fetch(endpoint, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "text/plain;charset=UTF-8" }, body: body, keepalive: Boolean(keepalive), credentials: "omit" @@ -96,18 +127,59 @@ export const tracker = String.raw` window.bufferdash = window.bufferdash || {}; window.bufferdash.track = function (type, metadata) { + if (type === "tool_used") { + var details = sanitizeMetadata(metadata) || {}; + var tool = String(details.tool || "").trim().slice(0, 120); + if (!tool) return; + details.tool = tool; + send(payload("tool_used", details)); + return; + } send(payload(type || "custom", metadata)); }; + window.bufferdash.trackTool = function (name, metadata) { + var tool = String(name || "").trim().slice(0, 120); + if (!tool) return; + var details = sanitizeMetadata(metadata) || {}; + details.tool = tool; + window.bufferdash.track("tool_used", details); + }; send(payload("pageview")); - window.addEventListener("pagehide", function () { + function flush(type) { var data = payload("pagehide"); - data.durationMs = Date.now() - startedAt; + data.type = type; + data.durationMs = persistEngagement(); send(data, true); + } + + if (window.setInterval) { + window.setInterval(function () { + if (document.visibilityState !== "hidden") { + var data = payload("session_ping"); + data.durationMs = currentEngagement(); + try { sessionStorage.setItem(sessionTimeKey, String(Date.now())); } catch (error) {} + send(data); + } + }, 15000); + } + + window.addEventListener("pagehide", function () { flush("pagehide"); }); + + document.addEventListener("visibilitychange", function () { + if (document.visibilityState === "hidden") { + flush("session_ping"); + } else if (activeSince === null) { + activeSince = Date.now(); + } }); document.addEventListener("click", function (event) { + var toolTarget = event.target && event.target.closest && event.target.closest("[data-bufferdash-tool]"); + if (toolTarget) { + window.bufferdash.trackTool(toolTarget.getAttribute("data-bufferdash-tool")); + } var target = event.target && event.target.closest && event.target.closest("a[href]"); if (!target) return; var href = target.href; diff --git a/components/Charts.tsx b/components/Charts.tsx index a72cd12..7388c6d 100644 --- a/components/Charts.tsx +++ b/components/Charts.tsx @@ -86,3 +86,16 @@ export function ServerChart({ data }: { data: Array<{ time: string; cpu: number; ); } + +export function HttpStatusChart({ data }: { data: Array<{ time: string; "2xx": number; "3xx": number; "4xx": number; "5xx": number }> }) { + return
+ + + + + + + + +
; +} diff --git a/components/ConfirmSubmit.tsx b/components/ConfirmSubmit.tsx new file mode 100644 index 0000000..e25a3e0 --- /dev/null +++ b/components/ConfirmSubmit.tsx @@ -0,0 +1,5 @@ +"use client"; + +export function ConfirmSubmit({ children, message }: { children: React.ReactNode; message: string }) { + return ; +} diff --git a/components/CopyField.tsx b/components/CopyField.tsx new file mode 100644 index 0000000..ae35877 --- /dev/null +++ b/components/CopyField.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { useState } from "react"; + +export function CopyField({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = useState(false); + return
{label}{value}
; +} diff --git a/components/DataTable.tsx b/components/DataTable.tsx new file mode 100644 index 0000000..67c5ad8 --- /dev/null +++ b/components/DataTable.tsx @@ -0,0 +1,3 @@ +export function DataTable({ children, label }: { children: React.ReactNode; label?: string }) { + return
{children}
; +} diff --git a/components/DateRangeFilter.tsx b/components/DateRangeFilter.tsx new file mode 100644 index 0000000..ddbbf39 --- /dev/null +++ b/components/DateRangeFilter.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { queryString, type DateRangeKey, type SearchParams } from "@/lib/filters"; + +const presets = [["24h", "24h"], ["7d", "7d"], ["30d", "30d"], ["90d", "90d"]] as const; + +export function DateRangeFilter({ path, params, selected, from, to }: { path: string; params: SearchParams; selected: DateRangeKey; from?: string; to?: string }) { + return ( +
+ +
+ {Object.entries(params).map(([key, value]) => !["range", "from", "to", "page"].includes(key) && typeof value === "string" + ? : null)} + + + + +
+
+ ); +} diff --git a/components/DisclosurePanel.tsx b/components/DisclosurePanel.tsx new file mode 100644 index 0000000..a464e3f --- /dev/null +++ b/components/DisclosurePanel.tsx @@ -0,0 +1,3 @@ +export function DisclosurePanel({ summary, children, open = false }: { summary: string; children: React.ReactNode; open?: boolean }) { + return
{summary}
{children}
; +} diff --git a/components/EmptyState.tsx b/components/EmptyState.tsx new file mode 100644 index 0000000..7193ddc --- /dev/null +++ b/components/EmptyState.tsx @@ -0,0 +1,3 @@ +export function EmptyState({ title, children }: { title: string; children?: React.ReactNode }) { + return
{title}{children &&

{children}

}
; +} diff --git a/components/FilterBar.tsx b/components/FilterBar.tsx new file mode 100644 index 0000000..1b6f06f --- /dev/null +++ b/components/FilterBar.tsx @@ -0,0 +1,3 @@ +export function FilterBar({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/components/InfoCallout.tsx b/components/InfoCallout.tsx new file mode 100644 index 0000000..d831493 --- /dev/null +++ b/components/InfoCallout.tsx @@ -0,0 +1,3 @@ +export function InfoCallout({ title, children, tone = "info" }: { title: string; children: React.ReactNode; tone?: "info" | "warning" }) { + return ; +} diff --git a/components/NavLinks.tsx b/components/NavLinks.tsx new file mode 100644 index 0000000..efc2c6f --- /dev/null +++ b/components/NavLinks.tsx @@ -0,0 +1,12 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export function NavLinks({ items }: { items: Array<[string, string]> }) { + const pathname = usePathname(); + return <>{items.map(([label, href]) => { + const active = pathname === href || (href === "/sites" && pathname.startsWith("/sites/")); + return {label}; + })}; +} diff --git a/components/Pagination.tsx b/components/Pagination.tsx new file mode 100644 index 0000000..88595cf --- /dev/null +++ b/components/Pagination.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { queryString, type SearchParams } from "@/lib/filters"; + +export function Pagination({ path, params, page, pageSize, total }: { path: string; params: SearchParams; page: number; pageSize: number; total: number }) { + const pages = Math.max(1, Math.ceil(total / pageSize)); + const first = Math.max(1, Math.min(page - 2, pages - 4)); + const numbers = Array.from({ length: Math.min(5, pages) }, (_, index) => first + index); + return ( + + ); +} diff --git a/components/RangeSelector.tsx b/components/RangeSelector.tsx index 81feb72..fdd867c 100644 --- a/components/RangeSelector.tsx +++ b/components/RangeSelector.tsx @@ -1,14 +1,15 @@ import Link from "next/link"; import { rangeOptions, type RangeKey } from "@/lib/range"; +import { queryString, type SearchParams } from "@/lib/filters"; -export function RangeSelector({ selected, basePath }: { selected: RangeKey; basePath: string }) { +export function RangeSelector({ selected, basePath, params = {} }: { selected: RangeKey; basePath: string; params?: SearchParams }) { return (