From fb85c431f093b1188209b36a9d0298d238f0d6bb Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 20:46:26 +0100 Subject: [PATCH] feat: add BunnyCDN integration --- CHANGELOG.md | 3 + app/sites/[id]/cdn/page.tsx | 477 +++++++++++++++++++++++++++++++ app/sites/[id]/settings/page.tsx | 224 ++++++++++++++- components/dashboard/SiteNav.tsx | 1 + lib/api/bunny.ts | 84 ++++++ lib/swr/dashboard.ts | 42 +++ 6 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 app/sites/[id]/cdn/page.tsx create mode 100644 lib/api/bunny.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6020623..7232e22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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. + - **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time. - **Integrations tab in Settings.** A new "Integrations" section in your site settings is where you connect and manage external services. Google Search Console is the first integration available — more will follow. - **Search performance on your dashboard.** When Google Search Console is connected, a new "Search" panel appears on your main dashboard alongside Campaigns — showing your total clicks, impressions, and average position at a glance, plus your top 5 search queries. Click "View all" to dive deeper. diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx new file mode 100644 index 0000000..86caf90 --- /dev/null +++ b/app/sites/[id]/cdn/page.tsx @@ -0,0 +1,477 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import Link from 'next/link' +import { getDateRange, formatDate, Select } from '@ciphera-net/ui' +import { ArrowSquareOut, CloudArrowUp } from '@phosphor-icons/react' +import { + ResponsiveContainer, + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from 'recharts' +import { useDashboard, useBunnyStatus, useBunnyOverview, useBunnyDailyStats, useBunnyTopCountries } from '@/lib/swr/dashboard' +import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' + +// ─── Helpers ──────────────────────────────────────────────────── + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + const value = bytes / Math.pow(1024, i) + return value.toFixed(i === 0 ? 0 : 1) + ' ' + units[i] +} + +function formatNumber(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return n.toLocaleString() +} + +function formatDateShort(date: string): string { + const d = new Date(date + 'T00:00:00') + return d.getDate() + ' ' + d.toLocaleString('en-US', { month: 'short' }) +} + +function changePercent( + current: number, + prev: number +): { value: number; positive: boolean } | null { + if (prev === 0) return null + const pct = ((current - prev) / prev) * 100 + return { value: pct, positive: pct >= 0 } +} + +// ─── Page ─────────────────────────────────────────────────────── + +export default function CDNPage() { + const params = useParams() + const siteId = params.id as string + + // Date range + const [period, setPeriod] = useState('7') + const [dateRange, setDateRange] = useState(() => getDateRange(7)) + + // Data fetching + const { data: bunnyStatus } = useBunnyStatus(siteId) + const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end) + const { data: overview } = useBunnyOverview(siteId, dateRange.start, dateRange.end) + const { data: dailyStats } = useBunnyDailyStats(siteId, dateRange.start, dateRange.end) + const { data: topCountries } = useBunnyTopCountries(siteId, dateRange.start, dateRange.end) + + const showSkeleton = useMinimumLoading(!bunnyStatus) + const fadeClass = useSkeletonFade(showSkeleton) + + // Document title + useEffect(() => { + const domain = dashboard?.site?.domain + document.title = domain ? `CDN \u00b7 ${domain} | Pulse` : 'CDN | Pulse' + }, [dashboard?.site?.domain]) + + // ─── Loading skeleton ───────────────────────────────────── + + if (showSkeleton) { + return ( +
+
+
+ + +
+ +
+
+ + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ ) + } + + // ─── Not connected state ────────────────────────────────── + + if (bunnyStatus && !bunnyStatus.connected) { + return ( +
+
+
+ +
+

+ Connect BunnyCDN +

+

+ Monitor your CDN performance including bandwidth usage, cache hit rates, request volumes, and geographic distribution. +

+ + Connect in Settings + + +
+
+ ) + } + + // ─── Connected — main view ──────────────────────────────── + + const bandwidthChange = overview ? changePercent(overview.total_bandwidth, overview.prev_total_bandwidth) : null + const requestsChange = overview ? changePercent(overview.total_requests, overview.prev_total_requests) : null + const cacheHitChange = overview ? changePercent(overview.cache_hit_rate, overview.prev_cache_hit_rate) : null + const originChange = overview ? changePercent(overview.avg_origin_response, overview.prev_avg_origin_response) : null + const errorsChange = overview ? changePercent(overview.total_errors, overview.prev_total_errors) : null + + const daily = dailyStats?.daily_stats ?? [] + const countries = topCountries?.countries ?? [] + const maxCountryBandwidth = countries.length > 0 ? countries[0].bandwidth : 1 + + return ( +
+ {/* Header */} +
+
+

+ CDN Analytics +

+

+ BunnyCDN performance, bandwidth, and cache metrics +

+
+ { + setBunnyApiKey(e.target.value) + setBunnyPullZones([]) + setBunnySelectedZone(null) + }} + placeholder="BunnyCDN API key" + className="flex-1 px-4 py-2.5 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white text-sm placeholder:text-neutral-400" + /> + +
+ + {bunnyPullZones.length > 0 && ( +
+
+ + +
+ +
+ )} +
+ )} + + ) : ( +
+
+
+
+ + + + + + +
+
+

BunnyCDN

+
+ + + {bunnyStatus.status === 'active' ? 'Connected' : bunnyStatus.status === 'syncing' ? 'Syncing...' : 'Error'} + +
+
+
+
+ +
+ {bunnyStatus.pull_zone_name && ( +
+

Pull Zone

+

{bunnyStatus.pull_zone_name}

+
+ )} + {bunnyStatus.last_synced_at && ( +
+

Last Synced

+

+ {new Date(bunnyStatus.last_synced_at).toLocaleString('en-GB')} +

+
+ )} + {bunnyStatus.created_at && ( +
+

Connected Since

+

+ {new Date(bunnyStatus.created_at).toLocaleString('en-GB')} +

+
+ )} +
+ + {bunnyStatus.status === 'error' && bunnyStatus.error_message && ( +
+

{bunnyStatus.error_message}

+
+ )} + + {canEdit && ( +
+ +
+ )} +
+ )} + )} diff --git a/components/dashboard/SiteNav.tsx b/components/dashboard/SiteNav.tsx index 0c81118..fd7c185 100644 --- a/components/dashboard/SiteNav.tsx +++ b/components/dashboard/SiteNav.tsx @@ -22,6 +22,7 @@ export default function SiteNav({ siteId }: SiteNavProps) { { label: 'Funnels', href: `/sites/${siteId}/funnels` }, { label: 'Behavior', href: `/sites/${siteId}/behavior` }, { label: 'Search', href: `/sites/${siteId}/search` }, + { label: 'CDN', href: `/sites/${siteId}/cdn` }, { label: 'Uptime', href: `/sites/${siteId}/uptime` }, ...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []), ] diff --git a/lib/api/bunny.ts b/lib/api/bunny.ts new file mode 100644 index 0000000..da83227 --- /dev/null +++ b/lib/api/bunny.ts @@ -0,0 +1,84 @@ +import apiRequest from './client' + +// ─── Types ────────────────────────────────────────────────────────── + +export interface BunnyStatus { + connected: boolean + pull_zone_id?: number + pull_zone_name?: string + status?: 'active' | 'syncing' | 'error' + error_message?: string | null + last_synced_at?: string | null + created_at?: string +} + +export interface BunnyOverview { + total_bandwidth: number + total_requests: number + cache_hit_rate: number + avg_origin_response: number + total_errors: number + prev_total_bandwidth: number + prev_total_requests: number + prev_cache_hit_rate: number + prev_avg_origin_response: number + prev_total_errors: number +} + +export interface BunnyDailyRow { + date: string + bandwidth_used: number + bandwidth_cached: number + requests_served: number + requests_cached: number + error_3xx: number + error_4xx: number + error_5xx: number + origin_response_time_avg: number +} + +export interface BunnyPullZone { + id: number + name: string +} + +export interface BunnyGeoRow { + country_code: string + bandwidth: number + requests: number +} + +// ─── API Functions ────────────────────────────────────────────────── + +export async function getBunnyPullZones(siteId: string, apiKey: string): Promise<{ pull_zones: BunnyPullZone[], message?: string }> { + return apiRequest<{ pull_zones: BunnyPullZone[], message?: string }>( + `/sites/${siteId}/integrations/bunny/pull-zones?api_key=${encodeURIComponent(apiKey)}` + ) +} + +export async function connectBunny(siteId: string, apiKey: string, pullZoneId: number, pullZoneName: string): Promise { + await apiRequest(`/sites/${siteId}/integrations/bunny`, { + method: 'POST', + body: JSON.stringify({ api_key: apiKey, pull_zone_id: pullZoneId, pull_zone_name: pullZoneName }), + }) +} + +export async function getBunnyStatus(siteId: string): Promise { + return apiRequest(`/sites/${siteId}/integrations/bunny/status`) +} + +export async function disconnectBunny(siteId: string): Promise { + await apiRequest(`/sites/${siteId}/integrations/bunny`, { method: 'DELETE' }) +} + +export async function getBunnyOverview(siteId: string, startDate: string, endDate: string): Promise { + return apiRequest(`/sites/${siteId}/bunny/overview?start_date=${startDate}&end_date=${endDate}`) +} + +export async function getBunnyDailyStats(siteId: string, startDate: string, endDate: string): Promise<{ daily_stats: BunnyDailyRow[] }> { + return apiRequest<{ daily_stats: BunnyDailyRow[] }>(`/sites/${siteId}/bunny/daily-stats?start_date=${startDate}&end_date=${endDate}`) +} + +export async function getBunnyTopCountries(siteId: string, startDate: string, endDate: string, limit = 20): Promise<{ countries: BunnyGeoRow[] }> { + return apiRequest<{ countries: BunnyGeoRow[] }>(`/sites/${siteId}/bunny/top-countries?start_date=${startDate}&end_date=${endDate}&limit=${limit}`) +} diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 1241f2d..2a6b7ac 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -35,6 +35,8 @@ import { listGoals, type Goal } from '@/lib/api/goals' import { listReportSchedules, type ReportSchedule } from '@/lib/api/report-schedules' import { getGSCStatus, getGSCOverview, getGSCTopQueries, getGSCTopPages, getGSCDailyTotals, getGSCNewQueries } from '@/lib/api/gsc' import type { GSCStatus, GSCOverview, GSCQueryResponse, GSCPageResponse, GSCDailyTotal, GSCNewQueries } from '@/lib/api/gsc' +import { getBunnyStatus, getBunnyOverview, getBunnyDailyStats, getBunnyTopCountries } from '@/lib/api/bunny' +import type { BunnyStatus, BunnyOverview, BunnyDailyRow, BunnyGeoRow } from '@/lib/api/bunny' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import type { Stats, @@ -86,6 +88,10 @@ const fetchers = { gscTopPages: (siteId: string, start: string, end: string, limit: number, offset: number) => getGSCTopPages(siteId, start, end, limit, offset), gscDailyTotals: (siteId: string, start: string, end: string) => getGSCDailyTotals(siteId, start, end), gscNewQueries: (siteId: string, start: string, end: string) => getGSCNewQueries(siteId, start, end), + bunnyStatus: (siteId: string) => getBunnyStatus(siteId), + bunnyOverview: (siteId: string, start: string, end: string) => getBunnyOverview(siteId, start, end), + bunnyDailyStats: (siteId: string, start: string, end: string) => getBunnyDailyStats(siteId, start, end), + bunnyTopCountries: (siteId: string, start: string, end: string) => getBunnyTopCountries(siteId, start, end), subscription: () => getSubscription(), } @@ -469,6 +475,42 @@ export function useGSCNewQueries(siteId: string, start: string, end: string) { ) } +// * Hook for BunnyCDN connection status +export function useBunnyStatus(siteId: string) { + return useSWR( + siteId ? ['bunnyStatus', siteId] : null, + () => fetchers.bunnyStatus(siteId), + { ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 30 * 1000 } + ) +} + +// * Hook for BunnyCDN overview metrics (bandwidth, requests, cache hit rate) +export function useBunnyOverview(siteId: string, startDate: string, endDate: string) { + return useSWR( + siteId && startDate && endDate ? ['bunnyOverview', siteId, startDate, endDate] : null, + () => fetchers.bunnyOverview(siteId, startDate, endDate), + dashboardSWRConfig + ) +} + +// * Hook for BunnyCDN daily stats (bandwidth & requests per day) +export function useBunnyDailyStats(siteId: string, startDate: string, endDate: string) { + return useSWR<{ daily_stats: BunnyDailyRow[] }>( + siteId && startDate && endDate ? ['bunnyDailyStats', siteId, startDate, endDate] : null, + () => fetchers.bunnyDailyStats(siteId, startDate, endDate), + dashboardSWRConfig + ) +} + +// * Hook for BunnyCDN top countries by bandwidth +export function useBunnyTopCountries(siteId: string, startDate: string, endDate: string) { + return useSWR<{ countries: BunnyGeoRow[] }>( + siteId && startDate && endDate ? ['bunnyTopCountries', siteId, startDate, endDate] : null, + () => fetchers.bunnyTopCountries(siteId, startDate, endDate), + dashboardSWRConfig + ) +} + // * Hook for subscription details (changes rarely) export function useSubscription() { return useSWR(