diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0eafb..c711b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [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. - **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 -- **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. - **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. diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 53e6acb..125e030 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import Image from 'next/image' 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 { getAuthErrorMessage } from '@ciphera-net/ui' import { ApiError } from '@/lib/api/client' @@ -13,7 +13,6 @@ import TopPages from '@/components/dashboard/ContentStats' import TopReferrers from '@/components/dashboard/TopReferrers' import Locations from '@/components/dashboard/Locations' 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 { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import ExportModal from '@/components/dashboard/ExportModal' @@ -257,7 +256,7 @@ export default function PublicDashboardPage() { 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 const safeDailyStats = daily_stats || [] @@ -395,29 +394,6 @@ export default function PublicDashboardPage() { /> - {/* Performance Stats - Only show if enabled */} - {performance && data.site?.enable_performance_insights && ( -
- { - return getPublicPerformanceByPage(siteId, startDate, endDate, opts, { - password, - captcha: { - captcha_id: captchaId, - captcha_solution: captchaSolution, - captcha_token: captchaToken - } - }) - }} - /> -
- )} - {/* Details Grid */}
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 ( -
-
-
-
-
-
-
-
-
-
- {[1, 2, 3].map(i => ( -
- ))} -
-
-
- ) -} - -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 - - if (site && !site.enable_performance_insights) { - return ( -
-
-

- Performance insights are disabled -

-

- Enable performance insights in your site settings to start collecting Core Web Vitals data. -

-
-
- ) - } - - return ( -
- {/* Header */} -
-
-

- Performance -

-

- Core Web Vitals from real user sessions -

-
- setFormData({ ...formData, enable_performance_insights: e.target.checked })} - className="sr-only peer" - /> -
- -
-
-
- {/* Data Retention */}

Data Retention

diff --git a/components/dashboard/PerformanceStats.tsx b/components/dashboard/PerformanceStats.tsx deleted file mode 100644 index bf9ce8a..0000000 --- a/components/dashboard/PerformanceStats.tsx +++ /dev/null @@ -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 ( -
-
{label}
-
- {value} - {unit && {unit}} -
-
- ) -} - -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(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 = { - 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 ( -
- {/* Overall badge + summary */} -
- - {overallLabel} - - {hasData && ( - - Based on {stats.samples.toLocaleString()} session{stats.samples !== 1 ? 's' : ''} (p75 values) - - )} -
- - {/* Metric cards */} -
- - - -
- - {!hasData && ( -
- No performance data collected yet. Core Web Vitals data will appear here once visitors browse your site with performance insights enabled. -
- )} - - {hasData && ( -
- * 75th percentile (p75) calculated from real user sessions. Lower is better. -
- )} - - {/* Worst pages by metric */} -
-
-

- Slowest pages by metric -

- {canRefetch && ( -