fix(analytics): stop UMAMI_NOT_CONFIGURED returning 409 — caused dev server hangs
Root cause of recurring dev server hangs:
/api/v1/website-analytics threw CodedError('UMAMI_NOT_CONFIGURED') which
rendered as HTTP 409. React Query default-retries on 4xx (we set retry=1
globally), so every page render fired the umami queries → 409 →
retry → 409. Each request queried system_settings to resolve umami
credentials. Six analytics widgets on the /website-analytics page +
two on the dashboard glance tile × 2 (initial + retry) = 16 system_settings
queries on first paint. Combined with React Query refetching on mount,
the postgres pool (max=20) saturated and the server appeared hung.
Fix: return 200 with `{ data: null, notConfigured: true }` instead of
4xx. Not-configured is a steady empty state, not a transient error —
no retry loop. Updated WebsiteGlanceTile (hides itself) and
WebsiteAnalyticsShell (renders configure-umami CTA) to check the new
notConfigured flag.
Also includes from in-flight work: package.json dev script binds
0.0.0.0 so iPhone on LAN can reach the dev server, and BrandedAuthShell
uses fixed/inset-0 + flex to lock the login surface to the viewport so
iOS Safari doesn't rubber-band-scroll the card.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.2",
|
"packageManager": "pnpm@10.33.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack -H 0.0.0.0",
|
||||||
"build": "next build && pnpm build:server",
|
"build": "next build && pnpm build:server",
|
||||||
"build:server": "esbuild src/server.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
|
"build:server": "esbuild src/server.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
|
||||||
"build:worker": "esbuild src/worker.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
|
"build:worker": "esbuild src/worker.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
|
||||||
|
|||||||
@@ -104,9 +104,14 @@ export const GET = withAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// `data === null` from the service means Umami isn't configured for
|
// `data === null` from the service means Umami isn't configured for
|
||||||
// this port - surface that explicitly so the UI can render a
|
// this port. Return 200 with `data: null` + the explicit
|
||||||
// "configure your credentials" empty state instead of a chart.
|
// `notConfigured: true` flag so the UI renders a "configure your
|
||||||
if (data === null) throw new CodedError('UMAMI_NOT_CONFIGURED');
|
// credentials" empty state. **Do not throw** — a 409 here would
|
||||||
|
// trigger React Query's default retry loop, and every retry fires
|
||||||
|
// a system_settings lookup → pool saturation → server hang.
|
||||||
|
if (data === null) {
|
||||||
|
return NextResponse.json({ metric, range, data: null, notConfigured: true });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ metric, range, data });
|
return NextResponse.json({ metric, range, data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -27,10 +27,9 @@ export function WebsiteGlanceTile() {
|
|||||||
|
|
||||||
// Hide the tile entirely if Umami isn't configured - this dashboard is
|
// Hide the tile entirely if Umami isn't configured - this dashboard is
|
||||||
// for sales, not for prompting the operator into integration setup.
|
// for sales, not for prompting the operator into integration setup.
|
||||||
if (
|
// The API surfaces `notConfigured: true` on a 200 response so React
|
||||||
stats.data?.error === 'umami_not_configured' ||
|
// Query doesn't retry-loop (a prior 409-throw caused server hangs).
|
||||||
active.data?.error === 'umami_not_configured'
|
if (stats.data?.notConfigured || active.data?.notConfigured) {
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,17 @@ interface BrandedAuthShellProps {
|
|||||||
export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps) {
|
export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps) {
|
||||||
const logoUrl = branding?.logoUrl || DEFAULT_LOGO_URL;
|
const logoUrl = branding?.logoUrl || DEFAULT_LOGO_URL;
|
||||||
const altText = branding?.appName || 'Port Nimara';
|
const altText = branding?.appName || 'Port Nimara';
|
||||||
|
// fixed inset-0 anchors the auth surface to the viewport directly —
|
||||||
|
// iOS Safari ignores overflow-hidden on inner divs for body-level
|
||||||
|
// scrolling, so a regular `h-[100dvh] overflow-hidden` wrapper doesn't
|
||||||
|
// stop the rubber-band bounce. Pinning to the viewport via position
|
||||||
|
// fixed does. The fixed-position shell then uses flex to center the
|
||||||
|
// card within the visible area.
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen min-h-[100dvh] flex items-center justify-center px-4 py-8">
|
<div className="fixed inset-0 flex items-center justify-center px-4 py-8 overscroll-none">
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="fixed inset-0 -z-10"
|
className="absolute inset-0 -z-10"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url('${DEFAULT_BG_URL}')`,
|
backgroundImage: `url('${DEFAULT_BG_URL}')`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ import type {
|
|||||||
interface MetricResponse<T> {
|
interface MetricResponse<T> {
|
||||||
metric: string;
|
metric: string;
|
||||||
range: DateRange;
|
range: DateRange;
|
||||||
data: T;
|
data: T | null;
|
||||||
/** Surfaced when Umami isn't configured for the port - UI can render a
|
/** True when Umami isn't configured for the port. Surfaced as a 200
|
||||||
* "set up Umami" empty state instead of a generic loading spinner. */
|
* response (not 4xx) so React Query doesn't infinitely retry — that
|
||||||
error?: string;
|
* retry loop previously saturated the postgres pool and hung the dev
|
||||||
|
* server. UI consumers should check this flag and render the
|
||||||
|
* "set up Umami" empty state. */
|
||||||
|
notConfigured?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rangeToQuery(range: DateRange): string {
|
function rangeToQuery(range: DateRange): string {
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ export function WebsiteAnalyticsShell() {
|
|||||||
const topReferrers = useUmamiTopReferrers(range);
|
const topReferrers = useUmamiTopReferrers(range);
|
||||||
const topCountries = useUmamiTopCountries(range);
|
const topCountries = useUmamiTopCountries(range);
|
||||||
|
|
||||||
// Any of the queries returning `error: 'umami_not_configured'` means we
|
// API surfaces `notConfigured: true` on a 200 response (not 4xx) so
|
||||||
// need to prompt the operator to set credentials. Single empty state
|
// React Query doesn't infinite-retry — that retry loop saturated the
|
||||||
// covers all widgets so the page doesn't show six loading spinners that
|
// postgres pool and stalled the dev server. Single empty state covers
|
||||||
// never resolve.
|
// all widgets so the page doesn't show six loading spinners forever.
|
||||||
const notConfigured = stats.data?.error === 'umami_not_configured';
|
const notConfigured = stats.data?.notConfigured === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user