refactor: 改进 Cloudflare 缓存清理与独立域名的流量统计面板
This commit is contained in:
1 parent
fa8c04e297
commit
6ad717533c
10 files changed
+560
-549
No files matched your search
@@ -0,0 +1,50 @@
|
|||||||
|
export interface TrafficRow {
|
||||||
|
count: number;
|
||||||
|
sum: { edgeResponseBytes: number };
|
||||||
|
dimensions: { date: string; cacheStatus: string; clientRequestPath: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficScope {
|
||||||
|
label: string;
|
||||||
|
hostname: string;
|
||||||
|
days: number;
|
||||||
|
rows: TrafficRow[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsData {
|
||||||
|
scopes: TrafficScope[];
|
||||||
|
d1: { sum: { rowsRead: number; rowsWritten: number } }[];
|
||||||
|
r2: { max: { payloadSize: number } }[];
|
||||||
|
missingEnvVars: boolean;
|
||||||
|
errorMsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeTraffic(rows: TrafficRow[]) {
|
||||||
|
let requests = 0;
|
||||||
|
let bytes = 0;
|
||||||
|
let hits = 0;
|
||||||
|
let privateRequests = 0;
|
||||||
|
const statuses: Record<string, number> = {};
|
||||||
|
const daily = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
requests += row.count;
|
||||||
|
bytes += row.sum.edgeResponseBytes;
|
||||||
|
const status = row.dimensions.cacheStatus.toUpperCase();
|
||||||
|
statuses[status] = (statuses[status] || 0) + row.count;
|
||||||
|
if (status === 'HIT') hits += row.count;
|
||||||
|
if (/^\/(admin|api)(\/|$)/.test(row.dimensions.clientRequestPath)) {
|
||||||
|
privateRequests += row.count;
|
||||||
|
}
|
||||||
|
daily.set(row.dimensions.date, (daily.get(row.dimensions.date) || 0) + row.count);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requests,
|
||||||
|
bytes,
|
||||||
|
hits,
|
||||||
|
privateRequests,
|
||||||
|
hitRate: requests ? (hits / requests) * 100 : null,
|
||||||
|
statuses,
|
||||||
|
daily: [...daily].sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,180 +1,102 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
import type { AnalyticsData } from '$lib/analytics';
|
||||||
let { analyticsPromise } = $props<{
|
import { summarizeTraffic } from '$lib/analytics';
|
||||||
analyticsPromise: Promise<any>;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
let chartCanvas = $state<HTMLCanvasElement | undefined>(undefined);
|
|
||||||
let chartInstance: any = null;
|
|
||||||
|
|
||||||
|
let { analyticsPromise }: { analyticsPromise: Promise<AnalyticsData> } = $props();
|
||||||
|
let data = $state<AnalyticsData | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let trafficData: any[] = $state([]);
|
|
||||||
let d1Data: any[] = $state([]);
|
|
||||||
let r2Data: any[] = $state([]);
|
|
||||||
let missingEnvVars = $state(false);
|
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let chartCanvas = $state<HTMLCanvasElement>();
|
||||||
let totalRequests = $state(0);
|
let scopes = $derived(
|
||||||
let totalCached = $state(0);
|
(data?.scopes || []).map((scope) => ({ ...scope, summary: summarizeTraffic(scope.rows) }))
|
||||||
let totalBytes = $state(0);
|
|
||||||
let hitRate = $state(0);
|
|
||||||
let totalPV = $state(0);
|
|
||||||
let totalUV = $state(0);
|
|
||||||
|
|
||||||
let d1RowsRead = $state(0);
|
|
||||||
let d1RowsWritten = $state(0);
|
|
||||||
let r2PayloadSize = $state(0);
|
|
||||||
|
|
||||||
let d1ReadDaily = $derived(d1Data.length > 0 ? Math.round(d1RowsRead / 30) : 0);
|
|
||||||
let d1ReadPercent = $derived(d1Data.length > 0 ? (d1ReadDaily / 5000000) * 100 : 0);
|
|
||||||
let d1WriteDaily = $derived(d1Data.length > 0 ? Math.round(d1RowsWritten / 30) : 0);
|
|
||||||
let d1WritePercent = $derived(d1Data.length > 0 ? (d1WriteDaily / 100000) * 100 : 0);
|
|
||||||
let r2Percent = $derived(
|
|
||||||
r2Data.length > 0 ? (r2PayloadSize / (10 * 1024 * 1024 * 1024)) * 100 : 0
|
|
||||||
);
|
);
|
||||||
|
let d1Data = $derived(data?.d1 || []);
|
||||||
|
let r2Data = $derived(data?.r2 || []);
|
||||||
|
let missingEnvVars = $derived(data?.missingEnvVars ?? true);
|
||||||
|
let d1ReadDaily = $derived(
|
||||||
|
Math.round(d1Data.reduce((sum, row) => sum + row.sum.rowsRead, 0) / 30)
|
||||||
|
);
|
||||||
|
let d1WriteDaily = $derived(
|
||||||
|
Math.round(d1Data.reduce((sum, row) => sum + row.sum.rowsWritten, 0) / 30)
|
||||||
|
);
|
||||||
|
let d1ReadPercent = $derived((d1ReadDaily / 5000000) * 100);
|
||||||
|
let d1WritePercent = $derived((d1WriteDaily / 100000) * 100);
|
||||||
|
let r2PayloadSize = $derived(Math.max(0, ...r2Data.map((row) => row.max.payloadSize)));
|
||||||
|
let r2Percent = $derived((r2PayloadSize / (10 * 1024 * 1024 * 1024)) * 100);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
loading = true;
|
||||||
|
data = null;
|
||||||
|
error = '';
|
||||||
analyticsPromise
|
analyticsPromise
|
||||||
.then((data: any) => {
|
.then((value) => {
|
||||||
if (data) {
|
if (!cancelled) {
|
||||||
trafficData = data.traffic || [];
|
data = value;
|
||||||
d1Data = data.d1 || [];
|
|
||||||
r2Data = data.r2 || [];
|
|
||||||
missingEnvVars = data.missingEnvVars || false;
|
|
||||||
|
|
||||||
if (trafficData.length > 0) {
|
|
||||||
trafficData.forEach((day: any) => {
|
|
||||||
totalRequests += day.sum.requests || 0;
|
|
||||||
totalCached += day.sum.cachedRequests || 0;
|
|
||||||
totalBytes += day.sum.bytes || 0;
|
|
||||||
totalPV += day.sum.pageViews || 0;
|
|
||||||
totalUV += day.uniq?.uniques || 0;
|
|
||||||
});
|
|
||||||
hitRate = totalRequests > 0 ? (totalCached / totalRequests) * 100 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (d1Data.length > 0) {
|
|
||||||
d1Data.forEach((row: any) => {
|
|
||||||
d1RowsRead += row.sum.rowsRead || 0;
|
|
||||||
d1RowsWritten += row.sum.rowsWritten || 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (r2Data.length > 0) {
|
|
||||||
r2PayloadSize = r2Data[0]?.max?.payloadSize || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.errorMsg) {
|
|
||||||
error = data.errorMsg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch(() => {
|
||||||
console.error(err);
|
if (!cancelled) {
|
||||||
error = '获取数据失败,请检查 Cloudflare 环境变量配置。';
|
error = '统计不可用,请检查网络和 Cloudflare 配置。';
|
||||||
loading = false;
|
loading = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (chartCanvas && trafficData && trafficData.length > 0) {
|
const canvas = chartCanvas;
|
||||||
renderChart(trafficData);
|
const current = scopes.filter((scope) => !scope.error);
|
||||||
}
|
if (!canvas || !current.length) return;
|
||||||
});
|
let cancelled = false;
|
||||||
|
let chart: import('chart.js').Chart | undefined;
|
||||||
async function renderChart(data: any[]) {
|
const labels = [
|
||||||
if (!chartCanvas) return;
|
...new Set(current.flatMap((scope) => scope.summary.daily.map(([date]) => date)))
|
||||||
|
].sort();
|
||||||
const ChartJS = await import('chart.js/auto');
|
void import('chart.js/auto')
|
||||||
const Chart = ChartJS.default || ChartJS;
|
.then(({ default: Chart }) => {
|
||||||
|
if (cancelled) return;
|
||||||
// 格式化日期:去除年份,将 2026-09-27 转为 09-27,降低 X 轴文字密度
|
chart = new Chart(canvas, {
|
||||||
const labels = data.map((d: any) => d.dimensions.date.substring(5));
|
|
||||||
const requests = data.map((d: any) => d.sum.requests);
|
|
||||||
const pvs = data.map((d: any) => d.sum.pageViews);
|
|
||||||
|
|
||||||
if (chartInstance) {
|
|
||||||
chartInstance.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
chartInstance = new Chart(chartCanvas, {
|
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: current.map((scope, index) => ({
|
||||||
{
|
label: `${scope.label}请求数`,
|
||||||
label: '总请求数',
|
data: labels.map((date) => new Map(scope.summary.daily).get(date) ?? null),
|
||||||
data: requests,
|
borderColor: index === 0 ? '#333' : '#2196f3',
|
||||||
borderColor: '#333',
|
tension: 0.3
|
||||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
}))
|
||||||
tension: 0.3,
|
|
||||||
fill: true,
|
|
||||||
yAxisID: 'y'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '页面浏览量 (PV)',
|
|
||||||
data: pvs,
|
|
||||||
borderColor: '#2196F3',
|
|
||||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
fill: true,
|
|
||||||
yAxisID: 'y1'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: {
|
scales: { y: { beginAtZero: true } }
|
||||||
mode: 'index',
|
|
||||||
intersect: false
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
tooltip: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
maxRotation: 45,
|
|
||||||
maxTicksLimit: 15 // 最多显示 15 个刻度,避免太拥挤
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
type: 'linear',
|
|
||||||
display: true,
|
|
||||||
position: 'left',
|
|
||||||
beginAtZero: true
|
|
||||||
},
|
|
||||||
y1: {
|
|
||||||
type: 'linear',
|
|
||||||
display: true,
|
|
||||||
position: 'right',
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: {
|
|
||||||
drawOnChartArea: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* The numeric metrics remain available if the chart cannot load. */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
chart?.destroy();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function formatBytes(bytes: number) {
|
function formatBytes(bytes: number) {
|
||||||
if (bytes === 0) return '0 B';
|
if (!bytes) return '0 B';
|
||||||
const k = 1024;
|
const index = Math.min(4, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
return `${(bytes / 1024 ** index).toFixed(2)} ${['B', 'KB', 'MB', 'GB', 'TB'][index]}`;
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
}
|
||||||
|
function formatNumber(value: number) {
|
||||||
function formatNumber(num: number) {
|
return value >= 1000000
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
? `${(value / 1000000).toFixed(1)}M`
|
||||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
: value >= 1000
|
||||||
return Math.round(num).toString();
|
? `${(value / 1000).toFixed(1)}K`
|
||||||
|
: Math.round(value).toLocaleString();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -182,129 +104,64 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading-state">
|
<div class="loading-state">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>正在从 Cloudflare 获取最近 30 天流量数据...</p>
|
<p>正在获取博客与 R2 附件统计...</p>
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="error-state">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{:else if !trafficData || trafficData.length === 0}
|
|
||||||
<div class="empty-state">
|
|
||||||
没有获取到有效的流量数据,请确保 CF_API_TOKEN 和 CF_ZONE_ID 配置正确,且网站已有访问。
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="section-title">30天访问流量</div>
|
{#if error || data?.errorMsg}<div class="error-state">{error || data?.errorMsg}</div>{/if}
|
||||||
|
<div class="section-title">项目访问流量</div>
|
||||||
<div class="dashboard-grid">
|
<p class="scope-note">
|
||||||
|
仅统计配置的博客域名和 R2 附件域名,不包含文章中的外链资源。CDN 命中率为 HIT
|
||||||
|
请求占比,不代表页面内部缓存命中率或数据库查询比例。数据可能由 Cloudflare 抽样估算。
|
||||||
|
</p>
|
||||||
|
{#each scopes as scope (scope.label)}
|
||||||
|
<section class="scope-card">
|
||||||
|
<h3>{scope.label} <span>{scope.hostname || '未配置域名'}</span></h3>
|
||||||
|
{#if scope.error}
|
||||||
|
<div class="error-state">统计不可用:{scope.error}</div>
|
||||||
|
{:else}
|
||||||
|
<p class="scope-note">
|
||||||
|
最近 {scope.days} 天 · UTC{scope.days < 30
|
||||||
|
? ' · 受查询范围或数据量限制,已缩短统计时段'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
<div class="scope-metrics">
|
||||||
|
<div>
|
||||||
|
<div class="metric-title">总请求数</div>
|
||||||
|
<div class="metric-value">{formatNumber(scope.summary.requests)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="metric-title">消耗带宽</div>
|
||||||
|
<div class="metric-value">{formatBytes(scope.summary.bytes)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="metric-title">CDN 请求命中率</div>
|
||||||
|
<div class="metric-value">
|
||||||
|
{scope.summary.hitRate === null
|
||||||
|
? '暂无请求'
|
||||||
|
: `${scope.summary.hitRate.toFixed(1)}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if scope.label === '博客'}<p class="scope-note">
|
||||||
|
后台及 API 请求:{formatNumber(
|
||||||
|
scope.summary.privateRequests
|
||||||
|
)};其他请求:{formatNumber(
|
||||||
|
scope.summary.requests - scope.summary.privateRequests
|
||||||
|
)}。均包含在总请求数中。
|
||||||
|
</p>{/if}
|
||||||
|
<div class="status-list" aria-label="缓存状态分布">
|
||||||
|
{#each Object.entries(scope.summary.statuses) as [status, count] (status)}<span
|
||||||
|
>{status}:{formatNumber(count)}</span
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
{#if scopes.some((scope) => !scope.error && scope.rows.length)}
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<div class="canvas-wrapper">
|
<div class="canvas-wrapper"><canvas bind:this={chartCanvas}></canvas></div>
|
||||||
<canvas bind:this={chartCanvas}></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
<div class="core-metrics">
|
|
||||||
<div class="metric-item">
|
|
||||||
<div class="metric-title">
|
|
||||||
<span class="metric-icon" style="color: #2196F3; background: rgba(33,150,243,0.1)">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="3"
|
|
||||||
></circle></svg
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
页面浏览 (PV)
|
|
||||||
</div>
|
|
||||||
<div class="metric-value" style="color: #2196F3">{totalPV.toLocaleString()}</div>
|
|
||||||
<div class="metric-subtitle">近 30 天累计访问量</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<div class="metric-title">
|
|
||||||
<span class="metric-icon">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle
|
|
||||||
cx="12"
|
|
||||||
cy="7"
|
|
||||||
r="4"
|
|
||||||
></circle></svg
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
独立访客 (UV)
|
|
||||||
</div>
|
|
||||||
<div class="metric-value">{totalUV.toLocaleString()}</div>
|
|
||||||
<div class="metric-subtitle">近 30 天去重访客数</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<div class="metric-title">
|
|
||||||
<span class="metric-icon">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect
|
|
||||||
x="2"
|
|
||||||
y="14"
|
|
||||||
width="20"
|
|
||||||
height="8"
|
|
||||||
rx="2"
|
|
||||||
ry="2"
|
|
||||||
></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line
|
|
||||||
x1="6"
|
|
||||||
y1="18"
|
|
||||||
x2="6.01"
|
|
||||||
y2="18"
|
|
||||||
></line></svg
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
消耗总带宽
|
|
||||||
</div>
|
|
||||||
<div class="metric-value">{formatBytes(totalBytes)}</div>
|
|
||||||
<div class="metric-subtitle">包含网页及所有静态资源</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<div class="metric-title">
|
|
||||||
<span class="metric-icon" style="color: #4caf50; background: rgba(76,175,80,0.1)">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
缓存命中率
|
|
||||||
</div>
|
|
||||||
<div class="metric-value" style="color: #4caf50">{hitRate.toFixed(1)}%</div>
|
|
||||||
<div class="metric-subtitle">由 Cloudflare CDN 成功拦截</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section-title">Cloudflare 资源配额</div>
|
<div class="section-title">Cloudflare 资源配额</div>
|
||||||
{#if missingEnvVars}
|
{#if missingEnvVars}
|
||||||
<div class="info-alert">
|
<div class="info-alert">
|
||||||
@@ -336,7 +193,7 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="quota-footer">
|
<div class="quota-footer">
|
||||||
<span>{d1ReadPercent < 1 ? '极度充裕' : '健康'}</span>
|
<span>{d1Data.length ? (d1ReadPercent < 1 ? '极度充裕' : '健康') : '暂无数据'}</span>
|
||||||
<span
|
<span
|
||||||
>{d1ReadPercent < 0.01 && d1Data.length > 0
|
>{d1ReadPercent < 0.01 && d1Data.length > 0
|
||||||
? '< 0.01'
|
? '< 0.01'
|
||||||
@@ -367,7 +224,7 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="quota-footer">
|
<div class="quota-footer">
|
||||||
<span>{d1WritePercent < 1 ? '极度充裕' : '健康'}</span>
|
<span>{d1Data.length ? (d1WritePercent < 1 ? '极度充裕' : '健康') : '暂无数据'}</span>
|
||||||
<span
|
<span
|
||||||
>{d1WritePercent < 0.01 && d1Data.length > 0
|
>{d1WritePercent < 0.01 && d1Data.length > 0
|
||||||
? '< 0.01'
|
? '< 0.01'
|
||||||
@@ -392,7 +249,7 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="quota-footer">
|
<div class="quota-footer">
|
||||||
<span>{r2Percent < 1 ? '空间海量' : '健康'}</span>
|
<span>{r2Data.length ? (r2Percent < 1 ? '空间海量' : '健康') : '暂无数据'}</span>
|
||||||
<span>{r2Percent < 0.01 && r2Data.length > 0 ? '< 0.01' : r2Percent.toFixed(2)}%</span>
|
<span>{r2Percent < 0.01 && r2Data.length > 0 ? '< 0.01' : r2Percent.toFixed(2)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -401,6 +258,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.scope-card {
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.scope-card h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.scope-card h3 span {
|
||||||
|
color: #777;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.scope-note {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.scope-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.status-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.scope-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.analytics-panel {
|
.analytics-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -434,20 +331,6 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 7fr 3fr; /* 增加图表宽度,缩小右侧网格 */
|
|
||||||
gap: 0;
|
|
||||||
border: 2px solid var(--text-color, #000);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.dashboard-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -461,56 +344,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.core-metrics {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
border-left: 2px solid var(--text-color, #000);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.core-metrics {
|
|
||||||
border-left: none;
|
|
||||||
border-top: 2px solid var(--text-color, #000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-item {
|
|
||||||
padding: 20px 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 为 2x2 网格绘制内部边框 */
|
|
||||||
.metric-item:nth-child(1),
|
|
||||||
.metric-item:nth-child(2) {
|
|
||||||
border-bottom: 2px solid var(--text-color, #000);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-item:nth-child(1),
|
|
||||||
.metric-item:nth-child(3) {
|
|
||||||
border-right: 2px solid var(--text-color, #000);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.core-metrics {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.metric-item:nth-child(1),
|
|
||||||
.metric-item:nth-child(3) {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
.metric-item:nth-child(1),
|
|
||||||
.metric-item:nth-child(2),
|
|
||||||
.metric-item:nth-child(3) {
|
|
||||||
border-bottom: 2px solid var(--text-color, #000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-title {
|
.metric-title {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -520,31 +353,12 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
background: rgba(0, 0, 0, 0.06);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-right: 6px;
|
|
||||||
color: var(--text-color, #000);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
.metric-value {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-subtitle {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--text-muted, rgba(0, 0, 0, 0.4));
|
|
||||||
margin-top: 2px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quota-container {
|
.quota-container {
|
||||||
border: 2px solid var(--text-color, #000);
|
border: 2px solid var(--text-color, #000);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -647,8 +461,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.loading-state,
|
.loading-state,
|
||||||
.error-state,
|
.error-state {
|
||||||
.empty-state {
|
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 2px dashed var(--border-color, rgba(0, 0, 0, 0.2));
|
border: 2px dashed var(--border-color, rgba(0, 0, 0, 0.2));
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fetchAnalytics, purgePublicCache } from './cloudflare';
|
||||||
|
import { summarizeTraffic, type TrafficRow } from '$lib/analytics';
|
||||||
|
|
||||||
|
function platform(extra: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
env: {
|
||||||
|
CF_API_TOKEN: 'test-token',
|
||||||
|
CF_ZONE_ID: 'blog-zone',
|
||||||
|
CF_DOMAIN: 'https://blog.example.com',
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
} as unknown as App.Platform;
|
||||||
|
}
|
||||||
|
function trafficResponse(rows: TrafficRow[] = []) {
|
||||||
|
return Response.json({ data: { viewer: { zones: [{ httpRequestsAdaptiveGroups: rows }] } } });
|
||||||
|
}
|
||||||
|
const row = (count: number, status: string, path = '/'): TrafficRow => ({
|
||||||
|
count,
|
||||||
|
dimensions: { date: '2026-09-26', cacheStatus: status, clientRequestPath: path },
|
||||||
|
sum: { edgeResponseBytes: count * 100 }
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('domain-scoped analytics', () => {
|
||||||
|
it('queries only configured hosts and eyeball traffic, with separate asset zone support', async () => {
|
||||||
|
const mock = vi.fn().mockImplementation(() => trafficResponse([row(2, 'hit')]));
|
||||||
|
vi.stubGlobal('fetch', mock);
|
||||||
|
const result = await fetchAnalytics(
|
||||||
|
platform({ CF_ASSETS_ZONE_ID: 'assets-zone' }),
|
||||||
|
'https://assets.example.com'
|
||||||
|
);
|
||||||
|
const requests = mock.mock.calls.map((call) => JSON.parse(call[1].body));
|
||||||
|
expect(requests.map((request) => request.variables.filter.clientRequestHTTPHost)).toEqual([
|
||||||
|
'blog.example.com',
|
||||||
|
'assets.example.com'
|
||||||
|
]);
|
||||||
|
expect(requests.map((request) => request.variables.zone)).toEqual(['blog-zone', 'assets-zone']);
|
||||||
|
expect(requests.every((request) => request.variables.filter.requestSource === 'eyeball')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(result.scopes.map((scope) => scope.rows.length)).toEqual([1, 1]);
|
||||||
|
});
|
||||||
|
it('does not widen scope when an asset domain is missing', async () => {
|
||||||
|
const mock = vi.fn().mockImplementation(() => trafficResponse());
|
||||||
|
vi.stubGlobal('fetch', mock);
|
||||||
|
const result = await fetchAnalytics(platform());
|
||||||
|
expect(mock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.scopes[1].error).toContain('未配置域名');
|
||||||
|
});
|
||||||
|
it('reports permission errors instead of displaying a false zero or zone-wide fallback', async () => {
|
||||||
|
const mock = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => Response.json({ errors: [{ message: 'not authorized' }] }));
|
||||||
|
vi.stubGlobal('fetch', mock);
|
||||||
|
const result = await fetchAnalytics(platform(), 'https://assets.example.com');
|
||||||
|
expect(mock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.scopes.every((scope) => scope.error === 'not authorized')).toBe(true);
|
||||||
|
});
|
||||||
|
it('retries smaller supported time windows and labels the actual period', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockImplementation((_url, options) => {
|
||||||
|
const { filter } = JSON.parse(options.body).variables;
|
||||||
|
const days = (Date.parse(filter.datetime_lt) - Date.parse(filter.datetime_geq)) / 86400000;
|
||||||
|
return days > 1
|
||||||
|
? Response.json({ errors: [{ message: 'query time range is too large' }] })
|
||||||
|
: trafficResponse([row(1, 'miss')]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const result = await fetchAnalytics(platform(), 'https://assets.example.com');
|
||||||
|
expect(result.scopes.map((scope) => [scope.days, scope.error])).toEqual([
|
||||||
|
[1, undefined],
|
||||||
|
[1, undefined]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('does not present truncated results as complete statistics', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() =>
|
||||||
|
trafficResponse(Array.from({ length: 10000 }, () => row(1, 'hit')))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const result = await fetchAnalytics(platform(), 'https://assets.example.com');
|
||||||
|
expect(result.scopes[0].error).toContain('查询上限');
|
||||||
|
expect(result.scopes[0].rows).toEqual([]);
|
||||||
|
});
|
||||||
|
it('keeps domain traffic visible if account-resource queries fail', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((_url, options) =>
|
||||||
|
JSON.parse(options.body).query.includes('accounts(')
|
||||||
|
? Response.json({ errors: [{ message: 'account denied' }] })
|
||||||
|
: trafficResponse([row(1, 'hit')])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const result = await fetchAnalytics(
|
||||||
|
platform({ CF_ACCOUNT_ID: 'account', CF_D1_ID: 'database' }),
|
||||||
|
'https://assets.example.com'
|
||||||
|
);
|
||||||
|
expect(result.errorMsg).toContain('account denied');
|
||||||
|
expect(result.scopes[0].rows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it('computes weighted HIT rates without accumulating across reloads or claiming UV', () => {
|
||||||
|
const rows = [row(2, 'hit'), row(98, 'dynamic', '/admin/settings')];
|
||||||
|
expect(summarizeTraffic(rows).hitRate).toBe(2);
|
||||||
|
expect(summarizeTraffic(rows).requests).toBe(100);
|
||||||
|
expect(summarizeTraffic(rows).privateRequests).toBe(98);
|
||||||
|
expect(summarizeTraffic([]).hitRate).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('public cache invalidation', () => {
|
||||||
|
it('purges only this blog tag, covering old slugs and query variants without clearing attachments', async () => {
|
||||||
|
const mock = vi.fn().mockResolvedValue(Response.json({ success: true }));
|
||||||
|
vi.stubGlobal('fetch', mock);
|
||||||
|
expect(await purgePublicCache(platform())).toBeNull();
|
||||||
|
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({
|
||||||
|
tags: ['blogflare-public:blog.example.com']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('persists a purge failure so saved content does not imply a successful refresh', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
Response.json({ success: false, errors: [{ message: 'rate limited' }] }, { status: 429 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const bind = vi.fn().mockReturnValue({ run: vi.fn().mockResolvedValue({ success: true }) });
|
||||||
|
const error = await purgePublicCache(platform({ DB: { prepare: () => ({ bind }) } }));
|
||||||
|
expect(error).toContain('rate limited');
|
||||||
|
expect(bind).toHaveBeenCalledWith(error);
|
||||||
|
});
|
||||||
|
it('fails closed with missing credentials and does not clear an entire zone', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const mock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', mock);
|
||||||
|
expect(await purgePublicCache(undefined)).toContain('缺少');
|
||||||
|
expect(mock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+174
-130
@@ -1,76 +1,179 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
import type { AnalyticsData, TrafficRow, TrafficScope } from '$lib/analytics';
|
||||||
export async function purgeCache(platform: App.Platform | undefined, paths: string[]) {
|
import { configuredHostname, publicCacheTag } from './cache';
|
||||||
// @ts-expect-error Cloudflare env types
|
|
||||||
const token = platform?.env?.CF_API_TOKEN;
|
|
||||||
// @ts-expect-error Cloudflare env types
|
|
||||||
const zoneId = platform?.env?.CF_ZONE_ID;
|
|
||||||
const domain = platform?.env?.CF_DOMAIN; // 必须是带协议的绝对域名,如 https://blog.example.com
|
|
||||||
|
|
||||||
if (!token || !zoneId) {
|
// Optional dashboard secrets are not generated from wrangler.jsonc.
|
||||||
console.warn('未配置 CF_API_TOKEN 或 CF_ZONE_ID,跳过清除 Cloudflare 缓存');
|
type AnalyticsEnv = Env &
|
||||||
return;
|
Partial<
|
||||||
}
|
Record<
|
||||||
|
| 'CF_API_TOKEN'
|
||||||
|
| 'CF_ZONE_ID'
|
||||||
|
| 'CF_ACCOUNT_ID'
|
||||||
|
| 'CF_D1_ID'
|
||||||
|
| 'CF_R2_BUCKET'
|
||||||
|
| 'CF_ASSETS_ZONE_ID',
|
||||||
|
string
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
if (!domain) {
|
async function graphql<T>(
|
||||||
console.warn('未配置 CF_DOMAIN,无法精准拼接 URL,跳过清除缓存以保护其他项目。');
|
token: string,
|
||||||
return;
|
query: string,
|
||||||
}
|
variables: Record<string, unknown> = {}
|
||||||
|
): Promise<T> {
|
||||||
// 将路径转换为带域名的绝对 URL,Cloudflare 的 purge_cache(files) 要求必须为绝对 URL
|
const response = await fetch('https://api.cloudflare.com/client/v4/graphql', {
|
||||||
const urls = paths.map((p) => {
|
method: 'POST',
|
||||||
// 确保拼接正确,防止出现双斜杠或少斜杠
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
const cleanDomain = domain.replace(/\/$/, '');
|
body: JSON.stringify({ query, variables }),
|
||||||
const cleanPath = p.startsWith('/') ? p : `/${p}`;
|
signal: AbortSignal.timeout(15000)
|
||||||
return `${cleanDomain}${cleanPath}`;
|
|
||||||
});
|
});
|
||||||
|
const result = (await response.json()) as { data?: T; errors?: { message: string }[] };
|
||||||
|
if (!response.ok || result.errors?.length || !result.data) {
|
||||||
|
throw new Error(
|
||||||
|
result.errors?.map((error) => error.message).join('; ') ||
|
||||||
|
`Cloudflare HTTP ${response.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgePublicCache(platform: App.Platform | undefined): Promise<string | null> {
|
||||||
|
const env = platform?.env as AnalyticsEnv | undefined;
|
||||||
|
let error: string | null = null;
|
||||||
try {
|
try {
|
||||||
const body = { files: urls };
|
if (!env?.CF_API_TOKEN || !env.CF_ZONE_ID) throw new Error('缺少 CF_API_TOKEN 或 CF_ZONE_ID');
|
||||||
|
const hostname = configuredHostname(env.CF_DOMAIN);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
|
`https://api.cloudflare.com/client/v4/zones/${encodeURIComponent(env.CF_ZONE_ID)}/purge_cache`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${env.CF_API_TOKEN}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify({ tags: [publicCacheTag(hostname)] }),
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
const result = (await response.json()) as { success?: boolean; errors?: { message: string }[] };
|
||||||
const result = (await response.json()) as any;
|
if (!response.ok || !result.success) {
|
||||||
if (result.success) {
|
throw new Error(
|
||||||
console.log(
|
`Cloudflare 清理失败(HTTP ${response.status}):${result.errors?.map((item) => item.message).join('; ') || '未返回成功确认'}`
|
||||||
'✅ 成功清除 Cloudflare 缓存',
|
|
||||||
urls ? `(URLs: ${urls.join(', ')})` : '(全部缓存)'
|
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
console.error('❌ 清除缓存失败:', result.errors);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (cause) {
|
||||||
console.error('❌ 调用 Cloudflare Purge API 时发生异常:', error);
|
error = cause instanceof Error ? cause.message : '缓存清理失败';
|
||||||
|
console.error('Public cache purge failed:', error);
|
||||||
}
|
}
|
||||||
|
// Persist failures so an asynchronous API failure is visible in the admin UI.
|
||||||
|
try {
|
||||||
|
await env?.DB.prepare(
|
||||||
|
"INSERT INTO settings (key, value, updated_at) VALUES ('cache.purge_error', ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP"
|
||||||
|
)
|
||||||
|
.bind(error || '')
|
||||||
|
.run();
|
||||||
|
} catch (cause) {
|
||||||
|
console.error('Failed to persist cache purge status', cause);
|
||||||
|
}
|
||||||
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAnalytics(platform: App.Platform | undefined) {
|
async function fetchTraffic(
|
||||||
// @ts-expect-error Cloudflare env types
|
token: string,
|
||||||
const token = platform?.env?.CF_API_TOKEN;
|
zoneId: string,
|
||||||
// @ts-expect-error Cloudflare env types
|
label: string,
|
||||||
const zoneId = platform?.env?.CF_ZONE_ID;
|
domain: string | undefined,
|
||||||
// @ts-expect-error Cloudflare env types
|
now: Date
|
||||||
const accountId = platform?.env?.CF_ACCOUNT_ID;
|
): Promise<TrafficScope> {
|
||||||
// @ts-expect-error Cloudflare env types
|
const scope: TrafficScope = { label, hostname: '', days: 30, rows: [] };
|
||||||
const d1Id = platform?.env?.CF_D1_ID;
|
try {
|
||||||
// @ts-expect-error Cloudflare env types
|
scope.hostname = configuredHostname(domain);
|
||||||
const r2Bucket = platform?.env?.CF_R2_BUCKET;
|
// Never fall back to zone-wide traffic: that would include unrelated external images.
|
||||||
|
for (const days of [30, 7, 1]) {
|
||||||
|
scope.days = days;
|
||||||
|
try {
|
||||||
|
const data = await graphql<{
|
||||||
|
viewer: { zones: { httpRequestsAdaptiveGroups: TrafficRow[] }[] };
|
||||||
|
}>(
|
||||||
|
token,
|
||||||
|
`
|
||||||
|
query Traffic($zone: string, $filter: filter) {
|
||||||
|
viewer {
|
||||||
|
zones(filter: { zoneTag: $zone }) {
|
||||||
|
httpRequestsAdaptiveGroups(limit: 10000, filter: $filter) {
|
||||||
|
count
|
||||||
|
sum {
|
||||||
|
edgeResponseBytes
|
||||||
|
}
|
||||||
|
dimensions {
|
||||||
|
date
|
||||||
|
cacheStatus
|
||||||
|
clientRequestPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
zone: zoneId,
|
||||||
|
filter: {
|
||||||
|
datetime_geq: new Date(now.getTime() - days * 86400000).toISOString(),
|
||||||
|
datetime_lt: now.toISOString(),
|
||||||
|
clientRequestHTTPHost: scope.hostname,
|
||||||
|
requestSource: 'eyeball'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const rows = data.viewer.zones[0]?.httpRequestsAdaptiveGroups;
|
||||||
|
if (!Array.isArray(rows))
|
||||||
|
throw new Error('未返回该 Zone 的数据,请检查域名归属和 Token 权限');
|
||||||
|
if (rows.length >= 10000) throw new Error('分组数量达到查询上限,无法显示完整统计');
|
||||||
|
scope.rows = rows;
|
||||||
|
return scope;
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : '流量查询失败';
|
||||||
|
if (
|
||||||
|
days !== 1 &&
|
||||||
|
/time range|time window|date range|older than|too old|retention|cannot request data before|query time|分组数量/i.test(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
scope.error = cause instanceof Error ? cause.message : '流量查询失败';
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAnalytics(
|
||||||
|
platform: App.Platform | undefined,
|
||||||
|
assetsDomain?: string
|
||||||
|
): Promise<AnalyticsData> {
|
||||||
|
const env = platform?.env as AnalyticsEnv | undefined;
|
||||||
|
const token = env?.CF_API_TOKEN;
|
||||||
|
const zoneId = env?.CF_ZONE_ID;
|
||||||
|
const accountId = env?.CF_ACCOUNT_ID;
|
||||||
|
const d1Id = env?.CF_D1_ID;
|
||||||
|
const r2Bucket = env?.CF_R2_BUCKET;
|
||||||
|
const now = new Date();
|
||||||
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().split('T')[0];
|
||||||
|
const result: AnalyticsData = {
|
||||||
|
scopes: [],
|
||||||
|
d1: [],
|
||||||
|
r2: [],
|
||||||
|
missingEnvVars: !accountId || !d1Id || !r2Bucket
|
||||||
|
};
|
||||||
if (!token || !zoneId) {
|
if (!token || !zoneId) {
|
||||||
return null;
|
result.errorMsg = '统计不可用:请配置 CF_API_TOKEN 和 CF_ZONE_ID';
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
const trafficPromise = Promise.all([
|
||||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
fetchTraffic(token, zoneId, '博客', env?.CF_DOMAIN, now),
|
||||||
|
fetchTraffic(token, env?.CF_ASSETS_ZONE_ID || zoneId, 'R2 附件', assetsDomain, now)
|
||||||
|
]);
|
||||||
let accountQuery = '';
|
let accountQuery = '';
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
let d1Query = '';
|
let d1Query = '';
|
||||||
@@ -78,7 +181,7 @@ export async function fetchAnalytics(platform: App.Platform | undefined) {
|
|||||||
d1Query = `
|
d1Query = `
|
||||||
d1AnalyticsAdaptiveGroups(
|
d1AnalyticsAdaptiveGroups(
|
||||||
limit: 10000,
|
limit: 10000,
|
||||||
filter: { date_geq: "${thirtyDaysAgo}", databaseId: "${d1Id}" }
|
filter: { date_geq: "${thirtyDaysAgo}", databaseId: ${JSON.stringify(d1Id)} }
|
||||||
) {
|
) {
|
||||||
sum {
|
sum {
|
||||||
readQueries
|
readQueries
|
||||||
@@ -95,7 +198,7 @@ export async function fetchAnalytics(platform: App.Platform | undefined) {
|
|||||||
r2Query = `
|
r2Query = `
|
||||||
r2StorageAdaptiveGroups(
|
r2StorageAdaptiveGroups(
|
||||||
limit: 10000,
|
limit: 10000,
|
||||||
filter: { datetime_geq: "${thirtyDaysAgo}T00:00:00Z", bucketName: "${r2Bucket}" }
|
filter: { datetime_geq: "${thirtyDaysAgo}T00:00:00Z", bucketName: ${JSON.stringify(r2Bucket)} }
|
||||||
) {
|
) {
|
||||||
max {
|
max {
|
||||||
payloadSize
|
payloadSize
|
||||||
@@ -106,90 +209,31 @@ export async function fetchAnalytics(platform: App.Platform | undefined) {
|
|||||||
|
|
||||||
if (d1Query || r2Query) {
|
if (d1Query || r2Query) {
|
||||||
accountQuery = `
|
accountQuery = `
|
||||||
accounts(filter: { accountTag: "${accountId}" }) {
|
accounts(filter: { accountTag: ${JSON.stringify(accountId)} }) {
|
||||||
${d1Query}
|
${d1Query}
|
||||||
${r2Query}
|
${r2Query}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trafficQuery = `
|
|
||||||
query {
|
|
||||||
viewer {
|
|
||||||
zones(filter: { zoneTag: "${zoneId}" }) {
|
|
||||||
httpRequests1dGroups(
|
|
||||||
limit: 30,
|
|
||||||
filter: { date_gt: "${thirtyDaysAgo}" }
|
|
||||||
orderBy: [date_ASC]
|
|
||||||
) {
|
|
||||||
dimensions {
|
|
||||||
date
|
|
||||||
}
|
|
||||||
sum {
|
|
||||||
cachedBytes
|
|
||||||
bytes
|
|
||||||
cachedRequests
|
|
||||||
requests
|
|
||||||
pageViews
|
|
||||||
}
|
|
||||||
uniq {
|
|
||||||
uniques
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trafficRes = await fetch('https://api.cloudflare.com/client/v4/graphql', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ query: trafficQuery })
|
|
||||||
});
|
|
||||||
|
|
||||||
const trafficResult = (await trafficRes.json()) as any;
|
|
||||||
const trafficData = trafficResult?.data?.viewer?.zones?.[0]?.httpRequests1dGroups || [];
|
|
||||||
|
|
||||||
let d1Data: any[] = [];
|
|
||||||
let r2Data: any[] = [];
|
|
||||||
let accErrorMsg = '';
|
|
||||||
|
|
||||||
if (accountQuery) {
|
if (accountQuery) {
|
||||||
const accQueryStr = `query { viewer { ${accountQuery} } }`;
|
const data = await graphql<{
|
||||||
const accRes = await fetch('https://api.cloudflare.com/client/v4/graphql', {
|
viewer: {
|
||||||
method: 'POST',
|
accounts: {
|
||||||
headers: {
|
d1AnalyticsAdaptiveGroups?: AnalyticsData['d1'];
|
||||||
'Content-Type': 'application/json',
|
r2StorageAdaptiveGroups?: AnalyticsData['r2'];
|
||||||
Authorization: `Bearer ${token}`
|
}[];
|
||||||
},
|
|
||||||
body: JSON.stringify({ query: accQueryStr })
|
|
||||||
});
|
|
||||||
const accResult = (await accRes.json()) as any;
|
|
||||||
if (accResult.errors) {
|
|
||||||
console.error('Account GraphQL Errors:', accResult.errors);
|
|
||||||
accErrorMsg = '账户数据查询失败: ' + (accResult.errors[0]?.message || 'Unknown');
|
|
||||||
}
|
|
||||||
const accountData = accResult?.data?.viewer?.accounts?.[0];
|
|
||||||
if (accountData) {
|
|
||||||
d1Data = accountData.d1AnalyticsAdaptiveGroups || [];
|
|
||||||
r2Data = accountData.r2StorageAdaptiveGroups || [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
traffic: trafficData,
|
|
||||||
d1: d1Data,
|
|
||||||
r2: r2Data,
|
|
||||||
missingEnvVars: !accountId || !d1Id || !r2Bucket,
|
|
||||||
errorMsg: accErrorMsg ? accErrorMsg : undefined
|
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
}>(token, `query { viewer { ${accountQuery} } }`);
|
||||||
console.error('Failed to fetch analytics', err);
|
const account = data.viewer.accounts[0];
|
||||||
return { errorMsg: '获取统计数据失败,请检查 Token 权限或网络环境' };
|
if (!account) throw new Error('未返回账户资源数据');
|
||||||
|
result.d1 = account.d1AnalyticsAdaptiveGroups || [];
|
||||||
|
result.r2 = account.r2StorageAdaptiveGroups || [];
|
||||||
}
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
result.errorMsg = `账户资源统计不可用:${cause instanceof Error ? cause.message : '查询失败'}`;
|
||||||
|
}
|
||||||
|
result.scopes = await trafficPromise;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { purgeCache } from '$lib/server/cloudflare';
|
|
||||||
|
|
||||||
interface PostListRow {
|
interface PostListRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -83,27 +82,12 @@ export const actions = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const placeholders = ids.map(() => '?').join(',');
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
const { results: postsToDelete } = await db
|
|
||||||
.prepare(`SELECT slug FROM posts WHERE id IN (${placeholders})`)
|
|
||||||
.bind(...ids)
|
|
||||||
.all<{ slug: string }>();
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.prepare(`DELETE FROM posts WHERE id IN (${placeholders})`)
|
.prepare(`DELETE FROM posts WHERE id IN (${placeholders})`)
|
||||||
.bind(...ids)
|
.bind(...ids)
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
try {
|
|
||||||
const pathsToPurge = ['/', '/posts', ...postsToDelete.map((p) => `/posts/${p.slug}`)];
|
|
||||||
if (platform?.context?.waitUntil) {
|
|
||||||
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
|
|
||||||
} else {
|
|
||||||
await purgeCache(platform, pathsToPurge);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to trigger cache purge:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error deleting post:', err);
|
console.error('Error deleting post:', err);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
resolveSeriesWeight,
|
resolveSeriesWeight,
|
||||||
SeriesValidationError
|
SeriesValidationError
|
||||||
} from '$lib/server/series';
|
} from '$lib/server/series';
|
||||||
import { purgeCache } from '$lib/server/cloudflare';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, platform, parent }) => {
|
export const load: PageServerLoad = async ({ params, platform, parent }) => {
|
||||||
const db = platform?.env?.DB;
|
const db = platform?.env?.DB;
|
||||||
@@ -177,17 +176,6 @@ export const actions: Actions = {
|
|||||||
return fail(500, { error: '保存修改失败' });
|
return fail(500, { error: '保存修改失败' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
|
|
||||||
if (platform?.context?.waitUntil) {
|
|
||||||
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
|
|
||||||
} else {
|
|
||||||
await purgeCache(platform, pathsToPurge);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to trigger cache purge:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect(303, '/admin/posts');
|
redirect(303, '/admin/posts');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
resolveSeriesWeight,
|
resolveSeriesWeight,
|
||||||
SeriesValidationError
|
SeriesValidationError
|
||||||
} from '$lib/server/series';
|
} from '$lib/server/series';
|
||||||
import { purgeCache } from '$lib/server/cloudflare';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ platform }) => {
|
export const load: PageServerLoad = async ({ platform }) => {
|
||||||
const db = platform?.env?.DB;
|
const db = platform?.env?.DB;
|
||||||
@@ -117,17 +116,6 @@ export const actions: Actions = {
|
|||||||
return fail(500, { error: '保存失败' });
|
return fail(500, { error: '保存失败' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
|
|
||||||
if (platform?.context?.waitUntil) {
|
|
||||||
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
|
|
||||||
} else {
|
|
||||||
await purgeCache(platform, pathsToPurge);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to trigger cache purge:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect(303, '/admin/posts');
|
redirect(303, '/admin/posts');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -25,7 +25,7 @@ export const load: PageServerLoad = async ({ locals, platform }) => {
|
|||||||
platformEnv: db ? 'Cloudflare D1 (Connected)' : 'Local Mock',
|
platformEnv: db ? 'Cloudflare D1 (Connected)' : 'Local Mock',
|
||||||
r2Env: r2 ? 'Cloudflare R2 (Connected)' : 'Local Mock',
|
r2Env: r2 ? 'Cloudflare R2 (Connected)' : 'Local Mock',
|
||||||
settings,
|
settings,
|
||||||
analytics: fetchAnalytics(platform) // Streaming promise
|
analytics: fetchAnalytics(platform, settings['r2.public_domain']) // Streaming promise
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,13 @@
|
|||||||
bind:activeTab
|
bind:activeTab
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if data.settings['cache.purge_error']}
|
||||||
|
<p role="alert" class="cache-warning">
|
||||||
|
内容已保存,但最近一次缓存刷新失败:{data.settings['cache.purge_error']}。列表页最多可能延迟 5
|
||||||
|
分钟,详情页最多 1 小时更新。请检查缓存清理权限后重试保存。
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
{#if activeTab === 'basic'}
|
{#if activeTab === 'basic'}
|
||||||
<div class="basic-top-row">
|
<div class="basic-top-row">
|
||||||
@@ -357,6 +364,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.cache-warning {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #735400;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
.admin-page-header {
|
.admin-page-header {
|
||||||
margin-bottom: 25px;
|
margin-bottom: 25px;
|
||||||
border-bottom: 2px solid var(--text-color);
|
border-bottom: 2px solid var(--text-color);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { purgeCache } from '$lib/server/cloudflare';
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, platform }) => {
|
export const GET: RequestHandler = async ({ params, platform }) => {
|
||||||
const db = platform?.env.DB;
|
const db = platform?.env.DB;
|
||||||
@@ -125,16 +124,6 @@ export const PUT: RequestHandler = async ({ params, request, platform }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
|
|
||||||
if (platform?.context?.waitUntil) {
|
|
||||||
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
|
|
||||||
} else {
|
|
||||||
await purgeCache(platform, pathsToPurge);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to trigger cache purge:', err);
|
|
||||||
}
|
|
||||||
return json({ success: true, slug });
|
return json({ success: true, slug });
|
||||||
} else {
|
} else {
|
||||||
return json({ error: 'Failed to update post' }, { status: 500 });
|
return json({ error: 'Failed to update post' }, { status: 500 });
|
||||||
@@ -164,16 +153,6 @@ export const DELETE: RequestHandler = async ({ params, platform }) => {
|
|||||||
const result = await db.prepare('DELETE FROM posts WHERE slug = ?').bind(slug).run();
|
const result = await db.prepare('DELETE FROM posts WHERE slug = ?').bind(slug).run();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
try {
|
|
||||||
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
|
|
||||||
if (platform?.context?.waitUntil) {
|
|
||||||
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
|
|
||||||
} else {
|
|
||||||
await purgeCache(platform, pathsToPurge);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to trigger cache purge:', err);
|
|
||||||
}
|
|
||||||
return json({ success: true });
|
return json({ success: true });
|
||||||
} else {
|
} else {
|
||||||
return json({ error: 'Failed to delete post' }, { status: 500 });
|
return json({ error: 'Failed to delete post' }, { status: 500 });
|
||||||
|
|||||||
Reference in new issue
Block a user