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..78c5837d0 100644 --- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/MonitoringTab.tsx +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/MonitoringTab.tsx @@ -15,8 +15,8 @@ * 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. */ @@ -33,6 +33,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 +53,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. * @@ -89,9 +90,7 @@ export const MonitoringTab = () => { const [domainScope, setDomainScope] = useState(null); const domains = useMemo(() => { const list = (projectData?.domains ?? []) as Array<{ domain?: string }>; - return Array.from( - new Set(list.map((d) => d.domain?.trim()).filter((d): d is string => !!d)), - ); + return Array.from(new Set(list.map((d) => d.domain?.trim()).filter((d): d is string => !!d))); }, [projectData?.domains]); // Primary-first, so the live-stream cap (MAX_STREAMS) drops the least-used tail, not the // domain that matters most. Mirrors ServerLogs so the map's default All scope fans out @@ -114,9 +113,18 @@ 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 { + usage: liveUsage, + isConnected, + error: usageError, + reconnect, + } = useProjectUsageStream(liveId); /** Scope for the history read. Held here rather than in the view because it keys the * fetch — the view raises changes through `onServiceKeyChange`. */ @@ -152,10 +160,10 @@ export const MonitoringTab = () => { // preview gets simulated hits instead — see below. enabled: liveOn && !mock, onEntry: (e) => hits.push({ country: e.country, path: e.path, statusCode: e.statusCode }), - onStatus: (st) => setLiveStatus(st.state === "error" || st.state === "unavailable" ? st.state : null), + onStatus: (st) => + setLiveStatus(st.state === "error" || st.state === "unavailable" ? st.state : null), }); - // Turning it off, or changing which domain is in scope, clears the counter and the feed — // carrying a total across a scope change would attribute one domain's hits to another. // Same reason as above: depend on the stable `reset`, not on the whole object. As a prop @@ -332,6 +340,8 @@ export const MonitoringTab = () => { 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/app/(dashboard)/projects/[id]/components/OverviewTab.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/OverviewTab.tsx index c0daef4e2..2b6b2de5f 100644 --- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/OverviewTab.tsx +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/OverviewTab.tsx @@ -7,7 +7,11 @@ import { workloadOf } from "@/context/deployment/types"; import { ConnectionCard } from "./ConnectionCard"; import { ConnectedServicesCard } from "./ConnectedServicesCard"; import { UsedByCard } from "./UsedByCard"; -import { useProjectInfo, useAnalyticsData } from "@/hooks/useProjectEndpoints"; +import { + useProjectInfo, + useAnalyticsData, + invalidateProjectCaches, +} from "@/hooks/useProjectEndpoints"; import { useI18n, interpolate } from "@/components/i18n-provider"; import type { Dictionary } from "@/i18n"; import { @@ -22,6 +26,8 @@ import { Layers, ChevronRight, Container, + AlertCircle, + RefreshCw, } from "lucide-react"; export const OverviewTab = () => { @@ -40,8 +46,7 @@ export const OverviewTab = () => { // Analytics are traffic-to-a-domain — with no assigned domain the whole // section stays empty (a port-only app / DB has no hostname to log). Hide it // until a domain exists rather than show empty charts. - const hasDomain = - !!(selectedDomain || domain) || (domainsData?.domains?.length ?? 0) > 0; + const hasDomain = !!(selectedDomain || domain) || (domainsData?.domains?.length ?? 0) > 0; // ATOMIC PER-ENDPOINT HOOKS — each one owns its own skeleton state. // No context coupling, no useMemo soup. Module-level caches dedup @@ -50,6 +55,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,125 +309,156 @@ export const OverviewTab = () => { {/* ── Monitoring (only with a domain — no domain ⇒ no traffic) ── */} {hasDomain && ( <> - {/* 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/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} /> )} -
); }; diff --git a/apps/dashboard/src/hooks/useProjectEndpoints.ts b/apps/dashboard/src/hooks/useProjectEndpoints.ts index e9c83320c..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>(); @@ -400,6 +398,9 @@ async function fetchProjectInfo(id: string): Promise { // first domain's cached numbers; `fetchOverview` splits it back apart. const OVERVIEW_KEY_SEP = "::"; +/** Analytics overview aggregates traffic server-side; high-traffic projects can exceed the 15s default. */ +const ANALYTICS_OVERVIEW_TIMEOUT_MS = 60_000; + function overviewCacheKey(id: string, domain?: string | null): string { return domain ? `${id}${OVERVIEW_KEY_SEP}${domain}` : id; } @@ -408,10 +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 } : {}) } }, - ); + 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"); } diff --git a/apps/dashboard/src/i18n/locales/ar/projects.json b/apps/dashboard/src/i18n/locales/ar/projects.json index b62983643..903276375 100644 --- a/apps/dashboard/src/i18n/locales/ar/projects.json +++ b/apps/dashboard/src/i18n/locales/ar/projects.json @@ -236,6 +236,9 @@ "waitingRequests": "بانتظار الطلبات", "noTransfer": "لا يوجد نقل بيانات بعد" }, + "analytics": { + "loadFailed": "تعذّر تحميل تحليلات حركة المرور" + }, "monitoring": { "noDataTitle": "لا توجد بيانات مراقبة بعد", "noDataDescription": "ستظهر التحليلات هنا بعد أن يبدأ التطبيق المنشور في استقبال حركة المرور.", diff --git a/apps/dashboard/src/i18n/locales/de/projects.json b/apps/dashboard/src/i18n/locales/de/projects.json index 40ddc42bd..10d09871a 100644 --- a/apps/dashboard/src/i18n/locales/de/projects.json +++ b/apps/dashboard/src/i18n/locales/de/projects.json @@ -236,6 +236,9 @@ "waitingRequests": "Warten auf Anfragen", "noTransfer": "Noch keine Übertragung" }, + "analytics": { + "loadFailed": "Traffic-Analysen konnten nicht geladen werden" + }, "monitoring": { "noDataTitle": "Noch keine Monitoring-Daten", "noDataDescription": "Analysen erscheinen hier, sobald die bereitgestellte App Traffic erhält.", diff --git a/apps/dashboard/src/i18n/locales/en/projects.json b/apps/dashboard/src/i18n/locales/en/projects.json index 464d0a613..48e152082 100644 --- a/apps/dashboard/src/i18n/locales/en/projects.json +++ b/apps/dashboard/src/i18n/locales/en/projects.json @@ -284,6 +284,9 @@ "waitingRequests": "Waiting for requests", "noTransfer": "No transfer yet" }, + "analytics": { + "loadFailed": "Couldn't load traffic analytics" + }, "monitoring": { "noDataTitle": "No monitoring data yet", "noDataDescription": "Analytics will appear here after the deployed app starts receiving traffic.", diff --git a/apps/dashboard/src/i18n/locales/es/projects.json b/apps/dashboard/src/i18n/locales/es/projects.json index 738ca3b8e..fd7167975 100644 --- a/apps/dashboard/src/i18n/locales/es/projects.json +++ b/apps/dashboard/src/i18n/locales/es/projects.json @@ -236,6 +236,9 @@ "waitingRequests": "Esperando solicitudes", "noTransfer": "Aún no hay transferencia" }, + "analytics": { + "loadFailed": "No se pudieron cargar las analíticas de tráfico" + }, "monitoring": { "noDataTitle": "Aún no hay datos de monitoreo", "noDataDescription": "Las analíticas aparecerán aquí una vez que la aplicación desplegada comience a recibir tráfico.", diff --git a/apps/dashboard/src/i18n/locales/fr/projects.json b/apps/dashboard/src/i18n/locales/fr/projects.json index 2c962df60..411324abd 100644 --- a/apps/dashboard/src/i18n/locales/fr/projects.json +++ b/apps/dashboard/src/i18n/locales/fr/projects.json @@ -236,6 +236,9 @@ "waitingRequests": "En attente de requêtes", "noTransfer": "Aucun transfert pour le moment" }, + "analytics": { + "loadFailed": "Impossible de charger les analyses de trafic" + }, "monitoring": { "noDataTitle": "Aucune donnée de surveillance pour le moment", "noDataDescription": "Les analyses apparaîtront ici une fois que l'application déployée commencera à recevoir du trafic.", diff --git a/apps/dashboard/src/i18n/locales/ja/projects.json b/apps/dashboard/src/i18n/locales/ja/projects.json index 2f4308164..24e96808e 100644 --- a/apps/dashboard/src/i18n/locales/ja/projects.json +++ b/apps/dashboard/src/i18n/locales/ja/projects.json @@ -236,6 +236,9 @@ "waitingRequests": "リクエストを待機中", "noTransfer": "まだ転送がありません" }, + "analytics": { + "loadFailed": "トラフィック分析を読み込めませんでした" + }, "monitoring": { "noDataTitle": "まだモニタリングデータがありません", "noDataDescription": "デプロイされたアプリがトラフィックを受信し始めると、ここにアナリティクスが表示されます。", diff --git a/apps/dashboard/src/i18n/locales/pt/projects.json b/apps/dashboard/src/i18n/locales/pt/projects.json index 7ca2186bf..e371ae0c4 100644 --- a/apps/dashboard/src/i18n/locales/pt/projects.json +++ b/apps/dashboard/src/i18n/locales/pt/projects.json @@ -236,6 +236,9 @@ "waitingRequests": "À espera de pedidos", "noTransfer": "Ainda não há transferência" }, + "analytics": { + "loadFailed": "Não foi possível carregar as análises de tráfego" + }, "monitoring": { "noDataTitle": "Ainda não há dados de monitorização", "noDataDescription": "As análises aparecerão aqui depois de a aplicação implementada começar a receber tráfego.", diff --git a/apps/dashboard/src/i18n/locales/tr/projects.json b/apps/dashboard/src/i18n/locales/tr/projects.json index a8869f8f0..ec56623f9 100644 --- a/apps/dashboard/src/i18n/locales/tr/projects.json +++ b/apps/dashboard/src/i18n/locales/tr/projects.json @@ -236,6 +236,9 @@ "waitingRequests": "İstekler bekleniyor", "noTransfer": "Henüz veri transferi yok" }, + "analytics": { + "loadFailed": "Trafik analitiği yüklenemedi" + }, "monitoring": { "noDataTitle": "Henüz izleme verisi yok", "noDataDescription": "Analiz verileri, dağıtılan uygulama trafik almaya başladıktan sonra burada görünecektir.", diff --git a/apps/dashboard/src/i18n/locales/zh/projects.json b/apps/dashboard/src/i18n/locales/zh/projects.json index 2e9548375..4e64298a2 100644 --- a/apps/dashboard/src/i18n/locales/zh/projects.json +++ b/apps/dashboard/src/i18n/locales/zh/projects.json @@ -236,6 +236,9 @@ "waitingRequests": "等待请求中", "noTransfer": "暂无传输" }, + "analytics": { + "loadFailed": "无法加载流量分析数据" + }, "monitoring": { "noDataTitle": "暂无监控数据", "noDataDescription": "部署的应用开始接收流量后,分析数据将显示在此处。",