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,
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user