Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/apps/metrics-systems/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
144 changes: 144 additions & 0 deletions src/apps/metrics-systems/components/StatsDashboard.tsx
Original file line number Diff line number Diff line change
@@ -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<StatsSummary | null>(null)
const [slugs, setSlugs] = useState<TopSlugs | null>(null)
const [loaded, setLoaded] = useState(false)

useEffect(() => {
let cancelled = false
onConnectionStateChange('connecting')
Promise.all([
fetchJson<StatsSummary>(`${STATS_API_URL}/summary?days=7`),
fetchJson<TopSlugs>(`${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<string, number> }
>()
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 <div className={styles.noData}>Loading stats…</div>
}
if (!summary) {
return (
<div className={styles.noData}>
Stats API unavailable. The stats profile may not be deployed yet.
</div>
)
}

const agentClasses = ['browser', 'ai_scraper', 'bot', 'other']

return (
<div className={styles.metricsGrid}>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Traffic by host — last {summary.days} days</h2>
<div className={styles.tableScroll}>
<table className={styles.containerTable}>
<thead>
<tr>
<th>Host</th>
<th>Requests</th>
<th>Errors</th>
<th>Browser</th>
<th>AI scrapers</th>
<th>Bots</th>
<th>Other</th>
</tr>
</thead>
<tbody>
{byHost.map(([host, entry]) => (
<tr key={host}>
<td>{host}</td>
<td>{entry.total.toLocaleString()}</td>
<td>{entry.errors.toLocaleString()}</td>
{agentClasses.map((agentClass) => (
<td key={agentClass}>{(entry.classes[agentClass] ?? 0).toLocaleString()}</td>
))}
</tr>
))}
{byHost.length === 0 && (
<tr>
<td colSpan={7}>No aggregated traffic yet.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>

<div className={styles.section}>
<h2 className={styles.sectionTitle}>
Top short links — last {slugs?.days ?? 30} days
</h2>
<div className={styles.tableScroll}>
<table className={styles.containerTable}>
<thead>
<tr>
<th>Slug</th>
<th>Follows</th>
</tr>
</thead>
<tbody>
{(slugs?.rows ?? []).map((row) => (
<tr key={row.slug}>
<td>{row.slug}</td>
<td>{row.requests.toLocaleString()}</td>
</tr>
))}
{(slugs?.rows ?? []).length === 0 && (
<tr>
<td colSpan={2}>No redirects aggregated yet.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}

export default StatsDashboard
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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(<StatsDashboard onConnectionStateChange={onState} />)

// 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(<StatsDashboard onConnectionStateChange={onState} />)

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(<StatsDashboard onConnectionStateChange={vi.fn()} />)

expect(await screen.findByText('No aggregated traffic yet.')).toBeInTheDocument()
expect(screen.getByText('No redirects aggregated yet.')).toBeInTheDocument()
})
})
6 changes: 5 additions & 1 deletion src/apps/metrics-systems/pages/MetricsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -32,7 +33,7 @@ const LEGACY_TABS: Record<string, string> = {

// 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 }>()
Expand Down Expand Up @@ -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),
Expand All @@ -95,6 +97,8 @@ const MetricsPage = () => {
<MetricsDashboard onConnectionStateChange={handleConnectionStateChange} />
) : activeTab === 'containers' ? (
<ContainersDashboard onConnectionStateChange={handleConnectionStateChange} />
) : activeTab === 'stats' ? (
<StatsDashboard onConnectionStateChange={handleConnectionStateChange} />
) : (
<ServiceDashboard service={activeTab} onConnectionStateChange={handleConnectionStateChange} />
)}
Expand Down
7 changes: 4 additions & 3 deletions src/apps/metrics-systems/pages/__tests__/MetricsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down
Loading