feat: add BunnyCDN integration

This commit is contained in:
Usman Baig
2026-03-14 20:46:26 +01:00
parent a8fe171c8c
commit fb85c431f0
6 changed files with 830 additions and 1 deletions

View File

@@ -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.

477
app/sites/[id]/cdn/page.tsx Normal file
View File

@@ -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 (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<SkeletonLine className="h-8 w-48 mb-2" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonLine className="h-9 w-36 rounded-lg" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-8">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 mb-6">
<SkeletonLine className="h-6 w-40 mb-4" />
<SkeletonLine className="h-64 w-full rounded-lg" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
<SkeletonLine className="h-6 w-32 mb-4" />
<SkeletonLine className="h-48 w-full rounded-lg" />
</div>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
<SkeletonLine className="h-6 w-32 mb-4" />
<SkeletonLine className="h-48 w-full rounded-lg" />
</div>
</div>
</div>
)
}
// ─── Not connected state ──────────────────────────────────
if (bunnyStatus && !bunnyStatus.connected) {
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
<CloudArrowUp size={40} className="text-neutral-400 dark:text-neutral-500" />
</div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
Connect BunnyCDN
</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md mb-6">
Monitor your CDN performance including bandwidth usage, cache hit rates, request volumes, and geographic distribution.
</p>
<Link
href={`/sites/${siteId}/settings?tab=integrations`}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-orange hover:bg-brand-orange/90 text-white text-sm font-medium transition-colors"
>
Connect in Settings
<ArrowSquareOut size={16} weight="bold" />
</Link>
</div>
</div>
)
}
// ─── 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 (
<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">
CDN Analytics
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
BunnyCDN performance, bandwidth, and cache metrics
</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 === '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' },
]}
/>
</div>
{/* Overview cards */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-8">
<OverviewCard
label="Bandwidth"
value={overview ? formatBytes(overview.total_bandwidth) : '-'}
change={bandwidthChange}
/>
<OverviewCard
label="Requests"
value={overview ? formatNumber(overview.total_requests) : '-'}
change={requestsChange}
/>
<OverviewCard
label="Cache Hit Rate"
value={overview ? overview.cache_hit_rate.toFixed(1) + '%' : '-'}
change={cacheHitChange}
/>
<OverviewCard
label="Origin Response"
value={overview ? overview.avg_origin_response.toFixed(0) + 'ms' : '-'}
change={originChange}
invertColor
/>
<OverviewCard
label="Errors"
value={overview ? formatNumber(overview.total_errors) : '-'}
change={errorsChange}
invertColor
/>
</div>
{/* Bandwidth chart */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 mb-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Bandwidth</h2>
{daily.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={daily} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="bandwidthGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FD5E0F" stopOpacity={0.2} />
<stop offset="100%" stopColor="#FD5E0F" stopOpacity={0} />
</linearGradient>
<linearGradient id="cachedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22C55E" stopOpacity={0.15} />
<stop offset="100%" stopColor="#22C55E" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-800" />
<XAxis
dataKey="date"
tickFormatter={formatDateShort}
tick={{ fontSize: 12, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(v) => formatBytes(v)}
tick={{ fontSize: 12, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
width={60}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p>
<p className="text-neutral-900 dark:text-white font-medium">
Total: {formatBytes(payload[0]?.value as number)}
</p>
{payload[1] && (
<p className="text-green-600 dark:text-green-400">
Cached: {formatBytes(payload[1]?.value as number)}
</p>
)}
</div>
)
}}
/>
<Area
type="monotone"
dataKey="bandwidth_used"
stroke="#FD5E0F"
strokeWidth={2}
fill="url(#bandwidthGrad)"
name="Total"
/>
<Area
type="monotone"
dataKey="bandwidth_cached"
stroke="#22C55E"
strokeWidth={2}
fill="url(#cachedGrad)"
name="Cached"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
No bandwidth data for this period.
</div>
)}
</div>
{/* Requests + Errors charts side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Requests chart */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Requests</h2>
{daily.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={daily} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-800" />
<XAxis
dataKey="date"
tickFormatter={formatDateShort}
tick={{ fontSize: 11, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(v) => formatNumber(v)}
tick={{ fontSize: 11, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
width={50}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p>
<p className="text-neutral-900 dark:text-white font-medium">
{formatNumber(payload[0]?.value as number)} requests
</p>
</div>
)
}}
/>
<Bar dataKey="requests_served" fill="#FD5E0F" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[220px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
No request data for this period.
</div>
)}
</div>
{/* Errors chart */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Errors</h2>
{daily.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={daily.map((d) => ({
date: d.date,
'3xx': d.error_3xx,
'4xx': d.error_4xx,
'5xx': d.error_5xx,
}))}
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-800" />
<XAxis
dataKey="date"
tickFormatter={formatDateShort}
tick={{ fontSize: 11, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(v) => formatNumber(v)}
tick={{ fontSize: 11, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
width={50}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p>
{payload.map((entry) => (
<p key={entry.name} style={{ color: entry.color }} className="font-medium">
{entry.name}: {formatNumber(entry.value as number)}
</p>
))}
</div>
)
}}
/>
<Bar dataKey="3xx" stackId="errors" fill="#FACC15" radius={[0, 0, 0, 0]} />
<Bar dataKey="4xx" stackId="errors" fill="#F97316" radius={[0, 0, 0, 0]} />
<Bar dataKey="5xx" stackId="errors" fill="#EF4444" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[220px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
No error data for this period.
</div>
)}
</div>
</div>
{/* Bandwidth by Country */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Bandwidth by Country</h2>
{countries.length > 0 ? (
<div className="space-y-3">
{countries.map((row) => (
<div key={row.country_code} className="flex items-center gap-3">
<span className="text-sm text-neutral-700 dark:text-neutral-300 w-10 shrink-0 font-medium">
{row.country_code}
</span>
<div className="flex-1 h-6 bg-neutral-100 dark:bg-neutral-800 rounded overflow-hidden">
<div
className="h-full rounded bg-brand-orange/80"
style={{ width: `${(row.bandwidth / maxCountryBandwidth) * 100}%` }}
/>
</div>
<span className="text-sm text-neutral-500 dark:text-neutral-400 w-20 text-right tabular-nums shrink-0">
{formatBytes(row.bandwidth)}
</span>
<span className="text-xs text-neutral-400 dark:text-neutral-500 w-16 text-right tabular-nums shrink-0">
{formatNumber(row.requests)} req
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-neutral-400 dark:text-neutral-500 py-8 text-center">
No geographic data for this period.
</p>
)}
</div>
</div>
)
}
// ─── 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 (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">{label}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{value}</p>
{changeLabel && (
<p className={`text-xs mt-1 font-medium ${
isGood ? 'text-green-600 dark:text-green-400' :
isBad ? 'text-red-600 dark:text-red-400' :
'text-neutral-500 dark:text-neutral-400'
}`}>
{changeLabel} vs previous period
</p>
)}
</div>
)
}

View File

@@ -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<BunnyPullZone[]>([])
const [bunnySelectedZone, setBunnySelectedZone] = useState<BunnyPullZone | null>(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<ReportSchedule | null>(null)
const [reportSaving, setReportSaving] = useState(false)
@@ -1600,6 +1609,219 @@ export default function SiteSettingsPage() {
</div>
)}
</div>
{/* BunnyCDN */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 p-6">
{!bunnyStatus?.connected ? (
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#FF6600" strokeWidth="1.5" fill="none" />
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2Z" stroke="#FF6600" strokeWidth="1.5" fill="none" />
<path d="M2 12h20" stroke="#FF6600" strokeWidth="1.5" />
<path d="M4.5 7h15M4.5 17h15" stroke="#FF6600" strokeWidth="1" opacity="0.5" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">BunnyCDN</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
Monitor CDN performance with bandwidth usage, cache hit rates, response times, and geographic distribution.
</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<svg className="w-4 h-4 text-neutral-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Your API key is encrypted at rest and only used to fetch read-only statistics. You can disconnect at any time.
</p>
</div>
{canEdit && (
<div className="space-y-3">
<div className="flex gap-2">
<input
type="password"
value={bunnyApiKey}
onChange={(e) => {
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"
/>
<button
onClick={async () => {
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 && <SpinnerGap className="w-4 h-4 animate-spin" />}
Load Zones
</button>
</div>
{bunnyPullZones.length > 0 && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Pull Zone</label>
<select
value={bunnySelectedZone?.id ?? ''}
onChange={(e) => {
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) => (
<option key={zone.id} value={zone.id}>{zone.name}</option>
))}
</select>
</div>
<button
onClick={async () => {
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 && <SpinnerGap className="w-4 h-4 animate-spin" />}
Connect BunnyCDN
</button>
</div>
)}
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#FF6600" strokeWidth="1.5" fill="none" />
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2Z" stroke="#FF6600" strokeWidth="1.5" fill="none" />
<path d="M2 12h20" stroke="#FF6600" strokeWidth="1.5" />
<path d="M4.5 7h15M4.5 17h15" stroke="#FF6600" strokeWidth="1" opacity="0.5" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">BunnyCDN</h3>
<div className="flex items-center gap-2 mt-1.5">
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${
bunnyStatus.status === 'active'
? 'text-green-600 dark:text-green-400'
: bunnyStatus.status === 'syncing'
? 'text-amber-600 dark:text-amber-400'
: 'text-red-600 dark:text-red-400'
}`}>
<span className={`w-2 h-2 rounded-full ${
bunnyStatus.status === 'active'
? 'bg-green-500'
: bunnyStatus.status === 'syncing'
? 'bg-amber-500 animate-pulse'
: 'bg-red-500'
}`} />
{bunnyStatus.status === 'active' ? 'Connected' : bunnyStatus.status === 'syncing' ? 'Syncing...' : 'Error'}
</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{bunnyStatus.pull_zone_name && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Pull Zone</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{bunnyStatus.pull_zone_name}</p>
</div>
)}
{bunnyStatus.last_synced_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Last Synced</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
{new Date(bunnyStatus.last_synced_at).toLocaleString('en-GB')}
</p>
</div>
)}
{bunnyStatus.created_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Connected Since</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
{new Date(bunnyStatus.created_at).toLocaleString('en-GB')}
</p>
</div>
)}
</div>
{bunnyStatus.status === 'error' && bunnyStatus.error_message && (
<div className="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/30">
<p className="text-sm text-red-700 dark:text-red-300">{bunnyStatus.error_message}</p>
</div>
)}
{canEdit && (
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<button
onClick={async () => {
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 && <SpinnerGap className="w-4 h-4 animate-spin" />}
Disconnect
</button>
</div>
)}
</div>
)}
</div>
</div>
)}
</motion.div>

View File

@@ -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` }] : []),
]

84
lib/api/bunny.ts Normal file
View File

@@ -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<void> {
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<BunnyStatus> {
return apiRequest<BunnyStatus>(`/sites/${siteId}/integrations/bunny/status`)
}
export async function disconnectBunny(siteId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/integrations/bunny`, { method: 'DELETE' })
}
export async function getBunnyOverview(siteId: string, startDate: string, endDate: string): Promise<BunnyOverview> {
return apiRequest<BunnyOverview>(`/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}`)
}

View File

@@ -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<BunnyStatus>(
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<BunnyOverview>(
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<SubscriptionDetails>(