refactor: remove performance insights (Web Vitals) feature entirely
Remove Performance tab, PerformanceStats component, settings toggle, Web Vitals observers from tracking script, and all related API types and SWR hooks. Duration tracking is preserved.
This commit is contained in:
@@ -6,9 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Removed
|
||||||
|
|
||||||
- **Dedicated Performance tab.** Core Web Vitals (LCP, CLS, INP) have moved from the main dashboard into their own "Performance" tab. This gives you a full-page view with your overall performance score, individual metric cards, and a "Slowest pages by metric" table you can sort by LCP, CLS, or INP. The tab includes its own date range picker so you can analyze performance trends independently.
|
- **Performance insights removed.** The Performance tab, Core Web Vitals tracking (LCP, CLS, INP), and the "Enable performance insights" toggle in Settings have been removed. The tracking script no longer collects Web Vitals data. Visit duration tracking continues to work as before.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time.
|
- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time.
|
||||||
- **Smart pull zone matching.** When connecting BunnyCDN, Pulse automatically filters your pull zones to only show the ones that match your tracked site's domain — so you can't accidentally connect the wrong pull zone.
|
- **Smart pull zone matching.** When connecting BunnyCDN, Pulse automatically filters your pull zones to only show the ones that match your tracked site's domain — so you can't accidentally connect the wrong pull zone.
|
||||||
@@ -22,9 +24,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Performance metrics no longer show "0 0 0" when no data exists.** Previously, if no visitors had reported Web Vitals data, the Performance section showed "LCP 0 ms, CLS 0, INP 0 ms" and rated everything as "Good" — which was misleading. It now clearly says "No data" when no metrics have been collected, and shows a helpful message explaining when data will appear.
|
|
||||||
- **Performance metrics no longer show inflated numbers from slow outliers.** A single very slow page load could skew the entire site's LCP or INP average to unrealistically high values. Pulse now uses the 75th percentile (p75) — the same methodology Google uses — so a handful of extreme outliers don't distort your scores.
|
|
||||||
|
|
||||||
- **BunnyCDN logo now displays correctly.** The BunnyCDN integration card in Settings previously showed a generic globe icon. It now shows the proper BunnyCDN bunny logo.
|
- **BunnyCDN logo now displays correctly.** The BunnyCDN integration card in Settings previously showed a generic globe icon. It now shows the proper BunnyCDN bunny logo.
|
||||||
- **Your BunnyCDN API key is no longer visible in network URLs.** When loading pull zones, the API key was previously sent as a URL parameter. It's now sent securely in the request body, just like when connecting.
|
- **Your BunnyCDN API key is no longer visible in network URLs.** When loading pull zones, the API key was previously sent as a URL parameter. It's now sent securely in the request body, just like when connecting.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useParams, useSearchParams, useRouter } from 'next/navigation'
|
import { useParams, useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
|
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { ApiError } from '@/lib/api/client'
|
import { ApiError } from '@/lib/api/client'
|
||||||
@@ -13,7 +13,6 @@ import TopPages from '@/components/dashboard/ContentStats'
|
|||||||
import TopReferrers from '@/components/dashboard/TopReferrers'
|
import TopReferrers from '@/components/dashboard/TopReferrers'
|
||||||
import Locations from '@/components/dashboard/Locations'
|
import Locations from '@/components/dashboard/Locations'
|
||||||
import TechSpecs from '@/components/dashboard/TechSpecs'
|
import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
|
||||||
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
||||||
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import ExportModal from '@/components/dashboard/ExportModal'
|
import ExportModal from '@/components/dashboard/ExportModal'
|
||||||
@@ -257,7 +256,7 @@ export default function PublicDashboardPage() {
|
|||||||
|
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
|
|
||||||
const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, performance, performance_by_page, realtime_visitors } = data
|
const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, realtime_visitors } = data
|
||||||
|
|
||||||
// Provide defaults for potentially undefined data
|
// Provide defaults for potentially undefined data
|
||||||
const safeDailyStats = daily_stats || []
|
const safeDailyStats = daily_stats || []
|
||||||
@@ -395,29 +394,6 @@ export default function PublicDashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Performance Stats - Only show if enabled */}
|
|
||||||
{performance && data.site?.enable_performance_insights && (
|
|
||||||
<div className="mb-8">
|
|
||||||
<PerformanceStats
|
|
||||||
stats={performance}
|
|
||||||
performanceByPage={performance_by_page}
|
|
||||||
siteId={siteId}
|
|
||||||
startDate={dateRange.start}
|
|
||||||
endDate={dateRange.end}
|
|
||||||
getPerformanceByPage={(siteId, startDate, endDate, opts) => {
|
|
||||||
return getPublicPerformanceByPage(siteId, startDate, endDate, opts, {
|
|
||||||
password,
|
|
||||||
captcha: {
|
|
||||||
captcha_id: captchaId,
|
|
||||||
captcha_solution: captchaSolution,
|
|
||||||
captcha_token: captchaToken
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Details Grid */}
|
{/* Details Grid */}
|
||||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||||
<TopPages
|
<TopPages
|
||||||
|
|||||||
@@ -234,9 +234,9 @@ export default function SiteDashboardPage() {
|
|||||||
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||||
}, [dateRange])
|
}, [dateRange])
|
||||||
|
|
||||||
// Single dashboard request replaces 7 focused hooks (overview, pages, locations,
|
// Single dashboard request replaces focused hooks (overview, pages, locations,
|
||||||
// devices, referrers, performance, goals). The backend runs all queries in parallel
|
// devices, referrers, goals). The backend runs all queries in parallel
|
||||||
// and caches the result in Redis, reducing requests from 12 to 6 per refresh cycle.
|
// and caches the result in Redis for efficient data loading.
|
||||||
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
|
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
|
||||||
const { data: realtimeData } = useRealtime(siteId)
|
const { data: realtimeData } = useRealtime(siteId)
|
||||||
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
|
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { getDateRange, formatDate } from '@ciphera-net/ui'
|
|
||||||
import { Select, DatePicker } from '@ciphera-net/ui'
|
|
||||||
import { getPerformanceByPage } from '@/lib/api/stats'
|
|
||||||
import { useDashboard } from '@/lib/swr/dashboard'
|
|
||||||
import { useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
|
|
||||||
const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats'))
|
|
||||||
|
|
||||||
function getThisWeekRange(): { start: string; end: string } {
|
|
||||||
const today = new Date()
|
|
||||||
const dayOfWeek = today.getDay()
|
|
||||||
const monday = new Date(today)
|
|
||||||
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
|
|
||||||
return { start: formatDate(monday), end: formatDate(today) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getThisMonthRange(): { start: string; end: string } {
|
|
||||||
const today = new Date()
|
|
||||||
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
|
|
||||||
return { start: formatDate(firstOfMonth), end: formatDate(today) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function PerformanceSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 animate-pulse">
|
|
||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="h-7 w-40 bg-neutral-200 dark:bg-neutral-800 rounded mb-2" />
|
|
||||||
<div className="h-4 w-64 bg-neutral-200 dark:bg-neutral-800 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="h-9 w-36 bg-neutral-200 dark:bg-neutral-800 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="h-6 w-24 bg-neutral-200 dark:bg-neutral-800 rounded mb-6" />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
|
||||||
{[1, 2, 3].map(i => (
|
|
||||||
<div key={i} className="h-24 bg-neutral-200 dark:bg-neutral-800 rounded-lg" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="h-64 bg-neutral-200 dark:bg-neutral-800 rounded-2xl" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PerformancePage() {
|
|
||||||
const params = useParams()
|
|
||||||
const siteId = params.id as string
|
|
||||||
|
|
||||||
const [period, setPeriod] = useState('30')
|
|
||||||
const [dateRange, setDateRange] = useState(() => getDateRange(30))
|
|
||||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
|
||||||
|
|
||||||
const { data: dashboard, isLoading: loading } = useDashboard(siteId, dateRange.start, dateRange.end)
|
|
||||||
|
|
||||||
const site = dashboard?.site ?? null
|
|
||||||
const showSkeleton = useMinimumLoading(loading && !dashboard)
|
|
||||||
const fadeClass = useSkeletonFade(showSkeleton)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const domain = site?.domain
|
|
||||||
document.title = domain ? `Performance \u00b7 ${domain} | Pulse` : 'Performance | Pulse'
|
|
||||||
}, [site?.domain])
|
|
||||||
|
|
||||||
if (showSkeleton) return <PerformanceSkeleton />
|
|
||||||
|
|
||||||
if (site && !site.enable_performance_insights) {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
|
||||||
<div className="text-center py-16">
|
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">
|
|
||||||
Performance insights are disabled
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
||||||
Enable performance insights in your site settings to start collecting Core Web Vitals data.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
|
|
||||||
Performance
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
||||||
Core Web Vitals from real user sessions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
variant="input"
|
|
||||||
className="min-w-[140px]"
|
|
||||||
value={period}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (value === 'today') {
|
|
||||||
const today = formatDate(new Date())
|
|
||||||
setDateRange({ start: today, end: today })
|
|
||||||
setPeriod('today')
|
|
||||||
} else if (value === '7') {
|
|
||||||
setDateRange(getDateRange(7))
|
|
||||||
setPeriod('7')
|
|
||||||
} else if (value === 'week') {
|
|
||||||
setDateRange(getThisWeekRange())
|
|
||||||
setPeriod('week')
|
|
||||||
} else if (value === '30') {
|
|
||||||
setDateRange(getDateRange(30))
|
|
||||||
setPeriod('30')
|
|
||||||
} else if (value === 'month') {
|
|
||||||
setDateRange(getThisMonthRange())
|
|
||||||
setPeriod('month')
|
|
||||||
} else if (value === 'custom') {
|
|
||||||
setIsDatePickerOpen(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ value: 'today', label: 'Today' },
|
|
||||||
{ value: '7', label: 'Last 7 days' },
|
|
||||||
{ value: '30', label: 'Last 30 days' },
|
|
||||||
{ value: 'divider-1', label: '', divider: true },
|
|
||||||
{ value: 'week', label: 'This week' },
|
|
||||||
{ value: 'month', label: 'This month' },
|
|
||||||
{ value: 'divider-2', label: '', divider: true },
|
|
||||||
{ value: 'custom', label: 'Custom' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PerformanceStats
|
|
||||||
stats={dashboard?.performance ?? null}
|
|
||||||
performanceByPage={dashboard?.performance_by_page ?? null}
|
|
||||||
siteId={siteId}
|
|
||||||
startDate={dateRange.start}
|
|
||||||
endDate={dateRange.end}
|
|
||||||
getPerformanceByPage={getPerformanceByPage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DatePicker
|
|
||||||
isOpen={isDatePickerOpen}
|
|
||||||
onClose={() => setIsDatePickerOpen(false)}
|
|
||||||
onApply={(range) => {
|
|
||||||
setDateRange(range)
|
|
||||||
setPeriod('custom')
|
|
||||||
setIsDatePickerOpen(false)
|
|
||||||
}}
|
|
||||||
initialRange={dateRange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -74,8 +74,6 @@ export default function SiteSettingsPage() {
|
|||||||
collect_device_info: true,
|
collect_device_info: true,
|
||||||
collect_geo_data: 'full' as GeoDataLevel,
|
collect_geo_data: 'full' as GeoDataLevel,
|
||||||
collect_screen_resolution: true,
|
collect_screen_resolution: true,
|
||||||
// Performance insights setting
|
|
||||||
enable_performance_insights: false,
|
|
||||||
// Bot and noise filtering
|
// Bot and noise filtering
|
||||||
filter_bots: true,
|
filter_bots: true,
|
||||||
// Hide unknown locations
|
// Hide unknown locations
|
||||||
@@ -135,7 +133,6 @@ export default function SiteSettingsPage() {
|
|||||||
collect_device_info: site.collect_device_info ?? true,
|
collect_device_info: site.collect_device_info ?? true,
|
||||||
collect_geo_data: site.collect_geo_data || 'full',
|
collect_geo_data: site.collect_geo_data || 'full',
|
||||||
collect_screen_resolution: site.collect_screen_resolution ?? true,
|
collect_screen_resolution: site.collect_screen_resolution ?? true,
|
||||||
enable_performance_insights: site.enable_performance_insights ?? false,
|
|
||||||
filter_bots: site.filter_bots ?? true,
|
filter_bots: site.filter_bots ?? true,
|
||||||
hide_unknown_locations: site.hide_unknown_locations ?? false,
|
hide_unknown_locations: site.hide_unknown_locations ?? false,
|
||||||
data_retention_months: site.data_retention_months ?? 6
|
data_retention_months: site.data_retention_months ?? 6
|
||||||
@@ -150,7 +147,6 @@ export default function SiteSettingsPage() {
|
|||||||
collect_device_info: site.collect_device_info ?? true,
|
collect_device_info: site.collect_device_info ?? true,
|
||||||
collect_geo_data: site.collect_geo_data || 'full',
|
collect_geo_data: site.collect_geo_data || 'full',
|
||||||
collect_screen_resolution: site.collect_screen_resolution ?? true,
|
collect_screen_resolution: site.collect_screen_resolution ?? true,
|
||||||
enable_performance_insights: site.enable_performance_insights ?? false,
|
|
||||||
filter_bots: site.filter_bots ?? true,
|
filter_bots: site.filter_bots ?? true,
|
||||||
hide_unknown_locations: site.hide_unknown_locations ?? false,
|
hide_unknown_locations: site.hide_unknown_locations ?? false,
|
||||||
data_retention_months: site.data_retention_months ?? 6
|
data_retention_months: site.data_retention_months ?? 6
|
||||||
@@ -423,8 +419,6 @@ export default function SiteSettingsPage() {
|
|||||||
collect_device_info: formData.collect_device_info,
|
collect_device_info: formData.collect_device_info,
|
||||||
collect_geo_data: formData.collect_geo_data,
|
collect_geo_data: formData.collect_geo_data,
|
||||||
collect_screen_resolution: formData.collect_screen_resolution,
|
collect_screen_resolution: formData.collect_screen_resolution,
|
||||||
// Performance insights setting
|
|
||||||
enable_performance_insights: formData.enable_performance_insights,
|
|
||||||
// Bot and noise filtering
|
// Bot and noise filtering
|
||||||
filter_bots: formData.filter_bots,
|
filter_bots: formData.filter_bots,
|
||||||
// Hide unknown locations
|
// Hide unknown locations
|
||||||
@@ -443,7 +437,6 @@ export default function SiteSettingsPage() {
|
|||||||
collect_device_info: formData.collect_device_info,
|
collect_device_info: formData.collect_device_info,
|
||||||
collect_geo_data: formData.collect_geo_data,
|
collect_geo_data: formData.collect_geo_data,
|
||||||
collect_screen_resolution: formData.collect_screen_resolution,
|
collect_screen_resolution: formData.collect_screen_resolution,
|
||||||
enable_performance_insights: formData.enable_performance_insights,
|
|
||||||
filter_bots: formData.filter_bots,
|
filter_bots: formData.filter_bots,
|
||||||
hide_unknown_locations: formData.hide_unknown_locations,
|
hide_unknown_locations: formData.hide_unknown_locations,
|
||||||
data_retention_months: formData.data_retention_months
|
data_retention_months: formData.data_retention_months
|
||||||
@@ -511,7 +504,6 @@ export default function SiteSettingsPage() {
|
|||||||
collect_device_info: formData.collect_device_info,
|
collect_device_info: formData.collect_device_info,
|
||||||
collect_geo_data: formData.collect_geo_data,
|
collect_geo_data: formData.collect_geo_data,
|
||||||
collect_screen_resolution: formData.collect_screen_resolution,
|
collect_screen_resolution: formData.collect_screen_resolution,
|
||||||
enable_performance_insights: formData.enable_performance_insights,
|
|
||||||
filter_bots: formData.filter_bots,
|
filter_bots: formData.filter_bots,
|
||||||
hide_unknown_locations: formData.hide_unknown_locations,
|
hide_unknown_locations: formData.hide_unknown_locations,
|
||||||
data_retention_months: formData.data_retention_months
|
data_retention_months: formData.data_retention_months
|
||||||
@@ -1113,30 +1105,6 @@ export default function SiteSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Performance Insights Toggle */}
|
|
||||||
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
|
||||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Performance Insights</h3>
|
|
||||||
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-neutral-900 dark:text-white">Performance Insights (Add-on)</h4>
|
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
|
||||||
Track Core Web Vitals (LCP, CLS, INP) to monitor site performance
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.enable_performance_insights}
|
|
||||||
onChange={(e) => setFormData({ ...formData, enable_performance_insights: e.target.checked })}
|
|
||||||
className="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Retention */}
|
{/* Data Retention */}
|
||||||
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
||||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Retention</h3>
|
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Retention</h3>
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
|
|
||||||
import { Select } from '@ciphera-net/ui'
|
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
stats: Stats | null
|
|
||||||
performanceByPage?: PerformanceByPageStat[] | null
|
|
||||||
siteId?: string
|
|
||||||
startDate?: string
|
|
||||||
endDate?: string
|
|
||||||
getPerformanceByPage?: typeof getPerformanceByPage
|
|
||||||
}
|
|
||||||
|
|
||||||
type Score = 'good' | 'needs-improvement' | 'poor'
|
|
||||||
|
|
||||||
const getScore = (metric: 'lcp' | 'cls' | 'inp', value: number): Score => {
|
|
||||||
if (metric === 'lcp') return value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'
|
|
||||||
if (metric === 'cls') return value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor'
|
|
||||||
if (metric === 'inp') return value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor'
|
|
||||||
return 'good'
|
|
||||||
}
|
|
||||||
|
|
||||||
const scoreColors = {
|
|
||||||
good: 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400 border-green-200 dark:border-green-800',
|
|
||||||
'needs-improvement': 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
|
|
||||||
poor: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border-red-200 dark:border-red-800',
|
|
||||||
}
|
|
||||||
|
|
||||||
const badgeColors = {
|
|
||||||
good: 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 border-green-200 dark:border-green-800',
|
|
||||||
'needs-improvement': 'text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
|
|
||||||
poor: 'text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 border-red-200 dark:border-red-800',
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCard({ label, value, unit, score }: { label: string, value: string, unit: string, score: Score | null }) {
|
|
||||||
const noData = score === null
|
|
||||||
const colorClass = noData
|
|
||||||
? 'text-neutral-500 bg-neutral-50 dark:bg-neutral-800/50 dark:text-neutral-400 border-neutral-200 dark:border-neutral-700'
|
|
||||||
: scoreColors[score]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`p-4 rounded-lg border ${colorClass}`}>
|
|
||||||
<div className="text-sm font-medium opacity-80 mb-1">{label}</div>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{value}
|
|
||||||
{unit && <span className="text-sm font-normal ml-1 opacity-70">{unit}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMetricValue(metric: 'lcp' | 'cls' | 'inp', val: number | null): string {
|
|
||||||
if (val == null) return 'No data'
|
|
||||||
if (metric === 'cls') return val.toFixed(3)
|
|
||||||
return `${Math.round(val)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMetricCell(metric: 'lcp' | 'cls' | 'inp', val: number | null): string {
|
|
||||||
if (val == null) return '\u2014'
|
|
||||||
if (metric === 'cls') return val.toFixed(3)
|
|
||||||
return `${Math.round(val)} ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PerformanceStats({ stats, performanceByPage, siteId, startDate, endDate, getPerformanceByPage }: Props) {
|
|
||||||
const [sortBy, setSortBy] = useState<'lcp' | 'cls' | 'inp'>('lcp')
|
|
||||||
const [overrideRows, setOverrideRows] = useState<PerformanceByPageStat[] | null>(null)
|
|
||||||
const [loadingTable, setLoadingTable] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOverrideRows(null)
|
|
||||||
}, [performanceByPage])
|
|
||||||
|
|
||||||
const rows = overrideRows ?? performanceByPage ?? []
|
|
||||||
const canRefetch = Boolean(getPerformanceByPage && siteId && startDate && endDate)
|
|
||||||
|
|
||||||
const handleSortChange = (value: string) => {
|
|
||||||
const v = value as 'lcp' | 'cls' | 'inp'
|
|
||||||
setSortBy(v)
|
|
||||||
if (!getPerformanceByPage || !siteId || !startDate || !endDate) return
|
|
||||||
setLoadingTable(true)
|
|
||||||
getPerformanceByPage(siteId, startDate, endDate, { sort: v, limit: 20 })
|
|
||||||
.then(setOverrideRows)
|
|
||||||
.finally(() => setLoadingTable(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasData = stats && stats.samples > 0
|
|
||||||
const lcp = stats?.lcp ?? null
|
|
||||||
const cls = stats?.cls ?? null
|
|
||||||
const inp = stats?.inp ?? null
|
|
||||||
|
|
||||||
const lcpScore = lcp != null ? getScore('lcp', lcp) : null
|
|
||||||
const clsScore = cls != null ? getScore('cls', cls) : null
|
|
||||||
const inpScore = inp != null ? getScore('inp', inp) : null
|
|
||||||
|
|
||||||
// Overall score: worst of available metrics
|
|
||||||
let overallScore: Score | null = null
|
|
||||||
if (hasData) {
|
|
||||||
const scores = [lcpScore, clsScore, inpScore].filter((s): s is Score => s !== null)
|
|
||||||
if (scores.length > 0) {
|
|
||||||
if (scores.includes('poor')) overallScore = 'poor'
|
|
||||||
else if (scores.includes('needs-improvement')) overallScore = 'needs-improvement'
|
|
||||||
else overallScore = 'good'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const overallLabel = overallScore
|
|
||||||
? { good: 'Good', 'needs-improvement': 'Needs improvement', poor: 'Poor' }[overallScore]
|
|
||||||
: 'No data'
|
|
||||||
|
|
||||||
const overallBadgeClass = overallScore
|
|
||||||
? badgeColors[overallScore]
|
|
||||||
: 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700'
|
|
||||||
|
|
||||||
const getCellScoreClass = (score: Score) => {
|
|
||||||
const m: Record<string, string> = {
|
|
||||||
good: 'text-green-600 dark:text-green-400',
|
|
||||||
'needs-improvement': 'text-yellow-600 dark:text-yellow-400',
|
|
||||||
poor: 'text-red-600 dark:text-red-400',
|
|
||||||
}
|
|
||||||
return m[score] ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCellClass = (metric: 'lcp' | 'cls' | 'inp', val: number | null) => {
|
|
||||||
if (val == null) return 'text-neutral-400 dark:text-neutral-500'
|
|
||||||
return getCellScoreClass(getScore(metric, val))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Overall badge + summary */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={`rounded-md border px-2.5 py-1 text-sm font-medium ${overallBadgeClass}`}>
|
|
||||||
{overallLabel}
|
|
||||||
</span>
|
|
||||||
{hasData && (
|
|
||||||
<span className="text-sm text-neutral-500">
|
|
||||||
Based on {stats.samples.toLocaleString()} session{stats.samples !== 1 ? 's' : ''} (p75 values)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metric cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<MetricCard
|
|
||||||
label="Largest Contentful Paint (LCP)"
|
|
||||||
value={formatMetricValue('lcp', lcp)}
|
|
||||||
unit={lcp != null ? 'ms' : ''}
|
|
||||||
score={lcpScore}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Cumulative Layout Shift (CLS)"
|
|
||||||
value={formatMetricValue('cls', cls)}
|
|
||||||
unit=""
|
|
||||||
score={clsScore}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Interaction to Next Paint (INP)"
|
|
||||||
value={formatMetricValue('inp', inp)}
|
|
||||||
unit={inp != null ? 'ms' : ''}
|
|
||||||
score={inpScore}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hasData && (
|
|
||||||
<div className="text-sm text-neutral-500 bg-neutral-50 dark:bg-neutral-800/50 rounded-lg p-4 border border-neutral-200 dark:border-neutral-700">
|
|
||||||
No performance data collected yet. Core Web Vitals data will appear here once visitors browse your site with performance insights enabled.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasData && (
|
|
||||||
<div className="text-xs text-neutral-500">
|
|
||||||
* 75th percentile (p75) calculated from real user sessions. Lower is better.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Worst pages by metric */}
|
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
|
||||||
<div className="flex items-center justify-between gap-4 mb-4">
|
|
||||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
|
||||||
Slowest pages by metric
|
|
||||||
</h3>
|
|
||||||
{canRefetch && (
|
|
||||||
<Select
|
|
||||||
value={sortBy}
|
|
||||||
onChange={handleSortChange}
|
|
||||||
options={[
|
|
||||||
{ value: 'lcp', label: 'Sort by LCP (worst)' },
|
|
||||||
{ value: 'cls', label: 'Sort by CLS (worst)' },
|
|
||||||
{ value: 'inp', label: 'Sort by INP (worst)' },
|
|
||||||
]}
|
|
||||||
variant="input"
|
|
||||||
align="right"
|
|
||||||
className="min-w-[180px]"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{loadingTable ? (
|
|
||||||
<TableSkeleton rows={5} cols={5} />
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<div className="py-6 text-center text-neutral-500 text-sm">
|
|
||||||
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto -mx-1">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-neutral-200 dark:border-neutral-700">
|
|
||||||
<th className="text-left py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">Path</th>
|
|
||||||
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">Samples</th>
|
|
||||||
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">LCP</th>
|
|
||||||
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">CLS</th>
|
|
||||||
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">INP</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((r) => (
|
|
||||||
<tr key={r.path} className="border-b border-neutral-100 dark:border-neutral-800/80">
|
|
||||||
<td className="py-2 px-2 text-neutral-900 dark:text-white font-mono truncate max-w-[200px]" title={r.path}>
|
|
||||||
{r.path || '/'}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-2 text-right text-neutral-600 dark:text-neutral-400">{r.samples}</td>
|
|
||||||
<td className={`py-2 px-2 text-right font-mono ${getCellClass('lcp', r.lcp)}`}>
|
|
||||||
{formatMetricCell('lcp', r.lcp)}
|
|
||||||
</td>
|
|
||||||
<td className={`py-2 px-2 text-right font-mono ${getCellClass('cls', r.cls)}`}>
|
|
||||||
{formatMetricCell('cls', r.cls)}
|
|
||||||
</td>
|
|
||||||
<td className={`py-2 px-2 text-right font-mono ${getCellClass('inp', r.inp)}`}>
|
|
||||||
{formatMetricCell('inp', r.inp)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ export default function SiteNav({ siteId }: SiteNavProps) {
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: 'Dashboard', href: `/sites/${siteId}` },
|
{ label: 'Dashboard', href: `/sites/${siteId}` },
|
||||||
{ label: 'Performance', href: `/sites/${siteId}/performance` },
|
|
||||||
{ label: 'Journeys', href: `/sites/${siteId}/journeys` },
|
{ label: 'Journeys', href: `/sites/${siteId}/journeys` },
|
||||||
{ label: 'Funnels', href: `/sites/${siteId}/funnels` },
|
{ label: 'Funnels', href: `/sites/${siteId}/funnels` },
|
||||||
{ label: 'Behavior', href: `/sites/${siteId}/behavior` },
|
{ label: 'Behavior', href: `/sites/${siteId}/behavior` },
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ export interface Site {
|
|||||||
collect_device_info?: boolean
|
collect_device_info?: boolean
|
||||||
collect_geo_data?: GeoDataLevel
|
collect_geo_data?: GeoDataLevel
|
||||||
collect_screen_resolution?: boolean
|
collect_screen_resolution?: boolean
|
||||||
// Performance insights setting
|
|
||||||
enable_performance_insights?: boolean
|
|
||||||
// Bot and noise filtering
|
// Bot and noise filtering
|
||||||
filter_bots?: boolean
|
filter_bots?: boolean
|
||||||
// Hide unknown locations from stats
|
// Hide unknown locations from stats
|
||||||
@@ -48,8 +46,6 @@ export interface UpdateSiteRequest {
|
|||||||
collect_device_info?: boolean
|
collect_device_info?: boolean
|
||||||
collect_geo_data?: GeoDataLevel
|
collect_geo_data?: GeoDataLevel
|
||||||
collect_screen_resolution?: boolean
|
collect_screen_resolution?: boolean
|
||||||
// Performance insights setting
|
|
||||||
enable_performance_insights?: boolean
|
|
||||||
// Bot and noise filtering
|
// Bot and noise filtering
|
||||||
filter_bots?: boolean
|
filter_bots?: boolean
|
||||||
// Hide unknown locations from stats
|
// Hide unknown locations from stats
|
||||||
|
|||||||
@@ -21,21 +21,6 @@ export interface ScreenResolutionStat {
|
|||||||
pageviews: number
|
pageviews: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PerformanceStats {
|
|
||||||
lcp: number | null
|
|
||||||
cls: number | null
|
|
||||||
inp: number | null
|
|
||||||
samples: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PerformanceByPageStat {
|
|
||||||
path: string
|
|
||||||
samples: number
|
|
||||||
lcp: number | null
|
|
||||||
cls: number | null
|
|
||||||
inp: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GoalCountStat {
|
export interface GoalCountStat {
|
||||||
event_name: string
|
event_name: string
|
||||||
count: number
|
count: number
|
||||||
@@ -226,31 +211,6 @@ export function getPublicCampaigns(siteId: string, startDate?: string, endDate?:
|
|||||||
.then(r => r?.campaigns || [])
|
.then(r => r?.campaigns || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Performance By Page ────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function getPerformanceByPage(
|
|
||||||
siteId: string,
|
|
||||||
startDate?: string,
|
|
||||||
endDate?: string,
|
|
||||||
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' }
|
|
||||||
): Promise<PerformanceByPageStat[]> {
|
|
||||||
return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
|
||||||
`/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort })}`
|
|
||||||
).then(r => r?.performance_by_page ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPublicPerformanceByPage(
|
|
||||||
siteId: string,
|
|
||||||
startDate?: string,
|
|
||||||
endDate?: string,
|
|
||||||
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' },
|
|
||||||
auth?: AuthParams
|
|
||||||
): Promise<PerformanceByPageStat[]> {
|
|
||||||
return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
|
||||||
`/public/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort }, auth)}`
|
|
||||||
).then(r => r?.performance_by_page ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Full Dashboard ─────────────────────────────────────────────────
|
// ─── Full Dashboard ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
@@ -269,8 +229,6 @@ export interface DashboardData {
|
|||||||
os: OSStat[]
|
os: OSStat[]
|
||||||
devices: DeviceStat[]
|
devices: DeviceStat[]
|
||||||
screen_resolutions: ScreenResolutionStat[]
|
screen_resolutions: ScreenResolutionStat[]
|
||||||
performance?: PerformanceStats
|
|
||||||
performance_by_page?: PerformanceByPageStat[]
|
|
||||||
goal_counts?: GoalCountStat[]
|
goal_counts?: GoalCountStat[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,11 +282,6 @@ export interface DashboardReferrersData {
|
|||||||
top_referrers: TopReferrer[]
|
top_referrers: TopReferrer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardPerformanceData {
|
|
||||||
performance?: PerformanceStats
|
|
||||||
performance_by_page?: PerformanceByPageStat[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardGoalsData {
|
export interface DashboardGoalsData {
|
||||||
goal_counts: GoalCountStat[]
|
goal_counts: GoalCountStat[]
|
||||||
}
|
}
|
||||||
@@ -388,17 +341,6 @@ export function getPublicDashboardReferrers(
|
|||||||
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<DashboardPerformanceData> {
|
|
||||||
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate, filters })}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPublicDashboardPerformance(
|
|
||||||
siteId: string, startDate?: string, endDate?: string,
|
|
||||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
|
||||||
): Promise<DashboardPerformanceData> {
|
|
||||||
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate }, { password, captcha })}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardGoalsData> {
|
export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardGoalsData> {
|
||||||
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`)
|
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
getDashboardLocations,
|
getDashboardLocations,
|
||||||
getDashboardDevices,
|
getDashboardDevices,
|
||||||
getDashboardReferrers,
|
getDashboardReferrers,
|
||||||
getDashboardPerformance,
|
|
||||||
getDashboardGoals,
|
getDashboardGoals,
|
||||||
getCampaigns,
|
getCampaigns,
|
||||||
getRealtime,
|
getRealtime,
|
||||||
@@ -48,7 +47,6 @@ import type {
|
|||||||
DashboardLocationsData,
|
DashboardLocationsData,
|
||||||
DashboardDevicesData,
|
DashboardDevicesData,
|
||||||
DashboardReferrersData,
|
DashboardReferrersData,
|
||||||
DashboardPerformanceData,
|
|
||||||
DashboardGoalsData,
|
DashboardGoalsData,
|
||||||
BehaviorData,
|
BehaviorData,
|
||||||
} from '@/lib/api/stats'
|
} from '@/lib/api/stats'
|
||||||
@@ -62,7 +60,6 @@ const fetchers = {
|
|||||||
dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters),
|
dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters),
|
||||||
dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters),
|
dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters),
|
||||||
dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters),
|
dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters),
|
||||||
dashboardPerformance: (siteId: string, start: string, end: string, filters?: string) => getDashboardPerformance(siteId, start, end, filters),
|
|
||||||
dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters),
|
dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters),
|
||||||
stats: (siteId: string, start: string, end: string, filters?: string) => getStats(siteId, start, end, filters),
|
stats: (siteId: string, start: string, end: string, filters?: string) => getStats(siteId, start, end, filters),
|
||||||
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
|
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
|
||||||
@@ -260,19 +257,6 @@ export function useDashboardReferrers(siteId: string, start: string, end: string
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Hook for focused dashboard performance data
|
|
||||||
export function useDashboardPerformance(siteId: string, start: string, end: string, filters?: string) {
|
|
||||||
return useSWR<DashboardPerformanceData>(
|
|
||||||
siteId && start && end ? ['dashboardPerformance', siteId, start, end, filters] : null,
|
|
||||||
() => fetchers.dashboardPerformance(siteId, start, end, filters),
|
|
||||||
{
|
|
||||||
...dashboardSWRConfig,
|
|
||||||
refreshInterval: 60 * 1000,
|
|
||||||
dedupingInterval: 10 * 1000,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// * Hook for focused dashboard goals data
|
// * Hook for focused dashboard goals data
|
||||||
export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) {
|
export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) {
|
||||||
return useSWR<DashboardGoalsData>(
|
return useSWR<DashboardGoalsData>(
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export function generatePrivacySnippet(site: Site): string {
|
|||||||
const device = site.collect_device_info ?? true
|
const device = site.collect_device_info ?? true
|
||||||
const geo = site.collect_geo_data || 'full'
|
const geo = site.collect_geo_data || 'full'
|
||||||
const screen = site.collect_screen_resolution ?? true
|
const screen = site.collect_screen_resolution ?? true
|
||||||
const perf = site.enable_performance_insights ?? false
|
|
||||||
const filterBots = site.filter_bots ?? true
|
const filterBots = site.filter_bots ?? true
|
||||||
const retentionMonths = site.data_retention_months ?? 6
|
const retentionMonths = site.data_retention_months ?? 6
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ export function generatePrivacySnippet(site: Site): string {
|
|||||||
if (geo === 'full') parts.push('country, region, and city')
|
if (geo === 'full') parts.push('country, region, and city')
|
||||||
else if (geo === 'country') parts.push('country')
|
else if (geo === 'country') parts.push('country')
|
||||||
if (screen) parts.push('screen resolution')
|
if (screen) parts.push('screen resolution')
|
||||||
if (perf) parts.push('Core Web Vitals (e.g. page load performance)')
|
|
||||||
|
|
||||||
const list =
|
const list =
|
||||||
parts.length > 0
|
parts.length > 0
|
||||||
|
|||||||
@@ -32,59 +32,15 @@
|
|||||||
const ttlHours = storageMode === 'local' ? parseFloat(script.getAttribute('data-storage-ttl') || '24') : 0;
|
const ttlHours = storageMode === 'local' ? parseFloat(script.getAttribute('data-storage-ttl') || '24') : 0;
|
||||||
const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0;
|
const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0;
|
||||||
|
|
||||||
// * Performance Monitoring (Core Web Vitals) State
|
|
||||||
let currentEventId = null;
|
let currentEventId = null;
|
||||||
let metrics = { lcp: 0, cls: 0, inp: 0 };
|
|
||||||
let lcpObserved = false;
|
|
||||||
let clsObserved = false;
|
|
||||||
let performanceInsightsEnabled = false;
|
|
||||||
|
|
||||||
// * Time-on-page tracking: records when the current pageview started
|
// * Time-on-page tracking: records when the current pageview started
|
||||||
var pageStartTime = 0;
|
var pageStartTime = 0;
|
||||||
|
|
||||||
// * Minimal Web Vitals Observer
|
|
||||||
function observeMetrics() {
|
|
||||||
try {
|
|
||||||
if (typeof PerformanceObserver === 'undefined') return;
|
|
||||||
|
|
||||||
// * LCP (Largest Contentful Paint) - fires when the browser has determined the LCP element (often 2–4s+ after load)
|
|
||||||
new PerformanceObserver((entryList) => {
|
|
||||||
const entries = entryList.getEntries();
|
|
||||||
const lastEntry = entries[entries.length - 1];
|
|
||||||
if (lastEntry) {
|
|
||||||
metrics.lcp = lastEntry.startTime;
|
|
||||||
lcpObserved = true;
|
|
||||||
}
|
|
||||||
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
|
||||||
|
|
||||||
// * CLS (Cumulative Layout Shift) - accumulates when elements shift after load
|
|
||||||
new PerformanceObserver((entryList) => {
|
|
||||||
for (const entry of entryList.getEntries()) {
|
|
||||||
if (!entry.hadRecentInput) {
|
|
||||||
metrics.cls += entry.value;
|
|
||||||
clsObserved = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).observe({ type: 'layout-shift', buffered: true });
|
|
||||||
|
|
||||||
// * INP (Interaction to Next Paint) - Simplified (track max duration)
|
|
||||||
new PerformanceObserver((entryList) => {
|
|
||||||
const entries = entryList.getEntries();
|
|
||||||
for (const entry of entries) {
|
|
||||||
// * Track longest interaction
|
|
||||||
if (entry.duration > metrics.inp) metrics.inp = entry.duration;
|
|
||||||
}
|
|
||||||
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
// * Browser doesn't support PerformanceObserver or specific entry types
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendMetrics() {
|
function sendMetrics() {
|
||||||
if (!currentEventId) return;
|
if (!currentEventId) return;
|
||||||
|
|
||||||
// * Calculate time-on-page in seconds (always sent, even without performance insights)
|
// * Calculate time-on-page in seconds
|
||||||
var durationSec = pageStartTime > 0 ? Math.round((Date.now() - pageStartTime) / 1000) : 0;
|
var durationSec = pageStartTime > 0 ? Math.round((Date.now() - pageStartTime) / 1000) : 0;
|
||||||
|
|
||||||
var payload = { event_id: currentEventId };
|
var payload = { event_id: currentEventId };
|
||||||
@@ -92,17 +48,8 @@
|
|||||||
// * Always include duration if we have a valid measurement
|
// * Always include duration if we have a valid measurement
|
||||||
if (durationSec > 0) payload.duration = durationSec;
|
if (durationSec > 0) payload.duration = durationSec;
|
||||||
|
|
||||||
// * Only include Web Vitals when performance insights are enabled
|
// * Skip if nothing to send (no duration)
|
||||||
if (performanceInsightsEnabled) {
|
if (!payload.duration) return;
|
||||||
// * Only include metrics the browser actually reported. Sending 0 would either be
|
|
||||||
// * rejected by the backend (LCP/INP must be > 0) or skew averages.
|
|
||||||
if (lcpObserved && metrics.lcp > 0) payload.lcp = metrics.lcp;
|
|
||||||
if (clsObserved) payload.cls = metrics.cls;
|
|
||||||
if (metrics.inp > 0) payload.inp = metrics.inp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// * Skip if nothing to send (no duration and no vitals)
|
|
||||||
if (!payload.duration && !performanceInsightsEnabled) return;
|
|
||||||
|
|
||||||
var data = JSON.stringify(payload);
|
var data = JSON.stringify(payload);
|
||||||
|
|
||||||
@@ -118,14 +65,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Start observing metrics immediately (buffered observers will capture early metrics)
|
|
||||||
// * Metrics will only be sent if performance insights are enabled (checked in sendMetrics)
|
|
||||||
observeMetrics();
|
|
||||||
|
|
||||||
// * Send metrics when user leaves or hides the page
|
// * Send metrics when user leaves or hides the page
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') {
|
if (document.visibilityState === 'hidden') {
|
||||||
// * Delay metrics slightly so in-flight LCP/CLS callbacks can run before we send
|
// * Delay slightly so duration measurement captures final moment
|
||||||
setTimeout(sendMetrics, 150);
|
setTimeout(sendMetrics, 150);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -306,9 +249,6 @@
|
|||||||
|
|
||||||
// * Track pageview
|
// * Track pageview
|
||||||
function trackPageview() {
|
function trackPageview() {
|
||||||
var routeChangeTime = performance.now();
|
|
||||||
var isSpaNav = !!currentEventId;
|
|
||||||
|
|
||||||
const path = cleanPath();
|
const path = cleanPath();
|
||||||
|
|
||||||
// * Skip if same path was just tracked (refresh dedup)
|
// * Skip if same path was just tracked (refresh dedup)
|
||||||
@@ -321,9 +261,6 @@
|
|||||||
sendMetrics();
|
sendMetrics();
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics = { lcp: 0, cls: 0, inp: 0 };
|
|
||||||
lcpObserved = false;
|
|
||||||
clsObserved = false;
|
|
||||||
currentEventId = null;
|
currentEventId = null;
|
||||||
pageStartTime = 0;
|
pageStartTime = 0;
|
||||||
// * Only send external referrer on the first pageview (landing page).
|
// * Only send external referrer on the first pageview (landing page).
|
||||||
@@ -375,21 +312,6 @@
|
|||||||
if (data && data.id) {
|
if (data && data.id) {
|
||||||
currentEventId = data.id;
|
currentEventId = data.id;
|
||||||
pageStartTime = Date.now();
|
pageStartTime = Date.now();
|
||||||
// * For SPA navigations the browser never emits a new largest-contentful-paint
|
|
||||||
// * (LCP is only for full document loads). After the new view has had time to
|
|
||||||
// * paint, we record time-from-route-change as an LCP proxy so /products etc.
|
|
||||||
// * get a value. If the user navigates away before the delay, we leave LCP unset.
|
|
||||||
if (isSpaNav) {
|
|
||||||
var thatId = data.id;
|
|
||||||
// * Run soon so we set lcpObserved before the user leaves; 500ms was too long
|
|
||||||
// * and we often sent metrics (next nav or visibilitychange+150ms) before it ran.
|
|
||||||
setTimeout(function() {
|
|
||||||
if (!lcpObserved && currentEventId === thatId) {
|
|
||||||
metrics.lcp = Math.round(performance.now() - routeChangeTime);
|
|
||||||
lcpObserved = true;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// * Silently fail - don't interrupt user experience
|
// * Silently fail - don't interrupt user experience
|
||||||
|
|||||||
Reference in New Issue
Block a user