feat(analytics): Umami integration with per-port admin settings

Adds /[portSlug]/website-analytics dashboard page (pageviews, top
pages, top referrers) and a per-port admin config UI for the
Umami URL / website-ID / API token. Settings live in system_settings
keyed per-port so a future second port has its own Umami account.
Adds a website glance tile to the main dashboard, a server-side
test-credentials endpoint, and a stable cache key for the active-
visitor poll so React Query doesn't fragment the cache per range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-04 22:53:06 +02:00
parent 49d34e00c8
commit f5772ce318
13 changed files with 1198 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ import {
Users, Users,
UsersRound, UsersRound,
Webhook, Webhook,
Globe,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -209,6 +210,12 @@ const GROUPS: AdminGroup[] = [
description: 'Configure the AI provider used by the mobile receipt scanner.', description: 'Configure the AI provider used by the mobile receipt scanner.',
icon: ScrollText, icon: ScrollText,
}, },
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: Globe,
},
], ],
}, },
]; ];

View File

@@ -0,0 +1,74 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test-button';
import { PageHeader } from '@/components/shared/page-header';
/**
* Per-port Umami credentials. We deliberately keep all three values
* port-scoped (per the operator decision) so different ports can point at
* different Umami instances if needed. The /website-analytics dashboard
* page reads these settings via the umami.service layer at request time.
*/
const FIELDS: SettingFieldDef[] = [
{
key: 'umami_api_url',
label: 'Umami API URL',
description:
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
type: 'string',
placeholder: 'https://analytics.portnimara.com',
defaultValue: '',
},
{
key: 'umami_api_token',
label: 'API token',
description:
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_username',
label: 'Username',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
type: 'string',
placeholder: 'admin',
defaultValue: '',
},
{
key: 'umami_password',
label: 'Password',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_website_id',
label: 'Website ID',
description:
'UUID of this ports website inside Umami. Find it in Umami → Settings → Websites → Edit → Website ID.',
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
];
export default function WebsiteAnalyticsSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Website analytics (Umami)"
description="Connect this port to its Umami website to display traffic, top pages, referrers, and conversion data on the Website Analytics dashboard."
/>
<SettingsFormCard
title="Umami connection"
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
fields={FIELDS}
extra={<UmamiTestButton />}
/>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import { WebsiteAnalyticsShell } from '@/components/website-analytics/website-analytics-shell';
export const metadata: Metadata = {
title: 'Website analytics',
};
export default function WebsiteAnalyticsPage() {
return <WebsiteAnalyticsShell />;
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { testConnection } from '@/lib/services/umami.service';
/**
* POST /api/v1/admin/umami/test - admin-only Umami connection check.
*
* Returns `{ data: { ok: true, visitors } }` on success or
* `{ data: { ok: false, error } }` on failure. Mirrors the shape used by
* the Documenso health endpoint so the existing test-button UI pattern
* just works.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const result = await testConnection(ctx.portId);
return NextResponse.json({ data: result });
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ data: { ok: false, error } });
}
}),
);

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range';
import {
getActiveVisitors,
getMetric,
getPageviewsSeries,
getStats,
type UmamiMetricType,
} from '@/lib/services/umami.service';
/**
* GET /api/v1/website-analytics?metric=...&range=...
*
* Single endpoint serving every Umami widget on the /website-analytics
* page. Mirrors the shape of /api/v1/analytics so the client side can
* reuse the same hook pattern.
*
* Supported metrics:
* - stats → KPI tiles (pageviews, visitors, visits, etc.)
* - pageviews → time-series for the trend chart
* - active → live "right now" count (range ignored)
* - top-{type} → top pages/referrers/countries/etc.
* where type ∈ url|referrer|country|browser|
* os|device|event
*
* Range param accepts the same presets as /api/v1/analytics, plus
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD`.
*/
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
const TOP_METRIC_RX = /^top-(url|referrer|country|browser|os|device|event)$/;
function parseRange(req: NextRequest): DateRange | { error: string } {
const url = new URL(req.url);
const rawRange = url.searchParams.get('range') ?? '30d';
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
if (rawRange === 'custom') {
if (!fromParam || !toParam) {
return { error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' };
}
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
return { error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' };
}
if (fromParam > toParam) {
return { error: '`from` must be on or before `to`' };
}
// Round-trip date check (catches "2026-02-31" type rollovers).
for (const [label, raw] of [
['from', fromParam],
['to', toParam],
] as const) {
const d = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
return { error: `\`${label}\` is not a valid calendar date` };
}
}
return { kind: 'custom', from: fromParam, to: toParam };
}
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
return { error: 'Invalid range' };
}
return rawRange as PresetDateRange;
}
export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const metric = url.searchParams.get('metric');
if (!metric) {
return NextResponse.json({ error: 'Missing metric' }, { status: 400 });
}
const rangeOrError = parseRange(req);
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
return NextResponse.json({ error: rangeOrError.error }, { status: 400 });
}
const range = rangeOrError as DateRange;
try {
let data: unknown;
if (metric === 'stats') {
data = await getStats(ctx.portId, range);
} else if (metric === 'pageviews') {
data = await getPageviewsSeries(ctx.portId, range);
} else if (metric === 'active') {
data = await getActiveVisitors(ctx.portId);
} else if (TOP_METRIC_RX.test(metric)) {
const type = metric.replace(/^top-/, '') as UmamiMetricType;
const limit = Number(url.searchParams.get('limit') ?? 10);
data = await getMetric(ctx.portId, range, type, limit);
} else {
return NextResponse.json({ error: `Unknown metric: ${metric}` }, { status: 400 });
}
// `data === null` from the service means Umami isn't configured for
// this port - surface that explicitly so the UI can render a
// "configure your credentials" empty state instead of a chart.
if (data === null) {
return NextResponse.json({ error: 'umami_not_configured', metric, range }, { status: 200 });
}
return NextResponse.json({ metric, range, data });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message, metric, range }, { status: 502 });
}
}),
);

View File

@@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
interface TestResponse {
ok: boolean;
visitors?: number;
error?: string;
}
/**
* Hits POST /api/v1/admin/umami/test which calls Umami's `/api/websites/:id/
* active` to verify auth + websiteId in one request. On success, shows the
* live visitor count as proof we got real data back.
*/
export function UmamiTestButton() {
const [pending, setPending] = useState(false);
const [result, setResult] = useState<TestResponse | null>(null);
async function runTest() {
setPending(true);
setResult(null);
try {
const res = await apiFetch<{ data: TestResponse }>('/api/v1/admin/umami/test', {
method: 'POST',
});
setResult(res.data);
if (res.data.ok) {
toast.success(`Umami reachable - ${res.data.visitors ?? 0} active visitor(s) right now`);
} else {
toast.error(res.data.error ?? 'Umami test failed');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Test failed';
setResult({ ok: false, error: message });
toast.error(message);
} finally {
setPending(false);
}
}
return (
<div className="flex items-center gap-3">
{result &&
(result.ok ? (
<span className="flex items-center text-xs text-green-600">
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Connected ({result.visitors ?? 0} active)
</span>
) : (
<span className="flex items-center text-xs text-destructive">
<XCircle className="mr-1 h-3.5 w-3.5" />
{result.error ?? 'Failed'}
</span>
))}
<Button type="button" size="sm" variant="outline" onClick={runTest} disabled={pending}>
{pending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Test connection
</Button>
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
/**
* Compact "Website at a glance" tile for the main sales dashboard. Shows
* pageviews today + active visitors right now + a deep-link to the full
* /website-analytics page. Soft-fails (renders nothing) when Umami isn't
* configured for this port - so the dashboard doesn't get cluttered with
* a "configure Umami" prompt that the user already saw on the dedicated
* page.
*/
import Link from 'next/link';
import { Globe, ArrowRight } from 'lucide-react';
import { useUIStore } from '@/stores/ui-store';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
useUmamiActive,
useUmamiStats,
} from '@/components/website-analytics/use-website-analytics';
export function WebsiteGlanceTile() {
const portSlug = useUIStore((s) => s.currentPortSlug);
const stats = useUmamiStats('today');
const active = useUmamiActive('today');
// Hide the tile entirely if Umami isn't configured - this dashboard is
// for sales, not for prompting the operator into integration setup.
if (
stats.data?.error === 'umami_not_configured' ||
active.data?.error === 'umami_not_configured'
) {
return null;
}
const today = stats.data?.data?.pageviews?.value ?? 0;
const activeNow = active.data?.data?.visitors ?? 0;
const loading = stats.isLoading || active.isLoading;
return (
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={portSlug ? (`/${portSlug}/website-analytics` as any) : ('/' as any)}
className="block group"
>
<Card className="relative overflow-hidden p-3 sm:p-5 transition-shadow hover:shadow-md">
<div className="absolute inset-x-0 top-0 h-1 bg-mint" aria-hidden />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
<Globe className="h-3 w-3" aria-hidden />
Website today
</div>
{loading ? (
<Skeleton className="mt-2 h-7 w-20" />
) : (
<div className="mt-1 flex items-baseline gap-2 text-lg font-semibold tabular-nums sm:mt-2 sm:text-2xl">
{today.toLocaleString()}
<span className="text-xs font-normal text-muted-foreground">pageviews</span>
</div>
)}
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
{activeNow} active right now
</div>
</div>
<ArrowRight
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</div>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { UmamiPageviewsSeries } from '@/lib/services/umami.service';
interface Props {
data: UmamiPageviewsSeries | null;
}
/**
* Stacks pageviews on top of sessions in a simple area chart. Umami's
* timeseries comes pre-bucketed (the service picks bucket size based on
* range span - minute/hour/day/month).
*
* X-axis labels are kept short to avoid overflow on dense ranges.
*/
export function PageviewsChart({ data }: Props) {
if (!data || data.pageviews.length === 0) {
return (
<div className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
No data in this range
</div>
);
}
// Merge the two series (Umami returns them separately) into one row per
// bucket so we can drive a single chart.
const byX = new Map<string, { x: string; pageviews: number; sessions: number }>();
for (const p of data.pageviews) {
byX.set(p.x, { x: p.x, pageviews: p.y, sessions: 0 });
}
for (const s of data.sessions) {
const row = byX.get(s.x);
if (row) row.sessions = s.y;
else byX.set(s.x, { x: s.x, pageviews: 0, sessions: s.y });
}
const merged = Array.from(byX.values()).sort((a, b) => a.x.localeCompare(b.x));
return (
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={merged} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
<defs>
<linearGradient id="pvFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-1))" stopOpacity={0.6} />
<stop offset="100%" stopColor="hsl(var(--chart-1))" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="sessFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-2))" stopOpacity={0.5} />
<stop offset="100%" stopColor="hsl(var(--chart-2))" stopOpacity={0.04} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="x"
fontSize={11}
tick={{ fill: 'hsl(var(--muted-foreground))' }}
tickFormatter={formatXTick}
/>
<YAxis
fontSize={11}
tick={{ fill: 'hsl(var(--muted-foreground))' }}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="pageviews"
stroke="hsl(var(--chart-1))"
fill="url(#pvFill)"
strokeWidth={2}
name="Pageviews"
/>
<Area
type="monotone"
dataKey="sessions"
stroke="hsl(var(--chart-2))"
fill="url(#sessFill)"
strokeWidth={2}
name="Sessions"
/>
</AreaChart>
</ResponsiveContainer>
);
}
/** Compact tick labels: full datetime → just MM-DD or MM-DD HH:00. */
function formatXTick(value: string): string {
// Umami can return either "YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD".
if (value.length >= 16) {
return value.slice(5, 16); // "MM-DD HH:mm"
}
return value.slice(5); // "MM-DD"
}

View File

@@ -0,0 +1,66 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import type { UmamiMetricRow } from '@/lib/services/umami.service';
interface Props {
title: string;
rows: UmamiMetricRow[] | null;
loading: boolean;
/** Label substituted when `x` is empty (e.g. direct traffic referrers). */
defaultLabel?: string;
}
/**
* Compact "top N" list used for top pages / referrers / countries.
* Renders each row as label + numeric count, with a thin progress bar
* scaled to the largest count in the set so the visual density tells
* the same story at a glance as the numbers.
*/
export function TopList({ title, rows, loading, defaultLabel = '-' }: Props) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-3/6" />
</div>
) : !rows || rows.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">No data</div>
) : (
<ul className="space-y-1.5">
{rows.slice(0, 10).map((row, i) => {
const max = rows[0]?.y ?? 1;
const pct = (row.y / max) * 100;
const label = row.x?.trim() || defaultLabel;
return (
<li key={`${row.x}-${i}`} className="text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-medium">{label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{row.y.toLocaleString()}
</span>
</div>
<div className="mt-0.5 h-1 w-full rounded-full bg-muted">
<div
className="h-1 rounded-full bg-brand"
style={{ width: `${Math.max(2, pct)}%` }}
aria-hidden
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
/**
* React Query hooks for the Website Analytics page. Mirrors the structure
* of `src/components/dashboard/use-analytics.ts` but talks to the Umami
* proxy at /api/v1/website-analytics.
*
* All hooks are gated on `currentPortId` so they don't fire before the
* port is resolved (which would cause a 401 to land in the React Query
* cache and persist as `isError: true` until staleTime expires).
*/
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { useUIStore } from '@/stores/ui-store';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
import type {
UmamiActiveVisitors,
UmamiMetricRow,
UmamiPageviewsSeries,
UmamiStats,
} from '@/lib/services/umami.service';
interface MetricResponse<T> {
metric: string;
range: DateRange;
data: T;
/** Surfaced when Umami isn't configured for the port - UI can render a
* "set up Umami" empty state instead of a generic loading spinner. */
error?: string;
}
function rangeToQuery(range: DateRange): string {
if (isCustomRange(range)) {
return `range=custom&from=${range.from}&to=${range.to}`;
}
return `range=${range}`;
}
function useUmamiQuery<T>(
metric: string,
range: DateRange,
extraParams = '',
/** Override the cache key segment that defaults to `range`. Use for
* metrics whose response is range-independent (e.g. active visitors)
* so the cache isn't fragmented across each date the user has picked. */
cacheKeySegment?: unknown,
) {
const portId = useUIStore((s) => s.currentPortId);
return useQuery<MetricResponse<T>>({
queryKey: ['website-analytics', metric, cacheKeySegment ?? range, portId],
queryFn: () =>
apiFetch<MetricResponse<T>>(
`/api/v1/website-analytics?metric=${metric}&${rangeToQuery(range)}${extraParams}`,
),
staleTime: 30_000, // umami data refreshes constantly; short stale time
retry: 1,
enabled: !!portId,
});
}
export const useUmamiStats = (range: DateRange) => useUmamiQuery<UmamiStats>('stats', range);
export const useUmamiPageviews = (range: DateRange) =>
useUmamiQuery<UmamiPageviewsSeries>('pageviews', range);
// Active visitors are server-side range-independent (Umami's /active endpoint
// returns the last-5-minute count regardless of what range we pass). Use a
// fixed cache-key segment so the dashboard tile (range='today') and the
// website-analytics shell (any selected range) share one cache entry instead
// of fragmenting it across every range the user picks.
export const useUmamiActive = (range: DateRange) =>
useUmamiQuery<UmamiActiveVisitors>('active', range, '', 'fixed');
export const useUmamiTopPages = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-url', range, `&limit=${limit}`);
export const useUmamiTopReferrers = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-referrer', range, `&limit=${limit}`);
export const useUmamiTopCountries = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-country', range, `&limit=${limit}`);

View File

@@ -0,0 +1,232 @@
'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);
// Any of the queries returning `error: 'umami_not_configured'` means we
// need to prompt the operator to set credentials. Single empty state
// covers all widgets so the page doesn't show six loading spinners that
// never resolve.
const notConfigured = stats.data?.error === 'umami_not_configured';
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>
);
}

View File

@@ -0,0 +1,76 @@
/**
* Pure date-range types and helpers shared by client components and the
* server-side analytics service.
*
* Lives outside `src/lib/services/analytics.service.ts` because that file
* imports the DB driver (`postgres`) which can't be bundled into client
* components - see Next.js "Module not found: net" build error.
*
* No DB / no IO / no React.
*/
/**
* Preset date ranges used by the dashboard's quick-pick tabs.
*/
export type PresetDateRange = '7d' | '30d' | '90d' | 'today';
/**
* A custom date range expressed as a pair of ISO date strings (YYYY-MM-DD).
* The lower bound is inclusive at 00:00; the upper bound is inclusive at
* 23:59:59.999 (resolved inside `rangeToBounds`).
*/
export interface CustomDateRange {
kind: 'custom';
from: string; // ISO YYYY-MM-DD
to: string; // ISO YYYY-MM-DD
}
export type DateRange = PresetDateRange | CustomDateRange;
export const ALL_RANGES: readonly PresetDateRange[] = ['today', '7d', '30d', '90d'] as const;
export function isCustomRange(range: DateRange): range is CustomDateRange {
return typeof range === 'object' && range.kind === 'custom';
}
/**
* Resolve any DateRange (preset or custom) to a concrete {from, to} pair.
* - Preset ranges anchor `to` at "now" and `from` at `now - N days`.
* - Custom ranges use the operator-supplied dates verbatim, with `to`
* normalized to end-of-day so a same-day range still includes that day.
*/
export function rangeToBounds(range: DateRange): { from: Date; to: Date } {
if (isCustomRange(range)) {
return {
from: new Date(`${range.from}T00:00:00.000Z`),
to: new Date(`${range.to}T23:59:59.999Z`),
};
}
const now = Date.now();
const days = rangeToDays(range);
return { from: new Date(now - days * 86_400_000), to: new Date(now) };
}
export function rangeToDays(range: PresetDateRange): number {
switch (range) {
case 'today':
return 1;
case '7d':
return 7;
case '30d':
return 30;
case '90d':
return 90;
}
}
/**
* Number of days a range spans (rounded up). Useful for sizing chart axes.
*/
export function rangeSpanDays(range: DateRange): number {
if (isCustomRange(range)) {
const { from, to } = rangeToBounds(range);
return Math.max(1, Math.ceil((to.getTime() - from.getTime()) / 86_400_000));
}
return rangeToDays(range);
}

View File

@@ -0,0 +1,258 @@
/**
* Umami v2 API client. Reads credentials from `system_settings` per port,
* caches JWTs in-memory when using the username/password flow, and exposes
* typed wrappers for the handful of endpoints the /website-analytics page
* uses.
*
* Auth resolution order (per port):
* 1. If `umami_api_token` is set → use it as a Bearer token (Umami Cloud
* pattern, also supported by v2 self-hosted with API keys enabled).
* 2. Otherwise → POST /api/auth/login with `umami_username` +
* `umami_password` to get a JWT, cache it, use it as Bearer.
*
* No env vars - all config lives in port-scoped system_settings so the
* operator can configure Umami at runtime via /admin/website-analytics.
*
* v2 docs: https://docs.umami.is/docs/api
*/
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
// ─── Settings access ────────────────────────────────────────────────────────
interface UmamiPortConfig {
apiUrl: string;
apiToken: string | null;
username: string | null;
password: string | null;
websiteId: string;
}
const SETTING_KEYS = [
'umami_api_url',
'umami_api_token',
'umami_username',
'umami_password',
'umami_website_id',
] as const;
/**
* Read the five Umami-related setting rows for one port and assemble them.
* Returns null if the minimum required config (URL + websiteId + an auth
* method) is missing - callers surface a "not configured" UI in that case.
*/
export async function loadUmamiConfig(portId: string): Promise<UmamiPortConfig | null> {
// Filter to ONLY the five Umami keys. Without this, every analytics page
// request pulls every system_settings row for the port (Documenso keys,
// SMTP, email templates, etc), which scales poorly as the port grows.
const rows = await db
.select({ key: systemSettings.key, value: systemSettings.value })
.from(systemSettings)
.where(and(eq(systemSettings.portId, portId), inArray(systemSettings.key, [...SETTING_KEYS])));
const map = new Map(rows.map((r) => [r.key, r.value as string | null | undefined]));
const apiUrl = (map.get('umami_api_url') ?? '').toString().trim().replace(/\/$/, '');
const apiToken = ((map.get('umami_api_token') ?? '') as string).trim() || null;
const username = ((map.get('umami_username') ?? '') as string).trim() || null;
const password = ((map.get('umami_password') ?? '') as string).trim() || null;
const websiteId = ((map.get('umami_website_id') ?? '') as string).trim();
if (!apiUrl || !websiteId) return null;
if (!apiToken && !(username && password)) return null;
return { apiUrl, apiToken, username, password, websiteId };
}
// ─── JWT cache (username/password flow only) ────────────────────────────────
interface CachedJwt {
token: string;
expiresAt: number;
}
// Keyed by `${apiUrl}::${username}` so different ports / different Umami
// instances don't share tokens. Tokens are presumed to last 1 hour; we
// refresh proactively a few minutes before expiry.
const jwtCache = new Map<string, CachedJwt>();
const JWT_TTL_MS = 55 * 60 * 1000; // 55 min - Umami JWTs default to 1h
async function loginAndCache(apiUrl: string, username: string, password: string): Promise<string> {
const res = await fetch(`${apiUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
throw new Error(`Umami login failed: ${res.status} ${res.statusText}`);
}
const body = (await res.json()) as { token?: string };
if (!body.token) throw new Error('Umami login response missing token');
jwtCache.set(`${apiUrl}::${username}`, {
token: body.token,
expiresAt: Date.now() + JWT_TTL_MS,
});
return body.token;
}
async function resolveBearer(config: UmamiPortConfig): Promise<string> {
if (config.apiToken) return config.apiToken;
if (!config.username || !config.password) {
throw new Error('Umami is misconfigured: no API token and no username/password.');
}
const cacheKey = `${config.apiUrl}::${config.username}`;
const cached = jwtCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.token;
return loginAndCache(config.apiUrl, config.username, config.password);
}
// ─── Generic request helper ─────────────────────────────────────────────────
async function umamiFetch<T>(
config: UmamiPortConfig,
path: string,
search: Record<string, string | number | undefined>,
): Promise<T> {
const bearer = await resolveBearer(config);
const url = new URL(`${config.apiUrl}${path}`);
for (const [k, v] of Object.entries(search)) {
if (v === undefined) continue;
url.searchParams.set(k, String(v));
}
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${bearer}`,
accept: 'application/json',
},
// Don't share Next.js's request cache - analytics figures change every
// few seconds. The service-layer cache (if any) is the right place.
cache: 'no-store',
});
if (res.status === 401 || res.status === 403) {
// Bearer rejected - drop cached JWT so next call re-logs in.
if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`);
throw new Error(`Umami unauthorized: ${res.status}`);
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`Umami ${path} failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`,
);
}
return (await res.json()) as T;
}
// ─── Range serialization ────────────────────────────────────────────────────
function rangeToParams(range: DateRange): { startAt: number; endAt: number } {
// Umami expects unix milliseconds for both bounds.
const { from, to } = rangeToBounds(range);
return { startAt: from.getTime(), endAt: to.getTime() };
}
/** Pick a sensible bucket size for the pageviews timeseries given the
* range span. Avoids returning thousands of points for a 90d range. */
function pickUnit(range: DateRange): 'hour' | 'day' | 'month' {
const { from, to } = rangeToBounds(range);
const days = (to.getTime() - from.getTime()) / 86_400_000;
if (days <= 2) return 'hour';
if (days <= 120) return 'day';
return 'month';
}
// ─── Public API ─────────────────────────────────────────────────────────────
export interface UmamiStats {
pageviews: { value: number; prev: number };
visitors: { value: number; prev: number };
visits: { value: number; prev: number };
bounces: { value: number; prev: number };
totaltime: { value: number; prev: number };
}
export async function getStats(portId: string, range: DateRange): Promise<UmamiStats | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiStats>(config, `/api/websites/${config.websiteId}/stats`, {
...rangeToParams(range),
});
}
export interface UmamiPageviewsSeries {
pageviews: Array<{ x: string; y: number }>;
sessions: Array<{ x: string; y: number }>;
}
export async function getPageviewsSeries(
portId: string,
range: DateRange,
): Promise<UmamiPageviewsSeries | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiPageviewsSeries>(config, `/api/websites/${config.websiteId}/pageviews`, {
...rangeToParams(range),
unit: pickUnit(range),
timezone: 'UTC',
});
}
export type UmamiMetricType =
| 'url'
| 'referrer'
| 'browser'
| 'os'
| 'device'
| 'country'
| 'event';
export interface UmamiMetricRow {
x: string;
y: number;
}
export async function getMetric(
portId: string,
range: DateRange,
type: UmamiMetricType,
limit = 10,
): Promise<UmamiMetricRow[] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiMetricRow[]>(config, `/api/websites/${config.websiteId}/metrics`, {
...rangeToParams(range),
type,
limit,
});
}
export interface UmamiActiveVisitors {
visitors: number;
}
export async function getActiveVisitors(portId: string): Promise<UmamiActiveVisitors | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiActiveVisitors>(config, `/api/websites/${config.websiteId}/active`, {});
}
/**
* Verify the connection by hitting `/api/websites/:id/active` - the cheapest
* authenticated endpoint that proves both auth + websiteId are good.
* Throws on any failure with a descriptive message; resolves on success.
*/
export async function testConnection(portId: string): Promise<{ ok: true; visitors: number }> {
const config = await loadUmamiConfig(portId);
if (!config) {
throw new Error('Umami is not configured for this port.');
}
const result = await umamiFetch<UmamiActiveVisitors>(
config,
`/api/websites/${config.websiteId}/active`,
{},
);
return { ok: true, visitors: result.visitors };
}