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,
UsersRound,
Webhook,
Globe,
} from 'lucide-react';
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.',
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 });
}
}),
);