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
+
+
+
{
+ 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 === '28') {
+ setDateRange(getDateRange(28))
+ setPeriod('28')
+ } else if (value === '30') {
+ setDateRange(getDateRange(30))
+ setPeriod('30')
+ }
+ }}
+ options={[
+ { value: 'today', label: 'Today' },
+ { value: '7', label: 'Last 7 days' },
+ { value: '28', label: 'Last 28 days' },
+ { value: '30', label: 'Last 30 days' },
+ ]}
+ />
+
+
+ {/* Overview cards */}
+
+
+
+
+
+
+
+
+ {/* Bandwidth chart */}
+
+
Bandwidth
+ {daily.length > 0 ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ formatBytes(v)}
+ tick={{ fontSize: 12, fill: 'currentColor' }}
+ className="text-neutral-400 dark:text-neutral-500"
+ axisLine={false}
+ tickLine={false}
+ width={60}
+ />
+ {
+ if (!active || !payload?.length) return null
+ return (
+
+
{formatDateShort(label)}
+
+ Total: {formatBytes(payload[0]?.value as number)}
+
+ {payload[1] && (
+
+ Cached: {formatBytes(payload[1]?.value as number)}
+
+ )}
+
+ )
+ }}
+ />
+
+
+
+
+ ) : (
+
+ No bandwidth data for this period.
+
+ )}
+
+
+ {/* Requests + Errors charts side by side */}
+
+ {/* Requests chart */}
+
+
Requests
+ {daily.length > 0 ? (
+
+
+
+
+ formatNumber(v)}
+ tick={{ fontSize: 11, fill: 'currentColor' }}
+ className="text-neutral-400 dark:text-neutral-500"
+ axisLine={false}
+ tickLine={false}
+ width={50}
+ />
+ {
+ if (!active || !payload?.length) return null
+ return (
+
+
{formatDateShort(label)}
+
+ {formatNumber(payload[0]?.value as number)} requests
+
+
+ )
+ }}
+ />
+
+
+
+ ) : (
+
+ No request data for this period.
+
+ )}
+
+
+ {/* Errors chart */}
+
+
Errors
+ {daily.length > 0 ? (
+
+ ({
+ date: d.date,
+ '3xx': d.error_3xx,
+ '4xx': d.error_4xx,
+ '5xx': d.error_5xx,
+ }))}
+ margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
+ >
+
+
+ formatNumber(v)}
+ tick={{ fontSize: 11, fill: 'currentColor' }}
+ className="text-neutral-400 dark:text-neutral-500"
+ axisLine={false}
+ tickLine={false}
+ width={50}
+ />
+ {
+ if (!active || !payload?.length) return null
+ return (
+
+
{formatDateShort(label)}
+ {payload.map((entry) => (
+
+ {entry.name}: {formatNumber(entry.value as number)}
+
+ ))}
+
+ )
+ }}
+ />
+
+
+
+
+
+ ) : (
+
+ No error data for this period.
+
+ )}
+
+
+
+ {/* Bandwidth by Country */}
+
+
Bandwidth by Country
+ {countries.length > 0 ? (
+
+ {countries.map((row) => (
+
+
+ {row.country_code}
+
+
+
+ {formatBytes(row.bandwidth)}
+
+
+ {formatNumber(row.requests)} req
+
+
+ ))}
+
+ ) : (
+
+ No geographic data for this period.
+
+ )}
+
+
+ )
+}
+
+// ─── Sub-components ─────────────────────────────────────────────
+
+function OverviewCard({
+ label,
+ value,
+ change,
+ invertColor = false,
+}: {
+ label: string
+ value: string
+ change: { value: number; positive: boolean } | null
+ invertColor?: boolean
+}) {
+ // For Origin Response and Errors, a decrease is good (green), an increase is bad (red)
+ const isGood = change ? (invertColor ? !change.positive : change.positive) : false
+ const isBad = change ? (invertColor ? change.positive : !change.positive) : false
+ const changeLabel = change ? (change.positive ? '+' : '') + change.value.toFixed(1) + '%' : null
+
+ return (
+
+
{label}
+
{value}
+ {changeLabel && (
+
+ {changeLabel} vs previous period
+
+ )}
+
+ )
+}
diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx
index 7c842ab..d136265 100644
--- a/app/sites/[id]/settings/page.tsx
+++ b/app/sites/[id]/settings/page.tsx
@@ -6,6 +6,8 @@ import { updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } f
import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
import { getGSCAuthURL, disconnectGSC } from '@/lib/api/gsc'
+import { getBunnyPullZones, connectBunny, disconnectBunny } from '@/lib/api/bunny'
+import type { BunnyPullZone } from '@/lib/api/bunny'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatDateTime } from '@/lib/utils/formatDate'
@@ -17,7 +19,7 @@ import { Select, Modal, Button } from '@ciphera-net/ui'
import { APP_URL } from '@/lib/api/client'
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges'
-import { useSite, useGoals, useReportSchedules, useSubscription, useGSCStatus } from '@/lib/swr/dashboard'
+import { useSite, useGoals, useReportSchedules, useSubscription, useGSCStatus, useBunnyStatus } from '@/lib/swr/dashboard'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '@/lib/auth/context'
@@ -98,6 +100,13 @@ export default function SiteSettingsPage() {
const { data: gscStatus, mutate: mutateGSCStatus } = useGSCStatus(siteId)
const [gscConnecting, setGscConnecting] = useState(false)
const [gscDisconnecting, setGscDisconnecting] = useState(false)
+ const { data: bunnyStatus, mutate: mutateBunnyStatus } = useBunnyStatus(siteId)
+ const [bunnyApiKey, setBunnyApiKey] = useState('')
+ const [bunnyPullZones, setBunnyPullZones] = useState([])
+ const [bunnySelectedZone, setBunnySelectedZone] = useState(null)
+ const [bunnyLoadingZones, setBunnyLoadingZones] = useState(false)
+ const [bunnyConnecting, setBunnyConnecting] = useState(false)
+ const [bunnyDisconnecting, setBunnyDisconnecting] = useState(false)
const [reportModalOpen, setReportModalOpen] = useState(false)
const [editingSchedule, setEditingSchedule] = useState(null)
const [reportSaving, setReportSaving] = useState(false)
@@ -1600,6 +1609,219 @@ export default function SiteSettingsPage() {
)}
+
+ {/* BunnyCDN */}
+
+ {!bunnyStatus?.connected ? (
+
+
+
+
+
BunnyCDN
+
+ Monitor CDN performance with bandwidth usage, cache hit rates, response times, and geographic distribution.
+
+
+
+
+
+
+
+
+ Your API key is encrypted at rest and only used to fetch read-only statistics. You can disconnect at any time.
+
+
+ {canEdit && (
+
+
+ {
+ 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"
+ />
+ {
+ if (!bunnyApiKey.trim()) {
+ toast.error('Please enter your BunnyCDN API key')
+ return
+ }
+ setBunnyLoadingZones(true)
+ setBunnyPullZones([])
+ setBunnySelectedZone(null)
+ try {
+ const { pull_zones, message } = await getBunnyPullZones(siteId, bunnyApiKey)
+ if (pull_zones.length === 0) {
+ toast.error(message || 'No pull zones match this site\'s domain')
+ } else {
+ setBunnyPullZones(pull_zones)
+ setBunnySelectedZone(pull_zones[0])
+ }
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || 'Failed to load pull zones')
+ } finally {
+ setBunnyLoadingZones(false)
+ }
+ }}
+ disabled={bunnyLoadingZones || !bunnyApiKey.trim()}
+ className="inline-flex items-center gap-2 px-4 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 text-sm font-medium rounded-xl hover:bg-neutral-800 dark:hover:bg-neutral-100 transition-colors disabled:opacity-50"
+ >
+ {bunnyLoadingZones && }
+ Load Zones
+
+
+
+ {bunnyPullZones.length > 0 && (
+
+
+ Pull Zone
+ {
+ const zone = bunnyPullZones.find(z => z.id === Number(e.target.value))
+ setBunnySelectedZone(zone || null)
+ }}
+ className="w-full 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"
+ >
+ {bunnyPullZones.map((zone) => (
+ {zone.name}
+ ))}
+
+
+
{
+ if (!bunnySelectedZone) return
+ setBunnyConnecting(true)
+ try {
+ await connectBunny(siteId, bunnyApiKey, bunnySelectedZone.id, bunnySelectedZone.name)
+ mutateBunnyStatus()
+ setBunnyApiKey('')
+ setBunnyPullZones([])
+ setBunnySelectedZone(null)
+ toast.success('BunnyCDN connected successfully')
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || 'Failed to connect BunnyCDN')
+ } finally {
+ setBunnyConnecting(false)
+ }
+ }}
+ disabled={bunnyConnecting || !bunnySelectedZone}
+ className="inline-flex items-center gap-2 px-4 py-2.5 bg-brand-orange text-white text-sm font-medium rounded-xl hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 disabled:opacity-50"
+ >
+ {bunnyConnecting && }
+ Connect BunnyCDN
+
+
+ )}
+
+ )}
+
+ ) : (
+
+
+
+
+
+
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 && (
+
+ {
+ if (!confirm('Disconnect BunnyCDN? All CDN analytics data will be removed from Pulse.')) return
+ setBunnyDisconnecting(true)
+ try {
+ await disconnectBunny(siteId)
+ mutateBunnyStatus()
+ toast.success('BunnyCDN disconnected')
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || 'Failed to disconnect')
+ } finally {
+ setBunnyDisconnecting(false)
+ }
+ }}
+ disabled={bunnyDisconnecting}
+ className="inline-flex items-center gap-2 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors disabled:opacity-50"
+ >
+ {bunnyDisconnecting && }
+ Disconnect
+
+
+ )}
+
+ )}
+
)}
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(