feat: add BunnyCDN integration
This commit is contained in:
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>
|
||||
|
||||
Reference in New Issue
Block a user