From ec5edbe5e4f07325b8c2c58cc68987161ef98126 Mon Sep 17 00:00:00 2001 From: Santhi Prakash Date: Tue, 4 Aug 2026 05:40:44 +0000 Subject: [PATCH 1/3] fix(dashboard): surface analytics overview timeouts instead of empty stats - Problem: /analytics/overview can exceed the 15s client default for high-traffic projects; the abort left Overview/Monitoring tabs showing 0/N/A with no error. - Fix: request a 60s timeout for the overview fetch and render a retryable error state when analytics.error is set. - Verification: bun run test src/i18n/i18n-parity.test.ts (pass); preflight_ship.py clear for #396. --- .../[id]/components/MonitoringTab.tsx | 36 ++++++++++++++++--- .../projects/[id]/components/OverviewTab.tsx | 30 +++++++++++++++- .../src/hooks/useProjectEndpoints.ts | 8 ++++- .../src/i18n/locales/ar/projects.json | 3 ++ .../src/i18n/locales/de/projects.json | 3 ++ .../src/i18n/locales/en/projects.json | 3 ++ .../src/i18n/locales/es/projects.json | 3 ++ .../src/i18n/locales/fr/projects.json | 3 ++ .../src/i18n/locales/ja/projects.json | 3 ++ .../src/i18n/locales/pt/projects.json | 3 ++ .../src/i18n/locales/tr/projects.json | 3 ++ .../src/i18n/locales/zh/projects.json | 3 ++ 12 files changed, 95 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/MonitoringTab.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/MonitoringTab.tsx index 31906ebf3..351f31ac7 100644 --- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/MonitoringTab.tsx +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/MonitoringTab.tsx @@ -15,12 +15,13 @@ * useAnalyticsData — per-MINUTE series for the traffic chart. * * The geo/overview split isn't arbitrary: countries, visitors and paths are only - * aggregated daily at the edge (per-minute would multiply its shared-dict cardinality - * by ~1440 and evict the request counters they annotate), so they can't come from the + * aggregated daily at the edge (per-minute would multiply the shared-dict cardinality + * by ~1440 and evict the counters they annotate), so they can't come from the * same fetch as the minute series. */ import React, { useMemo, useState } from "react"; +import { AlertCircle, RefreshCw } from "lucide-react"; import { TrafficChart, TopPaths } from "./general"; import { MonitoringView } from "@/components/monitoring/MonitoringView"; import { useProjectSettings } from "@/context/ProjectSettingsContext"; @@ -33,6 +34,7 @@ import { useAnalyticsData, useAnalyticsGeo, useProjectUsageHistory, + invalidateProjectCaches, } from "@/hooks/useProjectEndpoints"; import { useProjectUsageStream } from "@/hooks/useProjectUsageStream"; import { MOCK_VARIANTS, type MockVariant } from "@/components/monitoring/fixtures"; @@ -52,7 +54,7 @@ const USAGE_HISTORY_POLL_MS = 60_000; * rather than `useSearchParams()`. * * Deliberate: `useSearchParams` is empty during prerender and only fills after - * hydration, and this needs to be right on the FIRST client render or the tab + * hydration, and this needs to be right on the FIRST client render or the URL * flashes its empty state. Reading `window.location` in a lazy `useState` * initializer runs exactly once, on the client, after the URL is final. * @@ -114,10 +116,16 @@ export const MonitoringTab = () => { // Hooks always run — calling them conditionally would break the hook order. The // mock swaps their RESULTS, and passes `null` id so no request is made. const liveId = mock ? null : id; - const { data: liveAnalytics, isLoading: isLoadingAnalytics } = useAnalyticsData(liveId, domainScope); + const { + data: liveAnalytics, + isLoading: isLoadingAnalytics, + error: liveAnalyticsError, + } = useAnalyticsData(liveId, domainScope); const { data: liveGeo, isLoading: isLoadingGeo } = useAnalyticsGeo(liveId, domainScope); const { usage: liveUsage, isConnected, error: usageError, reconnect } = useProjectUsageStream(liveId); + const showAnalyticsError = !!liveAnalyticsError && !isLoadingAnalytics; + /** Scope for the history read. Held here rather than in the view because it keys the * fetch — the view raises changes through `onServiceKeyChange`. */ const [serviceKey, setServiceKey] = React.useState(null); @@ -317,6 +325,26 @@ export const MonitoringTab = () => { ? `${new Date(analytics.summary.firstRequest).toLocaleDateString()} - ${new Date(analytics.summary.lastRequest).toLocaleDateString()}` : undefined; + if (showAnalyticsError) { + return ( +
+ +

+ {t.projects.analytics.loadFailed} +

+

{liveAnalyticsError}

+ +
+ ); + } + return ( { @@ -50,6 +56,8 @@ export const OverviewTab = () => { const projectInfoQuery = useProjectInfo(id); const analytics = useAnalyticsData(id, selectedDomain); const analyticsData = analytics.data; + const analyticsError = analytics.error; + const showAnalyticsError = !!analyticsError && !analytics.isLoading; const services = servicesData.services; const serviceCount = servicesData.isLoading ? (projectData.serviceCount ?? services.length) @@ -302,6 +310,24 @@ export const OverviewTab = () => { {/* ── Monitoring (only with a domain — no domain ⇒ no traffic) ── */} {hasDomain && ( <> + {showAnalyticsError ? ( +
+ +

+ {t.projects.analytics.loadFailed} +

+

{analyticsError}

+ +
+ ) : ( + <> {/* Compact stats row */}
{stats.map((s) => ( @@ -423,6 +449,8 @@ export const OverviewTab = () => {
)} + + )} {/* Connected Services bar */} - - ) : ( - <> - {/* Compact stats row */} -
- {stats.map((s) => ( -
-
- {s.icon} - {s.label} + {showAnalyticsError ? ( +
+ +

+ {t.projects.analytics.loadFailed} +

+

{analyticsError}

+
- {s.loading ? ( - <> - {/* Skeleton bars roughly matching the value (large) and + ) : ( + <> + {/* Compact stats row */} +
+ {stats.map((s) => ( +
+
+ {s.icon} + + {s.label} + +
+ {s.loading ? ( + <> + {/* Skeleton bars roughly matching the value (large) and subtext (small) line heights so the card doesn't visibly jump when the data lands. Tuned to `bg-muted-foreground/*` instead of `bg-muted/*` - the latter is nearly identical to the card surface in this theme and renders almost invisible. */} -
-
- - ) : ( - <> -

{s.value}

- {s.subtext && ( -

{s.subtext}

- )} - - )} -
- ))} -
- - {/* Compact traffic chart */} -
-
-
- - - {t.projects.overview.traffic} - -
- {dateRange && {dateRange}} -
- {showChartSkeleton ? ( - // Chart-shaped skeleton - animated bars at varied heights so - // the placeholder reads as "a chart is coming" instead of a - // bare text line. Gated on `showChartSkeleton` (periods - // hydration) only — the stat cards above use their own - // `showStatsSkeleton`, so a fast `summary` endpoint can flip - // those even while `periods` is still in flight. -
- {Array.from({ length: 32 }).map((_, i) => { - // Deterministic varied heights - sine-based so the bars - // form a wave rather than a uniform block, and the - // sequence stays stable across re-renders. - const h = 18 + Math.abs(Math.sin(i * 0.7)) * 70; - return ( -
- ); - })} -
- ) : !hasAnalytics ? ( -
- - {t.projects.overview.noTrafficData} - -
- ) : ( -
-
- - - - - - - - { - const x = areaData.length === 1 ? 500 : (i / (areaData.length - 1)) * 1000; - const y = 200 - (d.requests / maxRequests) * 180; - return `L ${x} ${y}`; - }) - .join(" ")} L 1000 200 Z`} - fill="url(#overviewAreaGrad)" - /> - { - const x = areaData.length === 1 ? 500 : (i / (areaData.length - 1)) * 1000; - const y = 200 - (d.requests / maxRequests) * 180; - return `${i === 0 ? "M" : "L"} ${x} ${y}`; - }) - .join(" ")} - fill="none" - stroke="currentColor" - strokeWidth="2" - /> - -
-
- {displayData - .filter((_, i) => i % 6 === 0) - .map((d, i) => ( - {d.hour}:00 +
+
+ + ) : ( + <> +

+ {s.value} +

+ {s.subtext && ( +

{s.subtext}

+ )} + + )} +
))} -
-
- )} -
- - )} +
+ + {/* Compact traffic chart */} +
+
+
+ + + {t.projects.overview.traffic} + +
+ {dateRange && ( + {dateRange} + )} +
+ {showChartSkeleton ? ( + // Chart-shaped skeleton - animated bars at varied heights so + // the placeholder reads as "a chart is coming" instead of a + // bare text line. Gated on `showChartSkeleton` (periods + // hydration) only — the stat cards above use their own + // `showStatsSkeleton`, so a fast `summary` endpoint can flip + // those even while `periods` is still in flight. +
+ {Array.from({ length: 32 }).map((_, i) => { + // Deterministic varied heights - sine-based so the bars + // form a wave rather than a uniform block, and the + // sequence stays stable across re-renders. + const h = 18 + Math.abs(Math.sin(i * 0.7)) * 70; + return ( +
+ ); + })} +
+ ) : !hasAnalytics ? ( +
+ + {t.projects.overview.noTrafficData} + +
+ ) : ( +
+
+ + + + + + + + { + const x = + areaData.length === 1 ? 500 : (i / (areaData.length - 1)) * 1000; + const y = 200 - (d.requests / maxRequests) * 180; + return `L ${x} ${y}`; + }) + .join(" ")} L 1000 200 Z`} + fill="url(#overviewAreaGrad)" + /> + { + const x = + areaData.length === 1 ? 500 : (i / (areaData.length - 1)) * 1000; + const y = 200 - (d.requests / maxRequests) * 180; + return `${i === 0 ? "M" : "L"} ${x} ${y}`; + }) + .join(" ")} + fill="none" + stroke="currentColor" + strokeWidth="2" + /> + +
+
+ {displayData + .filter((_, i) => i % 6 === 0) + .map((d, i) => ( + {d.hour}:00 + ))} +
+
+ )} +
+ + )} )} diff --git a/apps/dashboard/src/hooks/useProjectEndpoints.ts b/apps/dashboard/src/hooks/useProjectEndpoints.ts index 769fb8b0c..edadf348a 100644 --- a/apps/dashboard/src/hooks/useProjectEndpoints.ts +++ b/apps/dashboard/src/hooks/useProjectEndpoints.ts @@ -188,9 +188,7 @@ interface AsyncState { * Invalidation: call `invalidateProjectCaches(id)` after a mutation that * could change the underlying data (e.g. after a domain save). */ -type CacheEntry = - | { kind: "loading"; promise: Promise } - | { kind: "ready"; data: T }; +type CacheEntry = { kind: "loading"; promise: Promise } | { kind: "ready"; data: T }; const infoCache = new Map>(); const overviewCache = new Map>(); @@ -411,13 +409,14 @@ async function fetchOverview(key: string): Promise { const sepIndex = key.indexOf(OVERVIEW_KEY_SEP); const projectId = sepIndex === -1 ? key : key.slice(0, sepIndex); const domain = sepIndex === -1 ? undefined : key.slice(sepIndex + OVERVIEW_KEY_SEP.length); - const response = await api.get<{ data: AnalyticsOverviewResponse; success?: boolean; error?: string }>( - endpoints.analytics.overview, - { - params: { projectId, ...(domain ? { domain } : {}) }, - timeout: ANALYTICS_OVERVIEW_TIMEOUT_MS, - }, - ); + const response = await api.get<{ + data: AnalyticsOverviewResponse; + success?: boolean; + error?: string; + }>(endpoints.analytics.overview, { + params: { projectId, ...(domain ? { domain } : {}) }, + timeout: ANALYTICS_OVERVIEW_TIMEOUT_MS, + }); if (response.success === false || !response.data) { throw new Error(response.error || "Failed to load analytics"); } From e217c1783ff3acafb0412fd4be4b25150427398a Mon Sep 17 00:00:00 2001 From: Santhi Prakash Date: Wed, 5 Aug 2026 17:12:40 +0000 Subject: [PATCH 3/3] fix(dashboard): scope analytics error to traffic summary block in MonitoringView Address maintainer review on #421: an /analytics/overview timeout was replacing the entire MonitoringTab via an early return, hiding the independent geo, usage, and history blocks. Move the error card into MonitoringView and scope it to the stats strip / traffic chart block (mirroring OverviewTab), so the rest of the tab still renders. --- .../[id]/components/MonitoringTab.tsx | 25 +-- .../components/monitoring/MonitoringView.tsx | 151 +++++++++++------- 2 files changed, 98 insertions(+), 78 deletions(-) diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/MonitoringTab.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/MonitoringTab.tsx index 30d46691a..78c5837d0 100644 --- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/MonitoringTab.tsx +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/MonitoringTab.tsx @@ -21,7 +21,6 @@ */ import React, { useMemo, useState } from "react"; -import { AlertCircle, RefreshCw } from "lucide-react"; import { TrafficChart, TopPaths } from "./general"; import { MonitoringView } from "@/components/monitoring/MonitoringView"; import { useProjectSettings } from "@/context/ProjectSettingsContext"; @@ -127,8 +126,6 @@ export const MonitoringTab = () => { reconnect, } = useProjectUsageStream(liveId); - const showAnalyticsError = !!liveAnalyticsError && !isLoadingAnalytics; - /** Scope for the history read. Held here rather than in the view because it keys the * fetch — the view raises changes through `onServiceKeyChange`. */ const [serviceKey, setServiceKey] = React.useState(null); @@ -328,26 +325,6 @@ export const MonitoringTab = () => { ? `${new Date(analytics.summary.firstRequest).toLocaleDateString()} - ${new Date(analytics.summary.lastRequest).toLocaleDateString()}` : undefined; - if (showAnalyticsError) { - return ( -
- -

- {t.projects.analytics.loadFailed} -

-

{liveAnalyticsError}

- -
- ); - } - return ( { isUsageConnected={fixture ? true : isConnected} usageError={fixture ? null : usageError} onReconnectUsage={reconnect} + analyticsError={fixture ? null : liveAnalyticsError} + onRetryAnalytics={() => id && invalidateProjectCaches(id)} serviceKey={serviceKey} onServiceKeyChange={setServiceKey} // Shown on the traffic block's fold line, so a collapsed block still states the diff --git a/apps/dashboard/src/components/monitoring/MonitoringView.tsx b/apps/dashboard/src/components/monitoring/MonitoringView.tsx index 7724fee61..99ba333aa 100644 --- a/apps/dashboard/src/components/monitoring/MonitoringView.tsx +++ b/apps/dashboard/src/components/monitoring/MonitoringView.tsx @@ -27,7 +27,7 @@ */ import React from "react"; -import { ArrowUpDown, Gauge, Server, Users } from "lucide-react"; +import { AlertCircle, ArrowUpDown, Gauge, RefreshCw, Server, Users } from "lucide-react"; import { useI18n, interpolate } from "@/components/i18n-provider"; import { ResourceCards } from "./ResourceCards"; import { ResourceHistoryChart, type UsageHistoryBucket } from "./ResourceHistoryChart"; @@ -58,6 +58,11 @@ export interface MonitoringViewProps { isUsageConnected: boolean; usageError: string | null; onReconnectUsage: () => void; + /** Error surfaced by the analytics overview fetch; when present the stats strip + * and traffic chart are replaced by a scoped retry card instead of blanking + * the whole tab. */ + analyticsError?: string | null; + onRetryAnalytics?: () => void; /** * Scope for the resource views. null = All (the summed stack). * @@ -136,6 +141,8 @@ export const MonitoringView: React.FC = ({ isUsageConnected, usageError, onReconnectUsage, + analyticsError, + onRetryAnalytics, serviceKey, onServiceKeyChange, trafficChart, @@ -172,6 +179,7 @@ export const MonitoringView: React.FC = ({ const visitorsLabel = isCloudGeo ? m.visitors : m.visitorDays; const hasAnalytics = !!analytics; + const showAnalyticsError = !!analyticsError && !isLoadingAnalytics; return (
@@ -208,51 +216,83 @@ export const MonitoringView: React.FC = ({ )} {/* ── Reference numbers, as a strip ─────────────────────────────────── */} -
- } - label={t.projects.stats.serverRequests} - value={hasAnalytics ? formatCount(analytics!.summary.totalRequests) : isLoadingAnalytics ? "…" : "0"} - hint={ - hasAnalytics - ? interpolate(t.projects.stats.requestsSubtext, { - total: formatCount(analytics!.summary.totalRequests), - avg: String(analytics!.summary.avgRequestsPerHour ?? 0), - }) - : undefined - } - /> - } - label={visitorsLabel} - value={visitors == null ? (isLoadingGeo ? "…" : "0") : formatCount(visitors)} - hint={geo?.approximate ? m.approximate : undefined} - /> - } - label={t.projects.stats.avgResponse} - value={ - hasAnalytics - ? `${analytics!.performance.avgResponseTimeMs.toFixed(0)}ms` - : isLoadingAnalytics - ? "…" - : "—" - } - hint={t.projects.stats.responseTime} - /> - } - label={t.projects.stats.bandwidthOut} - value={hasAnalytics ? analytics!.bandwidth.totalOutFormatted : isLoadingAnalytics ? "…" : "0 B"} - hint={ - hasAnalytics - ? interpolate(t.projects.stats.bandwidthInSubtext, { - value: analytics!.bandwidth.totalInFormatted, - }) - : undefined - } - /> -
+ {showAnalyticsError ? ( +
+ +

+ {t.projects.analytics.loadFailed} +

+

{analyticsError}

+ {onRetryAnalytics && ( + + )} +
+ ) : ( +
+ } + label={t.projects.stats.serverRequests} + value={ + hasAnalytics + ? formatCount(analytics!.summary.totalRequests) + : isLoadingAnalytics + ? "…" + : "0" + } + hint={ + hasAnalytics + ? interpolate(t.projects.stats.requestsSubtext, { + total: formatCount(analytics!.summary.totalRequests), + avg: String(analytics!.summary.avgRequestsPerHour ?? 0), + }) + : undefined + } + /> + } + label={visitorsLabel} + value={visitors == null ? (isLoadingGeo ? "…" : "0") : formatCount(visitors)} + hint={geo?.approximate ? m.approximate : undefined} + /> + } + label={t.projects.stats.avgResponse} + value={ + hasAnalytics + ? `${analytics!.performance.avgResponseTimeMs.toFixed(0)}ms` + : isLoadingAnalytics + ? "…" + : "—" + } + hint={t.projects.stats.responseTime} + /> + } + label={t.projects.stats.bandwidthOut} + value={ + hasAnalytics + ? analytics!.bandwidth.totalOutFormatted + : isLoadingAnalytics + ? "…" + : "0 B" + } + hint={ + hasAnalytics + ? interpolate(t.projects.stats.bandwidthInSubtext, { + value: analytics!.bandwidth.totalInFormatted, + }) + : undefined + } + /> +
+ )} {/* ── Resources: now AND over time, one subject, one card ───────────── */} {showResources && ( @@ -292,7 +332,7 @@ export const MonitoringView: React.FC = ({
)} - {!isLoadingAnalytics && !hasAnalytics && ( + {!showAnalyticsError && !isLoadingAnalytics && !hasAnalytics && (

{m.noDataTitle}

{m.noDataDescription}

@@ -341,13 +381,15 @@ export const MonitoringView: React.FC = ({ tooltip pinned to the primary domain, while this one has tooltips and follows the domain-scope selector above. So it folds down to one header row that still states the total, and the choice is remembered. */} - - {trafficChart} - + {!showAnalyticsError && ( + + {trafficChart} + + )} {/* Two short lists, side by side rather than two more full-width screens. */}
@@ -370,7 +412,6 @@ export const MonitoringView: React.FC = ({ scopeLabel={scopeLabel} /> )} -
); };