feat: add BunnyCDN integration
This commit is contained in:
@@ -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
477
app/sites/[id]/cdn/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
84
lib/api/bunny.ts
Normal 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}`)
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user