diff --git a/app/applications/[uuid]/page.tsx b/app/applications/[uuid]/page.tsx new file mode 100644 index 0000000..bdbd87b --- /dev/null +++ b/app/applications/[uuid]/page.tsx @@ -0,0 +1,231 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import CountUpTimer from '@/components/CountUpTimer'; + +const DEFAULT_SUBSCRIPTION_UUID = process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID || ''; +interface PageProps { + params: { uuid: string }; +} + +export default function ApplicationDetailPage({ params }: any) { + const [subscriptionUuid, setSubscriptionUuid] = useState(DEFAULT_SUBSCRIPTION_UUID); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [loading, setLoading] = useState(false); + const [loadingStep, setLoadingStep] = useState(''); + const [elapsedTime, setElapsedTime] = useState(null); + const [appName, setAppName] = useState(''); + const [views, setViews] = useState(0); + const [visits, setVisits] = useState(0); + const [viewsPct, setViewsPct] = useState(0); + const [visitsPct, setVisitsPct] = useState(0); + const [error, setError] = useState(null); + + // Fetch application name on mount or when subscriptionUuid changes + useEffect(() => { + const fetchAppName = async () => { + try { + setLoadingStep('Fetching application info...'); + const res = await fetch(`/api/acquia/applications?subscriptionUuid=${subscriptionUuid}`); + const apps = await res.json(); + const app = Array.isArray(apps) ? apps.find((a: any) => a.uuid === params.uuid) : null; + setAppName(app ? app.name : ''); + } catch { + setAppName(''); + } finally { + setLoadingStep(''); + } + }; + if (subscriptionUuid) fetchAppName(); + }, [subscriptionUuid, params.uuid]); + + const fetchAppDetail = async () => { + setLoading(true); + setLoadingStep('Fetching analytics data...'); + setError(null); + setElapsedTime(null); + const startTime = Date.now(); + try { + const paramsObj: Record = {}; + if (subscriptionUuid) paramsObj.subscriptionUuid = subscriptionUuid; + if (from) paramsObj.from = from; + if (to) paramsObj.to = to; + const query = new URLSearchParams(paramsObj).toString(); + + setLoadingStep('Fetching views and visits...'); + const [viewsRes, visitsRes] = await Promise.all([ + fetch(`/api/acquia/views?${query}`), + fetch(`/api/acquia/visits?${query}`), + ]); + const [viewsRaw, visitsRaw] = await Promise.all([ + viewsRes.ok ? viewsRes.json() : [], + visitsRes.ok ? visitsRes.json() : [], + ]); + const viewsArr = Array.isArray(viewsRaw) + ? viewsRaw + : viewsRaw && Array.isArray(viewsRaw.data) + ? viewsRaw.data + : []; + const visitsArr = Array.isArray(visitsRaw) + ? visitsRaw + : visitsRaw && Array.isArray(visitsRaw.data) + ? visitsRaw.data + : []; + const appViewsTotal = viewsArr + .filter((v: any) => v.uuid === params.uuid || v.applicationUuid === params.uuid) + .reduce((sum: number, v: any) => sum + (v.views || 0), 0); + + const appVisitsTotal = visitsArr + .filter((v: any) => v.uuid === params.uuid || v.applicationUuid === params.uuid) + .reduce((sum: number, v: any) => sum + (v.visits || 0), 0); + + const totalViews = viewsArr.reduce((sum: number, v: any) => sum + (v.views || 0), 0); + const totalVisits = visitsArr.reduce((sum: number, v: any) => sum + (v.visits || 0), 0); + + setViews(appViewsTotal); + setVisits(appVisitsTotal); + setViewsPct(totalViews ? (appViewsTotal / totalViews) * 100 : 0); + setVisitsPct(totalVisits ? (appVisitsTotal / totalVisits) * 100 : 0); + setLoadingStep('Complete!'); + } catch (err) { + setError('Failed to fetch application details.'); + } finally { + const endTime = Date.now(); + setElapsedTime((endTime - startTime) / 1000); + setLoading(false); + setLoadingStep(''); + } + }; + + return ( +
+

+ Views and Visits Data for {appName ? appName : {params.uuid}} +

+
+
+ + setSubscriptionUuid(e.target.value)} + className="w-full p-2 border rounded mb-4" + style={{ + borderColor: 'var(--stanford-gray)', + color: 'var(--stanford-black)', + fontFamily: 'Source Sans Pro, Arial, sans-serif', + }} + required + /> +
+
+ + setFrom(e.target.value)} + className="w-full px-3 py-2 border rounded-md focus:outline-none" + style={{ + borderColor: 'var(--stanford-gray)', + color: 'var(--stanford-black)', + fontFamily: 'Source Sans Pro, Arial, sans-serif', + }} + disabled={loading} + /> +
+
+ + setTo(e.target.value)} + className="w-full px-3 py-2 border rounded-md focus:outline-none" + style={{ + borderColor: 'var(--stanford-gray)', + color: 'var(--stanford-black)', + fontFamily: 'Source Sans Pro, Arial, sans-serif', + }} + disabled={loading} + /> +
+
+ +
+ + {loading && ( +
+ +
{loadingStep}
+
+ )} + + {!loading && elapsedTime !== null && ( +
+ +
+ Data loaded in {elapsedTime.toFixed(1)} seconds +
+
+ )} +
+ +

+ (Note that it can take several minutes to fetch data from the Acquia API.) +

+
+
+ {error && ( +
{error}
+ )} + {!appName ? ( +
No application found with UUID: {params.uuid}
+ ) : ( +
+
+ Name: {appName} +
+
+ UUID: {params.uuid} +
+
+ Views{from && to ? ` (${from} to ${to})` : ''}: {views.toLocaleString()} ({viewsPct.toFixed(1)}%) +
+
+ Visits{from && to ? ` (${from} to ${to})` : ''}: {visits.toLocaleString()} ({visitsPct.toFixed(1)}%) +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/applications/page.tsx b/app/applications/page.tsx new file mode 100644 index 0000000..832ac75 --- /dev/null +++ b/app/applications/page.tsx @@ -0,0 +1,87 @@ +import React from 'react'; + +const BASE_URL = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : process.env.NEXT_PUBLIC_BASE_URL + ? process.env.NEXT_PUBLIC_BASE_URL + : 'http://localhost:3000'; + +async function fetchData() { + const [appsRes, viewsRes, visitsRes] = await Promise.all([ + fetch(`${BASE_URL}/api/acquia/applications`), + fetch(`${BASE_URL}/api/acquia/views`), + fetch(`${BASE_URL}/api/acquia/visits`), + ]); + const [apps, viewsRaw, visitsRaw] = await Promise.all([ + appsRes.ok ? appsRes.json() : [], + viewsRes.ok ? viewsRes.json() : [], + visitsRes.ok ? visitsRes.json() : [], + ]); +// console.log('viewsRaw:', viewsRaw); +// console.log('visitsRaw:', visitsRaw); + // Defensive: ensure arrays + const views = Array.isArray(viewsRaw) + ? viewsRaw + : viewsRaw && Array.isArray(viewsRaw.data) + ? viewsRaw.data + : []; + const visits = Array.isArray(visitsRaw) + ? visitsRaw + : visitsRaw && Array.isArray(visitsRaw.data) + ? visitsRaw.data + : []; + return { apps, views, visits }; +} + +function getAppStats(apps: any[], views: { map: (arg0: (v: any) => any[]) => Iterable; reduce: (arg0: (sum: any, v: any) => any, arg1: number) => any; }, visits: { map: (arg0: (v: any) => any[]) => Iterable; reduce: (arg0: (sum: any, v: any) => any, arg1: number) => any; }) { + // Map views/visits by app uuid + const viewsByApp = Object.fromEntries(views.map(v => [v.uuid, v.views])); + const visitsByApp = Object.fromEntries(visits.map(v => [v.uuid, v.visits])); + // Calculate totals + const totalViews = views.reduce((sum, v) => sum + v.views, 0); + const totalVisits = visits.reduce((sum, v) => sum + v.visits, 0); + + // Merge stats + return apps.map(app => ({ + ...app, + views: viewsByApp[app.uuid] || 0, + visits: visitsByApp[app.uuid] || 0, + viewsPct: totalViews ? ((viewsByApp[app.uuid] || 0) / totalViews) * 100 : 0, + visitsPct: totalVisits ? ((visitsByApp[app.uuid] || 0) / totalVisits) * 100 : 0, + })); +} + +export default async function ApplicationsPage() { + const { apps, views, visits } = await fetchData(); + const stats = getAppStats(apps, views, visits); + + return ( +
+

Application Views & Visits

+ + + + + + + + + + + + + {stats.map(app => ( + + + + + + + + + ))} + +
ApplicationUUIDViews% of ViewsVisits% of Visits
{app.name}{app.uuid}{app.views.toLocaleString()}{app.viewsPct.toFixed(1)}%{app.visits.toLocaleString()}{app.visitsPct.toFixed(1)}%
+
+ ); +} \ No newline at end of file diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index 96c1944..c6268dd 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -346,6 +346,7 @@ const Dashboard: React.FC = () => { uuid: app.uuid, }))} valueLabel="Views" + total={viewsSummary.reduce((sum, app) => sum + app.views, 0)} /> { uuid: app.uuid, }))} valueLabel="Visits" + total={visitsSummary.reduce((sum, app) => sum + app.visits, 0)} /> diff --git a/components/DataTable.tsx b/components/DataTable.tsx index 8c768b7..38a2978 100644 --- a/components/DataTable.tsx +++ b/components/DataTable.tsx @@ -1,7 +1,7 @@ import React from 'react'; interface DataItem { - rank?: number; // Rank is now an optional property + rank?: number; name: string; value: number; uuid: string; @@ -11,9 +11,10 @@ interface DataTableProps { title: string; data: DataItem[]; valueLabel: string; + total: number; } -const DataTable: React.FC = ({ title, data, valueLabel }) => { +const DataTable: React.FC = ({ title, data, valueLabel, total }) => { return (

@@ -29,9 +30,15 @@ const DataTable: React.FC = ({ title, data, valueLabel }) => { Name + + UUID + {valueLabel} + + % of Total + @@ -43,19 +50,28 @@ const DataTable: React.FC = ({ title, data, valueLabel }) => { {item.name} + + {item.uuid} + {item.value.toLocaleString()} + + {total > 0 ? ((item.value / total) * 100).toFixed(1) + '%' : '—'} + ))} - + Total - {data.reduce((sum, item) => sum + item.value, 0).toLocaleString()} + {total.toLocaleString()} + + + 100% diff --git a/middleware.ts b/middleware.ts index 383c9f8..92a20e1 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -import auth from 'basic-auth'; const USERNAME = 'sws'; const PASSWORD = 'sws'; export function middleware(request: NextRequest) { - // Only protect paths you want (here, all except /_next, /api/public, etc.) const { pathname } = request.nextUrl; + + // Allow static assets and public API routes if ( pathname.startsWith('/_next') || pathname.startsWith('/api/public') || @@ -16,6 +16,19 @@ export function middleware(request: NextRequest) { return NextResponse.next(); } + // Allow requests from localhost (IPv4 and IPv6) + const ip = + request.headers.get('x-forwarded-for') || + request.headers.get('x-real-ip') || + ''; + if ( + ip === '127.0.0.1' || + ip === '::1' || + ip.startsWith('::ffff:127.0.0.1') + ) { + return NextResponse.next(); + } + // Get the Authorization header const authHeader = request.headers.get('authorization'); if (!authHeader) { @@ -43,7 +56,6 @@ export function middleware(request: NextRequest) { }); } -// Optionally, define which paths to match export const config = { matcher: ['/((?!_next|api/public|favicon.ico).*)'], }; \ No newline at end of file