Root cause of recurring dev server hangs:
/api/v1/website-analytics threw CodedError('UMAMI_NOT_CONFIGURED') which
rendered as HTTP 409. React Query default-retries on 4xx (we set retry=1
globally), so every page render fired the umami queries → 409 →
retry → 409. Each request queried system_settings to resolve umami
credentials. Six analytics widgets on the /website-analytics page +
two on the dashboard glance tile × 2 (initial + retry) = 16 system_settings
queries on first paint. Combined with React Query refetching on mount,
the postgres pool (max=20) saturated and the server appeared hung.
Fix: return 200 with `{ data: null, notConfigured: true }` instead of
4xx. Not-configured is a steady empty state, not a transient error —
no retry loop. Updated WebsiteGlanceTile (hides itself) and
WebsiteAnalyticsShell (renders configure-umami CTA) to check the new
notConfigured flag.
Also includes from in-flight work: package.json dev script binds
0.0.0.0 so iPhone on LAN can reach the dev server, and BrandedAuthShell
uses fixed/inset-0 + flex to lock the login surface to the viewport so
iOS Safari doesn't rubber-band-scroll the card.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
233 lines
8.0 KiB
TypeScript
233 lines
8.0 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* /website-analytics page shell. Mirrors the dashboard shell's layout:
|
|
* - PageHeader with date-range picker (presets + custom)
|
|
* - KPI tiles (pageviews / visitors / visits / bounces)
|
|
* - Two-column grid: pageviews trend + active visitors badge
|
|
* - Top-N tables: pages, referrers, countries
|
|
*
|
|
* Gracefully renders an empty state when Umami isn't configured for the
|
|
* port - points the operator at /admin/website-analytics to set creds.
|
|
*/
|
|
|
|
import { useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { Globe, Settings, ExternalLink } from 'lucide-react';
|
|
|
|
import { PageHeader } from '@/components/shared/page-header';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { KPITile } from '@/components/ui/kpi-tile';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Button } from '@/components/ui/button';
|
|
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
|
|
import { type DateRange } from '@/lib/analytics/range';
|
|
import { useUIStore } from '@/stores/ui-store';
|
|
import {
|
|
useUmamiActive,
|
|
useUmamiPageviews,
|
|
useUmamiStats,
|
|
useUmamiTopCountries,
|
|
useUmamiTopPages,
|
|
useUmamiTopReferrers,
|
|
} from './use-website-analytics';
|
|
import { PageviewsChart } from './pageviews-chart';
|
|
import { TopList } from './top-list';
|
|
|
|
export function WebsiteAnalyticsShell() {
|
|
const [range, setRange] = useState<DateRange>('30d');
|
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
|
|
|
const stats = useUmamiStats(range);
|
|
const pageviews = useUmamiPageviews(range);
|
|
const active = useUmamiActive(range);
|
|
const topPages = useUmamiTopPages(range);
|
|
const topReferrers = useUmamiTopReferrers(range);
|
|
const topCountries = useUmamiTopCountries(range);
|
|
|
|
// API surfaces `notConfigured: true` on a 200 response (not 4xx) so
|
|
// React Query doesn't infinite-retry — that retry loop saturated the
|
|
// postgres pool and stalled the dev server. Single empty state covers
|
|
// all widgets so the page doesn't show six loading spinners forever.
|
|
const notConfigured = stats.data?.notConfigured === true;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title="Website analytics"
|
|
eyebrow="Marketing"
|
|
description="Live data from Umami - site traffic, top pages, referrers, and audience geography."
|
|
variant="gradient"
|
|
actions={<DateRangePicker value={range} onChange={setRange} />}
|
|
/>
|
|
|
|
{notConfigured ? (
|
|
<NotConfiguredEmptyState portSlug={portSlug} />
|
|
) : (
|
|
<>
|
|
{/* Live indicator + KPI tiles */}
|
|
<div className="grid gap-3 grid-cols-2 sm:gap-4 lg:grid-cols-5">
|
|
<ActiveVisitorsBadge value={active.data?.data?.visitors} loading={active.isLoading} />
|
|
<KpiPair
|
|
label="Pageviews"
|
|
loading={stats.isLoading}
|
|
value={stats.data?.data?.pageviews?.value}
|
|
prev={stats.data?.data?.pageviews?.prev}
|
|
accent="brand"
|
|
/>
|
|
<KpiPair
|
|
label="Visitors"
|
|
loading={stats.isLoading}
|
|
value={stats.data?.data?.visitors?.value}
|
|
prev={stats.data?.data?.visitors?.prev}
|
|
accent="teal"
|
|
/>
|
|
<KpiPair
|
|
label="Visits"
|
|
loading={stats.isLoading}
|
|
value={stats.data?.data?.visits?.value}
|
|
prev={stats.data?.data?.visits?.prev}
|
|
accent="success"
|
|
/>
|
|
<KpiPair
|
|
label="Bounces"
|
|
loading={stats.isLoading}
|
|
value={stats.data?.data?.bounces?.value}
|
|
prev={stats.data?.data?.bounces?.prev}
|
|
accent="purple"
|
|
invertDelta
|
|
/>
|
|
</div>
|
|
|
|
{/* Pageviews trend */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Pageviews trend</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{pageviews.isLoading ? (
|
|
<Skeleton className="h-[260px] w-full" />
|
|
) : (
|
|
<PageviewsChart data={pageviews.data?.data ?? null} />
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Top-N tables */}
|
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
|
|
<TopList
|
|
title="Top pages"
|
|
loading={topPages.isLoading}
|
|
rows={topPages.data?.data ?? null}
|
|
/>
|
|
<TopList
|
|
title="Top referrers"
|
|
loading={topReferrers.isLoading}
|
|
rows={topReferrers.data?.data ?? null}
|
|
defaultLabel="(direct)"
|
|
/>
|
|
<TopList
|
|
title="Top countries"
|
|
loading={topCountries.isLoading}
|
|
rows={topCountries.data?.data ?? null}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActiveVisitorsBadge({ value, loading }: { value?: number; loading: boolean }) {
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm relative overflow-hidden">
|
|
<div className="absolute inset-x-0 top-0 h-1 bg-mint" aria-hidden />
|
|
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
|
Active right now
|
|
</div>
|
|
<div className="mt-1 flex items-center gap-2">
|
|
{loading ? (
|
|
<Skeleton className="h-7 w-12" />
|
|
) : (
|
|
<>
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
|
</span>
|
|
<span className="text-lg font-semibold tabular-nums sm:text-2xl">{value ?? 0}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KpiPair({
|
|
label,
|
|
value,
|
|
prev,
|
|
accent,
|
|
loading,
|
|
invertDelta = false,
|
|
}: {
|
|
label: string;
|
|
value: number | undefined;
|
|
prev: number | undefined;
|
|
accent: 'brand' | 'success' | 'warning' | 'mint' | 'teal' | 'purple';
|
|
loading: boolean;
|
|
/** For metrics where lower is better (bounces). Flip the sign so green
|
|
* still means "good" in the UI. */
|
|
invertDelta?: boolean;
|
|
}) {
|
|
if (loading) {
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm">
|
|
<Skeleton className="h-3 w-20" />
|
|
<Skeleton className="mt-2 h-7 w-16" />
|
|
</div>
|
|
);
|
|
}
|
|
const v = value ?? 0;
|
|
const p = prev ?? 0;
|
|
let delta: number | undefined;
|
|
if (p > 0) {
|
|
delta = Math.round(((v - p) / p) * 100);
|
|
if (invertDelta) delta = -delta;
|
|
}
|
|
return <KPITile title={label} value={v.toLocaleString()} accent={accent} delta={delta} />;
|
|
}
|
|
|
|
function NotConfiguredEmptyState({ portSlug }: { portSlug: string | null }) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center gap-4 py-16 text-center">
|
|
<Globe className="h-12 w-12 text-muted-foreground/40" aria-hidden />
|
|
<div>
|
|
<h3 className="text-base font-semibold">Connect Umami to see your website analytics</h3>
|
|
<p className="mt-1 text-sm text-muted-foreground max-w-md">
|
|
Add your Umami URL, API token (or username/password), and Website ID for this port to
|
|
unlock pageview trends, top pages, referrers, and audience geography.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button asChild>
|
|
<Link
|
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
href={`/${portSlug}/admin/website-analytics` as any}
|
|
>
|
|
<Settings className="mr-2 h-4 w-4" />
|
|
Configure
|
|
</Link>
|
|
</Button>
|
|
<Button asChild variant="outline">
|
|
<a href="https://docs.umami.is/docs/api" target="_blank" rel="noreferrer">
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
Umami API docs
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|