From fa386df918ac515beae2c18f7ab3cf45b28fedc0 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 22:19:33 -0400 Subject: [PATCH 01/13] First functional setup --- .../ScopeDropdown/ScopeDropdown.tsx | 1 + client/containers/App/paths.ts | 5 + .../DashboardImpact2/DashboardImpact2.tsx | 438 +++++++++++ .../DashboardImpact2/dashboardImpact2.scss | 151 ++++ client/containers/index.ts | 1 + server/analyticsDailyCache/model.ts | 43 ++ server/apiRoutes.ts | 2 + server/impact2/api.ts | 137 ++++ server/models.ts | 3 + server/routes/dashboardImpact2.tsx | 45 ++ server/routes/index.ts | 2 + server/utils/cloudflareAnalytics.ts | 721 ++++++++++++++++++ utils/dashboard.ts | 1 + 13 files changed, 1550 insertions(+) create mode 100644 client/containers/DashboardImpact2/DashboardImpact2.tsx create mode 100644 client/containers/DashboardImpact2/dashboardImpact2.scss create mode 100644 server/analyticsDailyCache/model.ts create mode 100644 server/impact2/api.ts create mode 100644 server/routes/dashboardImpact2.tsx create mode 100644 server/utils/cloudflareAnalytics.ts diff --git a/client/components/ScopeDropdown/ScopeDropdown.tsx b/client/components/ScopeDropdown/ScopeDropdown.tsx index 993c2dd0a1..0236356475 100644 --- a/client/components/ScopeDropdown/ScopeDropdown.tsx +++ b/client/components/ScopeDropdown/ScopeDropdown.tsx @@ -201,6 +201,7 @@ const ScopeDropdown = (props: Props) => { pubPubIcons.member, )} {renderDropddownButton(scope, 'impact', pubPubIcons.impact)} + {renderDropddownButton(scope, 'impact2', pubPubIcons.impact)} {scope.type === 'Collection' && renderDropddownButton( scope, diff --git a/client/containers/App/paths.ts b/client/containers/App/paths.ts index d943db19d8..e6309aa89c 100644 --- a/client/containers/App/paths.ts +++ b/client/containers/App/paths.ts @@ -13,6 +13,7 @@ import { DashboardEdges, DashboardFacets, DashboardImpact, + DashboardImpact2, DashboardMembers, DashboardPage, DashboardPages, @@ -84,6 +85,10 @@ export default (viewData, locationData, chunkName) => { ActiveComponent: DashboardImpact, isDashboard: true, }, + DashboardImpact2: { + ActiveComponent: DashboardImpact2, + isDashboard: true, + }, DashboardMembers: { ActiveComponent: DashboardMembers, isDashboard: true, diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx new file mode 100644 index 0000000000..56c22f09cd --- /dev/null +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -0,0 +1,438 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; + +import { Button, ButtonGroup, Callout, NonIdealState, Spinner, Tag } from '@blueprintjs/core'; +import { + Area, + AreaChart, + CartesianGrid, + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { DashboardFrame } from 'components'; +import { usePageContext } from 'utils/hooks'; + +import './dashboardImpact2.scss'; + +// Country code → full name (browser Intl API) +const countryDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' }); +function countryName(code: string): string { + if (!code || code.length !== 2) return code || 'Unknown'; + try { + return countryDisplayNames.of(code.toUpperCase()) ?? code; + } catch { + return code; + } +} + +type DailyAnalytics = { + date: string; + visits: number; + pageViews: number; +}; + +type TopPath = { path: string; count: number }; +type CountryBreakdown = { country: string; count: number }; +type DeviceBreakdown = { device: string; count: number }; +type ReferrerBreakdown = { referrer: string; count: number }; + +type AnalyticsData = { + daily: DailyAnalytics[]; + topPaths: TopPath[]; + countries: CountryBreakdown[]; + devices: DeviceBreakdown[]; + referrers: ReferrerBreakdown[]; + totals: { + visits: number; + pageViews: number; + }; + rawTotals: { + visits: number; + pageViews: number; + }; + stale?: boolean; +}; + +type DateRange = '1d' | '7d' | '30d'; + +const 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(); +}; + +const formatDateLabel = (dateStr: string): string => { + const d = new Date(dateStr + 'T00:00:00'); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +}; + +const getDateRange = (range: DateRange): { startDate: string; endDate: string } => { + const end = new Date(); + const start = new Date(); + switch (range) { + case '1d': + start.setDate(end.getDate() - 1); + break; + case '7d': + start.setDate(end.getDate() - 7); + break; + case '30d': + start.setDate(end.getDate() - 30); + break; + default: + start.setDate(end.getDate() - 7); + break; + } + return { + startDate: start.toISOString().slice(0, 10), + endDate: end.toISOString().slice(0, 10), + }; +}; + +const StatCard = ({ + label, + value, + subtext, +}: { + label: string; + value: string; + subtext?: string; +}) => ( +
+
{value}
+
{label}
+ {subtext &&
{subtext}
} +
+); + +const CHART_COLORS = { + visits: '#2B95D6', + pageViews: '#15B371', +}; + +const PIE_COLORS = [ + '#2B95D6', + '#15B371', + '#D9822B', + '#8F398F', + '#F5498B', + '#29A634', + '#D99E0B', + '#669EFF', +]; + +const SimpleTable = ({ + data, + columns, +}: { + data: Array>; + columns: Array<{ key: string; label: string; format?: (v: any) => string }>; +}) => ( + + + + {columns.map((col) => ( + + ))} + + + + {data.map((row) => ( + row[c.key]).join('-')}> + {columns.map((col) => ( + + ))} + + ))} + +
{col.label}
+ {col.format ? col.format(row[col.key]) : row[col.key]} +
+); + +const DashboardImpact2 = () => { + const { scopeData } = usePageContext(); + const { + elements: { activeTargetName }, + activePermissions: { canView }, + } = scopeData; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [stale, setStale] = useState(false); + const [dateRange, setDateRange] = useState('7d'); + + const fetchData = useCallback(async (range: DateRange) => { + setLoading(true); + setError(null); + setStale(false); + try { + const { startDate, endDate } = getDateRange(range); + const res = await fetch(`/api/impact2?startDate=${startDate}&endDate=${endDate}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed (${res.status})`); + } + const json: AnalyticsData = await res.json(); + setData(json); + setStale(!!json.stale); + } catch (err: any) { + setError(err.message ?? 'Failed to load analytics'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (canView) { + fetchData(dateRange); + } + }, [dateRange, canView, fetchData]); + + const chartData = useMemo(() => { + if (!data) return []; + return data.daily.map((d) => ({ + ...d, + label: formatDateLabel(d.date), + })); + }, [data]); + + const handleRangeChange = (range: DateRange) => { + setDateRange(range); + }; + + if (!canView) { + return ( + +

Login or ask the community administrator for access to impact data.

+
+ ); + } + + return ( + + + + + + } + > + {loading && ( +
+ +
+ )} + + {error && ( + fetchData(dateRange)} icon="refresh"> + Retry + + } + /> + )} + + {!loading && !error && data && ( + <> + {/* Stale data warning */} + {stale && ( + + Data may be slightly delayed due to temporary limits on live updates. + Please try again in a few minutes. + + )} + + {/* Summary stats */} +
+ + +
+

+ Estimated after adjusting for suspected bot and spam traffic.* +

+ + {/* Visits over time */} + {chartData.length > 1 && ( +
+

Unique Sessions & Pages Viewed Over Time

+ + + + + + + + + + +
+ )} + + {/* Top pages */} + {data.topPaths.length > 0 && ( +
+

Top Pages

+ +
+ )} + + {/* Countries + Devices side by side */} +
+ {data.countries.length > 0 && ( +
+

Top Countries

+ ({ + ...c, + country: countryName(c.country), + }))} + columns={[ + { key: 'country', label: 'Country' }, + { key: 'count', label: 'Views', format: formatNumber }, + ]} + /> +
+ )} + {data.devices.length > 0 && ( +
+

Devices

+ + + + `${device} ${(percent * 100).toFixed(0)}%` + } + > + {data.devices.map((entry, i) => ( + + ))} + + formatNumber(v)} /> + + + +
+ )} +
+ + {/* Referrers */} + {data.referrers.length > 0 && ( +
+

Top Referrers

+ +
+ )} + +
+

Analytics sourced from Cloudflare edge traffic data.

+

+ * Totals are adjusted to exclude traffic from known bot and spam routes + (e.g. /wp-login, /cdn-cgi/,{' '} + /robots.txt). Unique sessions are estimated proportionally + since session counts can't be attributed to individual paths. + Pre-adjustment totals: {formatNumber(data.rawTotals.visits)} sessions /{' '} + {formatNumber(data.rawTotals.pageViews)} page views. +

+
+ + )} +
+ ); +}; + +export default DashboardImpact2; diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss new file mode 100644 index 0000000000..3f667b75fe --- /dev/null +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -0,0 +1,151 @@ +.dashboard-impact2-container { + .loading-container { + display: flex; + justify-content: center; + padding: 60px 0; + } + + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; + margin: 24px 0 32px; + } + + .stat-card { + background: rgba(0, 0, 0, 0.03); + border-radius: 8px; + padding: 20px; + text-align: center; + + .stat-value { + font-size: 28px; + font-weight: 600; + line-height: 1.2; + color: #1c2127; + } + + .stat-label { + font-size: 13px; + color: #5c7080; + margin-top: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .stat-subtext { + font-size: 11px; + color: #8a9ba8; + margin-top: 2px; + } + } + + .chart-section { + margin: 32px 0; + + h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 8px; + } + + .source-tag { + font-size: 10px; + font-weight: 500; + } + } + + .analytics-footer { + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid #e1e8ed; + + p { + font-size: 13px; + color: #8a9ba8; + line-height: 1.5; + } + } + + .two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + } + + .simple-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + padding: 8px 12px; + border-bottom: 2px solid #e1e8ed; + font-weight: 600; + color: #5c7080; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + td { + padding: 6px 12px; + border-bottom: 1px solid #e1e8ed; + color: #1c2127; + } + + tr:last-child td { + border-bottom: none; + } + + tr:hover td { + background: rgba(0, 0, 0, 0.02); + } + } +} + +/* Dark mode support */ +.bp5-dark, +.bp3-dark { + .dashboard-impact2-container { + .stat-card { + background: rgba(255, 255, 255, 0.06); + + .stat-value { + color: #f5f8fa; + } + + .stat-label { + color: #a7b6c2; + } + } + + .analytics-footer { + border-top-color: #30404d; + } + + .simple-table { + th { + border-bottom-color: #30404d; + color: #a7b6c2; + } + + td { + border-bottom-color: #30404d; + color: #f5f8fa; + } + + tr:hover td { + background: rgba(255, 255, 255, 0.04); + } + } + } +} diff --git a/client/containers/index.ts b/client/containers/index.ts index 83442f6d9c..c52e22c247 100644 --- a/client/containers/index.ts +++ b/client/containers/index.ts @@ -10,6 +10,7 @@ export { default as DashboardDiscussions } from './DashboardDiscussions/Dashboar export { default as DashboardEdges } from './DashboardEdges/DashboardEdges'; export { default as DashboardFacets } from './DashboardFacets/DashboardFacets'; export { default as DashboardImpact } from './DashboardImpact/DashboardImpact'; +export { default as DashboardImpact2 } from './DashboardImpact2/DashboardImpact2'; export { default as DashboardMembers } from './DashboardMembers/DashboardMembers'; export { DashboardCollectionOverview, diff --git a/server/analyticsDailyCache/model.ts b/server/analyticsDailyCache/model.ts new file mode 100644 index 0000000000..390cdfa033 --- /dev/null +++ b/server/analyticsDailyCache/model.ts @@ -0,0 +1,43 @@ +import type { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'; + +import { AllowNull, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'; + +/** + * Caches per-day Cloudflare analytics for a community hostname. + * + * Composite primary key: (hostname, date). + * Past days are cached permanently (expiresAt = null). + * Today's partial data is cached with a short TTL (expiresAt = now + 3h). + */ +@Table({ tableName: 'AnalyticsDailyCaches', timestamps: false }) +export class AnalyticsDailyCache extends Model< + InferAttributes, + InferCreationAttributes +> { + /** Community hostname, e.g. "demo.pubpub.org" or "journal.example.com" */ + @PrimaryKey + @AllowNull(false) + @Column(DataType.TEXT) + declare hostname: string; + + /** Calendar date (ISO format, e.g. "2026-04-01") */ + @PrimaryKey + @AllowNull(false) + @Column(DataType.DATEONLY) + declare date: string; + + /** + * Pre-aggregated analytics payload for this day. + * Shape: { visits, pageViews, topPaths[], countries[], devices[], referrers[] } + */ + @AllowNull(false) + @Column(DataType.JSONB) + declare data: object; + + /** + * When this cache entry expires. NULL = permanent (completed past days). + * For today's partial data, set to ~3 hours from write time. + */ + @Column(DataType.DATE) + declare expiresAt: CreationOptional; +} diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index 772b4cec09..82bbb16c60 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -4,6 +4,7 @@ import { isProd } from 'utils/environment'; import { activityItemRouter } from './activityItem/api'; import { router as apiDocsRouter } from './apiDocs/api'; +import { router as impact2Router } from './impact2/api'; import { router as captchaRouter } from './captcha/api'; import { router as citationRouter } from './citation/api'; import { router as communityBanRouter } from './communityBan/api'; @@ -72,6 +73,7 @@ const apiRouter = Router() .use(userNotificationPreferencesRouter) .use(userSubscriptionRouter) .use(zoteroIntegrationRouter) + .use(impact2Router) .use(apiDocsRouter); if (!isProd() && process.env.NODE_ENV !== 'test') { diff --git a/server/impact2/api.ts b/server/impact2/api.ts new file mode 100644 index 0000000000..dddc34fa92 --- /dev/null +++ b/server/impact2/api.ts @@ -0,0 +1,137 @@ +import { Router } from 'express'; + +import { + fetchCommunityAnalytics, + testCloudflareConnection, + debugCommunityAnalytics, +} from 'server/utils/cloudflareAnalytics'; +import { Community } from 'server/community/model'; +import { handleErrors, ForbiddenError } from 'server/utils/errors'; +import { getInitialData } from 'server/utils/initData'; +import { hostIsValid } from 'server/utils/routes'; + +export const router = Router(); + +/** + * Resolve the hostname Cloudflare actually sees for a community. + * + * We query the Community model directly because getInitialData overwrites + * communityData.domain with the localhost proxy header in dev mode. + * + * Priority: + * 1. Raw `domain` column from the DB (if it's a real domain, not localhost). + * 2. Fallback to {subdomain}.pubpub.org. + */ +async function resolveCloudflareHostname(communityId: string): Promise { + const row = await Community.findByPk(communityId, { + attributes: ['subdomain', 'domain'], + }); + if (!row) { + throw new Error(`Community not found: ${communityId}`); + } + const { domain, subdomain } = row; + if (domain && !domain.includes('localhost') && !domain.includes('127.0.0.1')) { + // Strip port if present (shouldn't be in prod, but just in case) + return domain.replace(/:\d+$/, ''); + } + return `${subdomain}.pubpub.org`; +} + +/** + * GET /api/impact2/test + * + * Quick diagnostic to verify Cloudflare env vars are set and working. + * Returns JSON with { ok, error?, zoneTag?, tokenPrefix? }. + */ +router.get('/api/impact2/test', async (_req, res) => { + const result = await testCloudflareConnection(); + const status = result.ok ? 200 : 503; + return res.status(status).json(result); +}); + +/** + * GET /api/impact2/debug + * + * Shows the exact hostname being used, the filter sent to Cloudflare, + * raw CF responses, and which hostnames actually have data in the zone. + * Accepts optional ?hostname=override&startDate=...&endDate=... + */ +router.get('/api/impact2/debug', async (req, res, next) => { + try { + if (!hostIsValid(req, 'community')) { + return next(); + } + const initialData = await getInitialData(req, { isDashboard: true }); + const { canView } = initialData.scopeData.activePermissions; + if (!canView) { + throw new ForbiddenError(); + } + + const communityData = initialData.communityData; + const defaultHostname = await resolveCloudflareHostname(communityData.id); + const hostname = (req.query.hostname as string) || defaultHostname; + + const now = new Date(); + const defaultStart = new Date(now); + defaultStart.setDate(defaultStart.getDate() - 7); + + const startDate = + (req.query.startDate as string) || defaultStart.toISOString().slice(0, 10); + const endDate = (req.query.endDate as string) || now.toISOString().slice(0, 10); + + const result = await debugCommunityAnalytics(hostname, startDate, endDate); + return res.json({ + communityFromDb: { + subdomain: communityData.subdomain, + domainRaw: communityData.domain, + resolvedHostname: defaultHostname, + }, + overrideHostname: req.query.hostname || null, + ...result, + }); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + +/** + * GET /api/impact2 + * + * Returns Cloudflare-sourced analytics for the current community. + * Query params: + * startDate – ISO date (e.g. "2026-03-01"). Defaults to 30 days ago. + * endDate – ISO date (e.g. "2026-03-31"). Defaults to today. + */ +router.get('/api/impact2', async (req, res, next) => { + try { + if (!hostIsValid(req, 'community')) { + return next(); + } + const initialData = await getInitialData(req, { isDashboard: true }); + const { canView } = initialData.scopeData.activePermissions; + if (!canView) { + throw new ForbiddenError(); + } + + const communityData = initialData.communityData; + const hostname = await resolveCloudflareHostname(communityData.id); + + const now = new Date(); + const defaultStart = new Date(now); + defaultStart.setDate(defaultStart.getDate() - 30); + + const startDate = + (req.query.startDate as string) || defaultStart.toISOString().slice(0, 10); + const endDate = (req.query.endDate as string) || now.toISOString().slice(0, 10); + + const result = await fetchCommunityAnalytics(hostname, startDate, endDate); + if (!result) { + return res.status(503).json({ + error: 'Cloudflare analytics not configured. Set CLOUDFLARE_ANALYTICS_API_TOKEN and CLOUDFLARE_ZONE_TAG environment variables.', + }); + } + return res.json(result); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); diff --git a/server/models.ts b/server/models.ts index 925e1b1b75..f1cdafdee2 100644 --- a/server/models.ts +++ b/server/models.ts @@ -3,6 +3,7 @@ import passportLocalSequelize from 'passport-local-sequelize'; /* Import and create all models. */ /* Also import them to make them available to other modules */ import { ActivityItem } from './activityItem/model'; +import { AnalyticsDailyCache } from './analyticsDailyCache/model'; import { AuthToken } from './authToken/model'; import { Collection } from './collection/model'; import { CollectionAttribution } from './collectionAttribution/model'; @@ -64,6 +65,7 @@ import { ZoteroIntegration } from './zoteroIntegration/model'; sequelize.addModels([ ActivityItem, + AnalyticsDailyCache, AuthToken, Collection, CollectionAttribution, @@ -159,6 +161,7 @@ export const includeUserModel = (() => { export { ActivityItem, + AnalyticsDailyCache, AuthToken, Collection, CollectionAttribution, diff --git a/server/routes/dashboardImpact2.tsx b/server/routes/dashboardImpact2.tsx new file mode 100644 index 0000000000..bce68a0a36 --- /dev/null +++ b/server/routes/dashboardImpact2.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Router } from 'express'; + +import Html from 'server/Html'; +import { handleErrors, NotFoundError } from 'server/utils/errors'; +import { getInitialData } from 'server/utils/initData'; +import { hostIsValid } from 'server/utils/routes'; +import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; + +export const router = Router(); + +router.get( + [ + '/dash/impact2', + '/dash/collection/:collectionSlug/impact2', + '/dash/pub/:pubSlug/impact2', + ], + async (req, res, next) => { + try { + if (!hostIsValid(req, 'community')) { + return next(); + } + const initialData = await getInitialData(req, { isDashboard: true }); + if (!initialData.scopeData.elements.activeTarget) { + throw new NotFoundError(); + } + return renderToNodeStream( + res, + , + ); + } catch (err) { + return handleErrors(req, res, next)(err); + } + }, +); diff --git a/server/routes/index.ts b/server/routes/index.ts index b5b2f9afd5..3357c47d45 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -12,6 +12,7 @@ import { router as dashboardDiscussionsRouter } from './dashboardDiscussions'; import { router as dashboardEdgesRouter } from './dashboardEdges'; import { router as dashboardFacetsRouter } from './dashboardFacets'; import { router as dashboardImpactRouter } from './dashboardImpact'; +import { router as dashboardImpact2Router } from './dashboardImpact2'; import { router as dashboardMembersRouter } from './dashboardMembers'; import { router as dashboardPageRouter } from './dashboardPage'; import { router as dashboardPagesRouter } from './dashboardPages'; @@ -64,6 +65,7 @@ rootRouter .use(dashboardEdgesRouter) .use(dashboardFacetsRouter) .use(dashboardImpactRouter) + .use(dashboardImpact2Router) .use(dashboardMembersRouter) .use(dashboardCommunityOverviewRouter) .use(dashboardCollectionOverviewRouter) diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts new file mode 100644 index 0000000000..f58e33234c --- /dev/null +++ b/server/utils/cloudflareAnalytics.ts @@ -0,0 +1,721 @@ +/** + * Cloudflare GraphQL Analytics API client. + * + * Uses the httpRequestsAdaptiveGroups dataset, filtered by clientRequestHTTPHost, + * to get per-community (per-domain) analytics sourced from Cloudflare's edge. + * + * Required env vars: + * CLOUDFLARE_ANALYTICS_API_TOKEN – a Cloudflare API token with Analytics:Read + * CLOUDFLARE_ZONE_TAG – the zone ID that fronts PubPub traffic + */ + +const CF_GRAPHQL_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql'; + +function getConfig() { + const apiToken = process.env.CLOUDFLARE_ANALYTICS_API_TOKEN; + const zoneTag = process.env.CLOUDFLARE_ZONE_TAG; + if (!apiToken || !zoneTag) { + return null; + } + return { apiToken, zoneTag }; +} + +export { getConfig as getCloudflareConfig }; + +/** + * Run a minimal introspection query to verify the API token + zone tag work. + * Returns { ok: true } or { ok: false, error: string }. + */ +export async function testCloudflareConnection(): Promise<{ + ok: boolean; + error?: string; + zoneTag?: string; + tokenPrefix?: string; +}> { + const config = getConfig(); + if (!config) { + return { + ok: false, + error: 'Missing env vars. Set CLOUDFLARE_ANALYTICS_API_TOKEN and CLOUDFLARE_ZONE_TAG.', + }; + } + const { apiToken, zoneTag } = config; + try { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const dateStr = yesterday.toISOString().slice(0, 10); + const result = await cfGraphQL( + `query Test($zoneTag: string, $date: Date!) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + httpRequests1dGroups(limit: 1, filter: { date_gt: $date }) { + dimensions { date } + } + } + } + }`, + { zoneTag, date: dateStr }, + apiToken, + ); + const zones = result?.data?.viewer?.zones; + if (!zones || zones.length === 0) { + return { + ok: false, + error: `No zone found for zoneTag "${zoneTag}". Check CLOUDFLARE_ZONE_TAG.`, + zoneTag, + tokenPrefix: apiToken.slice(0, 6) + '…', + }; + } + return { ok: true, zoneTag, tokenPrefix: apiToken.slice(0, 6) + '…' }; + } catch (err: any) { + return { + ok: false, + error: err.message ?? String(err), + zoneTag, + tokenPrefix: apiToken.slice(0, 6) + '…', + }; + } +} + +async function cfGraphQL(query: string, variables: Record, apiToken: string) { + const res = await fetch(CF_GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }); + const body = await res.json(); + if (!res.ok) { + const detail = JSON.stringify(body?.errors ?? body); + throw new Error( + `Cloudflare GraphQL request failed: ${res.status} ${res.statusText} – ${detail}`, + ); + } + if (body.errors?.length) { + throw new Error(`Cloudflare GraphQL errors: ${JSON.stringify(body.errors)}`); + } + return body; +} + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export type DailyAnalytics = { + date: string; + visits: number; + pageViews: number; +}; + +export type TopPath = { + path: string; + count: number; +}; + +export type CountryBreakdown = { + country: string; + count: number; +}; + +export type DeviceBreakdown = { + device: string; + count: number; +}; + +export type ReferrerBreakdown = { + referrer: string; + count: number; +}; + +export type CloudflareAnalyticsResult = { + daily: DailyAnalytics[]; + topPaths: TopPath[]; + countries: CountryBreakdown[]; + devices: DeviceBreakdown[]; + referrers: ReferrerBreakdown[]; + totals: { + visits: number; + pageViews: number; + }; + /** Pre-adjustment totals (before noise/bot filtering). */ + rawTotals: { + visits: number; + pageViews: number; + }; + /** True when CF returned an error (e.g. rate limit) and we fell back to cache. */ + stale?: boolean; +}; + +// --------------------------------------------------------------------------- +// Noise path filter — strip bot probes / infrastructure routes +// --------------------------------------------------------------------------- + +const NOISE_PATH_PREFIXES = [ + '/cdn-cgi/', + '/wp-', + '/.env', + '/.git', + '/xmlrpc.php', + '/wp-login', + '/wp-admin', + '/wp-content', + '/wp-includes', + '/api/', + '/dist/', + '/static/', +]; +const NOISE_EXACT_PATHS = new Set([ + '/robots.txt', + '/favicon.ico', + '/sitemap.xml', + '/sitemap_index.xml', + '/.well-known/security.txt', +]); + +function isNoisePath(path: string): boolean { + if (NOISE_EXACT_PATHS.has(path)) return true; + if (path.endsWith('.xml')) return true; + return NOISE_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +// --------------------------------------------------------------------------- +// Postgres-backed daily cache +// --------------------------------------------------------------------------- +// +// Each row stores a full day's pre-aggregated analytics JSON for one hostname. +// Uses the AnalyticsDailyCache Sequelize model (shared Postgres → works across swarm). +// +// Past days: expiresAt = NULL → permanent cache. +// Today: expiresAt = now + 3h → cached, but refreshed periodically. +// +// Effect: +// • First load for a community: 1 CF API call, all days stored. +// • Repeat load within 3h: 0 CF calls, pure Postgres. +// • After 3h: 1 CF call for just today (past days still cached permanently). + +import { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; +import { Op } from 'sequelize'; + +/** 3 hours in milliseconds. */ +const TODAY_CACHE_TTL_MS = 3 * 60 * 60 * 1000; + +/** + * Delete cache rows older than 90 days. + * Throttled to run at most once per hour — the Date.now() check is ~free, + * so we skip the DB round-trip on 99.9% of calls. Triggered from the + * analytics fetch path (not a background job). + */ +const CACHE_MAX_AGE_DAYS = 90; +const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +let lastCleanup = 0; + +function pruneOldCacheRows(): Promise { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return Promise.resolve(); + lastCleanup = now; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - CACHE_MAX_AGE_DAYS); + return AnalyticsDailyCache.destroy({ + where: { date: { [Op.lt]: cutoff.toISOString().slice(0, 10) } }, + }) + .then(() => undefined) + .catch((err) => { + console.error('Analytics cache cleanup failed:', err); + }); +} + +/** What we store per cached day. */ +type DayCachePayload = { + visits: number; + pageViews: number; + topPaths: Array<{ path: string; count: number }>; + countries: Array<{ country: string; count: number }>; + devices: Array<{ device: string; count: number }>; + referrers: Array<{ referrer: string; count: number }>; +}; + +async function getCachedDays( + hostname: string, + dates: string[], +): Promise> { + if (dates.length === 0) return new Map(); + const rows = await AnalyticsDailyCache.findAll({ + where: { + hostname, + date: dates, + [Op.or]: [ + { expiresAt: null }, // permanent (past days) + { expiresAt: { [Op.gt]: new Date() } }, // not yet expired (today) + ], + }, + }); + const map = new Map(); + for (const row of rows) { + map.set(row.date, row.data as DayCachePayload); + } + return map; +} + +async function storeCachedDays( + hostname: string, + entries: Map, + today: string, +) { + if (entries.size === 0) return; + const promises = Array.from(entries.entries()).map(([date, data]) => { + const expiresAt = date === today ? new Date(Date.now() + TODAY_CACHE_TTL_MS) : null; + return AnalyticsDailyCache.upsert({ hostname, date, data, expiresAt }); + }); + await Promise.all(promises); +} + +// --------------------------------------------------------------------------- +// Single combined GraphQL query (1 API call per ≤30-day chunk) +// --------------------------------------------------------------------------- +// +// Fetches daily counts + all breakdowns in one request per chunk. +// Breakdowns use per-day grouping so each cached day holds its own slice. + +const CF_MAX_DAYS = 30; + +const COMBINED_QUERY = ` + query CommunityAnalytics($zoneTag: string, $filter: ZoneHttpRequestsAdaptiveGroupsFilter_InputObject!) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + daily: httpRequestsAdaptiveGroups( + filter: $filter + limit: 10000 + orderBy: [date_ASC] + ) { + count + sum { visits } + dimensions { date } + } + topPaths: httpRequestsAdaptiveGroups( + filter: $filter + limit: 200 + orderBy: [count_DESC] + ) { + count + dimensions { date clientRequestPath } + } + countries: httpRequestsAdaptiveGroups( + filter: $filter + limit: 200 + orderBy: [count_DESC] + ) { + count + dimensions { date clientCountryName } + } + devices: httpRequestsAdaptiveGroups( + filter: $filter + limit: 200 + orderBy: [count_DESC] + ) { + count + dimensions { date clientDeviceType } + } + referrers: httpRequestsAdaptiveGroups( + filter: $filter + limit: 200 + orderBy: [count_DESC] + ) { + count + dimensions { date clientRefererHost } + } + } + } + } +`; + +// --------------------------------------------------------------------------- +// Date helpers +// --------------------------------------------------------------------------- + +function dateRange(startDate: string, endDate: string): string[] { + const dates: string[] = []; + const cursor = new Date(startDate + 'T00:00:00Z'); + const end = new Date(endDate + 'T00:00:00Z'); + while (cursor <= end) { + dates.push(cursor.toISOString().slice(0, 10)); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return dates; +} + +function splitDateRange( + startDate: string, + endDate: string, + maxDays: number, +): Array<{ start: string; end: string }> { + const chunks: Array<{ start: string; end: string }> = []; + let cursor = new Date(startDate + 'T00:00:00Z'); + const end = new Date(endDate + 'T00:00:00Z'); + while (cursor <= end) { + const chunkEnd = new Date(cursor); + chunkEnd.setUTCDate(chunkEnd.getUTCDate() + maxDays - 1); + if (chunkEnd > end) chunkEnd.setTime(end.getTime()); + chunks.push({ + start: cursor.toISOString().slice(0, 10), + end: chunkEnd.toISOString().slice(0, 10), + }); + cursor = new Date(chunkEnd); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return chunks; +} + +/** + * Group sorted date strings into contiguous spans. + * e.g. ["2026-03-25", "2026-03-26", "2026-04-01"] → [["2026-03-25","2026-03-26"], ["2026-04-01"]] + * This prevents a single CF query from spanning cached dates in the middle. + */ +function groupContiguousDates(dates: string[]): string[][] { + if (dates.length === 0) return []; + const spans: string[][] = [[dates[0]]]; + for (let i = 1; i < dates.length; i++) { + const prev = new Date(dates[i - 1] + 'T00:00:00Z'); + const curr = new Date(dates[i] + 'T00:00:00Z'); + const diffMs = curr.getTime() - prev.getTime(); + if (diffMs <= 86_400_000) { + // consecutive day + spans[spans.length - 1].push(dates[i]); + } else { + spans.push([dates[i]]); + } + } + return spans; +} + +// --------------------------------------------------------------------------- +// Main fetch +// --------------------------------------------------------------------------- + +/** + * Fetch analytics for a hostname over a date range. + * + * Strategy: + * 1. Check Postgres cache for each day in the range. + * 2. Group uncached days into contiguous spans. + * 3. For each span, query CF (1 API call per ≤30-day chunk) — the combined + * query includes `date` in every dimension so breakdowns are per-day. + * 4. Store every fetched day (visits, pageViews, AND breakdowns) in cache. + * 5. Aggregate all days from cache into final result. + * + * Cost: 0 CF API calls when all days are cached (within TTL). + * 1 CF call per uncached ≤30-day chunk otherwise. + */ +export async function fetchCommunityAnalytics( + hostname: string, + startDate: string, + endDate: string, +): Promise { + const config = getConfig(); + if (!config) return null; + const { apiToken, zoneTag } = config; + + const allDates = dateRange(startDate, endDate); + const today = new Date().toISOString().slice(0, 10); + + // 1. Read cache + prune stale rows in parallel + const [cached] = await Promise.all([ + getCachedDays(hostname, allDates), + pruneOldCacheRows(), + ]); + + // 2. Fetch any uncached days from CF + const uncachedDates = allDates.filter((d) => !cached.has(d)); + let stale = false; + if (uncachedDates.length > 0) { + try { + const spans = groupContiguousDates(uncachedDates); + + async function fetchSpan(span: string[]) { + const chunks = splitDateRange(span[0], span[span.length - 1], CF_MAX_DAYS); + + const allNodes: { + daily: any[]; + topPaths: any[]; + countries: any[]; + devices: any[]; + referrers: any[]; + } = { daily: [], topPaths: [], countries: [], devices: [], referrers: [] }; + + let chunkPromise: Promise = Promise.resolve(); + for (const chunk of chunks) { + chunkPromise = chunkPromise.then(async () => { + const filter = { + date_geq: chunk.start, + date_leq: chunk.end, + clientRequestHTTPHost: hostname, + requestSource: 'eyeball', + }; + const result = await cfGraphQL(COMBINED_QUERY, { zoneTag, filter }, apiToken); + const zone = result?.data?.viewer?.zones?.[0] ?? {}; + allNodes.daily.push(...(zone.daily ?? [])); + allNodes.topPaths.push(...(zone.topPaths ?? [])); + allNodes.countries.push(...(zone.countries ?? [])); + allNodes.devices.push(...(zone.devices ?? [])); + allNodes.referrers.push(...(zone.referrers ?? [])); + }); + } + await chunkPromise; + + // Group breakdowns by date + type Arr = Array<{ key: string; count: number }>; + const byDate = new Map(); + function ensure(d: string) { + if (!byDate.has(d)) { + byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + } + return byDate.get(d)!; + } + for (const n of allNodes.topPaths) { + ensure(n.dimensions.date).topPaths.push({ + key: n.dimensions.clientRequestPath, count: n.count, + }); + } + for (const n of allNodes.countries) { + ensure(n.dimensions.date).countries.push({ + key: n.dimensions.clientCountryName || 'Unknown', count: n.count, + }); + } + for (const n of allNodes.devices) { + ensure(n.dimensions.date).devices.push({ + key: n.dimensions.clientDeviceType || 'Unknown', count: n.count, + }); + } + for (const n of allNodes.referrers) { + ensure(n.dimensions.date).referrers.push({ + key: n.dimensions.clientRefererHost || '(direct)', count: n.count, + }); + } + + // Build per-day payloads + const toStore = new Map(); + for (const node of allNodes.daily) { + const d = node.dimensions.date; + const bd = byDate.get(d); + const payload: DayCachePayload = { + visits: node.sum.visits ?? 0, + pageViews: node.count ?? 0, + topPaths: (bd?.topPaths ?? []).map((p) => ({ path: p.key, count: p.count })), + countries: (bd?.countries ?? []).map((c) => ({ country: c.key, count: c.count })), + devices: (bd?.devices ?? []).map((dv) => ({ device: dv.key, count: dv.count })), + referrers: (bd?.referrers ?? []).map((r) => ({ referrer: r.key, count: r.count })) + .filter((r) => r.referrer !== hostname), + }; + cached.set(d, payload); + toStore.set(d, payload); + } + + // Backfill any requested dates CF returned no data for + for (const d of span) { + if (!cached.has(d)) { + const empty: DayCachePayload = { + visits: 0, pageViews: 0, + topPaths: [], countries: [], devices: [], referrers: [], + }; + cached.set(d, empty); + toStore.set(d, empty); + } + } + + await storeCachedDays(hostname, toStore, today).catch((err) => { + console.error('Failed to store analytics cache:', err); + }); + } + + let spanChain: Promise = Promise.resolve(); + for (const span of spans) { + spanChain = spanChain.then(() => fetchSpan(span)); + } + await spanChain; + } catch (err) { + // CF error (rate limit, network, etc.) — fall back to whatever is cached. + console.error('Cloudflare analytics fetch failed, using cached data:', err); + stale = cached.size > 0; + // If we have zero cached data, re-throw so the API returns an error. + if (cached.size === 0) throw err; + } + } + + // 3. Aggregate all cached days into final result + const daily: DailyAnalytics[] = []; + const pathMap = new Map(); + const countryMap = new Map(); + const deviceMap = new Map(); + const refMap = new Map(); + let totalVisits = 0; + let totalPageViews = 0; + + for (const date of allDates) { + const day = cached.get(date); + if (!day) continue; + daily.push({ date, visits: day.visits, pageViews: day.pageViews }); + totalVisits += day.visits; + totalPageViews += day.pageViews; + for (const p of day.topPaths) pathMap.set(p.path, (pathMap.get(p.path) ?? 0) + p.count); + for (const c of day.countries) countryMap.set(c.country, (countryMap.get(c.country) ?? 0) + c.count); + for (const d of day.devices) deviceMap.set(d.device, (deviceMap.get(d.device) ?? 0) + d.count); + for (const r of day.referrers) refMap.set(r.referrer, (refMap.get(r.referrer) ?? 0) + r.count); + } + + const topPaths = Array.from(pathMap.entries()) + .map(([path, count]) => ({ path, count })) + .filter((p) => !isNoisePath(p.path)) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + + // Subtract noise/bot path hits from totals so headline numbers + // reflect real human traffic as closely as possible. + let noisePageViews = 0; + for (const [path, count] of pathMap) { + if (isNoisePath(path)) noisePageViews += count; + } + const adjustedPageViews = Math.max(0, totalPageViews - noisePageViews); + // Visits (unique sessions) aren't per-path, so scale proportionally. + const ratio = totalPageViews > 0 ? adjustedPageViews / totalPageViews : 1; + const adjustedVisits = Math.round(totalVisits * ratio); + + const countries = Array.from(countryMap.entries()) + .map(([country, count]) => ({ country, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + + const devices = Array.from(deviceMap.entries()) + .map(([device, count]) => ({ device, count })) + .sort((a, b) => b.count - a.count); + + const referrers = Array.from(refMap.entries()) + .map(([referrer, count]) => ({ referrer, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 15); + + return { + daily, + topPaths, + countries, + devices, + referrers, + totals: { visits: adjustedVisits, pageViews: adjustedPageViews }, + rawTotals: { visits: totalVisits, pageViews: totalPageViews }, + ...(stale ? { stale: true } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Debug helper +// --------------------------------------------------------------------------- + +/** + * Raw debug query — returns the exact filter + raw Cloudflare response. + */ +export async function debugCommunityAnalytics( + hostname: string, + startDate: string, + endDate: string, +) { + const config = getConfig(); + if (!config) { + return { error: 'Missing env vars' }; + } + const { apiToken, zoneTag } = config; + + const filter = { + date_geq: startDate, + date_leq: endDate, + clientRequestHTTPHost: hostname, + requestSource: 'eyeball', + }; + + const zoneCheckQuery = ` + query ZoneCheck($zoneTag: string) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + totals: httpRequestsAdaptiveGroups( + filter: { date_geq: "${startDate}", date_leq: "${endDate}", requestSource: "eyeball" } + limit: 5 + orderBy: [count_DESC] + ) { + count + dimensions { date } + } + } + } + } + `; + + const hostnameQuery = ` + query HostnameCheck($zoneTag: string, $filter: ZoneHttpRequestsAdaptiveGroupsFilter_InputObject!) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + daily: httpRequestsAdaptiveGroups( + filter: $filter + limit: 5 + orderBy: [count_DESC] + ) { + count + sum { visits } + dimensions { date clientRequestHTTPHost } + } + } + } + } + `; + + const hostnamesQuery = ` + query Hostnames($zoneTag: string) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + byHost: httpRequestsAdaptiveGroups( + filter: { date_geq: "${startDate}", date_leq: "${endDate}", requestSource: "eyeball" } + limit: 25 + orderBy: [count_DESC] + ) { + count + dimensions { clientRequestHTTPHost } + } + } + } + } + `; + + let zoneCheck: any; + let hostnameCheck: any; + let hostnamesCheck: any; + + try { + zoneCheck = await cfGraphQL(zoneCheckQuery, { zoneTag }, apiToken); + } catch (err: any) { + zoneCheck = { error: err.message }; + } + try { + hostnameCheck = await cfGraphQL(hostnameQuery, { zoneTag, filter }, apiToken); + } catch (err: any) { + hostnameCheck = { error: err.message }; + } + try { + hostnamesCheck = await cfGraphQL(hostnamesQuery, { zoneTag }, apiToken); + } catch (err: any) { + hostnamesCheck = { error: err.message }; + } + + return { + input: { + hostname, + startDate, + endDate, + zoneTag, + tokenPrefix: apiToken.slice(0, 6) + '…', + filter, + }, + zoneWideData: zoneCheck, + filteredByHostname: hostnameCheck, + topHostnames: hostnamesCheck, + }; +} diff --git a/utils/dashboard.ts b/utils/dashboard.ts index 3f1d57e401..3ac35b0e7a 100644 --- a/utils/dashboard.ts +++ b/utils/dashboard.ts @@ -2,6 +2,7 @@ export type DashboardMode = | 'activity' | 'connections' | 'impact' + | 'impact2' | 'layout' | 'members' | 'overview' From df2589ee734397780d3b99868725b54c63a89213 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:31:20 -0400 Subject: [PATCH 02/13] Layout cleanup and filtering improvements --- .../ScopeDropdown/ScopeDropdown.tsx | 6 +- .../DashboardImpact2/DashboardImpact2.tsx | 428 ++++++++---------- .../DashboardImpact2/dashboardImpact2.scss | 204 ++++++--- server/apiRoutes.ts | 2 +- server/impact2/api.ts | 6 +- server/routes/dashboardImpact2.tsx | 6 +- server/utils/cloudflareAnalytics.ts | 290 +++++++----- 7 files changed, 522 insertions(+), 420 deletions(-) diff --git a/client/components/ScopeDropdown/ScopeDropdown.tsx b/client/components/ScopeDropdown/ScopeDropdown.tsx index 0236356475..e1232ad9c5 100644 --- a/client/components/ScopeDropdown/ScopeDropdown.tsx +++ b/client/components/ScopeDropdown/ScopeDropdown.tsx @@ -201,7 +201,11 @@ const ScopeDropdown = (props: Props) => { pubPubIcons.member, )} {renderDropddownButton(scope, 'impact', pubPubIcons.impact)} - {renderDropddownButton(scope, 'impact2', pubPubIcons.impact)} + {renderDropddownButton( + scope, + 'impact2', + pubPubIcons.impact, + )} {scope.type === 'Collection' && renderDropddownButton( scope, diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 56c22f09cd..16fcb84d38 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -1,14 +1,11 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, ButtonGroup, Callout, NonIdealState, Spinner, Tag } from '@blueprintjs/core'; +import { Button, ButtonGroup, Callout, NonIdealState, Spinner } from '@blueprintjs/core'; import { Area, AreaChart, CartesianGrid, - Cell, Legend, - Pie, - PieChart, ResponsiveContainer, Tooltip, XAxis, @@ -31,12 +28,7 @@ function countryName(code: string): string { } } -type DailyAnalytics = { - date: string; - visits: number; - pageViews: number; -}; - +type DailyAnalytics = { date: string; visits: number; pageViews: number }; type TopPath = { path: string; count: number }; type CountryBreakdown = { country: string; count: number }; type DeviceBreakdown = { device: string; count: number }; @@ -48,107 +40,65 @@ type AnalyticsData = { countries: CountryBreakdown[]; devices: DeviceBreakdown[]; referrers: ReferrerBreakdown[]; - totals: { - visits: number; - pageViews: number; - }; - rawTotals: { - visits: number; - pageViews: number; - }; + totals: { visits: number; pageViews: number }; + rawTotals: { visits: number; pageViews: number }; stale?: boolean; }; type DateRange = '1d' | '7d' | '30d'; -const formatNumber = (n: number): string => { +const fmt = (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(); }; -const formatDateLabel = (dateStr: string): string => { - const d = new Date(dateStr + 'T00:00:00'); +const fmtDate = (s: string): string => { + const d = new Date(s + 'T00:00:00'); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }; -const getDateRange = (range: DateRange): { startDate: string; endDate: string } => { +const getRange = (r: DateRange) => { const end = new Date(); const start = new Date(); - switch (range) { - case '1d': - start.setDate(end.getDate() - 1); - break; - case '7d': - start.setDate(end.getDate() - 7); - break; - case '30d': - start.setDate(end.getDate() - 30); - break; - default: - start.setDate(end.getDate() - 7); - break; - } + const days = r === '1d' ? 1 : r === '7d' ? 7 : 30; + start.setDate(end.getDate() - days); return { startDate: start.toISOString().slice(0, 10), endDate: end.toISOString().slice(0, 10), }; }; -const StatCard = ({ - label, - value, - subtext, -}: { - label: string; - value: string; - subtext?: string; -}) => ( -
+const StatCard = ({ label, value, color }: { label: string; value: string; color: string }) => ( +
{value}
{label}
- {subtext &&
{subtext}
}
); -const CHART_COLORS = { - visits: '#2B95D6', - pageViews: '#15B371', -}; - -const PIE_COLORS = [ - '#2B95D6', - '#15B371', - '#D9822B', - '#8F398F', - '#F5498B', - '#29A634', - '#D99E0B', - '#669EFF', -]; +const COLORS = { visits: '#2B95D6', pageViews: '#15B371' }; -const SimpleTable = ({ - data, +/** Compact table used inside data panels. */ +const CompactTable = ({ + rows, columns, }: { - data: Array>; - columns: Array<{ key: string; label: string; format?: (v: any) => string }>; + rows: Array>; + columns: Array<{ key: string; label: string; render?: (v: any, row: any) => React.ReactNode }>; }) => ( - +
- {columns.map((col) => ( - + {columns.map((c) => ( + ))} - {data.map((row) => ( - row[c.key]).join('-')}> - {columns.map((col) => ( - + {rows.map((row) => ( + row[c.key]).join('|')}> + {columns.map((c) => ( + ))} ))} @@ -156,10 +106,12 @@ const SimpleTable = ({
{col.label}{c.label}
- {col.format ? col.format(row[col.key]) : row[col.key]} -
{c.render ? c.render(row[c.key], row) : row[c.key]}
); +// ───────────────────────────────────────────────────────────────────────────── + const DashboardImpact2 = () => { const { scopeData } = usePageContext(); const { - elements: { activeTargetName }, + // elements: { activeTargetName }, activePermissions: { canView }, } = scopeData; @@ -174,7 +126,7 @@ const DashboardImpact2 = () => { setError(null); setStale(false); try { - const { startDate, endDate } = getDateRange(range); + const { startDate, endDate } = getRange(range); const res = await fetch(`/api/impact2?startDate=${startDate}&endDate=${endDate}`); if (!res.ok) { const body = await res.json().catch(() => ({})); @@ -191,29 +143,20 @@ const DashboardImpact2 = () => { }, []); useEffect(() => { - if (canView) { - fetchData(dateRange); - } + if (canView) fetchData(dateRange); }, [dateRange, canView, fetchData]); - const chartData = useMemo(() => { - if (!data) return []; - return data.daily.map((d) => ({ - ...d, - label: formatDateLabel(d.date), - })); - }, [data]); - - const handleRangeChange = (range: DateRange) => { - setDateRange(range); - }; + const chartData = useMemo( + () => (data ? data.daily.map((d) => ({ ...d, label: fmtDate(d.date) })) : []), + [data], + ); if (!canView) { return (

Login or ask the community administrator for access to impact data.

@@ -224,28 +167,16 @@ const DashboardImpact2 = () => { - - - @@ -272,162 +203,175 @@ const DashboardImpact2 = () => { {!loading && !error && data && ( <> - {/* Stale data warning */} {stale && ( - - Data may be slightly delayed due to temporary limits on live updates. - Please try again in a few minutes. - +
+ Data may be slightly delayed — try again in a few minutes. +
)} - {/* Summary stats */} -
- - + {/* ── Row 1: Stats + Chart ── */} +
+
+ + +

+ All numbers adjusted for suspected bot/spam traffic.* +

+
+ {chartData.length > 1 && ( +
+

Traffic Over Time

+ + + + + + v.toLocaleString()} /> + + + + + +
+ )}
-

- Estimated after adjusting for suspected bot and spam traffic.* -

- - {/* Visits over time */} - {chartData.length > 1 && ( -
-

Unique Sessions & Pages Viewed Over Time

- - - - - - - - - - -
- )} - {/* Top pages */} - {data.topPaths.length > 0 && ( -
-

Top Pages

- -
- )} + {/* ── Row 2: data grid ── */} +
+ {/* Top Pages */} + {data.topPaths.length > 0 && ( +
+

Top Pages

+ ( + + {v} + + ), + }, + { + key: 'count', + label: 'Views', + render: (v: number) => fmt(v), + }, + ]} + /> +
+ )} - {/* Countries + Devices side by side */} -
+ {/* Countries */} {data.countries.length > 0 && ( -
-

Top Countries

- ({ +
+

Countries

+ ({ ...c, country: countryName(c.country), }))} columns={[ { key: 'country', label: 'Country' }, - { key: 'count', label: 'Views', format: formatNumber }, + { + key: 'count', + label: 'Views', + render: (v: number) => fmt(v), + }, ]} /> -
+
)} + + {/* Referrers */} + {data.referrers.length > 0 && ( +
+

Referrers

+ fmt(v), + }, + ]} + /> +
+ )} + + {/* Devices */} {data.devices.length > 0 && ( -
+

Devices

- - - - `${device} ${(percent * 100).toFixed(0)}%` - } - > - {data.devices.map((entry, i) => ( - - ))} - - formatNumber(v)} /> - - - -
+ { + const total = data.devices.reduce((s, d) => s + d.count, 0); + return data.devices.map((d) => ({ + device: d.device, + pct: total + ? `${((d.count / total) * 100).toFixed(1)}%` + : '0%', + })); + })()} + columns={[ + { key: 'device', label: 'Type' }, + { key: 'pct', label: '%' }, + ]} + /> +
)}
- {/* Referrers */} - {data.referrers.length > 0 && ( -
-

Top Referrers

- -
- )} - + {/* Footer */}
-

Analytics sourced from Cloudflare edge traffic data.

- * Totals are adjusted to exclude traffic from known bot and spam routes - (e.g. /wp-login, /cdn-cgi/,{' '} - /robots.txt). Unique sessions are estimated proportionally - since session counts can't be attributed to individual paths. - Pre-adjustment totals: {formatNumber(data.rawTotals.visits)} sessions /{' '} - {formatNumber(data.rawTotals.pageViews)} page views. + * Totals adjusted to exclude known bot/spam routes (e.g.{' '} + /wp-login, /cdn-cgi/). Sessions estimated + proportionally. Raw totals: {fmt(data.rawTotals.visits)} sessions /{' '} + {fmt(data.rawTotals.pageViews)} page views.

+

+ All web analytics capture unavoidable noise. While we apply filtering + and normalization, some non-human or ambiguous traffic almost surely + persists. Treat these numbers as directional indicators, not exact + measurements. +

+

Analytics sourced from Cloudflare edge traffic data.

)} diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss index 3f667b75fe..71ca5a1f2c 100644 --- a/client/containers/DashboardImpact2/dashboardImpact2.scss +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -5,147 +5,233 @@ padding: 60px 0; } - .stats-grid { + // ── Top row: stats + chart ────────────────────────────────────────── + .top-row { display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 16px; - margin: 24px 0 32px; + grid-template-columns: 200px 1fr; + gap: 20px; + margin-bottom: 32px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + } + + .stats-column { + display: flex; + flex-direction: column; + gap: 20px; + padding-top: 4px; } .stat-card { - background: rgba(0, 0, 0, 0.03); - border-radius: 8px; - padding: 20px; - text-align: center; + background: none; + border-left: 3px solid #2b95d6; + border-radius: 0; + padding: 4px 0 4px 12px; .stat-value { font-size: 28px; - font-weight: 600; - line-height: 1.2; + font-weight: 700; + line-height: 1.1; color: #1c2127; + font-variant-numeric: tabular-nums; } .stat-label { - font-size: 13px; + font-size: 11px; color: #5c7080; - margin-top: 4px; + margin-top: 1px; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.6px; + font-weight: 600; } .stat-subtext { - font-size: 11px; + font-size: 10px; color: #8a9ba8; - margin-top: 2px; + margin-top: 1px; } } - .chart-section { - margin: 32px 0; + .chart-column { + min-height: 0; h3 { - font-size: 16px; + font-size: 12px; font-weight: 600; - margin-bottom: 16px; - display: flex; - align-items: center; - gap: 8px; - } - - .source-tag { - font-size: 10px; - font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #5c7080; + margin: 0 0 6px; } } - .analytics-footer { - margin-top: 40px; - padding-top: 20px; - border-top: 1px solid #e1e8ed; - - p { - font-size: 13px; - color: #8a9ba8; - line-height: 1.5; - } + .fine-print { + font-size: 11px; + color: #777; + margin: 4px 0 0; } - .two-col { + // ── Data grid: breakdown panels ───────────────────────────────────── + .data-grid { display: grid; - grid-template-columns: 1fr 1fr; - gap: 24px; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 36px; - @media (max-width: 768px) { + @media (max-width: 1100px) { + grid-template-columns: 1fr 1fr; + } + @media (max-width: 600px) { grid-template-columns: 1fr; } } - .simple-table { + .data-panel { + h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #5c7080; + margin: 0 0 6px; + padding: 0 4px; + } + } + + + + // ── Compact table ─────────────────────────────────────────────────── + .compact-table { width: 100%; border-collapse: collapse; font-size: 13px; th { text-align: left; - padding: 8px 12px; - border-bottom: 2px solid #e1e8ed; + padding: 4px; + border-bottom: 1px solid #e1e8ed; font-weight: 600; - color: #5c7080; + color: #8a9ba8; font-size: 11px; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.4px; } td { - padding: 6px 12px; - border-bottom: 1px solid #e1e8ed; + padding: 3px 4px; color: #1c2127; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 0; } - tr:last-child td { - border-bottom: none; + td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; + width: 60px; + max-width: none; + color: #5c7080; + font-weight: 500; } tr:hover td { background: rgba(0, 0, 0, 0.02); } + + a { + color: inherit; + text-decoration: none; + &:hover { + color: #2b95d6; + text-decoration: underline; + } + } + } + + // ── Footer ────────────────────────────────────────────────────────── + .analytics-footer { + margin-top: 20px; + padding-top: 12px; + border-top: 1px solid #e1e8ed; + + p { + font-size: 11px; + color: #777; + line-height: 1.5; + margin: 0 0 4px; + + code { + font-size: 10px; + background: rgba(0, 0, 0, 0.04); + padding: 1px 3px; + border-radius: 2px; + } + } + } + + .stale-callout { + margin-bottom: 12px; + font-size: 11px; + padding: 5px 10px; + background: rgba(217, 130, 43, 0.08); + color: #946638; + border-radius: 4px; } } -/* Dark mode support */ +/* ── Dark mode ─────────────────────────────────────────────────────────── */ .bp5-dark, .bp3-dark { .dashboard-impact2-container { .stat-card { - background: rgba(255, 255, 255, 0.06); - + background: none; .stat-value { color: #f5f8fa; } - .stat-label { color: #a7b6c2; } } + .fine-print { + color: #738694; + } + + .data-panel h3, + .chart-column h3 { + color: #a7b6c2; + } + .analytics-footer { border-top-color: #30404d; + p { + color: #738694; + code { + background: rgba(255, 255, 255, 0.06); + } + } } - .simple-table { + .compact-table { th { border-bottom-color: #30404d; - color: #a7b6c2; + color: #738694; } - td { - border-bottom-color: #30404d; color: #f5f8fa; } - + td:last-child { + color: #a7b6c2; + } tr:hover td { background: rgba(255, 255, 255, 0.04); } + a:hover { + color: #48aff0; + } } } } diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index 82bbb16c60..b6ab10446e 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -4,7 +4,6 @@ import { isProd } from 'utils/environment'; import { activityItemRouter } from './activityItem/api'; import { router as apiDocsRouter } from './apiDocs/api'; -import { router as impact2Router } from './impact2/api'; import { router as captchaRouter } from './captcha/api'; import { router as citationRouter } from './citation/api'; import { router as communityBanRouter } from './communityBan/api'; @@ -14,6 +13,7 @@ import { router as devApiRouter } from './dev/api'; import { router as discussionRouter } from './discussion/api'; import { router as doiRouter } from './doi/api'; import { router as editorRouter } from './editor/api'; +import { router as impact2Router } from './impact2/api'; import { router as integrationDataOAuth1Router } from './integrationDataOAuth1/api'; import { router as landingPageFeatureRouter } from './landingPageFeature/api'; import { router as layoutRouter } from './layout/api'; diff --git a/server/impact2/api.ts b/server/impact2/api.ts index dddc34fa92..6fb6513584 100644 --- a/server/impact2/api.ts +++ b/server/impact2/api.ts @@ -1,12 +1,12 @@ import { Router } from 'express'; +import { Community } from 'server/community/model'; import { + debugCommunityAnalytics, fetchCommunityAnalytics, testCloudflareConnection, - debugCommunityAnalytics, } from 'server/utils/cloudflareAnalytics'; -import { Community } from 'server/community/model'; -import { handleErrors, ForbiddenError } from 'server/utils/errors'; +import { ForbiddenError, handleErrors } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; import { hostIsValid } from 'server/utils/routes'; diff --git a/server/routes/dashboardImpact2.tsx b/server/routes/dashboardImpact2.tsx index bce68a0a36..c7198d1336 100644 --- a/server/routes/dashboardImpact2.tsx +++ b/server/routes/dashboardImpact2.tsx @@ -11,11 +11,7 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); router.get( - [ - '/dash/impact2', - '/dash/collection/:collectionSlug/impact2', - '/dash/pub/:pubSlug/impact2', - ], + ['/dash/impact2', '/dash/collection/:collectionSlug/impact2', '/dash/pub/:pubSlug/impact2'], async (req, res, next) => { try { if (!hostIsValid(req, 'community')) { diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index f58e33234c..6168567a8a 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -165,6 +165,7 @@ const NOISE_PATH_PREFIXES = [ '/api/', '/dist/', '/static/', + '/login', ]; const NOISE_EXACT_PATHS = new Set([ '/robots.txt', @@ -195,9 +196,10 @@ function isNoisePath(path: string): boolean { // • Repeat load within 3h: 0 CF calls, pure Postgres. // • After 3h: 1 CF call for just today (past days still cached permanently). -import { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; import { Op } from 'sequelize'; +import { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; + /** 3 hours in milliseconds. */ const TODAY_CACHE_TTL_MS = 3 * 60 * 60 * 1000; @@ -246,7 +248,7 @@ async function getCachedDays( hostname, date: dates, [Op.or]: [ - { expiresAt: null }, // permanent (past days) + { expiresAt: null }, // permanent (past days) { expiresAt: { [Op.gt]: new Date() } }, // not yet expired (today) ], }, @@ -420,10 +422,7 @@ export async function fetchCommunityAnalytics( const today = new Date().toISOString().slice(0, 10); // 1. Read cache + prune stale rows in parallel - const [cached] = await Promise.all([ - getCachedDays(hostname, allDates), - pruneOldCacheRows(), - ]); + const [cached] = await Promise.all([getCachedDays(hostname, allDates), pruneOldCacheRows()]); // 2. Fetch any uncached days from CF const uncachedDates = allDates.filter((d) => !cached.has(d)); @@ -433,108 +432,161 @@ export async function fetchCommunityAnalytics( const spans = groupContiguousDates(uncachedDates); async function fetchSpan(span: string[]) { - const chunks = splitDateRange(span[0], span[span.length - 1], CF_MAX_DAYS); - - const allNodes: { - daily: any[]; - topPaths: any[]; - countries: any[]; - devices: any[]; - referrers: any[]; - } = { daily: [], topPaths: [], countries: [], devices: [], referrers: [] }; - - let chunkPromise: Promise = Promise.resolve(); - for (const chunk of chunks) { - chunkPromise = chunkPromise.then(async () => { - const filter = { - date_geq: chunk.start, - date_leq: chunk.end, - clientRequestHTTPHost: hostname, - requestSource: 'eyeball', - }; - const result = await cfGraphQL(COMBINED_QUERY, { zoneTag, filter }, apiToken); - const zone = result?.data?.viewer?.zones?.[0] ?? {}; - allNodes.daily.push(...(zone.daily ?? [])); - allNodes.topPaths.push(...(zone.topPaths ?? [])); - allNodes.countries.push(...(zone.countries ?? [])); - allNodes.devices.push(...(zone.devices ?? [])); - allNodes.referrers.push(...(zone.referrers ?? [])); - }); - } - await chunkPromise; - - // Group breakdowns by date - type Arr = Array<{ key: string; count: number }>; - const byDate = new Map(); - function ensure(d: string) { - if (!byDate.has(d)) { - byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + const chunks = splitDateRange(span[0], span[span.length - 1], CF_MAX_DAYS); + + const allNodes: { + daily: any[]; + topPaths: any[]; + countries: any[]; + devices: any[]; + referrers: any[]; + } = { daily: [], topPaths: [], countries: [], devices: [], referrers: [] }; + + let chunkPromise: Promise = Promise.resolve(); + for (const chunk of chunks) { + chunkPromise = chunkPromise.then(async () => { + // Cloudflare filter — designed to count real human page views only. + // + // requestSource: 'eyeball' + // CF built-in: excludes known bots, prefetch, and healthcheck traffic. + // + // edgeResponseStatus 200–399 + // Only successful responses. Excludes 4xx (bot probes hitting + // /wp-login, /.env, etc. that return 404/403) and 5xx errors. + // + // edgeResponseContentTypeName: 'html' + // Only HTML page loads. Excludes asset requests (JS/CSS/images), + // API calls (JSON), RSS feeds (XML), and other non-page traffic. + // + // clientRequestHTTPMethodName: 'GET' + // Excludes HEAD/OPTIONS/POST probes from scanners and bots. + // + // Combined with the server-side isNoisePath() filter (which removes + // /wp-*, /cdn-cgi/, /api/, /static/, /login, etc. from Top Pages + // and proportionally adjusts all totals), these filters ensure the + // dashboard reflects genuine human readership rather than raw + // Cloudflare edge hit counts. + const filter = { + date_geq: chunk.start, + date_leq: chunk.end, + clientRequestHTTPHost: hostname, + requestSource: 'eyeball', + edgeResponseStatus_geq: 200, + edgeResponseStatus_lt: 400, + edgeResponseContentTypeName: 'html', + clientRequestHTTPMethodName: 'GET', + }; + const result = await cfGraphQL( + COMBINED_QUERY, + { zoneTag, filter }, + apiToken, + ); + const zone = result?.data?.viewer?.zones?.[0] ?? {}; + allNodes.daily.push(...(zone.daily ?? [])); + allNodes.topPaths.push(...(zone.topPaths ?? [])); + allNodes.countries.push(...(zone.countries ?? [])); + allNodes.devices.push(...(zone.devices ?? [])); + allNodes.referrers.push(...(zone.referrers ?? [])); + }); + } + await chunkPromise; + + // Group breakdowns by date + type Arr = Array<{ key: string; count: number }>; + const byDate = new Map< + string, + { + topPaths: Arr; + countries: Arr; + devices: Arr; + referrers: Arr; + } + >(); + function ensure(d: string) { + if (!byDate.has(d)) { + byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + } + return byDate.get(d)!; + } + for (const n of allNodes.topPaths) { + ensure(n.dimensions.date).topPaths.push({ + key: n.dimensions.clientRequestPath, + count: n.count, + }); + } + for (const n of allNodes.countries) { + ensure(n.dimensions.date).countries.push({ + key: n.dimensions.clientCountryName || 'Unknown', + count: n.count, + }); + } + for (const n of allNodes.devices) { + ensure(n.dimensions.date).devices.push({ + key: n.dimensions.clientDeviceType || 'Unknown', + count: n.count, + }); + } + for (const n of allNodes.referrers) { + ensure(n.dimensions.date).referrers.push({ + key: n.dimensions.clientRefererHost || '(direct)', + count: n.count, + }); } - return byDate.get(d)!; - } - for (const n of allNodes.topPaths) { - ensure(n.dimensions.date).topPaths.push({ - key: n.dimensions.clientRequestPath, count: n.count, - }); - } - for (const n of allNodes.countries) { - ensure(n.dimensions.date).countries.push({ - key: n.dimensions.clientCountryName || 'Unknown', count: n.count, - }); - } - for (const n of allNodes.devices) { - ensure(n.dimensions.date).devices.push({ - key: n.dimensions.clientDeviceType || 'Unknown', count: n.count, - }); - } - for (const n of allNodes.referrers) { - ensure(n.dimensions.date).referrers.push({ - key: n.dimensions.clientRefererHost || '(direct)', count: n.count, - }); - } - - // Build per-day payloads - const toStore = new Map(); - for (const node of allNodes.daily) { - const d = node.dimensions.date; - const bd = byDate.get(d); - const payload: DayCachePayload = { - visits: node.sum.visits ?? 0, - pageViews: node.count ?? 0, - topPaths: (bd?.topPaths ?? []).map((p) => ({ path: p.key, count: p.count })), - countries: (bd?.countries ?? []).map((c) => ({ country: c.key, count: c.count })), - devices: (bd?.devices ?? []).map((dv) => ({ device: dv.key, count: dv.count })), - referrers: (bd?.referrers ?? []).map((r) => ({ referrer: r.key, count: r.count })) - .filter((r) => r.referrer !== hostname), - }; - cached.set(d, payload); - toStore.set(d, payload); - } - // Backfill any requested dates CF returned no data for - for (const d of span) { - if (!cached.has(d)) { - const empty: DayCachePayload = { - visits: 0, pageViews: 0, - topPaths: [], countries: [], devices: [], referrers: [], + // Build per-day payloads + const toStore = new Map(); + for (const node of allNodes.daily) { + const d = node.dimensions.date; + const bd = byDate.get(d); + const payload: DayCachePayload = { + visits: node.sum.visits ?? 0, + pageViews: node.count ?? 0, + topPaths: (bd?.topPaths ?? []).map((p) => ({ + path: p.key, + count: p.count, + })), + countries: (bd?.countries ?? []).map((c) => ({ + country: c.key, + count: c.count, + })), + devices: (bd?.devices ?? []).map((dv) => ({ + device: dv.key, + count: dv.count, + })), + referrers: (bd?.referrers ?? []) + .map((r) => ({ referrer: r.key, count: r.count })) + .filter((r) => r.referrer !== hostname), }; - cached.set(d, empty); - toStore.set(d, empty); + cached.set(d, payload); + toStore.set(d, payload); } - } - await storeCachedDays(hostname, toStore, today).catch((err) => { - console.error('Failed to store analytics cache:', err); - }); - } + // Backfill any requested dates CF returned no data for + for (const d of span) { + if (!cached.has(d)) { + const empty: DayCachePayload = { + visits: 0, + pageViews: 0, + topPaths: [], + countries: [], + devices: [], + referrers: [], + }; + cached.set(d, empty); + toStore.set(d, empty); + } + } - let spanChain: Promise = Promise.resolve(); - for (const span of spans) { - spanChain = spanChain.then(() => fetchSpan(span)); - } - await spanChain; + await storeCachedDays(hostname, toStore, today).catch((err) => { + console.error('Failed to store analytics cache:', err); + }); + } + + let spanChain: Promise = Promise.resolve(); + for (const span of spans) { + spanChain = spanChain.then(() => fetchSpan(span)); + } + await spanChain; } catch (err) { // CF error (rate limit, network, etc.) — fall back to whatever is cached. console.error('Cloudflare analytics fetch failed, using cached data:', err); @@ -560,9 +612,12 @@ export async function fetchCommunityAnalytics( totalVisits += day.visits; totalPageViews += day.pageViews; for (const p of day.topPaths) pathMap.set(p.path, (pathMap.get(p.path) ?? 0) + p.count); - for (const c of day.countries) countryMap.set(c.country, (countryMap.get(c.country) ?? 0) + c.count); - for (const d of day.devices) deviceMap.set(d.device, (deviceMap.get(d.device) ?? 0) + d.count); - for (const r of day.referrers) refMap.set(r.referrer, (refMap.get(r.referrer) ?? 0) + r.count); + for (const c of day.countries) + countryMap.set(c.country, (countryMap.get(c.country) ?? 0) + c.count); + for (const d of day.devices) + deviceMap.set(d.device, (deviceMap.get(d.device) ?? 0) + d.count); + for (const r of day.referrers) + refMap.set(r.referrer, (refMap.get(r.referrer) ?? 0) + r.count); } const topPaths = Array.from(pathMap.entries()) @@ -582,22 +637,35 @@ export async function fetchCommunityAnalytics( const ratio = totalPageViews > 0 ? adjustedPageViews / totalPageViews : 1; const adjustedVisits = Math.round(totalVisits * ratio); + // Apply noise ratio to daily chart data so the chart numbers + // are consistent with the adjusted headline totals (shape preserved). + const adjustedDaily = daily.map((d) => ({ + date: d.date, + visits: Math.round(d.visits * ratio), + pageViews: Math.round(d.pageViews * ratio), + })); + + // Apply the same proportional noise ratio to all breakdowns so that + // country / device / referrer counts are consistent with the adjusted totals. const countries = Array.from(countryMap.entries()) - .map(([country, count]) => ({ country, count })) + .map(([country, count]) => ({ country, count: Math.round(count * ratio) })) + .filter((c) => c.count > 0) .sort((a, b) => b.count - a.count) .slice(0, 20); const devices = Array.from(deviceMap.entries()) - .map(([device, count]) => ({ device, count })) + .map(([device, count]) => ({ device, count: Math.round(count * ratio) })) + .filter((d) => d.count > 0) .sort((a, b) => b.count - a.count); const referrers = Array.from(refMap.entries()) - .map(([referrer, count]) => ({ referrer, count })) + .map(([referrer, count]) => ({ referrer, count: Math.round(count * ratio) })) + .filter((r) => r.count > 0) .sort((a, b) => b.count - a.count) .slice(0, 15); return { - daily, + daily: adjustedDaily, topPaths, countries, devices, @@ -631,6 +699,10 @@ export async function debugCommunityAnalytics( date_leq: endDate, clientRequestHTTPHost: hostname, requestSource: 'eyeball', + edgeResponseStatus_geq: 200, + edgeResponseStatus_lt: 400, + edgeResponseContentTypeName: 'html', + clientRequestHTTPMethodName: 'GET', }; const zoneCheckQuery = ` From 0d26bef6a49413171be0c17df01dd1e7a0d1b40b Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:40:04 -0400 Subject: [PATCH 03/13] Add proper .env vars in enc files. Add UI fallback if env vars not configured --- .../DashboardImpact2/DashboardImpact2.tsx | 14 +++ infra/.env.dev.enc | 90 ++++++++--------- infra/.env.enc | 96 ++++++++++--------- server/utils/cloudflareAnalytics.ts | 8 ++ 4 files changed, 117 insertions(+), 91 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 16fcb84d38..05edef97ff 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -118,6 +118,7 @@ const DashboardImpact2 = () => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [notConfigured, setNotConfigured] = useState(false); const [stale, setStale] = useState(false); const [dateRange, setDateRange] = useState('7d'); @@ -125,11 +126,16 @@ const DashboardImpact2 = () => { setLoading(true); setError(null); setStale(false); + setNotConfigured(false); try { const { startDate, endDate } = getRange(range); const res = await fetch(`/api/impact2?startDate=${startDate}&endDate=${endDate}`); if (!res.ok) { const body = await res.json().catch(() => ({})); + if (res.status === 503) { + setNotConfigured(true); + return; + } throw new Error(body.error || `Request failed (${res.status})`); } const json: AnalyticsData = await res.json(); @@ -201,6 +207,14 @@ const DashboardImpact2 = () => { /> )} + {notConfigured && ( + + )} + {!loading && !error && data && ( <> {stale && ( diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index 3ed84c1eed..edfc9c2fe9 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,52 +1,54 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:Kz4/KiHal4JIP5kmXBSVg+VI/nvNGfPtRv8Rrf9mUdY7bDuc5k1In1oSPHsjjNQLDN8bs6YuzCSBzWyyouDUAg==,iv:QC65R/DVjioNsTb6EnChdSaugtlOqvEx+m4C57eZStM=,tag:kc9NcW6faLAdJIy17wcjtQ==,type:str] -ALGOLIA_ID=ENC[AES256_GCM,data:Pu/lBy/Vo/9p1g==,iv:4HhG6IjEyheW+Ug1mI9m+6xzxRuVf2xvJ+dPtONp130=,tag:9LNs+U1klSpxqVgAnQJaug==,type:str] -ALGOLIA_KEY=ENC[AES256_GCM,data:bY2wVEofNkVMlvwhEhGr4punAQ/JMShYYPpKfR/kyK8=,iv:NIrux9ntBLGphMQ0yem+puX9gjnvlGb3jUiDupiyths=,tag:8DkKbPwvQtPPWHGmH0zRWw==,type:str] -ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:MWIU9p3KmmsDjdxk+IRUJgzB7SQw479ZUG9WdlXLXk8=,iv:ujK8AHJPvvbCi7HxhL7cGvxQ/HYFJRriCFbsNkCAHXk=,tag:KaU7v4CrQJRKmH+QfJwGlA==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:nnIlGdEBlwi268NlCFi8d5Ugjmg=,iv:uP5wFBOWZJrSH50uYNzJ4kJdv3jYUcEFlMRVrDMlTng=,tag:Uv0D0mKNKebrNwuySxIM7g==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:aWJiiVfgnam1My2J/J3k1p/8j4g=,iv:XD+OHujRRMUyyZ8cMGJ2mw1TCtwELrR16txekGpwMi8=,tag:HEsGvUYMNEiv1mk2mb4GYw==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:fYAtsyYDbTp3EBvm6vtabfmBupk9ryfpcu4P3ROoyTEOBnZyPrhPnQ==,iv:L/VZB/DccoB55GFWPfsVbQ9uzdIk3nwadkKZ15YzFGE=,tag:WEbCfpCYN0jHrfzWsc5TNg==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:DGbljDUXr1ehACzOOWFFN2fCIrzq5aChbZg++7IXzLwBxWNldm1vqQ==,iv:G4lKHMPP4BL0kaZz4oH1CulYBiRh7SyZAu3pKhGoUys=,tag:LIVhg5BYgGuH7B7Eyk82Dw==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:eQLNXNHy2Uv6TJt0nn2aZFCa/cyUFQFp490E+sv4+oMY2c0zZ1VpYSuIQCg=,iv:oFQ6xrc6v0z9K0t9m9EU3nXCwsW0lYQNoRbXclnAIFU=,tag:nO+JnRZKD5wBlIoMtyqnuw==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:+uZ8Kkhyxa6KersbX7p6hnqSpp9KcjCEttWRnxDWatf8jr3h+MhyP1SW/6c=,iv:3HQ97+1Gj+7+UjE798q3B4tF9CH7Oi8T4217dgiSygE=,tag:SXFgzJRgDXECYqx1yW6h1w==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Q+H2KthVe4xO5RChejW0YU1TgGtJqXW4I7kc+5WXpM40GQ==,iv:jZjvyIZf3p/a4hC2LhqW6eo4wDGPLFgTn4YTWPVSrxI=,tag:WVguZkbwTPQ5kot//hkSew==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:MnV5UPz2,iv:WILeu6evYxrmctdiCKU9xuuW1E4u7RdFSjG5abdBZZs=,tag:u5gWtVGfeuXrJeqRREMPBg==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:gNfWFreLj6v6dXP/hxKvD7M7jr0=,iv:VAVh9dBRQP/ggQmKHOLepqDN8+pCmN5kIm+Xe7fpYis=,tag:w4dAIBjRMKUBnoX3FqjtXw==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:BjI1Ebz5t00X3n8PSmjPUk6IaIFgWOqEDDA+SheOHLDNotjabDjUE2k=,iv:FB5IXGUm4c/rdVfzD6S8WnmA7CW8y91YsvDr+EUf1NA=,tag:Q+OwRyQPNaBfNLb21AQVfg==,type:str] -FASTLY_PURGE_TOKEN_DUQDUQ=ENC[AES256_GCM,data:MN2kB/F4ztrOXpuDVV3qDBotky/1suF4NsZbtTmE9gY=,iv:VzvRIdYjX4Tw2op5qTsdLg2l3ABeGQnTRp2EOf4F1S0=,tag:3Ua3WcAWJHVHxyRIfNTDCw==,type:str] -FASTLY_SERVICE_ID_DUQDUQ=ENC[AES256_GCM,data:BpF94SQhZj9+gwlWtGT23VFFQaZBMw==,iv:HRVbeeO7QCYi9wO841TCwS8BZYTvb//3diy9u07RL7c=,tag:EqtpAL5cWLYNulhMupkYOw==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:gApN9Y2dF+3z6KbQcR5TmRIBO0DK5B907WaPhD9LIuE=,tag:Hl5fty+5t8g221MjMyfCqA==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:NQOkmw==,iv:0tqLjo3HE/XZnR6UgZZBFZtn2vq6mj01XgO9j5B0r9s=,tag:dX45cwa5tWLgIzjxVe1Log==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:PjgE+THClW+wcA1gvnT5Ej4sOmIjYJCXtSw7//wpxOaeuKs5Ic9Jt45qFErknu4lL6RtHEHXvQuuxgYEKkVqzpYuH2ckxFXRW/UChWMj/xw1ye8ZHjca1CqY8kHyN5wMGgxAAaNZAuK9IvRGV8DEWdepRR9JKM8f3RwsZSE4mw0/U/ClBbc9NywFFAGotouVPxexqWZgb8K1qA7i59FXYMP8HqBu0Z9XZzSPJ1Jj9lWe0HbRyE5IC5DX4sqCDuC2UdboDRfp5RwpJhMYWkKMf3q+3xccTm/jMuMX1BgybG26r4xgmko13g7aTcznLj0qzzY3OWilk0jZWJ5zIXp7C8282BxnFZmWfE224zi+TEaJBu8aAn/WsZznqp6dHv1PqqhXEKf109/fHKOlxFjQKx4qL4/AwS9rUBWWQZ34Ldm9Y/7F17/Hmo+DMztL9xfRyu3NkzwjOCWKXL+PX5A9AszKELOPkaJxT4MIdtNZp1CLKl3RQtpZ33l7gqb3kJuYA9+WGgya7HN0QX9IbkX9D1YdAV/cA2V9dMd7tb3pvnxybbEODig2eDwxEp0fVAsULwxTF72PRgczGTiza13zCE31NEwqEYEBRE6Nq1UIJSus2aiBocrETDtfvELzvGSTOtlJUOM+ARoZiGyUvg/BpTuNoqEkCB2lYmSiX4b1jrxiE70qgMdbG85Bi+POqcfrg68cI5rk/zIbj08mBw5QTteELNI0EFeTzd013nxYXWkAlpC+OtlaZk/Pi1n8qhqHmkPfSlZVjxL/hqTwSviybwIJDh/cZCPanSpZ+Sn7fgEG8KzaQQHXRVqxXJp/2FRt80dY2v+nX54K664DPMFWgMHlqVQYIDoWn8Yv8NVVlP2lAxAajuOAC0sRRVgbZ6IOM6Gw/MD/u7mG+cxm/cvNZZfhT2kjzotEPqsGOGJk1tBbNuxcosi1sCmFlhZsFm1gjy09kwbRTVF68U/dpfjC+bl2+e7uMWeFcJOOJfI+AczUNu8MI+Perzs1C+P8bCne+27yvNAnPnerXUdpnJyfaRsbD4kOqm89681iMEhJNA2XToK7DTBslgXlLa2YS+Cp2jQAttsoTDKsEfqZIDN11JxXYgRoBjb3usglxGZRSp3pqGv3rrqXyqx4umL77mreb2xdil4kOBySFSKG4l1hHaK/NRJqsiaHX36F1ELFy3P0V+z0hW9QujyWDlt13gymBZalCgiLfawSDplhgULcFFoEQGc/NvFQvuvlo8ZvFycsBlZaam52WXM0BsoDvfGGyunTg27KCjx0+eB2hqsIABTU4qKNIxFFdWCeT325NAO82oO65TgfSglkXRDujjAh/nj3VgSeLU+SGGobM30+Bg==,iv:JHBkoIafd0nzqnzZNYhBx5Y12q94fDdQktC6iW43cyc=,tag:wV01DXrn1ypimNFrzzJLcQ==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:6Co8J9KOFq1Q4qIVk3h8gayLsmP/kJ8Unczd56XjT/lC4Hkl,iv:2sNxyJmFZMigRKzqsaiDM09N/Dank783OE9SxvpUZfU=,tag:n8AzKw/BKoXciipBZkb6VA==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:+JBNQ4LxSOkZWtB2AQPSDA4juXF5jUKZrTAQo8xGB0boQEW7,iv:AxtIwHYoNnSN9QjgRQNDK85WK+sCyYB+Rlzks106eyg=,tag:G70UlOuspR+BDsx/5r697w==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:GW/O7+RQWlC+rBb2A/xIHKyYyrEcmUMy7dxqDC5nQ/UpkzOHK1SOpRvandIa62YivcNNvxwEzAFHu7BASjZK6A==,iv:GPPmRbHHys6I5FGddg/AZsnysFQ3PVqPquupbhiAtng=,tag:XoN4puLeTMwVB4zQ72Qpqw==,type:str] -NODE_ENV=ENC[AES256_GCM,data:7zwIoxkdMtNzBg==,iv:b8n1uKjI71k1dc1kMYJphEQnnefqS6+C8VzGgOldUm0=,tag:rN6nFQjBY4cghk/n0aeBuw==,type:str] -#ENC[AES256_GCM,data:AQh6rlnCeWROveEJsICN/veNdoKD22C++/M=,iv:Sw5zGhSVxTzs0m5aRWoGiPRARwNnCujwdlEN1i9ZMQg=,tag:oSE0CuDZoN3hJNhykICfbA==,type:comment] -#ENC[AES256_GCM,data:189wvfDMP4Wv/4CUSOi/ls4jIZpU7PBTa8mcDYxqnU5N9NhSjq8=,iv:ohTmqGFpcn4cbIos8TTemUwNGzTXFRZHAEo3qf8TqEc=,tag:NrWA7r7wP4MVSwJU8A42Dg==,type:comment] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:kinsYfgL36qIqGou0TaalR1HGiWvX/6nCcNe/gYPp9ypSGo=,iv:Nz5Bub7iBiXlo5S5vpiv1v7OrX/2dx3VOcXNSNtigaY=,tag:rpAu5dDTZnveOMMjWCBI+A==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:4/yI1dtcnuRx+wzb3F+ygBDsKxs=,iv:ybu3IGbI8K98byov4TR9iy3FcG59Yr7EZC5oVHbUpUk=,tag:+pDV21jYmde6KlGa9HfV1w==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:wEFLWIh6UL+eI9eMsgqad5lI5dppWIzac/E7B8Fa3PUSLQejPQgpeA==,iv:1r3DFRl7i0QPZ7ITr0jhKV08b1ZbnDmnmIw7FCpOkzo=,tag:rIeDFnAM4M4VaoagzalxOQ==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:aJ9K549KzJSlgNs=,iv:Ca9+3SxNXvyd7pvX9ShtGnjPUn8LARnMhOfSaauUAEg=,tag:0sRvNSahQTOsw3Xk9YrOPw==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:OTK6lJ6IK46n8wXKVLTH5WwKXn47iLIZjEPQasLPW29RfBjOfihuZN2kEf4T8pBwDQh0fGaY6crs1dewF4ama4hXvmg13kWqO9MwSU+NHiuroX4gUZ+Fg8xkBOjnarEe7NMiRMP0BrlntD1LwwcTDV3p/Df6SphgyYfqeXDiJP1O4AosPuv+4egBKqqY2KCy3czc47PcMWapSxWJfMU7KlWggYoSiU3BR3EhbP8cRVZ1tYUttrb0brI28A==,iv:c+h68p57wTshNPaAzd0EiEBVXcAPvBUvgozWJEpZ7n4=,tag:aV3vlkzWJCAp1n6kLXCFNA==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:m8Qr,iv:RdVMbGg1oRNC6t+Ly27+wbjR7oI6uGYmPgGagNFE3cs=,tag:/xqPyr8e2SZgMw4dM9eRrQ==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:ius=,iv:SgH1AcWawWQRDHcB0E5aWCfp3UXDSOvCXocgsV1eeKo=,tag:1QfV5Jl9bQvV64Ty+IcNwA==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:eLJUhmgQ+zOYRHnYKJrp60DQpKR6Li3iOCeJ1VaHWv9IwItuakAEIRbVrC8hp8Q0c02M76a6nlObomuRFSqK/zOMc3aqomg9mCpObYsgQw==,iv:vCCjOU36LdEwEHaA1Rqzqfs0Po3uodY4qJSbbih2BlQ=,tag:er56v92xiIMel1MjqRL/cQ==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:FFBgY/8j+ZkVDJlx20UsSc7OiR7V4yg38AC6jJWKZXZ/KnRhCjMgsqwgGHwxUBbZ1YgB/ZKLscAyhlGWeF9An9BsFueUJk1v9W+w8IOBpiczjftfC4VEqVWTqpiWT09iGbiiHYZTJKUvdxeNnsStoNAmcN7R,iv:GUY0HRWcMHKlYhVDo/LsXrS0UXMdIuak8bn0gdtAVFs=,tag:Vo5Qvjt9JHpRVbG01s01kw==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:whlgZOo3vAFqntlSdO+vvrBIws8=,iv:yiBuRlqWtTD7VZuylXFs8c+rDCZbce3N0c3kG8i50Ss=,tag:V7mbPzU7QGJcv98IUc/Mig==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:oFi5Jd45an5EUeIfbNL2cmcMa2U=,iv:Qda3XG/A/3ilj4Ak0AL2LBSq+N5TClAtIZ15w6//U1I=,tag:5tx1ESB4JOQNGM18L4Y0xA==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzZWZlN0NjRG9aZHlKWHVR\nOElRZlVscVFseGRORHZoSEpHS1ViUHpnWFVNCkR5NUU0ZUJlR2VOZHV5aHRJTi9G\nK29CVHhvOUt6OUs2aWNOSG1RNHdzMlUKLS0tIDVHcUF3Z3dzKzhXeHNERUlUb0Na\nUXBaS1doRkx0aVdFWGJVUGFpNlN4aG8K0cjHGDgqdu4DnvrU1QIZAkaMIoZA02aE\nVlURBU9Y4MInhk3xs/9MSxNLaqlOPDu5sCXRI9ATO02fkiWNDIiDRg==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:QwIZX8X4prK25PkL71h7QgSfTzxi6jrOPrmPZriM1HS+mARLkZS+CsH0L0bCVWU0/RnY9Ul0mtT6W8JrMiENmw==,iv:VZDXJXHEK15m2Aga6qWKw1r9X0/HejZmFD/hySKV9eU=,tag:YtJWe6RgC27XEN1R2Pmt3Q==,type:str] +ALGOLIA_ID=ENC[AES256_GCM,data:LFdkdnTJBkJ6lw==,iv:O18JPjnSZeBlU8ZPbQUz/lz0e46MCeBtRtzvfUf0SFQ=,tag:YB1AML9eiLqUbuBhtPDG5Q==,type:str] +ALGOLIA_KEY=ENC[AES256_GCM,data:9LgXsH/OXSg+8gwTGjwhvikHqWa00Kz7sOSZgBYgZ54=,iv:Puv8Uas8aj/oHl3kEVAfoi1SO73nQqq3BfU7YjPXxPM=,tag:OnFllRwJZdVT8qBl9XhR4w==,type:str] +ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:etwRyHHPvUcPAirfMBFl4/RcPNMAHoTj35HG3RZRZXQ=,iv:mV8xbHxdZvqKznqnOTnApd2eu2j3FVnvhR0CkO/rTrs=,tag:iuEM8tkQZ8S8r5JH44iHvA==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:ojDBZhAQ+y27AqpYacJtWxFrU3s=,iv:uWRxeAM83iwkVR2hiRoGKs7gFXJqoHAjUOgwH58gTQo=,tag:Eh22ZHk6PxoFAOMRwAEZBw==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:Q5o3MLWBhafVt8N0qv4j5qAod5w=,iv:CUa4NoqfCI9UI6G9rqF7rLvZ48O2n2Yy9VmIzN08pGc=,tag:J4LoI56Ak4M+KPSDLn0iAQ==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:+u2Nwl9ycTYvA75Qd/UxeT0Ve0JU2BAbpO9GrZy2iwkV+/rNw3T1TA==,iv:EkyKV2vy/SzNaGQ5Oisfywz0dnF6Vrt14RINFs1Rewc=,tag:QpPRGK/1/fzhTEPS0ZipLA==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:QnDJMxc2tEaQJSgNIY8D4moZ38o3oZLSxJHMuQgvu1kMO+AgrWKUjw==,iv:HCoq5iDtFfwttcUQI1iyUOSopozZpjeLlbXvBr8aYAo=,tag:b8grQVQ4NHG/zvSkTeE8Ug==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:shNTuKUSpUr3PgbvLUgqqd/yANmz8UcsiS3dhrB5BSYVJ4SuWnzzOS863+k=,iv:dn2UsBtL22BolPl7y9cg+mVHiQiR24PnT1giPBM0bZI=,tag:jmC2ATslqzV3geLRXNdFXQ==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:SGsNasbiVnbBhDlHQW53q00lPSYidNK9/FfYorR7BJmqRcSWCYoZpKmuem++LCLtfbQ95g8=,iv:hl7lgEobh/WPp0XqI09dp3E4GEjjPDt91g0JK6Qm7Qo=,tag:+pIX5QGfSQJpl1Yj7aZx7Q==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:Vy6jGSJX2QMVWg71RD6dzREpTLWXnKlGeP0CT9K2KB4=,iv:anwDOF0sl+plFJefNm7swlmHpPFfPkQDBDysEX/55w0=,tag:KJrzy4VHx96jETzyx1pw7Q==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:d9IYhTltxN6k4EuVP9hfvJhYqBcsRW04XoJDpPSYLGtcMGNvqKwL7whD6jA=,iv:0XJOEdBAsrBmCkElxPbOX/kzWCzLxQRwAOezVKHUB+A=,tag:ZbwKfyIdCf39LMb1Vkt58Q==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:poD5QWfdh/kPOiNMzZdJWWaJ8w1p/aG9xA4zu7Xm2ofEsQ==,iv:NanbyvJ7IpSO5JjJbEaQvgVkiKt5GU8w4knZS7sn5fc=,tag:WLGMLnT9dUWun8IJQG1Sew==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:/TScbpSP,iv:MWZ1nCD43O2lFpWj2w0MJkkkX/WcwywaYwGaA66pppo=,tag:Kbf8dccofZhTooD6jU0lYA==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:FMCoA4s1709MnRWGpjaIyNhxYhw=,iv:WRLN4pC2DlRPWk2wHQN1jkWWSDR63TBlLoIOWQkQotI=,tag:VdecgaxWBNEKa4BXneP0Eg==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:EB9oz9jb96SwiCcH/m0va9lFQ/LEJs155K3thqCrDEfCmdahjwzfrzg=,iv:WZDN3jxWyy/AO/BA0jZq/0xWX5e8XLnyB+xHqHFSrlU=,tag:yOpHh4pfkSoJrQ050GCMYw==,type:str] +FASTLY_PURGE_TOKEN_DUQDUQ=ENC[AES256_GCM,data:2vSXaeqJKThevWutefRuLf6HEsAca2UMVirzkEjLZcQ=,iv:lyZruIZvw6870bdtb5uYyZZWPaxGeahWLKznBXM4+y0=,tag:SaJ7gYPIIoLeO97K56V2Rg==,type:str] +FASTLY_SERVICE_ID_DUQDUQ=ENC[AES256_GCM,data:OtLTPCYI/Nni11+bYgxF9G+oIY1auQ==,iv:fyhp7pFod3PY59lqhBccAkt0NuAE5ZyMCiyBiSbTAdE=,tag:N5LObQVbbw94BuWi1cR2rg==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:lECKfIfdMikTO1b98fqJz28G4CHJjYwKvaEZ4h2bcEw=,tag:DjScRvE8w7XbW46nZZT3gw==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:YjPwEA==,iv:zsSPad1f5sbg73RRjhT81GGMNUr2Ua/NFdME65i2rIE=,tag:ooJrJwbpgSyvc4fAZBunDA==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:Wv+bQx9NgAqlX/pJKvs5LeDirgYkdKZmHWZBxzxK7aisYEkOngJmK0ktMfEwqdY85/AcQROXaoytiihmNZFvzxaXHv1rpXcjsupUsdfGzrn705XxZUsq58r/AvQlKNNCvsvgQpVF8cT2gYnPp1PCtV5o1phd+b45qmX95g66im+7SZFWbnay02bTEl2Cnb+upnbCUOog0GcGfiqnu2FT7tAKBNFIefpV66ajcVH3+cPzDtNuKKbF/Kq0VS61uLHEEXYYDMspwmQpnCgyKUmOuRa6rcosbXPVCnWEjmYrZlnsKdaNfN07Kx2KzrhiJAi08a6cFV9qxzTeaOBvuQx5abZ5yjTARKFQ5Wt3smy9AjJNnYl1uqnJU87ueFfX7br+7bza4LOe+5DytI9MdCmi9kq2/JNPrEHd7hYljbsNYJmCAN1nKypxdtc7UEZOvAl4asqtW3KZkHNpKK9hZKswrCg6YdyICvEndQlxFe7lM5FSMMYLS0kTJCOicng6LVb2J0EaNaTPYG3G7w6XKXk19Jzec6RoQkOHLbNJ1E0MTRAd66S0jQZnYjF0Kw3OgidX1E4ZTFPeMrYwvH7kGdsPT9aSA02ANgV711Yfb1kz3BxlxZQgWAdKsqpukavDPMsx7OrcQjUw/bq6LWdaQ0IBrcSgdlK43hvpUEANIzEta9D6H3zCBZSw+4woI6a+ZWL4TmrT1MUjdWQMb+h7n8huBFBYkYPShKcu40mCSmB9lxJFsfeEE4h0KwRz8Wky0ldjxqCtgcVvCRgFkYuMN4Q8DFLoGUmzfUDfzFmlGcW399av9B4fR2uQeSbY49uYAkOHiOQgyW9rn7YM0K/cu4+/kTxXiPIx7Gd9zxBbFsgYFUuYP5C9m/8Q8gKyrNFOavbALppzuHnIq7Ol4aheHwRO9qOdsUIq78WAwtg9vlPEBgzh3IV59VahGT7vK2lzvSwn3aO/pP5WaS4+X5tg/mzKey6W55BrGk4nHhj+Qyn5G9ZoDCuAO/806k+MEzDM+1UnPk14lvm8stMIYRw7uGn7b3gNwoTCqZEtkO5I2kw7+4AwAFsVLOzQjzeXu5thquLrlKpKY9YUTYUwoje1D2vFm3SaA+AvSvmyfqZn8WfKQfC3TIkmENrAY9PMBx9FbFFrSrjRpSADvDF9sMgiVs64/9SB/jnJlR1uq8lnOJoVR4gd1UV3sYljULfLE8QWkIx9U1mOW/4T+kmurvEwxBgAFNSFdPdi/AzIBqFRDFtx/tsTT3PQ9xwCCOK1HWWKZzMAijJpInJe4+Zv031Ef0jisifF3jLefH19nAF776eVhXdZbRJgnr8OsX9E33bhkacsSI63STJDak4PiBVpyfTa4A==,iv:4AB+Gy3o2YjSzpcWf4NlYb+hVzEBJkHVkIEe9AkEnTY=,tag:YY7l8sVvMWvzoG6tt7m8KA==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:Hni3CZSMCAw1Ko3L0QAxmTdyxE2fyGjz6uPf2MDO3p134SZb,iv:eg34UYvFQ6GikDk/3/HDIU7KP6agV1CK5sq9y3yz78M=,tag:Fh+nd2Op5Cq/p/nb+ow9zA==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:SyyJoOncFXFF2aKmfV827hDCdv5Zr2sSXJiSszSGIW784lQ9,iv:corJ7R8AbJXk8ipaI7O/q5LnsDZfT/erIIPgJsWehNA=,tag:X/fBZgjnDvLKJ4/7TSvy0g==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:rrtTqdChIjTs4lrCrV7IwaZaCLG6AoJvglNZWiLAKmo4xrwlohAlNV4hBp1tloq0Mpyw5m2xl4Dd3h8A7hxzew==,iv:FVBVNeTJbhF+E+rpbgXDBS0CCniam24qzr3CKY7pz10=,tag:R7PbxajT0uIJJ/r6ZeOfbw==,type:str] +NODE_ENV=ENC[AES256_GCM,data:+eUy9Aj12c95lg==,iv:ccaeTWNgHUjVPdWBGut4XC6XAVYypqT2EhEU1f3LiK0=,tag:nJcq9H/CcKiRHB93s+ixGw==,type:str] +#ENC[AES256_GCM,data:x86kxjwXzrjAoX9V+4AIRIk+QbAO5UPGpZc=,iv:S2aq8qkVD9VnWyZpfYq+lVoxjFgcWm0AngLj3Nxbf58=,tag:mxS3SBeP8RqFZ3GP+KLv6g==,type:comment] +#ENC[AES256_GCM,data:zJiGD91h0y8+SSRtayvj655B4/opVIFNlKS3fxTVrmVt3ptcYHE=,iv:IJcRp3bvZA3cmdO50jdl7D4RGQXRubnVBf+70cm/Re0=,tag:nS46w09X4ANQQGpCKag7PQ==,type:comment] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:bS20RsEE6SXJ/gD0UYRb1tLFzvTc7oc+VUB+rYGeKyMSLvY=,iv:bGDPWDsvphCwax/vwwrmrpeWOZet3o8RmaoX3KFxfbw=,tag:gyoTaOAr+z9IhkWhvmSTew==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:HMAq2zxdbPKwCifMpnqt0xCTKiU=,iv:vfx8Gzf/5KfFUqMeNDNRxdIZSi+Rxxqm5397ziVmhVI=,tag:mWg2KveS+HKpPh+LhcEVBw==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:OYeD5BX6pVGOT2L8u7r8Z8dbv3AYqS2mvZ7B6NqJld2f9mMTpTmZGQ==,iv:MrViCuxr7VDn89zeigwWzyhJZ0hWUd2Q/VB56t5/9B4=,tag:lUrJxATW+PWeeeAC7Po+1g==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:AlixUPNBxfAovcg=,iv:KuOXNH8ixFIinRWsHZKHqvyq/TuLRUC8Fc2SWCDxFxY=,tag:SDjLkobA0aqKp0DS/jJUQQ==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:N1hLCzeQjMbPzn0SSAo0vT+4FA6BihKM7x6uPJDVkK1SvnKcwdjKXH0Y4GxKPI7U39EWK8cbx9PGmk1GeCDgSzkq83tNRD21e88veFnEKP02F8efLnfR0j4NM3Kbg9J8g4DT4Yp1fOvaQ6Twli6+stWFYxde5ziIzrnbUOrMvdAUZPHvbFOvZiJJtYR77pyAR9/dbYMDrVHnCydt9oRmYRsJJbbNiNbrC2ATTQru1kmI1oMcdqLY6iQx7Q==,iv:A1nqc4legVPc+0Gl9RWCaWGMm5/R5yUucrUawEDWTNQ=,tag:GGgNa22SIxa4AcBKFv/89w==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:GFdm,iv:/ewODHXse8BOGqSumC8ZGIC0chmiyu2b7UfzRBduc7k=,tag:/MRFSdMJDanUqWO+hKxZng==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:61s=,iv:mr3svYNDAD60QHqUvps165FUbJoU7reh8AvERwRIzDw=,tag:zwOLOx7JrVe3YoYa8FJ8ow==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:IZGLpb0MwS2hIKiiSNW6zGYPkSXqxRmrBVWOZCMhK81prXVy6Xa43zHZnkFgPw8euE0tOnwPl/Qwqt+7It3UQXJLKfV8Xo8rSmoPG9w/RA==,iv:3YvSRMDG6REfBeKNFYQWtYLk89S9ewUtkWVzRsJa4Q4=,tag:Nj9UWPdP/r+/RZgAwpbK3Q==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:lsLU8jzBMTLt/UawVWgUjal1YQJWYACXrNCc1vrhqiLgHFzfoGNgttWP8lVK6kBoAloJBcYg8eUxdoMQM5+tq+WNk/iZ8DEri8yIewZSt+NL64/3VvHSsPBw0nbY8HUR1Fm+iYE7sujNx6mA1E6nDhdzIcHl,iv:w6pmAV0gvjX8saQHlEPgk572gPJ6xnPwVnazijFjccE=,tag:1KKKipxmDv7+sKSTco7S2Q==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:Y9mSpaxJbrISeMNMsUOqVBLOn78=,iv:pGwjaAaeaaQL99edzjt4XvSfP2GrbsVOF1U4zsU8lI0=,tag:lwbndzd9SQvJ1Uw/TqVVog==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:twNvhGqTnp1uLflUUPsWyFeXEE4=,iv:t6x/BvikRLoG3YJwZtQ2WDmmO5A7SSmRSj+bNeNjFCA=,tag:6O0XkKaPepDarB1bkxZBNw==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4R0tWbGk0WGJOZnRSYW44\ncmdGc2xlWmJnblY1dE9kR3Y2cTJjNlhrN2dBCmpSZzBiTUlGMnVnVWlTYUM2MkRu\nZEM5SWV2emVHb04venMvd1owTDNnbncKLS0tIC94SGNOY2NOYm9ha0d0YlVRb3dw\nanhEUTNuZ01vcjRBUHNyZFhYdGhJSmsKPUlgyLqiDrSKJ5LivHiHeoIlr43G5LTA\n1ca/d6ifM95xgULFcB7sZkwoHbeoxxJW9/AdrQB5pee01OnLSB0Xrg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvT3RxWDBqTS9LZ2NDWnND\nZ0p1dkxTNHZ3S1dLaTNPK0Jsd3h3L2xmcDFjCmpoNlgzWU50Tk5DWDc5NWtnTW5X\nZ0VMVzEremEwdWFDL1FDUjBKeUdnRjgKLS0tIFJEeXFqMnlMNzk4cjN3bHBiVm8z\nbVgzdmtaVTdzelRJWXRWQ3VnUllvVEkK7+wFnHcWlQ578ZBdYdEfbstSSUHyftzm\no6E9oYEqwH+oxftQN5E2nVQ2QxwdsXKlnThkMJgVxH3ncgfMSUt5zg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCQ3VENHdqNmVZVkd1Nnl3\nZ0tvUndoSmZXWVloTHJVRjNYT1htKzcyRWx3CmovV3g1MktwQUI0OTA4aUdmL2JL\nZFd2bmZpcFd5d0MyRjdZUmhZTko0a2cKLS0tIGN6bmhqOWhkM0sxSXlweWpPMUhY\nbXV4RGU4NWRzRGV1eGxaODc1SGVYNzQKK5nbnkgNTGcS6pkrIxydTeuLlmD4pbdo\nKHfVnYvOh2mCzwWaNbRitgvpBE+xFcl2iB98rBR2YeV3K9kCmA56og==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4eFhnQkJVaVFiWmdzYTd6\ncXduWCtZdG42dTU3VVJ4cEhlOEhpRndSN0JBCkdTWWhFQnFCTzBOalM1S3RTbDFo\naHBGdDdUcFB2M3o1R25VcTJ5YzZ1YnMKLS0tIDFnVnhzcVRtZmJZSmdxSE5JSGFK\nVkRRZWRPMi92VFpUNldYZ0MxU0lTSlEKh6gcbf04oOIrmLzfoK/0wagfzxDh/DSb\nQCRvyhkY4cFQgO1fn6fU4UXdOq8Lp0rXPEuaK15L7hq3q3hEo74O3A==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMTUozS290K2NLdGNFQ2ZV\nL1A3OURkWkkvZzdLR2dwZCtNMkwxSENCb3lNCkFwVlEwZWNSeis2dDlzUUpseDhq\nSWtwT3owMXFaTVRiM0FwOXZ4SktBYWMKLS0tIGlKeEpKakJTNEtFZDR4U1hSc0I3\nWXg2UFJIRVRJeU1BaUNHaXV3aWVIVVEK/8vGKWaoOz0Tabq1KS3PZiAaBbdHdfJX\nKDm4iUpXqBFd7xALNKbRDDNY0AbsgTHebS+8QQQM01o/Lf36AFZSoQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2T1gvVzZKQkx0NkJmek9i\nUmtWUXN6bGt0WVM4V0NIN0pZdzMxNXpUZGtjCk4zRVNRMzlraFNyQjU4Qkl5WkpQ\nbDcxQ3c4cXFtYnBFd3hmM2owNVZaVjgKLS0tIHpVbGlLbDFzZFhHVGlFRU12c2tC\nYktGSm9JRVlCUmxFOURHeENVU1UzMDAKbUeckn/3XgXyPFn/W4Ha0ayo2v5wVMQb\nNrsjhYQFn9cdG8H8hqeGh/yE1KLfIwzI8U/HSXlYs/NtsvH3h5qUPQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlOHJNWVRDb1FqMTh0ZU1W\nN1pkRDhBZTJpTE5kazRBU0RMMW14aEgvTGljCjZVUllGN1VXUit6d0t4Nm1jRU9G\nMkF0ckxhVCtrb1BjeTBiUFJzWHBwaDQKLS0tIHcrVmZ4VlRGNkhYc2k1MVUzenQ2\nRjBIQzYzOGxXZmxwMGtJNnlxd1o3dzQK+QHtZU6x6U74tFWysjjRwFYGZvUjAB7I\nzRHc6qH4zJ7T5H7AyA6IJlauoY9ChQT9lRughZRgcpq5nHvPfjf25A==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQWkY5YkJiTUVxQXFES1Q4\nYkY5UXp6N0YxaVZILzFkZGMvaEZ3TU1XVkVBCmV1dVhCQU4yZU10MnFhUGxVTEQ3\nNjFOeGlHdGRBQ003WHZGMHJzbk9zZU0KLS0tIFBCb0NRY3UrazRzM3g0TEw3MUhn\nTThUbzdFZkJONGNYVTF5QitLaTV6cW8KR/S0wl3+auYy9Ag0tLckJ2Xhy92e+s47\nm0lLrUGvjLYSGdA9Ox3KS2nmem+RQp0RCjTzErDlsY7X5Ai7duCJRA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwR2xvUlFyckxYVjk0alFF\nVnRuUFg1aG5zVHNUWmc2N3VkQkxDeVhtK1hZCjdZQ2xKVzlnMjV5V0Rka1ZlSTZx\nWFlrMTM1bmVURTBKeEl3Z2pacGE4S00KLS0tIERxYUpzZEtJSkVYTlpPR0cvQ1d4\naTUreDNsWGNyNWhZSkNOcWpnd0FOYTQK69rYFQ7g/cFPUQf+4sIbKTkE6UKG5t8N\nbQwQ7C7yqBd/JGq4bUd95n6tUACvWZJZb6PjkdpDnA5Z5z04LOCI5w==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1RWdBT2pqd0w3UHhtMzU3\nYlU2Z2ZsWUlCd3lIZkNMQmV3aElYTHBLNURVCkloRmZHRTM5T0MxVWxjQklRVDJx\ncDN3SnM3ZFBYWk9LdXFEQmQ2ZGFTd3cKLS0tIEs0SHhEQTdRdWp3MkdNa25mK1Zz\ncEU4ME9sMFVXT0NtMFNoVnZnOURmd3cKii8ocexy9c0xfxPaV5FtBWlWy9KsaIEh\nMpH3eJTuAK0ElMjFrrI2AvjuW3OYp3WQU7ZnqI6ubZvi8mW7iZH51Q==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvL1FJMTN3VDZKakhiY05U\nSjB5ZnR3d2hjNWR2MC9VZTFrbWNCbTVEdUFFCnNxWXFVYmtkNjFxWm54YXJOWnR0\nQVFhN2JGRmlFdVlEajRJdWVhZFk4RDgKLS0tIGNXc1ZYZnFQMFZTV21lQkZreHUz\naGpSTlJRdWE0aXNJV21wTG5Uakp5Mk0KnLJe5q2TO+JOaoqKBqMiDsZfZJiGQv4+\n9T1XiTPId/FbEPW7ClBTy4IsOLcxH16JPxg2h5bxHnip9+eMJhO4yQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-03-31T02:48:03Z -sops_mac=ENC[AES256_GCM,data:PtfB+K2c3gueL/a8MnWPsaO5XL/+uk9vgilMCAGHRW53R0ilHzWujyuNTtjsFGbReRP+SXbU0hllj3RkxCQtwZKouci2Qn9PmAKEonhRLjdYl5owSJsgSaXTL97wUS0hAZrCeYYAlLLPNYgP3XE7DqtFx9mY41vcn0ViyK0MPx8=,iv:F09inZuqJ1oRn8IUQE1noenfjdsyMNBkqtW37Z4qogs=,tag:M4Tc4+MD3zl/YZ+VcUxP2w==,type:str] +sops_lastmodified=2026-04-02T03:37:47Z +sops_mac=ENC[AES256_GCM,data:Hk0oh/ZzGjkcf5vBV9s98p09rzF3r7jTj0UjpYPDYOKjN9zuSTPx36RziF339emo112xUEPCAvpudUgLYpV7VxkuKYXl0UeghYNpKw3NyBYGrE6SMWwYBJNJXHCdl4nFAZD7fbc0bD42nzq7HpEKOmqLzl6P/V0kWItk9+diiHc=,iv:onY4fMd1xxlF4IUQ73ONawi4/MAuRmN43EsDtjgEA5k=,tag:UKplSAOZre74TMLl1x7UBg==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/infra/.env.enc b/infra/.env.enc index 1be76f8416..9c3a86747b 100644 --- a/infra/.env.enc +++ b/infra/.env.enc @@ -1,55 +1,57 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:vbymPb9pIX3Ar73KrjW62lYM/8A8fWoh7MnJA3IUAusQ6+MpOXNpL5k8eTu7ieGwiV7gY90MnQAra5upymG3Jg==,iv:xY8Lh00p9lcIT9B9LCARxQihN+GrivS7C2QlVKt1J2w=,tag:RDwybIdDYRk7Ecx/5L5vDA==,type:str] -ALGOLIA_ID=ENC[AES256_GCM,data:T9BnAyvLkom50Q==,iv:hntMtsqHiA+1V6D26KA756wRBm1BkFl5AuopGhLhUT8=,tag:GblUMhdJJG+VrGCniCq3zQ==,type:str] -ALGOLIA_KEY=ENC[AES256_GCM,data:72FxZOZkqzMIdbvYpO1yEZFyG0YZY7rVxJjuDPgcGD4=,iv:v5QGHSSljTYWL0/7djTGtPDP/VPGQeq0zPzDHrIfE7k=,tag:QmtvW6czoCHf0QuqZzyOug==,type:str] -ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:PhxMicSMXQSqsFVb1K2Z3ByLwpZROrob50lICyVokto=,iv:+BjJtiUur6YEiP3BxNi34wyPw7D5hVZoXYZVs6CtL8g=,tag:78je9z1AKBKwbJbEs7aKbQ==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:T+llsxSjDyEyDPzQ5ocRshwFLTeqmG/BsQd1P/aTYxd+3IsWbKuDJzuyikAAQozpahKkJZefeN2nmdFhNJlrPPE7xXXvTn2Mm+gnMRJFb5X2H6/OGua+4Ds/wcREkKhCXJJ8PrUgwR6UNagqfvfa5id34ULX3Q4ZyIUCUnE3EtQ=,iv:/Zt+E7wtlGwiMqwIVOgNT8AH74JciranTzaaPi+5esY=,tag:E6LPkAGz+odAlMMsmRbHIg==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:lZO5lg0B0KwZZqcNsVkLYkQN+xc=,iv:gZiZvft7AAcfl1NEXbrkB0FSvb7ncYZ9BQ7TWK2IyLw=,tag:XdtmjbCznNYvh1UfJrgbdg==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:4VovW11aEAmALfR0GJzQTUqhPoM=,iv:alQBBxvedCD10OPIf8/J/VRriY38LCQDtHkkzHfdxvc=,tag:RK5YxQOPb0A1MVbLK3ho/g==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:FZn5un+lcsDlePnkGlMTH6lUjncFMYjzl7aV4xyJMtCHhGHx9OOrMg==,iv:zQR6Z1HxiNJAv/sN3/A3UjRr33wbdNZtCIB6EWj0yNM=,tag:Uk7gvphLvDeXACoCzmryjQ==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:YZb27G49IssZyhiQJIc596Obf2H1g96Nyh4mXr5rNhHypTUSMYCH8w==,iv:r7aMjD/RZBUjTP5q2rsNOlSt6+ncKgfDuBL4OX39Q7A=,tag:t0Q+Wb9Zq8aDiXRTFdS+SQ==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:4BDHdFZ4+N1bdc2Ae/BO8gUIqcBozcAAW8btH6zbkcTViLq0FVa7jqdfK48=,iv:3f9W35UnnR8KDxTQ1nZAuhMX+9ByCIKJctjjsJ5HvDg=,tag:1xWFZOBVZnK1RQxK3BQ4zA==,type:str] -BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:hyphmo+BecobINiBmCOtn1uiM91/,iv:5Mz9NiC3siH/ne5a0oomvFdJKsXODXuP9C6gx4kyHIs=,tag:oGRKbNCP+/8S6mEzyccF6Q==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:VlGdDtXbuz4Z03la2CGzYPgaWd9jNjbnZL0Hp7nntg/wsThrxsI5gJcxjNw=,iv:1neCIDqq0sdaaP/5YGi9CDh5bppHCqMeTtMcUwbQ8kg=,tag:8oNLRkD2QccR8a2EBNntjQ==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Ffm/UaNqtwglqDY4pJbrVSvbxoV8AS2hpL5/0NI=,iv:rnCV7miKSbOwpDlKpOr076gMhnas/MhZz+YqOSNrzls=,tag:UR3UiN3wZDv3dKyE/A8sig==,type:str] -NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:KiA=,iv:JyFPxlkDeW6mbwFKmIb1V1RmQ79L1NrvJwg8F+ZoDjc=,tag:X7a/MrnxM6Rx41Gj0fxaQg==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:rnyWq5KO,iv:ES5zyrlIYq+YiM76fbQkQWOkRlHCfCJfvJKFkkvbe6k=,tag:SbakzlP6bPGata6MxQPN6Q==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:LQKtRZYGCzbIYHTXou+Ajpk8VMk=,iv:GgF8FClckpq+ZzfzxQKxTz9LXXblxIie5JeBM3d9kk0=,tag:I1gRtcQevT63dUQrWeCtUg==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:FIeG9pi8ABy0OOS15akayMmbyVDu5yS0WHZk18Zf/QQ2In5bsC52nQ==,iv:XPADr6CQ4Wq1SOlDoGgrbVxrVM941/jtS49uNW7rQfs=,tag:amR+BxRepiiTrjPxIvpZFQ==,type:str] -FASTLY_PURGE_TOKEN_PROD=ENC[AES256_GCM,data:tEeV4a6MslOGAHCO8O9exE2OR+FWI7/P/DEZr1JQEtI=,iv:rs2G/2JHcjYej7XubvvazBuysfGvcR3JqIT1KHYdaqo=,tag:i5vbABs45IDWLZA6stIZKw==,type:str] -FASTLY_SERVICE_ID_PROD=ENC[AES256_GCM,data:r7i2ROh2K0SPmvjdg7S9fRLewlZmiw==,iv:tyvLY7zHaHM7g/7RJt0WP/k/iNeFIu8wCaZeGkWDFak=,tag:+S9pjgNt1xhmxujbKIEOUA==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:hscfsdG8NtRkfYYmGDxtiAlkbac2RTyBsKTkl2jr/m8=,tag:bcuZAcwY7JRrysMC9uFu/A==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:7IwVrNGrGdp2uECsH3QOtZ/OvskoO/LGCs/XHVbwMRVtPPo4ydbHxvmxbQiDzk+Q0GHv0sKMSbsd8B3ybYYzEU5jWp4qL8uQnqYqT8UaPVoPqgfXiaugUYLlWN+GVqTyfgOKQFTdyfcr4EGJ3e6njmaZ1rMF66AJyhJPAKHSqi7bgNWqOECjNISV1SZL9vVgo7L3xuwu9t2tUqTE95y5wWlz7XQvOjDCBJB0nuLd3ztfzI6qwMkzkg2nb18tHDTcPDItrA4t+rEcpC7ZeAhjE38536k3WcU60kCXeXnlzn4AP/2boGLGPfYC/qxVJ4fAXVf9WFmmjuhjGNMP4NqcsqufgpwgmxEVJpLC6ZFb0QFNjtPZK+SnLd/qJUzyJVpuIRUdCjK/V587dVBMh53V0Kuc2q88fZKA2cWQtjihJUUyyGayFoBIkgEi5aVmYX04w7szcaEFaCPDphKbV57UZstfrO8oYXAIDLVH4pGBSD97RiIvJkM7u0gItYzk/n3C/8L28g8/Fm1MVTvF+PsEnpb3F1q49fch9/y7ffyqb+i1JdlGdlDKUyVKScs9Ead8BuVe6VyDHxSEUJnNhmEAftTPyLXf/VLUlEtAxNAfsLDZ3tsb8/kZhp27elwvpCLmYZez+zCBnF6m2iCfqDWrextk+Iu4yghzt86g6Qkimqo=,iv:YcUxqeIcGA1lk45lVLThieXDtcfL71tJnmxfGUyut2k=,tag:1sxWXaALcISDV6zhXfafzA==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:KQhcMqowStnITI9nwlMYy4tdRb4HfyEOf429vgMCvh4DNtza,iv:ANcTeRiOVQRPWYADhSuSr4Iec0wD7LPmR2uJkfSaEV4=,tag:Ys18nnsHrPzeFmXirt4/QQ==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:qZ/mvlCYsb09CUd6oAxausNEYiWsrxvQgFyAWuqmuvnHDZHn,iv:1n4ndvLhR/ZsZG5id4Zc8rThI87gN8NpK+zdguLJ2Pw=,tag:Q5gZsNDyMqjclOv+xWfP9A==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:NYstQY07BV9I8vS7bNQ/Cc0MKh0IasFWGldiG+PIzLpg1sicBORDLZlJjD2/9d8v+pZl7d9f8ETjMSJDK2JTQg==,iv:V665xBdAyDoJISCKCGlr3fHz0ukkGPeIFciu4l0/ykI=,tag:10lk2XHC502P2zmOhIMzqw==,type:str] -NODE_ENV=ENC[AES256_GCM,data:d8aLeE89dNwCcw==,iv:O0Hiqq/LRysBJpT5a598UJq4VW37OHf4OBcB7vYWga8=,tag:nlRerWMVGSrBO6ir8SxD5w==,type:str] -PUBPUB_PRODUCTION=ENC[AES256_GCM,data:+YMqvw==,iv:8BX5vv/f5+joMxH5IbRmps+jy1a/PGlWhdocif+LACU=,tag:jPQwqLcBtVrzxgtuX7KZFQ==,type:str] -#ENC[AES256_GCM,data:a1UATWoyeGcPNbqZDI/5t4ZeGHJ4lQGxIw==,iv:gfIL58YSIUuJ2Dd35HWTKqdKHDWlHFxb22etNoqbXzE=,tag:eQeJq8FzglAtgcxTw6GJHw==,type:comment] -#ENC[AES256_GCM,data:2xM1MTnAs4v2RlHq5XcEe1UNIIhV+JCTvdBAGDzH6EUvZEIh0w==,iv:sr9KzmZsbhzXZ2VEA6jeP9LP23JVxv+EwrQQcwtG7Wc=,tag:hB8YG7IuE/syrIylMkDq+Q==,type:comment] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:vtIbmGq13DPp6xmmPnoLZ9yNa08X4mGaa+10yG8xfvXzgKc=,iv:8AG4A52LWFlJ0ENL/iusIM5I6kBBTnYQjpPZrvKWPQY=,tag:tw0Fm3VItc0SduiVfZS2OQ==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:2Th8rzoPAvzCYE9Wuqb8dT7x8Uk=,iv:bBspSn1lZyl9fXwni8kHBXF3HNqT4N24Q0qWCJJrfkI=,tag:aL3JNfqt3z9EtS4e7a6Kuw==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:Kn+bvOY060HrZ5iKt8LX9FAxUTqE3lsmFQhlX0CV9HOHL1h8JN+i+A==,iv:1EBNgDsy/kAvUJqxFwTVmH3YLlVWb9yyVwhNqaIYpBM=,tag:d1+up1HVO2YbM5UhFRtn3w==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:Q6+2yr3yNTDohG8=,iv:HO7haOuCSn6wqL2n9PZd+wV+yGue1GtZZlu32zvIzKc=,tag:XgrLlDcIfENqw4ILP9pSbQ==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:foNoUlRH4NSmcZb8wcJQWLGFYT+M4WhBOxcuKrMO3yk3M9ffyXASYEfcQuMX0JtyUHODCjIcZBEenxAjorXLPxRRA/86BvxrmUaQW3Ytzyr1oG7MMbx7C/0eomzahMONHFLqUitEiv+IzAm28/qwEvT7C0JoVjX5Inq1MXVJBlXNtn4eIvFVwFoIZD0DjhDPhNAbb9J/pZ7QP7/kXLd7O0ekWZAuyUeor0xSkvHu3RusGhkZp5E9k2idkw==,iv:P4dLkW6qYWo3V7Z828gjA5c/2HDO6g2ACA5QDY0YsdQ=,tag:PVzxgpN9V8nrvlsT2OWVRg==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:uD4Y,iv:xCwzDtvUsME6rkXD9ZjnWEpWlkkQeCHBTWO/KQ3d1tk=,tag:arH2UqCWDEnx63u0hYfGrA==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:xZc=,iv:+VnTLa4E9ebKgzkL6ZXmJ77/z1Wg4IPchiLomjucoKw=,tag:FxcZKE2WP24MtmMqysZcaQ==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:14eklwo2DK7XzFCl4B0zKorH2mjDJRPdLOQGlILCrrnEf9R7lNE9tAg6Jcw27UrNArwmHA4OoEUKEV2dyCVO8b3MPjsUNtm4X4lhFeQQKA==,iv:GMQNtmbaZf3fr9Lz1lhOyx3WQpEGt7HY0NhFqogT2Ys=,tag:P7Cmgkz8Fp797vDIIz3oDw==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:0DTdF0yYSzaR7kjhJlKAUI69vlizcQvVNKutdsgkJXEM7j7r7+Aa//kxWzs2RC4iybgqh0cd9SYNyMGgvcP/5ilOoV01LGhMEV4BpEzbC/y71U4PVpzJCRPrUDNbnW6tHcqsMA5neXXZUK9a0iae5NHV4PST,iv:kDMMDSMSYs2f+vd+rzEhAUYWhtO0ihNIF7W7eJWh4Z4=,tag:r7B+miNzxPb2MrANLj9GSQ==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:3qkmTVWiUpP1D7vYahpLdQh1sG4=,iv:trEME/o+ZXXizoRX3zgRm7d00GGDuhIhPXWO/Ck/5WQ=,tag:WsIG8PSLsA5LIOeOZkTCfA==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:tVHxu0XEMCKOai0fZcCiddozBKw=,iv:WALv9GYJDI5eDo4YiOZhkhGt+I6RFN7V7GpF/P3MVFk=,tag:0XG3bv9CpS72tC07qDYeyg==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1dmQrZ1ZWZ1ozdU96RWs3\nRDU3MEhPbDNXLzRJZ1pKbUl2Y1E4U2Qyd1c4CkQ4TlBCNHM5L28yRDJIZ1N2ZE5D\ndWJQQ3RXQmJjcnZhc0hxYnFhYXpua00KLS0tIEZyeTJMT243cnliVW1TWEFPTVlh\nZS9wTjUybzJ2cFh5YVdsbE1TUmE2K0kK1k8ivaJttK0pzp5UijicG/NT9BaQ8Xog\n0TQpR54x4vtLBgfEMi8vy/V6jBYtCFHoefIUfKw9psNxrYx6NhJz8A==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:wwndqSv2MyIjmKwq9JqjxKFsUqvg/hBDbDibDDahffBYswCv87Db8XknpyqNGfsSHWbcyltDCZVIBeSuCsyyyA==,iv:Z3c+5UqWqd3/mk42iTM7zXB+7Be7FFKX0ZC9ScO6PuM=,tag:p7QSKjd7FkarIcUn4naqsw==,type:str] +ALGOLIA_ID=ENC[AES256_GCM,data:4JzulnozFmPZLA==,iv:tfrffJmRI5MTP4vK7XFYBPY21TYzUlYvcoEfYGsmtjs=,tag:MtO3Hy0/zBN/mqRSNbLYaA==,type:str] +ALGOLIA_KEY=ENC[AES256_GCM,data:mqwXWlYIl9KTjvG4l1kaiBe3ZBNOncdY9Y1wIgU4lck=,iv:mhYEoF6MpyqYEL6P2j6fnsbfnKIfHTdes0+erVx9DVc=,tag:cuWXTOcX2pRbYaAV0ThbUg==,type:str] +ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:jc8dIUKYUodnCWBoCrDHV9y1lMVtc+BspV97xU0sJMQ=,iv:NV0M+aRp7MRxtO0pOb644Ef3d7Sca3/ytEIE662nLGY=,tag:iauwsjDU60rlpxhk+laJew==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:8Arh/hBVuNDElqQKYp3kXpVlEvtEgAHn/pMwFDh40yPEb0Dt7jbLas8vtDNY+V65cOe7V4kKRSzHXKilt1psz7i30WjwMs9+m7DLsZA79T6zrd//QA3Je8rmnR4wR2boLhzC3qk8a1u7QkCAW9bgLkuIjvT+QyC1IjjZlH2jjs8=,iv:p4oSMyE9nWOjMSvxJRpJhD7T171uqe0HUqgQ5y0lLY0=,tag:73UZAjsF7OfCn6QgqvyhTA==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:9m10wQNGx5pC2SlheVnqB+fePnc=,iv:1AQbyF8z4EgBXEpXukh9tkVw7PiiPlgoY3/4eo+30cg=,tag:uhZtDF2RI1q8He6g+93JZQ==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:fGTOKI7qvPVHyiqlvgQoyOpAAf0=,iv:mAzaf/YL+KNRVFIDHhK1qpxl7kAJTWA12F9eZACFbaM=,tag:CwEtd88OeL1+6rPKkWDg4A==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:ug166i5Al6l8Fu+8CeN1S3S6TVsnIVZmCgP1R9OyWEB+g7lvT8sA9Q==,iv:HLpf24PeVoOIUiwpTZXA9EEqBcbgi3TGVT1iHlfYUmw=,tag:oEPPgqrTvH15F/Eh3olP0A==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:E/n5h9ghjmTVF5h5hAxF+43N/y2D1z2JyzcTZqYjD7XXNglR7mKK4A==,iv:4FGDZPE8zI1gDHugHl6ybENx5G3pPuDgk8hJvIj3qfU=,tag:AdJ2Foz7YwtaFnje6x9gkQ==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:EHaQxEk4tSDiXTEgPNyZUivr38e4Sgk6140YIboJrq3p+beDeD+RLenM/H8=,iv:tboegTeauTew1SKL35KqdQxIrg0Eya2p70BvzfEkpqY=,tag:aCBl0Fy2IUO5AryIDxQtJw==,type:str] +BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:1BfJtijgn55OVPAdM5F/3FYwNjfC,iv:RqIcEzrWbYKBEBhOvGm8VH9fHaVlcPdZMjdrQB19Pq8=,tag:uIb/WXMhdYvCz9styt3s5g==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:VS8Q4NChEnWSQmuk8l4lb9yWLwyP0cUKsGANIIu6dImkacumOtWyHqenAECNla1cbECzoyU=,iv:A7IWdFAYZp6rtGZvihVnbZvP7yVtPkWNWwHbo8VtaEg=,tag:oRPSqS4u3HmMDXmFCXqY9A==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:fywQzuQEn9pdNIimV9GuhmfY4xLL2ELP6Ksq7MWFPck=,iv:nXIZDugREgzlNJPABnBmWIdPVp0gb0q30d0yDRtetx8=,tag:GKCKCwltl31qWMBuDuaN1g==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:9BTb4c8rWijnB4ky24X3o5+45IUUKaKOQZ8NAa3DcvpqFbimRwjkgPRaZeg=,iv:8NwXuFcTsN02rz3GnARXJQ4r3kbQZ8i6Hcp5woipi90=,tag:1xq+bqeUBZZ7rzw21nysmQ==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:ccb1p55D6HVnZMhjI/JOA3W1fQr2iCQUe4YdX9w=,iv:hc3xKVZ64ceE0X70Ma3DSQhpdTC9G74IF9at5u9zgCU=,tag:g6vci/24Hyuu4+uyvr3Kmg==,type:str] +NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:9Js=,iv:aqV/ihhmshyl+AitBR/3ht2lcZfAlbUN1aWv4ykqcZ0=,tag:soRsQ79JSp2dS23z+wJNIQ==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:7G7CJCl8,iv:Yx7O3m/9ogWC+MC7ELskHY0ds0nd5DhEkCDs9DoUkoo=,tag:lBsZKxlWmeKRXwXq1ZwNPA==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:d+HERBAHsB/Jgcv1Y1lLZ7SDxbw=,iv:Y/yHx6njcVHspagOVmwml1AF+wiO/xFk9hWNAGXg2gc=,tag:nvMDR6RQs7tqPK+JAJjp4A==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:/yBd5dBnbnT7Wwngak2wetscOFlIk3LdVJIeI5yaOGqSnmnspQ9PLg==,iv:3I22HNo6q+B42PVY7xX9WkhnwhPCkMUFi4nlQEk3EYo=,tag:PwyZbykobCdtFxsbogoo0w==,type:str] +FASTLY_PURGE_TOKEN_PROD=ENC[AES256_GCM,data:quujm0S9LXd15BrFu3Qr2ZG0wXSPl+VV75xWJHELMUY=,iv:KqZ4/pVs3QNP/Lnuqsy0lChxHCKfB1cv6lpr3k9JbLE=,tag:oXfTC8sJfiLmdW2oJT4EOA==,type:str] +FASTLY_SERVICE_ID_PROD=ENC[AES256_GCM,data:5mwHBBxnWSIjgFnL9tvSxhV6z5yiaw==,iv:Oxrpy+indTLVWQIAEguDRMRo3KXrIFEtWwC+5qzns9M=,tag:/16gsvBgWjd02SKEGc98ug==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:KhxfkNZ8u6Tb0MYevW55/6JA68DJtQuiBdFZj/0K/ek=,tag:Ef6tcnyMlRRxz1papcrbcA==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:CfBlcHV441Ql64es2Ryb5xD7fp2Jlk4H7TTZAGYfH8++NNxlwQx1gBqAYfe95u43hZEZJVSUGlDdFGX+uwsM7sw/mGr3OxzLscNGq+4ZdauFHZmmT2hjGa0G/uHjtXDgHgOT/HTf2/nUTJhegWYHUjSzLN7/ObbT9wfYb5iKAKraAsF1okWw+/OPFfKv8I508qCp5foEN7eFvTMLE6waMW1EVvNrX59UQJRNvlqKPHiRfel6F0GK4OEs+eE+3g6kWoBNyPHADpLufTfG3eyaN1SwwbMnHzBn/DC5H/zjGkPUwubwjAhZkcbvZpHjvDblufJfUz23jQ7CDIupjwrBqCtiK2lV2VbDYKdboQdpCKlFu+bMhYQI0CmG1Znnr9dvbiuXSHLIGj2oX7BuDZ92+AGzKXmHEgZIlKOgILFj6IS+mbYY9eyVS8AhxVj1vMsQ9NGfM1eeP6gV2hl0P9EFvRps102fc9GWWKp5Qrl03xhj9irjglNcUHywYBnI0Su3fmwXf3LqOerWo4iDFaaiRvUbY0Rx6ZXqNsSx+d3HDTHr5y2Kj2N8MW6Oo4ShoB6CygVuaoaChTH+gf+1FsiZj43R/mOkcW3jnzQLG/cRzZkIGAiDMA/rqcSHfScQoI9RMCxdtD0XGwibb8nalqYvP2TBdupRc6+HkMvIERDwgmg=,iv:n/ADcQsr2NPEsyu9HqXtW5UL1HmkT2J02X4q5QU9cdU=,tag:MepIH6qtyy1xfNb48vQXaQ==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:oK6a7gaUFbVOHc8oZ5Hbbbwh0pgm2B9FwYuYETLkdUrDvRGx,iv:o/BGp68U+kT+aohKavkbxD7vSRFU23BjyeTUycPQGAg=,tag:uPq8Xe9SXvt9bpTgdDVOcQ==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:Kl1xxcaV1Bxk3yR/wlXrEPuREZ9wisXC2wMUGuMa80Q/wSLu,iv:CSi+GdAJdwkxiX0xjMR4nkVecAHxWpy3DLtc/lsi8do=,tag:XOGaaYuI3orhgW2c0Vfa7A==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:Z+bYXP/Pr+uIukXhkHRzjDo62+xxEX1rUwkJr7xhVj0bQ/oCBfUvboT+yPf2QzloTak9mFanqinJV+aO+KvXdQ==,iv:t5qBdfiR+39tNTbTOfZZWwrHZltXGoF1hv/GOoZhjhg=,tag:eT5raUeWsHyLIb8Jswn0xQ==,type:str] +NODE_ENV=ENC[AES256_GCM,data:3G1tICQGSXh0Tw==,iv:hrDUTIj06vuIo5slHfCLbjnIGH19bQPfqSiy50zgj6s=,tag:CHuz/X8cY1P+XIriMVVK1w==,type:str] +PUBPUB_PRODUCTION=ENC[AES256_GCM,data:7xMNvQ==,iv:t55FWrYc9cqXQWuwFNS/dxcpFeQxyyELLcIQDAhl1uw=,tag:FjKAUIck/D9TsmR+Rt5oSg==,type:str] +#ENC[AES256_GCM,data:aQAu1OBmTBnIl5Tr7+v/rxGklFf1I2mFkQ==,iv:VMII2xT6vkeYcV4ec5CRYxmQtXZyMlVXjPR60dQenpg=,tag:FZTW69WLfSAmMsexlUzedg==,type:comment] +#ENC[AES256_GCM,data:+oQ7KAt8wey3IXZ+MKbdzFA/lWVm4uLjDez/QHdjn39fLzPz3Q==,iv:shnQ6gjhjeTSd8W+l7xcBFg6+s4x4R3752liFFsix60=,tag:2ed0Kf4sSAUf+9Aatbb5Zg==,type:comment] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:90qdmqeJ/i67Uz1m5H/Zuogmm/inKQpoVuatEhOgU1RYLjU=,iv:35YyOqCA+20LzQnVVHoeOeafY4NOA8XhFwyJ/hr+5A8=,tag:BTKfA/aLEzv+q9geSSjEFg==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:bXG4zuMIweU8MXtZ15iONM1YbDs=,iv:ZpHEbcFctnM8hgHlBNfioAOrnCAqDJdaadK6qbDiTps=,tag:MOZbmMIG3mIPkWL3WaFDKQ==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:nyM9m9CKTfnFW06d5ddQmvAbTddeJakR7ahCxBa8s+Rf1WsIeJzfEg==,iv:LdLPa0kjBUmSw2o8njxdJD9+e+B0JnV2cm5+DxYm61E=,tag:bKwfaqVbY+NitmKVnCaUfg==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:RAjKU/MeRci7Au8=,iv:XsvfLEBlVFohMkXgdrFioVzrliD9aIMxDjSIFCZLnVo=,tag:/S+CWzhHxlS7aaURuBev9g==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:AHroshPbxk84wXNJBv7AzufUd8cAyrU+9KgI8zSDRqWaKamsYNzvX4fNTcWB+19Vjq3KhCR6I5h4IF2+GF5Z/dtGC611g+/NdFnlNQN4zreNhmvQJnWrIkYmXcvID81llWCcDvNaMrUVJIirG4nwxMz0jCH6bIFuQIhcEZdqy6oIxEW8J2XU/myyEfIk3WaivR0J16oQ8MhIKyicJUIEtzP+/oc8dxuAsI3ZUYayn7CLSgPtWbUJffPi1w==,iv:UlKd/SkdIG74swZQD/eTgMVgmGEMH+2h2IqXIy9L2UI=,tag:9DcnwXQvXcIBtRovpEMx4g==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:+tA9,iv:RmHgSpZE2uWtWvTvz+AJB/wlcNIGy8LcMImuDxEsQMQ=,tag:bJcv29ozdpkF5Dd6trm35g==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:Rx0=,iv:b/Ho1bVGN2KM9t4cZhB/yQ/w74mYo78nr7G9zIfeR/Q=,tag:xJRaQ+AqB4M8RA0S0EKqsg==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:1AbX/MCuiCfda3ppkqd41qolQepZZ3KAgWk1TiC7fQbs2AUFg4D1F8DDssD9LcJ//nvnkje7gcoeud518YH6UvNSt+ATPGVPGQahSM6RbQ==,iv:ecAE+HXyz6To1a4kxLq54HcDzMiT6dv1VqpmhfandT8=,tag:QqEjlJ8hCPwvnBetvN+Jbg==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:wT6Wo4wqeAQYS5L5HcIplFlQkFD2RMj+0PZq5jolVCsFOaPNY9DfeiRtW9hLYzshOBNRtAV2bzvppXqcmHPhZAx3oioFpypmV2IZB2YmmWqW/UwQAdg2OkcMUBfDeR/nWrcEaxuxYskevApuWaZ/YLuYInCh,iv:YC8ooLHoBLJeNYyA/qAB6kg/E0UzFCXo9CfkJ/mrtmA=,tag:yUHYcv4ZX7E622EqnOIaCg==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:c5BnrGOM/vrtSNWacJvF6ueuv74=,iv:1pD4U5qb9bTTIfDjro+Wu/W7Zfv9P0AsSGkD8JvOELU=,tag:TYMDMriv1RF+Sojanx7b3A==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:YeT5B4h74UW78q9bvonDSwWQKUU=,iv:xBvsrk6HPKR/XBltyOgogARcX/+18nihlHeYOXcYTUE=,tag:5y26F8nCo/ZLXAFncolWOQ==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNdDEyaGRqY2ltSjRNSjJz\nWjR2NGdsNDZzeGdWNEV0ckVIRE5kdTBWTFc4CnZiTno3NkdVY0t5VVNuN0o2d1pJ\nVUF6am9NalNReEtDcEF2cVlIT3d5ZWsKLS0tIDBoS3ZnUzB4dStWUXBzMFRrZHFl\nMTVkeEpFdmxRSW9Xenp2eGJ0em9jaEEK0KJEmAMNOu04+3BXpzXHxjK1w8yuPHPm\nWmuWU84VM6hflDfi0w+z4nyB6gfiBnehA61hEwvrzgUF7s+B8DNNug==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqMGFITjNJazkzNGtwQUF2\ndURyRktTTE9VY3l4TWVlam9zc2psQzBSMUNBClpIcU81VitObWtnM2U2TVhGaHo5\nK25Cb01CQjdwSlRjVGNmdGlUY2xHa1kKLS0tIFMwOUtuVTB6WmNrTDhXd09tRGU0\nLzM1bi8wVmpnazMyLzhvOFYxVmNtUmsKSjl6gGc/oBnkd7rGz5HsXVlRtSY6PopE\nOoGXRVElkLdhBzC9LY6HbgkWSJyu+v56mIbYro7euczYX+6dzrrerw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyNndlNnl0Q2ppYzQ4eHhQ\na3JmL2hhTWF2NTdRVjQxM2RmaXUrM3hqM3hVCmV4ZXF0T0VJMTJDVTU4TmZzdFlm\ncXdEYWtkUzNrc1IraWhlZW80N3hqcmsKLS0tIEU0MkRoRFFrWjZtaWJOb0RtWkth\nSkZpYVJYekhtVFR4RW9GN0hiK1N5bHcKUpaRALmGXcbE/dAlx/ijWgccaghfRIcy\naYtpnP5H/CRvuG095RNXit3LN/MV3JiRYKe3QUjks+mDQHrKnUQ49Q==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2V2c3dkxMQnZwTDRrZWZI\nL3A0ck9CaEo1WGx3UE96bEZnWmVBUXJaazJRCkRLYVN4aFMwN3gvaThkUUE2dGpn\nS3ZGMjlTUW1NL1Radk4xOXIvYm42dHcKLS0tIEJxbHRIQUo3ZlU4QUR5K1RCL2RK\nNmc0bllPUmNDUkVuRFdUaGJYZU5yVmcKZKktxJzujZ/C8VogEoRE4l6RrYEUf41q\nObBXtOgawAax/R0gdCfoTkoC5yfNT27yu5ngIz0wlDpusNF4L+fMpQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1TW1YYkxrWm1sbTJkaUQw\nbzFwbmlsTXJBSkJvalBXam1vQkV0WnZURkV3CkxBYVVpR3BkL0RUOWN1alMzV0lM\nUWI3cmk3b2pHSE0zZjh6bWt2SDNMUVkKLS0tIFpvL05pNW11aGlMTmtLT3lIZy85\ndkJiZHlnVCtSc0hEN1lKRzhvQ0Fxdk0K6bJZXUueiyg1T8fZK3yM/knasxHmR7jn\nNLhaJsdVatDOoBqRFx7SFk4ETQy/eC1hK10K8wfqIuuqICrC/mhVcA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHc2RSV0trMW1qZ2dRaFlS\ndjZ6ZHliM3hVS2FKa2R2R3FiYklwQllrQTFFCjJwNk1ZZEo1UkNDMTBPQy9vcVM2\nbE1MSHg0WXZ3NGd0MmUzY0pmTWRkSlEKLS0tIGxKTThVR016enMvZi8rSjBHNC9V\nL2MwNWhBdlplYnJ2Q3lLMzNDZGJQZVEKkNanfMXN+vxtDXaUSK/w4q4/bc0NwVQw\nVyKvEmwT2IagUVqD7ey+4upLgiGdRuhkooAGfiBtJHyPVsKvCPeMgg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKc1NaSzlFdUZYYUZXcGV5\nQVJJUmZYUkp4K3pVTkNQNEZmam9YZGJQb3hBCjRSc0NaNkVubHhuc3VvVURKdU5R\nM0xQUmdFTG9TWFdoYm5NWXhJQzlBaUEKLS0tIFpwcVNVN1dWTHNRSWJVaVUyV0pT\nend6T084WkJ1NG5ldmZ1dXF4akI4MGsKQVC/chR6Spkp4z2RIvERs3OgOF7vam2J\nm3wAsUo23LNhtD2AlohPLmrLMNnMl4rhkkVEJUazJwa4vO7vSpATyg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1N0JjWkt1cGxwbzhPVUVk\nVE9MS1R3MHRIL1VLRHlGNGxpSUdGZWNVc21NCnhMamUyd0RwYWw1SHhQQXBzbzh1\nRVlvMXpaUTNId1BwYU1LZFVONExhNzQKLS0tIEZpZ3dhVHJtRHBidU1OZ1NyZFUw\nT04reTVzR0tuK3QxZ1ZzdTJqUThCTzQK3vTB9CQLDcOJShwvYOkmOcLQfJ9QCkZF\n9SNn1wPd1MNGSfxdSOYWl9t1o0z3gz65YSL71KJC8xZ90TaV3Esthw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCbjBHemdCdUFtS2RjNHFa\nY3gvbmVXMEI0S0tkOFFya3NFOEtsbUlsR3cwCndEMWxJMlI5bFFZbFFGMnBUZ3hq\neWJYQUxoTEdmRGlUdko4TjN4aG1ZelUKLS0tIDZ0a0wyTGk1Q1d6cWJveC85Y1dt\nclVHUEhkbDNOUFBGcmJTNkpQQ0RFNXMKXaJaLRGZ5Iof5UcRpal/4mgk7JU3xvZ6\nUz3fK/5c9nPT3Q4m90QBpF2yC/dglKkUoyxSJo2tYEByWhVOw4382g==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyeXlpa0xmWkMzSGVNQ2V1\nOXMwWi9ibitBeXk1b0lsdUVYVlZieEVZS25nCkVUcHdkTlpuUjdkTTlwV3BmOW5i\nbEQ0cEY4TEJubGV1YjlnQjdjckRKbjgKLS0tIHRHa252aXVxT3Bwd1JaWW5Ld0xr\nTW84ZHlWNXF0K2dDMEJ2NmxWWndYckUKEsCbco1+C6mhFUxFj9zGQuo1Xs5U2HMb\nFq/OLyclKqJryJbkNRPQTfD0J/vzLKk1TLjiyn6fE74vvHVp0qUOCQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDYUtwNEowcE1yT1pwaWl2\nOHlSdmhNSWkxOUVuWU9ydGI1RlBOc21zcmxNCllSR3pDNzk2VXlRZ2JQSDE0Y0VD\nU2hTUlRwMGp0M2dLVjJxM1RQYWFYSUUKLS0tIFFab1pxUVlXL0RwVUI0U3phTVRW\nQXFtaDVXckdocXN6Q1VzWkxEUHNINlEKMJ8AA0gdiKV8XNytk7eof0W49iUlzr2g\n1et7FiDDzpQdPBD3IB9PkbwmVjBJADm2LkQ0x1DWuInNU+nDpsowZQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-04-01T03:03:08Z -sops_mac=ENC[AES256_GCM,data:hQlfCmb8jk2U5+U1cRI0W662mzTMEfaHXhmX2nHLnTSn/n3I3rVnl/tl16XikJjrO6LQpoELhs0Espu+1PYF7VRknDI0vmoP4XYciV/W2LiXZ8Q9au7NSfNrOmkvNEGdXgOedzxO5cCl96ARmmXsz1Yi5SzWWaxDW3mFb6RPSm4=,iv:TLr9e4RhdNm6Yc/YKPXBxQRzb9Q1RqYeW+0dQodcKDo=,tag:Z2TIN79C44dWJWmgzfdd8g==,type:str] +sops_lastmodified=2026-04-02T03:37:53Z +sops_mac=ENC[AES256_GCM,data:Ip76fcVfHTtyP3DS9DGF2L9tSBWhrZ/8HzvzRPfXP4sOfdoq9tUNiJButvgKxhjtleUSaNv23LWD3EsTSyTLGbNx0eluFqSbvSIlhyPG5rUGXSGQuE7fQnKgqqJH4f5Vfs2iXuHY98uK6mfqICS2f++EeVqulQPnDMObTDnVVSg=,iv:Nl2L6K+UPMrBFX5hQagazdKIgj31uPgUCyHUs/pXzko=,tag:jr+t2DlSrCbHWdl0sKLdDg==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 6168567a8a..23916937ef 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -15,6 +15,14 @@ function getConfig() { const apiToken = process.env.CLOUDFLARE_ANALYTICS_API_TOKEN; const zoneTag = process.env.CLOUDFLARE_ZONE_TAG; if (!apiToken || !zoneTag) { + const missing = [ + !apiToken && 'CLOUDFLARE_ANALYTICS_API_TOKEN', + !zoneTag && 'CLOUDFLARE_ZONE_TAG', + ].filter(Boolean); + console.warn( + `[Impact2] Cloudflare analytics disabled — missing env var(s): ${missing.join(', ')}. ` + + 'Set these to enable the Impact dashboard.', + ); return null; } return { apiToken, zoneTag }; From 82b39615e07fd6a77f735d62f4017e94ef05d80d Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:40:17 -0400 Subject: [PATCH 04/13] Lint --- server/utils/cloudflareAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 23916937ef..5464589acf 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -21,7 +21,7 @@ function getConfig() { ].filter(Boolean); console.warn( `[Impact2] Cloudflare analytics disabled — missing env var(s): ${missing.join(', ')}. ` + - 'Set these to enable the Impact dashboard.', + 'Set these to enable the Impact dashboard.', ); return null; } From 8dddd47f5ac59196c7aceea575d079c199e89b10 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:42:24 -0400 Subject: [PATCH 05/13] Don't show debug api in prod --- server/impact2/api.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/impact2/api.ts b/server/impact2/api.ts index 6fb6513584..8080ae385d 100644 --- a/server/impact2/api.ts +++ b/server/impact2/api.ts @@ -55,8 +55,13 @@ router.get('/api/impact2/test', async (_req, res) => { * Shows the exact hostname being used, the filter sent to Cloudflare, * raw CF responses, and which hostnames actually have data in the zone. * Accepts optional ?hostname=override&startDate=...&endDate=... + * + * Only available in non-production environments. */ router.get('/api/impact2/debug', async (req, res, next) => { + if (process.env.NODE_ENV === 'production') { + return res.status(404).json({ error: 'Not available in production' }); + } try { if (!hostIsValid(req, 'community')) { return next(); From c352dcd790d62ebb720dafd971abd2277ffa6ff8 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:51:50 -0400 Subject: [PATCH 06/13] Set live refresh to 1 hour from 3 --- client/containers/DashboardImpact2/DashboardImpact2.tsx | 2 +- server/utils/cloudflareAnalytics.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 05edef97ff..62c894b6b8 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -385,7 +385,7 @@ const DashboardImpact2 = () => { persists. Treat these numbers as directional indicators, not exact measurements.

-

Analytics sourced from Cloudflare edge traffic data.

+

Analytics sourced from Cloudflare edge traffic data. Today's data is refreshed at most every hour.

)} diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 5464589acf..1bd39f16f2 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -208,8 +208,8 @@ import { Op } from 'sequelize'; import { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; -/** 3 hours in milliseconds. */ -const TODAY_CACHE_TTL_MS = 3 * 60 * 60 * 1000; +/** 1 hour in milliseconds. */ +const TODAY_CACHE_TTL_MS = 1 * 60 * 60 * 1000; /** * Delete cache rows older than 90 days. From a731d4580d17865f6e917aed70736f30fcc1e963 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:52:09 -0400 Subject: [PATCH 07/13] lint --- client/containers/DashboardImpact2/DashboardImpact2.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 62c894b6b8..1d15404eac 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -385,7 +385,10 @@ const DashboardImpact2 = () => { persists. Treat these numbers as directional indicators, not exact measurements.

-

Analytics sourced from Cloudflare edge traffic data. Today's data is refreshed at most every hour.

+

+ Analytics sourced from Cloudflare edge traffic data. Today's data is + refreshed at most every hour. +

)} From 7513b707a2146eede0513b40b4e83870010fd48d Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:59:09 -0400 Subject: [PATCH 08/13] Small comment edit --- server/analyticsDailyCache/model.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/analyticsDailyCache/model.ts b/server/analyticsDailyCache/model.ts index 390cdfa033..aaa8d7be0b 100644 --- a/server/analyticsDailyCache/model.ts +++ b/server/analyticsDailyCache/model.ts @@ -7,7 +7,7 @@ import { AllowNull, Column, DataType, Model, PrimaryKey, Table } from 'sequelize * * Composite primary key: (hostname, date). * Past days are cached permanently (expiresAt = null). - * Today's partial data is cached with a short TTL (expiresAt = now + 3h). + * Today's partial data is cached with a short TTL (expiresAt = now + 1h). */ @Table({ tableName: 'AnalyticsDailyCaches', timestamps: false }) export class AnalyticsDailyCache extends Model< @@ -36,7 +36,7 @@ export class AnalyticsDailyCache extends Model< /** * When this cache entry expires. NULL = permanent (completed past days). - * For today's partial data, set to ~3 hours from write time. + * For today's partial data, set to ~1 hour from write time. */ @Column(DataType.DATE) declare expiresAt: CreationOptional; From 7262646452f51699ad564bb3b23608ae74965f84 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 2 Apr 2026 14:16:55 -0400 Subject: [PATCH 09/13] Minor padding --- client/containers/DashboardImpact2/dashboardImpact2.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss index 71ca5a1f2c..e56522d328 100644 --- a/client/containers/DashboardImpact2/dashboardImpact2.scss +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -161,7 +161,7 @@ font-size: 11px; color: #777; line-height: 1.5; - margin: 0 0 4px; + margin: 0 0 8px; code { font-size: 10px; From f87388e57b2dbf9e7b1241cef558b76aa96639a3 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 2 Apr 2026 15:23:07 -0400 Subject: [PATCH 10/13] Update routes and add banner --- client/components/ScopeDropdown/ScopeDropdown.tsx | 5 ----- .../containers/DashboardImpact2/DashboardImpact2.tsx | 12 ++++++++++++ .../DashboardImpact2/dashboardImpact2.scss | 6 ++++++ server/routes/dashboardImpact.tsx | 2 +- server/routes/dashboardImpact2.tsx | 2 +- utils/analytics/featureFlags.ts | 2 +- utils/dashboard.ts | 2 +- 7 files changed, 22 insertions(+), 9 deletions(-) diff --git a/client/components/ScopeDropdown/ScopeDropdown.tsx b/client/components/ScopeDropdown/ScopeDropdown.tsx index e1232ad9c5..993c2dd0a1 100644 --- a/client/components/ScopeDropdown/ScopeDropdown.tsx +++ b/client/components/ScopeDropdown/ScopeDropdown.tsx @@ -201,11 +201,6 @@ const ScopeDropdown = (props: Props) => { pubPubIcons.member, )} {renderDropddownButton(scope, 'impact', pubPubIcons.impact)} - {renderDropddownButton( - scope, - 'impact2', - pubPubIcons.impact, - )} {scope.type === 'Collection' && renderDropddownButton( scope, diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 1d15404eac..796c02b427 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -217,6 +217,18 @@ const DashboardImpact2 = () => { {!loading && !error && data && ( <> + + This dashboard reflects recent activity based on edge data and is designed + for quick, transient insight. It does not provide historical reporting or + long-term retention. For comprehensive analytics, we strongly recommend + connecting a dedicated analytics tool in Settings. +
+
+ Our legacy analytics system has been deprecated but is still accessible{' '} + here for now. We plan to fully retire it at + the end of 2026. +
+ {stale && (
Data may be slightly delayed — try again in a few minutes. diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss index e56522d328..c0780b0a2f 100644 --- a/client/containers/DashboardImpact2/dashboardImpact2.scss +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -5,6 +5,12 @@ padding: 60px 0; } + .analytics-callout { + margin-bottom: 24px; + font-size: 13px; + line-height: 1.5; + } + // ── Top row: stats + chart ────────────────────────────────────────── .top-row { display: grid; diff --git a/server/routes/dashboardImpact.tsx b/server/routes/dashboardImpact.tsx index d082304f21..76acd2b3dc 100644 --- a/server/routes/dashboardImpact.tsx +++ b/server/routes/dashboardImpact.tsx @@ -12,7 +12,7 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); router.get( - ['/dash/impact', '/dash/collection/:collectionSlug/impact', '/dash/pub/:pubSlug/impact'], + ['/dash/impact-v1', '/dash/collection/:collectionSlug/impact-v1', '/dash/pub/:pubSlug/impact-v1'], async (req, res, next) => { try { if (!hostIsValid(req, 'community')) { diff --git a/server/routes/dashboardImpact2.tsx b/server/routes/dashboardImpact2.tsx index c7198d1336..615c5e8660 100644 --- a/server/routes/dashboardImpact2.tsx +++ b/server/routes/dashboardImpact2.tsx @@ -11,7 +11,7 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); router.get( - ['/dash/impact2', '/dash/collection/:collectionSlug/impact2', '/dash/pub/:pubSlug/impact2'], + ['/dash/impact', '/dash/collection/:collectionSlug/impact', '/dash/pub/:pubSlug/impact'], async (req, res, next) => { try { if (!hostIsValid(req, 'community')) { diff --git a/utils/analytics/featureFlags.ts b/utils/analytics/featureFlags.ts index 2f986d8740..e9b0a90548 100644 --- a/utils/analytics/featureFlags.ts +++ b/utils/analytics/featureFlags.ts @@ -4,7 +4,7 @@ export const shouldUseNewAnalytics = (featureFlags: InitialData['featureFlags']) featureFlags?.newAnalytics; export const canUseCustomAnalyticsProvider = (featureFlags: InitialData['featureFlags']) => - featureFlags?.customAnalyticsProvider; + true || featureFlags?.customAnalyticsProvider; export const noCookieBanner = (featureFlags: InitialData['featureFlags']) => featureFlags?.noCookieBanner; diff --git a/utils/dashboard.ts b/utils/dashboard.ts index 3ac35b0e7a..67b455db83 100644 --- a/utils/dashboard.ts +++ b/utils/dashboard.ts @@ -2,7 +2,7 @@ export type DashboardMode = | 'activity' | 'connections' | 'impact' - | 'impact2' + | 'impact-v1' | 'layout' | 'members' | 'overview' From 7ce024a71e24a77931cec423bb705f2def15e205 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 2 Apr 2026 15:55:27 -0400 Subject: [PATCH 11/13] Add scope support --- .../DashboardImpact2/DashboardImpact2.tsx | 83 +++-- server/analyticsDailyCache/model.ts | 13 +- server/impact2/api.ts | 46 ++- server/utils/cloudflareAnalytics.ts | 308 +++++++++++++++++- 4 files changed, 407 insertions(+), 43 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 796c02b427..eb83d94ba1 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -13,6 +13,7 @@ import { } from 'recharts'; import { DashboardFrame } from 'components'; +import { getDashUrl } from 'utils/dashboard'; import { usePageContext } from 'utils/hooks'; import './dashboardImpact2.scss'; @@ -111,7 +112,7 @@ const CompactTable = ({ const DashboardImpact2 = () => { const { scopeData } = usePageContext(); const { - // elements: { activeTargetName }, + elements: { activeTargetType, activeTargetName, activePub, activeCollection }, activePermissions: { canView }, } = scopeData; @@ -122,31 +123,53 @@ const DashboardImpact2 = () => { const [stale, setStale] = useState(false); const [dateRange, setDateRange] = useState('7d'); - const fetchData = useCallback(async (range: DateRange) => { - setLoading(true); - setError(null); - setStale(false); - setNotConfigured(false); - try { - const { startDate, endDate } = getRange(range); - const res = await fetch(`/api/impact2?startDate=${startDate}&endDate=${endDate}`); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - if (res.status === 503) { - setNotConfigured(true); - return; + const legacyImpactUrl = getDashUrl({ + mode: 'impact-v1', + pubSlug: activePub?.slug, + collectionSlug: activeCollection?.slug, + }); + + // Build scope query params based on active dashboard scope + const scopeParams = useMemo(() => { + if (activeTargetType === 'pub' && activePub) { + return `&pubSlug=${encodeURIComponent(activePub.slug)}`; + } + if (activeTargetType === 'collection' && activeCollection) { + return `&collectionId=${encodeURIComponent(activeCollection.id)}`; + } + return ''; + }, [activeTargetType, activePub, activeCollection]); + + const fetchData = useCallback( + async (range: DateRange) => { + setLoading(true); + setError(null); + setStale(false); + setNotConfigured(false); + try { + const { startDate, endDate } = getRange(range); + const res = await fetch( + `/api/impact2?startDate=${startDate}&endDate=${endDate}${scopeParams}`, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + if (res.status === 503) { + setNotConfigured(true); + return; + } + throw new Error(body.error || `Request failed (${res.status})`); } - throw new Error(body.error || `Request failed (${res.status})`); + const json: AnalyticsData = await res.json(); + setData(json); + setStale(!!json.stale); + } catch (err: any) { + setError(err.message ?? 'Failed to load analytics'); + } finally { + setLoading(false); } - const json: AnalyticsData = await res.json(); - setData(json); - setStale(!!json.stale); - } catch (err: any) { - setError(err.message ?? 'Failed to load analytics'); - } finally { - setLoading(false); - } - }, []); + }, + [scopeParams], + ); useEffect(() => { if (canView) fetchData(dateRange); @@ -221,12 +244,13 @@ const DashboardImpact2 = () => { This dashboard reflects recent activity based on edge data and is designed for quick, transient insight. It does not provide historical reporting or long-term retention. For comprehensive analytics, we strongly recommend - connecting a dedicated analytics tool in Settings. + connecting a dedicated analytics tool in{' '} + Settings.

- Our legacy analytics system has been deprecated but is still accessible{' '} - here for now. We plan to fully retire it at - the end of 2026. + Legacy analytics remain available here. We + plan to wind down legacy analytics at the end of 2026. If you need + historical data, please export it. {stale && ( @@ -386,8 +410,7 @@ const DashboardImpact2 = () => { {/* Footer */}

- * Totals adjusted to exclude known bot/spam routes (e.g.{' '} - /wp-login, /cdn-cgi/). Sessions estimated + * Totals adjusted to exclude known bot/spam routes. Sessions estimated proportionally. Raw totals: {fmt(data.rawTotals.visits)} sessions /{' '} {fmt(data.rawTotals.pageViews)} page views.

diff --git a/server/analyticsDailyCache/model.ts b/server/analyticsDailyCache/model.ts index aaa8d7be0b..c7b5cfcd3f 100644 --- a/server/analyticsDailyCache/model.ts +++ b/server/analyticsDailyCache/model.ts @@ -3,9 +3,12 @@ import type { CreationOptional, InferAttributes, InferCreationAttributes } from import { AllowNull, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'; /** - * Caches per-day Cloudflare analytics for a community hostname. + * Caches per-day Cloudflare analytics for a community hostname + scope. * - * Composite primary key: (hostname, date). + * Composite primary key: (hostname, date, scope). + * scope = 'community' for community-wide data, + * 'pub:' for per-pub data, + * etc. * Past days are cached permanently (expiresAt = null). * Today's partial data is cached with a short TTL (expiresAt = now + 1h). */ @@ -26,6 +29,12 @@ export class AnalyticsDailyCache extends Model< @Column(DataType.DATEONLY) declare date: string; + /** Scope identifier: 'community', 'pub:my-slug', etc. */ + @PrimaryKey + @AllowNull(false) + @Column({ type: DataType.TEXT, defaultValue: 'community' }) + declare scope: CreationOptional; + /** * Pre-aggregated analytics payload for this day. * Shape: { visits, pageViews, topPaths[], countries[], devices[], referrers[] } diff --git a/server/impact2/api.ts b/server/impact2/api.ts index 8080ae385d..6b6df9d10c 100644 --- a/server/impact2/api.ts +++ b/server/impact2/api.ts @@ -1,9 +1,14 @@ import { Router } from 'express'; +import { Collection } from 'server/collection/model'; +import { CollectionPub } from 'server/collectionPub/model'; import { Community } from 'server/community/model'; +import { Pub } from 'server/pub/model'; import { debugCommunityAnalytics, + fetchCollectionAnalytics, fetchCommunityAnalytics, + fetchPubAnalytics, testCloudflareConnection, } from 'server/utils/cloudflareAnalytics'; import { ForbiddenError, handleErrors } from 'server/utils/errors'; @@ -102,10 +107,12 @@ router.get('/api/impact2/debug', async (req, res, next) => { /** * GET /api/impact2 * - * Returns Cloudflare-sourced analytics for the current community. + * Returns Cloudflare-sourced analytics for the current scope. * Query params: * startDate – ISO date (e.g. "2026-03-01"). Defaults to 30 days ago. * endDate – ISO date (e.g. "2026-03-31"). Defaults to today. + * pubSlug – if set, returns pub-scoped analytics (CF path filter). + * collectionId – if set, returns collection-scoped analytics (merged). */ router.get('/api/impact2', async (req, res, next) => { try { @@ -129,7 +136,42 @@ router.get('/api/impact2', async (req, res, next) => { (req.query.startDate as string) || defaultStart.toISOString().slice(0, 10); const endDate = (req.query.endDate as string) || now.toISOString().slice(0, 10); - const result = await fetchCommunityAnalytics(hostname, startDate, endDate); + const pubSlug = req.query.pubSlug as string | undefined; + const collectionId = req.query.collectionId as string | undefined; + + let result; + + if (pubSlug) { + // Pub scope: CF query filtered by path prefix + result = await fetchPubAnalytics(hostname, pubSlug, startDate, endDate); + } else if (collectionId) { + // Collection scope: community data + pub cache enrichment + const collection = await Collection.findByPk(collectionId, { + attributes: ['slug'], + }); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + const collectionPubs = await CollectionPub.findAll({ + where: { collectionId }, + include: [{ model: Pub, as: 'pub', attributes: ['slug'] }], + }); + const pubSlugs = collectionPubs + .map((cp) => cp.pub?.slug) + .filter((s): s is string => !!s); + + result = await fetchCollectionAnalytics( + hostname, + collection.slug, + pubSlugs, + startDate, + endDate, + ); + } else { + // Community scope (default) + result = await fetchCommunityAnalytics(hostname, startDate, endDate); + } + if (!result) { return res.status(503).json({ error: 'Cloudflare analytics not configured. Set CLOUDFLARE_ANALYTICS_API_TOKEN and CLOUDFLARE_ZONE_TAG environment variables.', diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 1bd39f16f2..75344743df 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -249,12 +249,14 @@ type DayCachePayload = { async function getCachedDays( hostname: string, dates: string[], + scope = 'community', ): Promise> { if (dates.length === 0) return new Map(); const rows = await AnalyticsDailyCache.findAll({ where: { hostname, date: dates, + scope, [Op.or]: [ { expiresAt: null }, // permanent (past days) { expiresAt: { [Op.gt]: new Date() } }, // not yet expired (today) @@ -272,11 +274,12 @@ async function storeCachedDays( hostname: string, entries: Map, today: string, + scope = 'community', ) { if (entries.size === 0) return; const promises = Array.from(entries.entries()).map(([date, data]) => { const expiresAt = date === today ? new Date(Date.now() + TODAY_CACHE_TTL_MS) : null; - return AnalyticsDailyCache.upsert({ hostname, date, data, expiresAt }); + return AnalyticsDailyCache.upsert({ hostname, date, scope, data, expiresAt }); }); await Promise.all(promises); } @@ -305,7 +308,7 @@ const COMBINED_QUERY = ` } topPaths: httpRequestsAdaptiveGroups( filter: $filter - limit: 200 + limit: 1000 orderBy: [count_DESC] ) { count @@ -605,6 +608,18 @@ export async function fetchCommunityAnalytics( } // 3. Aggregate all cached days into final result + return aggregateDays(allDates, cached, stale); +} + +// --------------------------------------------------------------------------- +// Shared aggregation: turn cached days into a CloudflareAnalyticsResult +// --------------------------------------------------------------------------- + +function aggregateDays( + allDates: string[], + cached: Map, + stale: boolean, +): CloudflareAnalyticsResult { const daily: DailyAnalytics[] = []; const pathMap = new Map(); const countryMap = new Map(); @@ -634,27 +649,20 @@ export async function fetchCommunityAnalytics( .sort((a, b) => b.count - a.count) .slice(0, 20); - // Subtract noise/bot path hits from totals so headline numbers - // reflect real human traffic as closely as possible. let noisePageViews = 0; for (const [path, count] of pathMap) { if (isNoisePath(path)) noisePageViews += count; } const adjustedPageViews = Math.max(0, totalPageViews - noisePageViews); - // Visits (unique sessions) aren't per-path, so scale proportionally. const ratio = totalPageViews > 0 ? adjustedPageViews / totalPageViews : 1; const adjustedVisits = Math.round(totalVisits * ratio); - // Apply noise ratio to daily chart data so the chart numbers - // are consistent with the adjusted headline totals (shape preserved). const adjustedDaily = daily.map((d) => ({ date: d.date, visits: Math.round(d.visits * ratio), pageViews: Math.round(d.pageViews * ratio), })); - // Apply the same proportional noise ratio to all breakdowns so that - // country / device / referrer counts are consistent with the adjusted totals. const countries = Array.from(countryMap.entries()) .map(([country, count]) => ({ country, count: Math.round(count * ratio) })) .filter((c) => c.count > 0) @@ -684,6 +692,288 @@ export async function fetchCommunityAnalytics( }; } +// --------------------------------------------------------------------------- +// Pub-scope fetch (single CF query with path prefix filter) +// --------------------------------------------------------------------------- + +/** + * Fetch analytics scoped to a single pub. + * + * Uses the same combined query but adds clientRequestPath_like to filter to + * /pub/{slug}%. Cached separately under scope='pub:{slug}'. + * + * Cost: 0 CF calls when cached. 1 call per ≤30-day chunk when not. + */ +export async function fetchPubAnalytics( + hostname: string, + pubSlug: string, + startDate: string, + endDate: string, +): Promise { + const config = getConfig(); + if (!config) return null; + const { apiToken, zoneTag } = config; + + const scope = `pub:${pubSlug}`; + const pathPrefix = `/pub/${pubSlug}`; + const allDates = dateRange(startDate, endDate); + const today = new Date().toISOString().slice(0, 10); + + const [cached] = await Promise.all([ + getCachedDays(hostname, allDates, scope), + pruneOldCacheRows(), + ]); + + const uncachedDates = allDates.filter((d) => !cached.has(d)); + let stale = false; + + if (uncachedDates.length > 0) { + try { + const spans = groupContiguousDates(uncachedDates); + + async function fetchSpan(span: string[]) { + const chunks = splitDateRange(span[0], span[span.length - 1], CF_MAX_DAYS); + const allNodes: { + daily: any[]; + topPaths: any[]; + countries: any[]; + devices: any[]; + referrers: any[]; + } = { daily: [], topPaths: [], countries: [], devices: [], referrers: [] }; + + let chunkPromise: Promise = Promise.resolve(); + for (const chunk of chunks) { + chunkPromise = chunkPromise.then(async () => { + const filter = { + date_geq: chunk.start, + date_leq: chunk.end, + clientRequestHTTPHost: hostname, + clientRequestPath_like: `${pathPrefix}%`, + requestSource: 'eyeball', + edgeResponseStatus_geq: 200, + edgeResponseStatus_lt: 400, + edgeResponseContentTypeName: 'html', + clientRequestHTTPMethodName: 'GET', + }; + const result = await cfGraphQL( + COMBINED_QUERY, + { zoneTag, filter }, + apiToken, + ); + const zone = result?.data?.viewer?.zones?.[0] ?? {}; + allNodes.daily.push(...(zone.daily ?? [])); + allNodes.topPaths.push(...(zone.topPaths ?? [])); + allNodes.countries.push(...(zone.countries ?? [])); + allNodes.devices.push(...(zone.devices ?? [])); + allNodes.referrers.push(...(zone.referrers ?? [])); + }); + } + await chunkPromise; + + // Group breakdowns by date + type Arr = Array<{ key: string; count: number }>; + const byDate = new Map(); + function ensure(d: string) { + if (!byDate.has(d)) byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + return byDate.get(d)!; + } + for (const n of allNodes.topPaths) { + ensure(n.dimensions.date).topPaths.push({ key: n.dimensions.clientRequestPath, count: n.count }); + } + for (const n of allNodes.countries) { + ensure(n.dimensions.date).countries.push({ key: n.dimensions.clientCountryName || 'Unknown', count: n.count }); + } + for (const n of allNodes.devices) { + ensure(n.dimensions.date).devices.push({ key: n.dimensions.clientDeviceType || 'Unknown', count: n.count }); + } + for (const n of allNodes.referrers) { + ensure(n.dimensions.date).referrers.push({ key: n.dimensions.clientRefererHost || '(direct)', count: n.count }); + } + + const toStore = new Map(); + for (const node of allNodes.daily) { + const d = node.dimensions.date; + const bd = byDate.get(d); + const payload: DayCachePayload = { + visits: node.sum.visits ?? 0, + pageViews: node.count ?? 0, + topPaths: (bd?.topPaths ?? []).map((p) => ({ path: p.key, count: p.count })), + countries: (bd?.countries ?? []).map((c) => ({ country: c.key, count: c.count })), + devices: (bd?.devices ?? []).map((dv) => ({ device: dv.key, count: dv.count })), + referrers: (bd?.referrers ?? []) + .map((r) => ({ referrer: r.key, count: r.count })) + .filter((r) => r.referrer !== hostname), + }; + cached.set(d, payload); + toStore.set(d, payload); + } + + for (const d of span) { + if (!cached.has(d)) { + const empty: DayCachePayload = { visits: 0, pageViews: 0, topPaths: [], countries: [], devices: [], referrers: [] }; + cached.set(d, empty); + toStore.set(d, empty); + } + } + + await storeCachedDays(hostname, toStore, today, scope).catch((err) => { + console.error('Failed to store pub analytics cache:', err); + }); + } + + let spanChain: Promise = Promise.resolve(); + for (const span of spans) { + spanChain = spanChain.then(() => fetchSpan(span)); + } + await spanChain; + } catch (err) { + console.error('Cloudflare pub analytics fetch failed, using cached data:', err); + stale = cached.size > 0; + if (cached.size === 0) throw err; + } + } + + return aggregateDays(allDates, cached, stale); +} + +// --------------------------------------------------------------------------- +// Collection-scope fetch (community data + pub cache enrichment) +// --------------------------------------------------------------------------- + +/** + * Fetch analytics scoped to a collection. + * + * Strategy: + * 1. Ensure community-level data is cached (triggers fetch if needed). + * 2. For each pub in the collection, check if pub-level cache exists. + * If so, use that (more accurate, includes low-traffic paths). + * If not, filter community topPaths by the pub's path prefix. + * 3. Also include the collection's own slug as a top-level page. + * 4. Countries/devices/referrers use community-level data (proportional). + * + * Cost: 0 extra CF API calls (reuses community + any existing pub caches). + */ +export async function fetchCollectionAnalytics( + hostname: string, + collectionSlug: string, + pubSlugs: string[], + startDate: string, + endDate: string, +): Promise { + const config = getConfig(); + if (!config) return null; + + const allDates = dateRange(startDate, endDate); + + // 1. Ensure community data is cached + const communityResult = await fetchCommunityAnalytics(hostname, startDate, endDate); + if (!communityResult) return null; + + // 2. Read community-level cached days (raw, pre-aggregation) + const communityCached = await getCachedDays(hostname, allDates, 'community'); + + // 3. Build the set of path prefixes that belong to this collection + const collectionPrefixes = [ + `/${collectionSlug}`, // collection layout page + ...pubSlugs.map((s) => `/pub/${s}`), + ]; + + function pathBelongsToCollection(path: string): boolean { + return collectionPrefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/')); + } + + // 4. For each pub, check if we have pub-scoped cache (more accurate) + const pubCacheEntries = await Promise.all( + pubSlugs.map(async (slug) => { + const pubCache = await getCachedDays(hostname, allDates, `pub:${slug}`); + return [slug, pubCache] as const; + }), + ); + const pubCaches = new Map>(); + for (const [slug, cache] of pubCacheEntries) { + if (cache.size > 0) { + pubCaches.set(slug, cache); + } + } + + // 5. Build collection-scoped day payloads by merging sources + const collectionDays = new Map(); + + for (const date of allDates) { + const communityDay = communityCached.get(date); + if (!communityDay) continue; + + // Start with collection paths filtered from community data + let dayVisits = 0; + let dayPageViews = 0; + const dayPaths: Array<{ path: string; count: number }> = []; + const slugsHandledByPubCache = new Set(); + + // First, add data from any pub-level caches (more accurate) + for (const [slug, cache] of pubCaches) { + const pubDay = cache.get(date); + if (pubDay) { + slugsHandledByPubCache.add(slug); + dayVisits += pubDay.visits; + dayPageViews += pubDay.pageViews; + for (const p of pubDay.topPaths) dayPaths.push(p); + } + } + + // Then, for pubs without pub-level cache + the collection page itself, + // filter from community topPaths + for (const p of communityDay.topPaths) { + // Skip paths already covered by pub-level cache + const coveredByPubCache = pubSlugs.some( + (slug) => + slugsHandledByPubCache.has(slug) && + (p.path === `/pub/${slug}` || p.path.startsWith(`/pub/${slug}/`)), + ); + if (coveredByPubCache) continue; + + if (pathBelongsToCollection(p.path)) { + dayPageViews += p.count; + dayPaths.push(p); + } + } + + // Estimate visits proportionally from community day + // (for paths from community data, not from pub cache) + if (communityDay.pageViews > 0 && dayPageViews > 0) { + const communityPathPageViews = dayPageViews - [...pubCaches.values()] + .reduce((sum, cache) => sum + (cache.get(date)?.pageViews ?? 0), 0); + if (communityPathPageViews > 0) { + const visitRatio = communityDay.visits / communityDay.pageViews; + dayVisits += Math.round(communityPathPageViews * visitRatio); + } + } + + // Countries/devices/referrers: use community-level, scaled by this + // collection's share of community traffic for the day + const shareRatio = communityDay.pageViews > 0 ? dayPageViews / communityDay.pageViews : 0; + + collectionDays.set(date, { + visits: dayVisits, + pageViews: dayPageViews, + topPaths: dayPaths, + countries: communityDay.countries.map((c) => ({ + country: c.country, + count: Math.round(c.count * shareRatio), + })).filter((c) => c.count > 0), + devices: communityDay.devices.map((d) => ({ + device: d.device, + count: Math.round(d.count * shareRatio), + })).filter((d) => d.count > 0), + referrers: communityDay.referrers.map((r) => ({ + referrer: r.referrer, + count: Math.round(r.count * shareRatio), + })).filter((r) => r.count > 0), + }); + } + + return aggregateDays(allDates, collectionDays, !!communityResult.stale); +} + // --------------------------------------------------------------------------- // Debug helper // --------------------------------------------------------------------------- From 25ae6123911cf4faa6109be4cb1e632555ad199c Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 2 Apr 2026 16:28:44 -0400 Subject: [PATCH 12/13] Update queryCounts and loading skeleton --- .../DashboardImpact2/DashboardImpact2.tsx | 62 ++-- .../DashboardImpact2/dashboardImpact2.scss | 67 ++++- server/utils/cloudflareAnalytics.ts | 272 +++++++++++++++--- 3 files changed, 338 insertions(+), 63 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index eb83d94ba1..883a3bdfeb 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, ButtonGroup, Callout, NonIdealState, Spinner } from '@blueprintjs/core'; +import { Button, ButtonGroup, Callout, NonIdealState } from '@blueprintjs/core'; import { Area, AreaChart, @@ -211,9 +211,52 @@ const DashboardImpact2 = () => { } > + + This dashboard reflects recent activity based on edge data and is designed + for quick, transient insight. It does not provide historical reporting or + long-term retention. For comprehensive analytics, we strongly recommend + connecting a dedicated analytics tool in{' '} + Settings. +
+
+ Legacy analytics remain available here. We + plan to wind down legacy analytics at the end of 2026. If you need + historical legacy data, please export it from there. +
+ {loading && ( -
- +
+ {/* Top row: stats + chart */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Data grid */} +
+ {[0, 1, 2, 3].map((i) => ( +
+
+ {[0, 1, 2, 3, 4, 5].map((j) => ( +
+
+
+
+ ))} +
+ ))} +
)} @@ -240,19 +283,6 @@ const DashboardImpact2 = () => { {!loading && !error && data && ( <> - - This dashboard reflects recent activity based on edge data and is designed - for quick, transient insight. It does not provide historical reporting or - long-term retention. For comprehensive analytics, we strongly recommend - connecting a dedicated analytics tool in{' '} - Settings. -
-
- Legacy analytics remain available here. We - plan to wind down legacy analytics at the end of 2026. If you need - historical data, please export it. -
- {stale && (
Data may be slightly delayed — try again in a few minutes. diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss index c0780b0a2f..d682634a04 100644 --- a/client/containers/DashboardImpact2/dashboardImpact2.scss +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -1,8 +1,60 @@ .dashboard-impact2-container { - .loading-container { + // ── Skeleton loading ──────────────────────────────────────────────── + @keyframes skeleton-pulse { + 0% { opacity: 0.15; } + 50% { opacity: 0.25; } + 100% { opacity: 0.15; } + } + + .skeleton-line { + background: #1c2127; + border-radius: 3px; + animation: skeleton-pulse 1.5s ease-in-out infinite; + } + + .skeleton-stat { + border-left-color: #d3d8de !important; + } + + .skeleton-value { + width: 80px; + height: 28px; + margin-bottom: 6px; + } + + .skeleton-label { + width: 100px; + height: 10px; + } + + .skeleton-heading { + width: 100px; + height: 10px; + margin-bottom: 10px; + } + + .skeleton-chart { + width: 100%; + height: 180px; + background: #1c2127; + border-radius: 4px; + animation: skeleton-pulse 1.5s ease-in-out infinite; + } + + .skeleton-table-row { display: flex; - justify-content: center; - padding: 60px 0; + justify-content: space-between; + padding: 5px 4px; + } + + .skeleton-cell { + width: 65%; + height: 12px; + } + + .skeleton-cell-short { + width: 30px; + height: 12px; } .analytics-callout { @@ -192,6 +244,15 @@ .bp5-dark, .bp3-dark { .dashboard-impact2-container { + .skeleton-line, + .skeleton-chart { + background: #f5f8fa; + } + + .skeleton-stat { + border-left-color: #5c7080 !important; + } + .stat-card { background: none; .stat-value { diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 75344743df..76749b5fa8 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -212,12 +212,13 @@ import { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; const TODAY_CACHE_TTL_MS = 1 * 60 * 60 * 1000; /** - * Delete cache rows older than 90 days. + * Delete cache rows older than 45 days. + * We only display up to 30 days, so 45 gives a comfortable buffer. * Throttled to run at most once per hour — the Date.now() check is ~free, * so we skip the DB round-trip on 99.9% of calls. Triggered from the * analytics fetch path (not a background job). */ -const CACHE_MAX_AGE_DAYS = 90; +const CACHE_MAX_AGE_DAYS = 45; const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour let lastCleanup = 0; @@ -308,7 +309,7 @@ const COMBINED_QUERY = ` } topPaths: httpRequestsAdaptiveGroups( filter: $filter - limit: 1000 + limit: 10000 orderBy: [count_DESC] ) { count @@ -837,21 +838,165 @@ export async function fetchPubAnalytics( } // --------------------------------------------------------------------------- -// Collection-scope fetch (community data + pub cache enrichment) +// Generic path-prefix scope cache (used by collection aggregation) +// --------------------------------------------------------------------------- + +/** + * Ensure we have a cached scope for a given path prefix and date range. + * + * Queries CF with `clientRequestPath_like` set to the given path prefix, + * storing results under the given scope key. + * + * Used for: + * - 'all-pub-paths' (pathLike = '/pub/%') — top 1000 pub-specific paths + * - 'collection-page:{slug}' (pathLike = '/{slug}%') — collection page data + * + * Cost: 1 CF query per ≤30-day chunk when not cached, 0 when cached. + */ +async function ensurePathScopeCached( + hostname: string, + allDates: string[], + apiToken: string, + zoneTag: string, + pathLike: string, + scope: string, +): Promise<{ cache: Map; stale: boolean }> { + const today = new Date().toISOString().slice(0, 10); + const cached = await getCachedDays(hostname, allDates, scope); + const uncachedDates = allDates.filter((d) => !cached.has(d)); + let stale = false; + + if (uncachedDates.length > 0) { + try { + const spans = groupContiguousDates(uncachedDates); + + async function fetchSpan(span: string[]) { + const chunks = splitDateRange(span[0], span[span.length - 1], CF_MAX_DAYS); + const allNodes: { + daily: any[]; + topPaths: any[]; + countries: any[]; + devices: any[]; + referrers: any[]; + } = { daily: [], topPaths: [], countries: [], devices: [], referrers: [] }; + + let chunkPromise: Promise = Promise.resolve(); + for (const chunk of chunks) { + chunkPromise = chunkPromise.then(async () => { + const filter = { + date_geq: chunk.start, + date_leq: chunk.end, + clientRequestHTTPHost: hostname, + clientRequestPath_like: pathLike, + requestSource: 'eyeball', + edgeResponseStatus_geq: 200, + edgeResponseStatus_lt: 400, + edgeResponseContentTypeName: 'html', + clientRequestHTTPMethodName: 'GET', + }; + const result = await cfGraphQL( + COMBINED_QUERY, + { zoneTag, filter }, + apiToken, + ); + const zone = result?.data?.viewer?.zones?.[0] ?? {}; + allNodes.daily.push(...(zone.daily ?? [])); + allNodes.topPaths.push(...(zone.topPaths ?? [])); + allNodes.countries.push(...(zone.countries ?? [])); + allNodes.devices.push(...(zone.devices ?? [])); + allNodes.referrers.push(...(zone.referrers ?? [])); + }); + } + await chunkPromise; + + // Group breakdowns by date + type Arr = Array<{ key: string; count: number }>; + const byDate = new Map(); + function ensure(d: string) { + if (!byDate.has(d)) byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + return byDate.get(d)!; + } + for (const n of allNodes.topPaths) { + ensure(n.dimensions.date).topPaths.push({ key: n.dimensions.clientRequestPath, count: n.count }); + } + for (const n of allNodes.countries) { + ensure(n.dimensions.date).countries.push({ key: n.dimensions.clientCountryName || 'Unknown', count: n.count }); + } + for (const n of allNodes.devices) { + ensure(n.dimensions.date).devices.push({ key: n.dimensions.clientDeviceType || 'Unknown', count: n.count }); + } + for (const n of allNodes.referrers) { + ensure(n.dimensions.date).referrers.push({ key: n.dimensions.clientRefererHost || '(direct)', count: n.count }); + } + + const toStore = new Map(); + for (const node of allNodes.daily) { + const d = node.dimensions.date; + const bd = byDate.get(d); + const payload: DayCachePayload = { + visits: node.sum.visits ?? 0, + pageViews: node.count ?? 0, + topPaths: (bd?.topPaths ?? []).map((p) => ({ path: p.key, count: p.count })), + countries: (bd?.countries ?? []).map((c) => ({ country: c.key, count: c.count })), + devices: (bd?.devices ?? []).map((dv) => ({ device: dv.key, count: dv.count })), + referrers: (bd?.referrers ?? []) + .map((r) => ({ referrer: r.key, count: r.count })) + .filter((r) => r.referrer !== hostname), + }; + cached.set(d, payload); + toStore.set(d, payload); + } + + for (const d of span) { + if (!cached.has(d)) { + const empty: DayCachePayload = { visits: 0, pageViews: 0, topPaths: [], countries: [], devices: [], referrers: [] }; + cached.set(d, empty); + toStore.set(d, empty); + } + } + + await storeCachedDays(hostname, toStore, today, scope).catch((err) => { + console.error(`Failed to store ${scope} cache:`, err); + }); + } + + let spanChain: Promise = Promise.resolve(); + for (const span of spans) { + spanChain = spanChain.then(() => fetchSpan(span)); + } + await spanChain; + } catch (err) { + console.error(`Cloudflare ${scope} fetch failed, falling back:`, err); + stale = cached.size > 0; + } + } + + return { cache: cached, stale }; +} + +// --------------------------------------------------------------------------- +// Collection-scope fetch (dedicated queries + pub cache enrichment) // --------------------------------------------------------------------------- /** * Fetch analytics scoped to a collection. * * Strategy: - * 1. Ensure community-level data is cached (triggers fetch if needed). - * 2. For each pub in the collection, check if pub-level cache exists. - * If so, use that (more accurate, includes low-traffic paths). - * If not, filter community topPaths by the pub's path prefix. - * 3. Also include the collection's own slug as a top-level page. - * 4. Countries/devices/referrers use community-level data (proportional). + * 1. Ensure community-level data is cached (for fallback breakdowns). + * 2. Dedicated CF query for the collection page itself (/{slug}%), + * cached as 'collection-page:{slug}'. Guarantees the collection + * always has *some* data even if it's outside any top-paths list. + * 3. Dedicated CF query for all pub paths (/pub/%), cached as + * 'all-pub-paths'. Top 1000 pub-specific paths — much better + * coverage than filtering the community top 1000. + * 4. For each pub in the collection, prefer individual pub-level cache + * (most accurate), then fall back to all-pub-paths, then community. + * 5. Countries/devices/referrers: proportional from all-pub-paths + * breakdowns, falling back to community breakdowns. * - * Cost: 0 extra CF API calls (reuses community + any existing pub caches). + * Cost: 0 when fully cached. At most 3 CF queries when cold + * (community + all-pub-paths + collection-page), but typically + * community is already warm, so 2 in practice. */ export async function fetchCollectionAnalytics( hostname: string, @@ -862,27 +1007,34 @@ export async function fetchCollectionAnalytics( ): Promise { const config = getConfig(); if (!config) return null; + const { apiToken, zoneTag } = config; const allDates = dateRange(startDate, endDate); - // 1. Ensure community data is cached + // 1. Ensure community data is cached (for breakdowns + fallback) const communityResult = await fetchCommunityAnalytics(hostname, startDate, endDate); if (!communityResult) return null; - // 2. Read community-level cached days (raw, pre-aggregation) - const communityCached = await getCachedDays(hostname, allDates, 'community'); - - // 3. Build the set of path prefixes that belong to this collection - const collectionPrefixes = [ - `/${collectionSlug}`, // collection layout page - ...pubSlugs.map((s) => `/pub/${s}`), - ]; + // 2 & 3. Fetch collection-page and all-pub-paths scopes in parallel + const [collectionPageResult, allPubPathsResult] = await Promise.all([ + ensurePathScopeCached( + hostname, allDates, apiToken, zoneTag, + `/${collectionSlug}%`, + `collection-page:${collectionSlug}`, + ), + ensurePathScopeCached( + hostname, allDates, apiToken, zoneTag, + '/pub/%', + 'all-pub-paths', + ), + ]); + const collectionPageCached = collectionPageResult.cache; + const allPubPathsCached = allPubPathsResult.cache; - function pathBelongsToCollection(path: string): boolean { - return collectionPrefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/')); - } + // 4. Read community-level cached days (raw, pre-aggregation) + const communityCached = await getCachedDays(hostname, allDates, 'community'); - // 4. For each pub, check if we have pub-scoped cache (more accurate) + // 5. For each pub, check if we have pub-scoped cache (most accurate) const pubCacheEntries = await Promise.all( pubSlugs.map(async (slug) => { const pubCache = await getCachedDays(hostname, allDates, `pub:${slug}`); @@ -896,20 +1048,22 @@ export async function fetchCollectionAnalytics( } } - // 5. Build collection-scoped day payloads by merging sources + // 6. Build collection-scoped day payloads by merging sources const collectionDays = new Map(); for (const date of allDates) { const communityDay = communityCached.get(date); if (!communityDay) continue; - // Start with collection paths filtered from community data + const allPubPathsDay = allPubPathsCached.get(date); + const collectionPageDay = collectionPageCached.get(date); + let dayVisits = 0; let dayPageViews = 0; const dayPaths: Array<{ path: string; count: number }> = []; const slugsHandledByPubCache = new Set(); - // First, add data from any pub-level caches (more accurate) + // (a) Add data from any individual pub-level caches (most accurate) for (const [slug, cache] of pubCaches) { const pubDay = cache.get(date); if (pubDay) { @@ -920,10 +1074,12 @@ export async function fetchCollectionAnalytics( } } - // Then, for pubs without pub-level cache + the collection page itself, - // filter from community topPaths - for (const p of communityDay.topPaths) { - // Skip paths already covered by pub-level cache + // (b) For pubs without individual cache, use all-pub-paths topPaths + // (top 1000 /pub/* paths — much better coverage than community top 1000). + // Falls back to community topPaths if all-pub-paths cache is unavailable. + const pubPathSource = allPubPathsDay?.topPaths ?? communityDay.topPaths; + for (const p of pubPathSource) { + // Skip paths already covered by individual pub-level cache const coveredByPubCache = pubSlugs.some( (slug) => slugsHandledByPubCache.has(slug) && @@ -931,47 +1087,75 @@ export async function fetchCollectionAnalytics( ); if (coveredByPubCache) continue; - if (pathBelongsToCollection(p.path)) { + // Check if this path belongs to a collection pub + const isPubInCollection = pubSlugs.some( + (slug) => p.path === `/pub/${slug}` || p.path.startsWith(`/pub/${slug}/`), + ); + if (isPubInCollection) { dayPageViews += p.count; dayPaths.push(p); } } + // (c) Collection layout page — from dedicated collection-page cache + // (guaranteed data even if the page isn't in any top-paths list). + // Falls back to community topPaths if the dedicated cache failed. + if (collectionPageDay && collectionPageDay.pageViews > 0) { + dayVisits += collectionPageDay.visits; + dayPageViews += collectionPageDay.pageViews; + for (const p of collectionPageDay.topPaths) dayPaths.push(p); + } else { + // Fallback: filter community topPaths for collection page + for (const p of communityDay.topPaths) { + if (p.path === `/${collectionSlug}` || p.path.startsWith(`/${collectionSlug}/`)) { + dayPageViews += p.count; + dayPaths.push(p); + } + } + } + // Estimate visits proportionally from community day - // (for paths from community data, not from pub cache) + // (for paths from community/all-pub-paths data, not from individual pub cache + // or the collection-page dedicated cache which has its own visits) if (communityDay.pageViews > 0 && dayPageViews > 0) { - const communityPathPageViews = dayPageViews - [...pubCaches.values()] - .reduce((sum, cache) => sum + (cache.get(date)?.pageViews ?? 0), 0); - if (communityPathPageViews > 0) { + const directVisitSources = [...pubCaches.values()] + .reduce((sum, cache) => sum + (cache.get(date)?.pageViews ?? 0), 0) + + (collectionPageDay && collectionPageDay.pageViews > 0 ? collectionPageDay.pageViews : 0); + const indirectPageViews = dayPageViews - directVisitSources; + if (indirectPageViews > 0) { const visitRatio = communityDay.visits / communityDay.pageViews; - dayVisits += Math.round(communityPathPageViews * visitRatio); + dayVisits += Math.round(indirectPageViews * visitRatio); } } - // Countries/devices/referrers: use community-level, scaled by this - // collection's share of community traffic for the day - const shareRatio = communityDay.pageViews > 0 ? dayPageViews / communityDay.pageViews : 0; + // Countries/devices/referrers: use all-pub-paths breakdowns if available + // (more accurate for pub-heavy collections), else community-level. + // Scaled by this collection's share of the source's total traffic. + const breakdownSource = allPubPathsDay ?? communityDay; + const sourcePageViews = allPubPathsDay ? allPubPathsDay.pageViews : communityDay.pageViews; + const shareRatio = sourcePageViews > 0 ? dayPageViews / sourcePageViews : 0; collectionDays.set(date, { visits: dayVisits, pageViews: dayPageViews, topPaths: dayPaths, - countries: communityDay.countries.map((c) => ({ + countries: breakdownSource.countries.map((c) => ({ country: c.country, count: Math.round(c.count * shareRatio), })).filter((c) => c.count > 0), - devices: communityDay.devices.map((d) => ({ + devices: breakdownSource.devices.map((d) => ({ device: d.device, count: Math.round(d.count * shareRatio), })).filter((d) => d.count > 0), - referrers: communityDay.referrers.map((r) => ({ + referrers: breakdownSource.referrers.map((r) => ({ referrer: r.referrer, count: Math.round(r.count * shareRatio), })).filter((r) => r.count > 0), }); } - return aggregateDays(allDates, collectionDays, !!communityResult.stale); + const anyStale = !!communityResult.stale || allPubPathsResult.stale || collectionPageResult.stale; + return aggregateDays(allDates, collectionDays, anyStale); } // --------------------------------------------------------------------------- From 8bdb6fe675998b5d388b941e82021c609fb2ef07 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 2 Apr 2026 16:29:30 -0400 Subject: [PATCH 13/13] lint --- .../DashboardImpact2/DashboardImpact2.tsx | 17 +- server/routes/dashboardImpact.tsx | 6 +- server/utils/cloudflareAnalytics.ts | 159 +++++++++++++----- 3 files changed, 130 insertions(+), 52 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 883a3bdfeb..fe634c46be 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -112,7 +112,7 @@ const CompactTable = ({ const DashboardImpact2 = () => { const { scopeData } = usePageContext(); const { - elements: { activeTargetType, activeTargetName, activePub, activeCollection }, + elements: { activeTargetType, activePub, activeCollection }, activePermissions: { canView }, } = scopeData; @@ -212,16 +212,15 @@ const DashboardImpact2 = () => { } > - This dashboard reflects recent activity based on edge data and is designed - for quick, transient insight. It does not provide historical reporting or - long-term retention. For comprehensive analytics, we strongly recommend - connecting a dedicated analytics tool in{' '} - Settings. + This dashboard reflects recent activity based on edge data and is designed for + quick, transient insight. It does not provide historical reporting or long-term + retention. For comprehensive analytics, we strongly recommend connecting a dedicated + analytics tool in Settings.

- Legacy analytics remain available here. We - plan to wind down legacy analytics at the end of 2026. If you need - historical legacy data, please export it from there. + Legacy analytics remain available here. We plan to + wind down legacy analytics at the end of 2026. If you need historical legacy data, + please export it from there.
{loading && ( diff --git a/server/routes/dashboardImpact.tsx b/server/routes/dashboardImpact.tsx index 76acd2b3dc..b2f99906a2 100644 --- a/server/routes/dashboardImpact.tsx +++ b/server/routes/dashboardImpact.tsx @@ -12,7 +12,11 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); router.get( - ['/dash/impact-v1', '/dash/collection/:collectionSlug/impact-v1', '/dash/pub/:pubSlug/impact-v1'], + [ + '/dash/impact-v1', + '/dash/collection/:collectionSlug/impact-v1', + '/dash/pub/:pubSlug/impact-v1', + ], async (req, res, next) => { try { if (!hostIsValid(req, 'community')) { diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 76749b5fa8..9a3b5c0355 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -773,22 +773,38 @@ export async function fetchPubAnalytics( // Group breakdowns by date type Arr = Array<{ key: string; count: number }>; - const byDate = new Map(); + const byDate = new Map< + string, + { topPaths: Arr; countries: Arr; devices: Arr; referrers: Arr } + >(); function ensure(d: string) { - if (!byDate.has(d)) byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + if (!byDate.has(d)) + byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); return byDate.get(d)!; } for (const n of allNodes.topPaths) { - ensure(n.dimensions.date).topPaths.push({ key: n.dimensions.clientRequestPath, count: n.count }); + ensure(n.dimensions.date).topPaths.push({ + key: n.dimensions.clientRequestPath, + count: n.count, + }); } for (const n of allNodes.countries) { - ensure(n.dimensions.date).countries.push({ key: n.dimensions.clientCountryName || 'Unknown', count: n.count }); + ensure(n.dimensions.date).countries.push({ + key: n.dimensions.clientCountryName || 'Unknown', + count: n.count, + }); } for (const n of allNodes.devices) { - ensure(n.dimensions.date).devices.push({ key: n.dimensions.clientDeviceType || 'Unknown', count: n.count }); + ensure(n.dimensions.date).devices.push({ + key: n.dimensions.clientDeviceType || 'Unknown', + count: n.count, + }); } for (const n of allNodes.referrers) { - ensure(n.dimensions.date).referrers.push({ key: n.dimensions.clientRefererHost || '(direct)', count: n.count }); + ensure(n.dimensions.date).referrers.push({ + key: n.dimensions.clientRefererHost || '(direct)', + count: n.count, + }); } const toStore = new Map(); @@ -798,9 +814,18 @@ export async function fetchPubAnalytics( const payload: DayCachePayload = { visits: node.sum.visits ?? 0, pageViews: node.count ?? 0, - topPaths: (bd?.topPaths ?? []).map((p) => ({ path: p.key, count: p.count })), - countries: (bd?.countries ?? []).map((c) => ({ country: c.key, count: c.count })), - devices: (bd?.devices ?? []).map((dv) => ({ device: dv.key, count: dv.count })), + topPaths: (bd?.topPaths ?? []).map((p) => ({ + path: p.key, + count: p.count, + })), + countries: (bd?.countries ?? []).map((c) => ({ + country: c.key, + count: c.count, + })), + devices: (bd?.devices ?? []).map((dv) => ({ + device: dv.key, + count: dv.count, + })), referrers: (bd?.referrers ?? []) .map((r) => ({ referrer: r.key, count: r.count })) .filter((r) => r.referrer !== hostname), @@ -811,7 +836,14 @@ export async function fetchPubAnalytics( for (const d of span) { if (!cached.has(d)) { - const empty: DayCachePayload = { visits: 0, pageViews: 0, topPaths: [], countries: [], devices: [], referrers: [] }; + const empty: DayCachePayload = { + visits: 0, + pageViews: 0, + topPaths: [], + countries: [], + devices: [], + referrers: [], + }; cached.set(d, empty); toStore.set(d, empty); } @@ -911,22 +943,38 @@ async function ensurePathScopeCached( // Group breakdowns by date type Arr = Array<{ key: string; count: number }>; - const byDate = new Map(); + const byDate = new Map< + string, + { topPaths: Arr; countries: Arr; devices: Arr; referrers: Arr } + >(); function ensure(d: string) { - if (!byDate.has(d)) byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + if (!byDate.has(d)) + byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); return byDate.get(d)!; } for (const n of allNodes.topPaths) { - ensure(n.dimensions.date).topPaths.push({ key: n.dimensions.clientRequestPath, count: n.count }); + ensure(n.dimensions.date).topPaths.push({ + key: n.dimensions.clientRequestPath, + count: n.count, + }); } for (const n of allNodes.countries) { - ensure(n.dimensions.date).countries.push({ key: n.dimensions.clientCountryName || 'Unknown', count: n.count }); + ensure(n.dimensions.date).countries.push({ + key: n.dimensions.clientCountryName || 'Unknown', + count: n.count, + }); } for (const n of allNodes.devices) { - ensure(n.dimensions.date).devices.push({ key: n.dimensions.clientDeviceType || 'Unknown', count: n.count }); + ensure(n.dimensions.date).devices.push({ + key: n.dimensions.clientDeviceType || 'Unknown', + count: n.count, + }); } for (const n of allNodes.referrers) { - ensure(n.dimensions.date).referrers.push({ key: n.dimensions.clientRefererHost || '(direct)', count: n.count }); + ensure(n.dimensions.date).referrers.push({ + key: n.dimensions.clientRefererHost || '(direct)', + count: n.count, + }); } const toStore = new Map(); @@ -936,9 +984,18 @@ async function ensurePathScopeCached( const payload: DayCachePayload = { visits: node.sum.visits ?? 0, pageViews: node.count ?? 0, - topPaths: (bd?.topPaths ?? []).map((p) => ({ path: p.key, count: p.count })), - countries: (bd?.countries ?? []).map((c) => ({ country: c.key, count: c.count })), - devices: (bd?.devices ?? []).map((dv) => ({ device: dv.key, count: dv.count })), + topPaths: (bd?.topPaths ?? []).map((p) => ({ + path: p.key, + count: p.count, + })), + countries: (bd?.countries ?? []).map((c) => ({ + country: c.key, + count: c.count, + })), + devices: (bd?.devices ?? []).map((dv) => ({ + device: dv.key, + count: dv.count, + })), referrers: (bd?.referrers ?? []) .map((r) => ({ referrer: r.key, count: r.count })) .filter((r) => r.referrer !== hostname), @@ -949,7 +1006,14 @@ async function ensurePathScopeCached( for (const d of span) { if (!cached.has(d)) { - const empty: DayCachePayload = { visits: 0, pageViews: 0, topPaths: [], countries: [], devices: [], referrers: [] }; + const empty: DayCachePayload = { + visits: 0, + pageViews: 0, + topPaths: [], + countries: [], + devices: [], + referrers: [], + }; cached.set(d, empty); toStore.set(d, empty); } @@ -1018,15 +1082,14 @@ export async function fetchCollectionAnalytics( // 2 & 3. Fetch collection-page and all-pub-paths scopes in parallel const [collectionPageResult, allPubPathsResult] = await Promise.all([ ensurePathScopeCached( - hostname, allDates, apiToken, zoneTag, + hostname, + allDates, + apiToken, + zoneTag, `/${collectionSlug}%`, `collection-page:${collectionSlug}`, ), - ensurePathScopeCached( - hostname, allDates, apiToken, zoneTag, - '/pub/%', - 'all-pub-paths', - ), + ensurePathScopeCached(hostname, allDates, apiToken, zoneTag, '/pub/%', 'all-pub-paths'), ]); const collectionPageCached = collectionPageResult.cache; const allPubPathsCached = allPubPathsResult.cache; @@ -1118,9 +1181,14 @@ export async function fetchCollectionAnalytics( // (for paths from community/all-pub-paths data, not from individual pub cache // or the collection-page dedicated cache which has its own visits) if (communityDay.pageViews > 0 && dayPageViews > 0) { - const directVisitSources = [...pubCaches.values()] - .reduce((sum, cache) => sum + (cache.get(date)?.pageViews ?? 0), 0) - + (collectionPageDay && collectionPageDay.pageViews > 0 ? collectionPageDay.pageViews : 0); + const directVisitSources = + [...pubCaches.values()].reduce( + (sum, cache) => sum + (cache.get(date)?.pageViews ?? 0), + 0, + ) + + (collectionPageDay && collectionPageDay.pageViews > 0 + ? collectionPageDay.pageViews + : 0); const indirectPageViews = dayPageViews - directVisitSources; if (indirectPageViews > 0) { const visitRatio = communityDay.visits / communityDay.pageViews; @@ -1139,22 +1207,29 @@ export async function fetchCollectionAnalytics( visits: dayVisits, pageViews: dayPageViews, topPaths: dayPaths, - countries: breakdownSource.countries.map((c) => ({ - country: c.country, - count: Math.round(c.count * shareRatio), - })).filter((c) => c.count > 0), - devices: breakdownSource.devices.map((d) => ({ - device: d.device, - count: Math.round(d.count * shareRatio), - })).filter((d) => d.count > 0), - referrers: breakdownSource.referrers.map((r) => ({ - referrer: r.referrer, - count: Math.round(r.count * shareRatio), - })).filter((r) => r.count > 0), + countries: breakdownSource.countries + .map((c) => ({ + country: c.country, + count: Math.round(c.count * shareRatio), + })) + .filter((c) => c.count > 0), + devices: breakdownSource.devices + .map((d) => ({ + device: d.device, + count: Math.round(d.count * shareRatio), + })) + .filter((d) => d.count > 0), + referrers: breakdownSource.referrers + .map((r) => ({ + referrer: r.referrer, + count: Math.round(r.count * shareRatio), + })) + .filter((r) => r.count > 0), }); } - const anyStale = !!communityResult.stale || allPubPathsResult.stale || collectionPageResult.stale; + const anyStale = + !!communityResult.stale || allPubPathsResult.stale || collectionPageResult.stale; return aggregateDays(allDates, collectionDays, anyStale); }