Files
pulse/app/sites/[id]/cdn/page.tsx
Usman Baig 24c71f7991 fix: mobile responsiveness across all pages
- SiteNav: add horizontal scroll for 8 tabs on mobile
- NotificationCenter: full-width dropdown on mobile
- ContentStats/Locations/TechSpecs: scrollable tab bars
- FrustrationTable: fix selector text overflow
- FrustrationByPageTable: horizontal scroll on mobile
- CDN: better stat card grid breakpoints
- Home: reduce stat card height, prevent button wrap
- Billing: shorter invoice labels on mobile
- Bump @ciphera-net/ui to 0.2.6 (AppLauncher mobile fix)
2026-03-15 18:15:06 +01:00

547 lines
24 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import * as Flags from 'country-flag-icons/react/3x2'
const DottedMap = dynamic(() => import('@/components/dashboard/DottedMap'), { ssr: false })
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 ────────────────────────────────────────────────────
// US state codes → map to "US" for the dotted map
const US_STATES = new Set([
'AL','AK','AZ','AR','CO','CT','DC','DE','FL','GA','HI','ID','IL','IN','IA',
'KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT','NE','NV','NH','NJ',
'NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT',
'VA','WA','WV','WI','WY',
])
// Canadian province codes → map to "CA"
const CA_PROVINCES = new Set(['AB','BC','MB','NB','NL','NS','NT','NU','ON','PE','QC','SK','YT'])
/**
* Extract ISO country code from BunnyCDN datacenter string.
* e.g. "EU: Zurich, CH" → "CH", "NA: Chicago, IL" → "US", "NA: Toronto, CA" → "CA"
*/
function extractCountryCode(datacenter: string): string {
const parts = datacenter.split(', ')
const code = parts[parts.length - 1]?.trim().toUpperCase()
if (!code || code.length !== 2) return ''
if (US_STATES.has(code)) return 'US'
if (CA_PROVINCES.has(code)) return 'CA'
return code
}
/**
* Extract the city name from a BunnyCDN datacenter string.
* e.g. "EU: Zurich, CH" → "Zurich"
*/
function extractCity(datacenter: string): string {
const afterColon = datacenter.split(': ')[1] || datacenter
return afterColon.split(',')[0]?.trim() || datacenter
}
/** Get flag icon component for a country code */
function getFlagIcon(code: string) {
if (!code) return null
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[code]
return FlagComponent ? <FlagComponent className="w-5 h-3.5 rounded-sm shadow-sm shrink-0" /> : null
}
/**
* Map each datacenter entry to its country's centroid for the dotted map.
* Each datacenter gets its own dot (sized by bandwidth) at the country's position.
*/
function mapToCountryCentroids(data: Array<{ country_code: string; bandwidth: number }>): Array<{ country: string; pageviews: number }> {
return data
.map((row) => ({
country: extractCountryCode(row.country_code),
pageviews: row.bandwidth,
}))
.filter((d) => d.country !== '')
}
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-3 lg: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 totalBandwidth = countries.reduce((sum, row) => sum + row.bandwidth, 0)
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-3 lg: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>
{/* Traffic Distribution */}
<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">Traffic Distribution</h2>
{countries.length > 0 ? (
<>
<div className="h-[360px] mb-8">
<DottedMap data={mapToCountryCentroids(countries)} formatValue={formatBytes} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-x-6 gap-y-5">
{countries.map((row) => {
const pct = totalBandwidth > 0 ? (row.bandwidth / totalBandwidth) * 100 : 0
const cc = extractCountryCode(row.country_code)
const city = extractCity(row.country_code)
return (
<div key={row.country_code} className="group relative">
<div className="flex items-center gap-2.5 mb-2">
{cc && getFlagIcon(cc)}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate block">{city}</span>
</div>
<span className="text-sm tabular-nums text-neutral-500 dark:text-neutral-400 shrink-0">
{formatBytes(row.bandwidth)}
</span>
</div>
<div className="relative h-1.5 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full bg-brand-orange transition-all"
style={{ width: `${Math.max(pct, 1)}%` }}
/>
</div>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-neutral-900 dark:bg-neutral-700 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10">
{pct.toFixed(1)}% of total traffic
</div>
</div>
)
})}
</div>
</>
) : (
<div className="h-[360px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
No geographic data for this period.
</div>
)}
</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>
)
}