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:
@@ -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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 port’s 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/(dashboard)/[portSlug]/website-analytics/page.tsx
Normal file
11
src/app/(dashboard)/[portSlug]/website-analytics/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
24
src/app/api/v1/admin/umami/test/route.ts
Normal file
24
src/app/api/v1/admin/umami/test/route.ts
Normal 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 } });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
113
src/app/api/v1/website-analytics/route.ts
Normal file
113
src/app/api/v1/website-analytics/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
67
src/components/admin/website-analytics/umami-test-button.tsx
Normal file
67
src/components/admin/website-analytics/umami-test-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/dashboard/website-glance-tile.tsx
Normal file
79
src/components/dashboard/website-glance-tile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/website-analytics/pageviews-chart.tsx
Normal file
111
src/components/website-analytics/pageviews-chart.tsx
Normal 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"
|
||||||
|
}
|
||||||
66
src/components/website-analytics/top-list.tsx
Normal file
66
src/components/website-analytics/top-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/website-analytics/use-website-analytics.ts
Normal file
80
src/components/website-analytics/use-website-analytics.ts
Normal 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}`);
|
||||||
232
src/components/website-analytics/website-analytics-shell.tsx
Normal file
232
src/components/website-analytics/website-analytics-shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/lib/analytics/range.ts
Normal file
76
src/lib/analytics/range.ts
Normal 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);
|
||||||
|
}
|
||||||
258
src/lib/services/umami.service.ts
Normal file
258
src/lib/services/umami.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user