diff --git a/src/apps/metrics-systems/api.ts b/src/apps/metrics-systems/api.ts index 21a24c3..96b5a07 100644 --- a/src/apps/metrics-systems/api.ts +++ b/src/apps/metrics-systems/api.ts @@ -416,3 +416,33 @@ export function splitHostTimeseries(merged: TimeSeriesResponse): { } return { system, container } } + +// --- Log-derived stats (MoonBase#1460): aggregates the stats service +// computes from shipped Caddy access logs. Separate base URL because the +// backend is a different service behind the same gateway. + +export const STATS_API_URL = + import.meta.env.VITE_STATS_API_URL || 'https://api.muchq.com/stats/v1' + +export interface StatsSummaryRow { + date: string + host: string + agent_class: string + requests: number + errors: number +} + +export interface StatsSummary { + days: number + rows: StatsSummaryRow[] +} + +export interface TopSlugRow { + slug: string + requests: number +} + +export interface TopSlugs { + days: number + rows: TopSlugRow[] +} diff --git a/src/apps/metrics-systems/components/StatsDashboard.tsx b/src/apps/metrics-systems/components/StatsDashboard.tsx new file mode 100644 index 0000000..a555b48 --- /dev/null +++ b/src/apps/metrics-systems/components/StatsDashboard.tsx @@ -0,0 +1,144 @@ +import { useEffect, useMemo, useState } from 'react' +import styles from './MetricsDashboard.module.css' +import { ConnectionState } from '@/shared/components/nav/ConnectionStatus' +import { + STATS_API_URL, + fetchJson, + type StatsSummary, + type TopSlugs, +} from '../api' + +interface Props { + onConnectionStateChange: (status: ConnectionState) => void +} + +// Traffic stats derived from shipped Caddy access logs (MoonBase#1460): +// who is crawling (the four agent classes, AI scrapers split out), per +// vhost, plus the most-followed iili short links. Counts refresh on the +// aggregator's own cadence, so this fetches once per mount rather than +// polling. +const StatsDashboard = ({ onConnectionStateChange }: Props) => { + const [summary, setSummary] = useState(null) + const [slugs, setSlugs] = useState(null) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + let cancelled = false + onConnectionStateChange('connecting') + Promise.all([ + fetchJson(`${STATS_API_URL}/summary?days=7`), + fetchJson(`${STATS_API_URL}/iili/top?days=30&limit=20`), + ]).then(([summaryResult, slugResult]) => { + if (cancelled) return + setSummary(summaryResult) + setSlugs(slugResult) + setLoaded(true) + onConnectionStateChange(summaryResult ? 'connected' : 'failed') + }) + return () => { + cancelled = true + } + }, [onConnectionStateChange]) + + // One row per host over the window, with the agent classes as columns — + // "how much of my traffic is AI scrapers" should be one glance, not a + // pivot the reader does in their head. + const byHost = useMemo(() => { + const hosts = new Map< + string, + { total: number; errors: number; classes: Record } + >() + for (const row of summary?.rows ?? []) { + const entry = hosts.get(row.host) ?? { total: 0, errors: 0, classes: {} } + entry.total += row.requests + entry.errors += row.errors + entry.classes[row.agent_class] = (entry.classes[row.agent_class] ?? 0) + row.requests + hosts.set(row.host, entry) + } + return [...hosts.entries()].sort((a, b) => b[1].total - a[1].total) + }, [summary]) + + if (!loaded) { + return
Loading stats…
+ } + if (!summary) { + return ( +
+ Stats API unavailable. The stats profile may not be deployed yet. +
+ ) + } + + const agentClasses = ['browser', 'ai_scraper', 'bot', 'other'] + + return ( +
+
+

Traffic by host — last {summary.days} days

+
+ + + + + + + + + + + + + + {byHost.map(([host, entry]) => ( + + + + + {agentClasses.map((agentClass) => ( + + ))} + + ))} + {byHost.length === 0 && ( + + + + )} + +
HostRequestsErrorsBrowserAI scrapersBotsOther
{host}{entry.total.toLocaleString()}{entry.errors.toLocaleString()}{(entry.classes[agentClass] ?? 0).toLocaleString()}
No aggregated traffic yet.
+
+
+ +
+

+ Top short links — last {slugs?.days ?? 30} days +

+
+ + + + + + + + + {(slugs?.rows ?? []).map((row) => ( + + + + + ))} + {(slugs?.rows ?? []).length === 0 && ( + + + + )} + +
SlugFollows
{row.slug}{row.requests.toLocaleString()}
No redirects aggregated yet.
+
+
+
+ ) +} + +export default StatsDashboard diff --git a/src/apps/metrics-systems/components/__tests__/StatsDashboard.test.tsx b/src/apps/metrics-systems/components/__tests__/StatsDashboard.test.tsx new file mode 100644 index 0000000..e710fc8 --- /dev/null +++ b/src/apps/metrics-systems/components/__tests__/StatsDashboard.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, cleanup, within } from '@testing-library/react' +import StatsDashboard from '../StatsDashboard' + +const summaryResponse = { + days: 7, + rows: [ + { date: '2026-08-30', host: 'api.1d4.net', agent_class: 'browser', requests: 100, errors: 2 }, + { date: '2026-08-31', host: 'api.1d4.net', agent_class: 'browser', requests: 50, errors: 0 }, + { date: '2026-08-30', host: 'git.muchq.com', agent_class: 'ai_scraper', requests: 900, errors: 700 }, + ], +} + +const slugsResponse = { + days: 30, + rows: [ + { slug: 'abc123', requests: 41 }, + { slug: 'xyz', requests: 7 }, + ], +} + +function mockFetch(bodies: Record) { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + for (const [fragment, body] of Object.entries(bodies)) { + if (url.includes(fragment)) { + return new Response(JSON.stringify(body), { status: 200 }) + } + } + return new Response('', { status: 500 }) + }) + ) +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('StatsDashboard', () => { + it('rolls the summary up per host with the agent classes as columns', async () => { + mockFetch({ '/summary': summaryResponse, '/iili/top': slugsResponse }) + const onState = vi.fn() + + render() + + // git.muchq.com sorts first (900 > 150), and the two api.1d4.net days + // roll up into one row. + const gitRow = (await screen.findByText('git.muchq.com')).closest('tr')! + expect(within(gitRow).getAllByRole('cell').map((c) => c.textContent)).toEqual([ + 'git.muchq.com', + '900', + '700', + '0', + '900', + '0', + '0', + ]) + const apiRow = screen.getByText('api.1d4.net').closest('tr')! + expect(within(apiRow).getAllByRole('cell').map((c) => c.textContent)).toEqual([ + 'api.1d4.net', + '150', + '2', + '150', + '0', + '0', + '0', + ]) + expect(screen.getByText('abc123')).toBeInTheDocument() + expect(onState).toHaveBeenLastCalledWith('connected') + }) + + it('reports failure without rendering a broken table when the API is down', async () => { + mockFetch({}) + const onState = vi.fn() + + render() + + expect(await screen.findByText(/Stats API unavailable/)).toBeInTheDocument() + expect(onState).toHaveBeenLastCalledWith('failed') + }) + + it('renders empty states rather than empty tables', async () => { + mockFetch({ '/summary': { days: 7, rows: [] }, '/iili/top': { days: 30, rows: [] } }) + + render() + + expect(await screen.findByText('No aggregated traffic yet.')).toBeInTheDocument() + expect(screen.getByText('No redirects aggregated yet.')).toBeInTheDocument() + }) +}) diff --git a/src/apps/metrics-systems/pages/MetricsPage.tsx b/src/apps/metrics-systems/pages/MetricsPage.tsx index 8bba54d..262d504 100644 --- a/src/apps/metrics-systems/pages/MetricsPage.tsx +++ b/src/apps/metrics-systems/pages/MetricsPage.tsx @@ -5,6 +5,7 @@ import ConnectionStatus, { ConnectionState } from '@/shared/components/nav/Conne import RotatingText from '@/shared/components/nav/RotatingText' import MetricsDashboard from '../components/MetricsDashboard' import ContainersDashboard from '../components/ContainersDashboard' +import StatsDashboard from '../components/StatsDashboard' import ServiceDashboard from '../components/ServiceDashboard' import styles from '../components/MetricsDashboard.module.css' import { METRICS_API_URL, fetchJson, serviceDisplayName, type ServiceCatalog } from '../api' @@ -32,7 +33,7 @@ const LEGACY_TABS: Record = { // Tabs that aren't services, so the catalog can't vouch for them and the // unknown-tab redirect must not bounce them. -const BUILT_IN_TABS = ['host', 'containers'] +const BUILT_IN_TABS = ['host', 'containers', 'stats'] const MetricsPage = () => { const { tab } = useParams<{ tab: string }>() @@ -72,6 +73,7 @@ const MetricsPage = () => { const tabs = [ { id: 'host', label: 'Host' }, { id: 'containers', label: 'Containers' }, + { id: 'stats', label: 'Stats' }, ...(catalog?.services ?? []).map((service) => ({ id: service.name, label: serviceDisplayName(service.name), @@ -95,6 +97,8 @@ const MetricsPage = () => { ) : activeTab === 'containers' ? ( + ) : activeTab === 'stats' ? ( + ) : ( )} diff --git a/src/apps/metrics-systems/pages/__tests__/MetricsPage.test.tsx b/src/apps/metrics-systems/pages/__tests__/MetricsPage.test.tsx index dd78956..9174d9a 100644 --- a/src/apps/metrics-systems/pages/__tests__/MetricsPage.test.tsx +++ b/src/apps/metrics-systems/pages/__tests__/MetricsPage.test.tsx @@ -85,10 +85,11 @@ describe('MetricsPage', () => { await new Promise((resolve) => setTimeout(resolve, 100)) }) - // Host and Containers are the two tabs that aren't services: the catalog - // never lists them, so they're prepended rather than derived from it. + // Host, Containers and Stats are the tabs that aren't services: the + // catalog never lists them, so they're prepended rather than derived + // from it. const tabs = screen.getAllByRole('button').map((button) => button.textContent) - expect(tabs.slice(0, 5)).toEqual(['Host', 'Containers', 'Golf Hub', 'MicroGPT', 'Portrait']) + expect(tabs.slice(0, 6)).toEqual(['Host', 'Containers', 'Stats', 'Golf Hub', 'MicroGPT', 'Portrait']) expect(screen.getByText('Host Metrics')).toBeTruthy() })