analytics

This commit is contained in:
lovebird 2026-04-05 13:00:03 +02:00
parent 7383b40b27
commit 0a73d24262
10 changed files with 110 additions and 157 deletions

View File

@ -23,7 +23,6 @@ const GlobalDragDrop = React.lazy(() => import("@/components/GlobalDragDrop"));
// Register all widgets on app boot
registerAllWidgets();
import Index from "./pages/Index";
import Auth from "./pages/Auth";
@ -284,9 +283,7 @@ const App = () => {
</DragDropProvider>
</BrowserRouter>
</ActionProvider>
</TooltipProvider>
</MediaRefreshProvider>
</LogProvider>
</AuthProvider>

View File

@ -105,10 +105,11 @@ async function apiResponseJson<T>(response: Response, endpoint: string): Promise
return response.text() as unknown as T;
}
/** Generic API client: `${serverUrl}${endpoint}`, auth headers, JSON error bodies when present */
export async function apiClient<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const headers = await getAuthHeaders();
const response = await fetch(`${serverUrl}${endpoint}`, {
const isAbsolute = endpoint.startsWith('http://') || endpoint.startsWith('https://');
const url = isAbsolute ? endpoint : `${serverUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
...headers,
@ -118,10 +119,11 @@ export async function apiClient<T>(endpoint: string, options: RequestInit = {}):
return apiResponseJson<T>(response, endpoint);
}
/** Same as apiClient but returns null on HTTP 404 (no throw). */
export async function apiClientOr404<T>(endpoint: string, options: RequestInit = {}): Promise<T | null> {
const headers = await getAuthHeaders();
const response = await fetch(`${serverUrl}${endpoint}`, {
const isAbsolute = endpoint.startsWith('http://') || endpoint.startsWith('https://');
const url = isAbsolute ? endpoint : `${serverUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
...headers,
@ -160,87 +162,3 @@ export const checkLikeStatus = async (userId: string, pictureId: string, client?
return !!data;
});
};
export const fetchAnalytics = async (options: { limit?: number, startDate?: string, endDate?: string, baseUrl?: string } = {}) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('No active session');
const headers: HeadersInit = {};
headers['Authorization'] = `Bearer ${token}`;
const params = new URLSearchParams();
if (options.limit) params.append('limit', String(options.limit));
if (options.startDate) params.append('startDate', options.startDate);
if (options.endDate) params.append('endDate', options.endDate);
const serverUrl = options.baseUrl || import.meta.env.VITE_SERVER_IMAGE_API_URL;
const res = await fetch(`${serverUrl}/api/analytics?${params.toString()}`, { headers });
if (!res.ok) {
if (res.status === 403 || res.status === 401) {
throw new Error('Unauthorized');
}
throw new Error(`Failed to fetch analytics: ${res.statusText}`);
}
return await res.json();
};
export const fetchAnalyticsOverview = async (options: { period?: string, tracking?: string, showBots?: boolean, showApi?: boolean, baseUrl?: string } = {}) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('No active session');
const headers: HeadersInit = { Authorization: `Bearer ${token}` };
const params = new URLSearchParams();
if (options.period) params.append('period', options.period);
if (options.tracking) params.append('tracking', options.tracking);
if (options.showBots === false) params.append('showBots', 'false');
if (options.showApi === true) params.append('showApi', 'true');
const serverUrl = options.baseUrl || import.meta.env.VITE_SERVER_IMAGE_API_URL;
const res = await fetch(`${serverUrl}/api/analytics/overview?${params.toString()}`, { headers });
if (!res.ok) {
if (res.status === 403 || res.status === 401) throw new Error('Unauthorized');
throw new Error(`Failed to fetch analytics overview: ${res.statusText}`);
}
return await res.json();
};
export const clearAnalytics = async (baseUrl?: string) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('No active session');
const headers: HeadersInit = {};
headers['Authorization'] = `Bearer ${token}`;
const serverUrl = baseUrl || import.meta.env.VITE_SERVER_IMAGE_API_URL;
const res = await fetch(`${serverUrl}/api/analytics`, {
method: 'DELETE',
headers
});
if (!res.ok) {
if (res.status === 403 || res.status === 401) {
throw new Error('Unauthorized');
}
throw new Error(`Failed to clear analytics: ${res.statusText}`);
}
return await res.json();
};

View File

@ -32,7 +32,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { fetchAnalytics, clearAnalytics } from '@/lib/db';
import { fetchAnalytics, clearAnalytics, subscribeToAnalyticsStream } from '@/modules/analytics/client-analytics';
import { supabase } from '@/integrations/supabase/client';
import {
filterModelToParams,
@ -227,34 +227,28 @@ const AnalyticsDashboard = () => {
setData(events.map((e: any, index: number) => ({ ...e, id: `init-${index}` })));
// Start Streaming (with auth token as query param — EventSource doesn't support headers)
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData.session?.access_token;
const streamBase = serverUrl || '';
const streamPath = '/api/analytics/stream';
const streamUrl = token
? `${streamBase}${streamPath}?token=${encodeURIComponent(token)}`
: `${streamBase}${streamPath}`;
eventSource = new EventSource(streamUrl);
eventSource.addEventListener('log', (event: any) => {
if (!isMounted) return;
try {
const newEntry = JSON.parse(event.data);
// Start Streaming
try {
eventSource = await subscribeToAnalyticsStream((newEntry) => {
if (!isMounted) return;
setData(prev => {
// Add new entry, keep max 2000 rows
const updated = [{ ...newEntry, id: `stream-${Date.now()}-${Math.random()}` }, ...prev];
return updated.slice(0, 2000);
});
} catch (e) {
console.error('Failed to parse SSE event', e);
}, serverUrl || undefined);
if (!isMounted) {
eventSource.close();
} else {
eventSource.onerror = (err) => {
console.error('SSE Error:', err);
if (eventSource) eventSource.close();
};
}
});
eventSource.onerror = (err) => {
console.error('SSE Error:', err);
if (eventSource) eventSource.close();
};
} catch (err) {
console.error('Failed to subscribe to analytics stream', err);
}
} catch (error) {
console.error("Failed to load analytics", error);

View File

@ -1,6 +1,5 @@
import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } from 'react';
import { fetchAnalyticsOverview } from '@/lib/db';
import { supabase } from '@/integrations/supabase/client';
import { fetchAnalyticsOverview, subscribeToAnalyticsStream } from '@/modules/analytics/client-analytics';
import { Button } from '@/components/ui/button';
import {
Globe, Bot, Loader2, RefreshCw
@ -71,20 +70,9 @@ const AnalyticsMapView: React.FC<Props> = ({ serverUrl }) => {
let mounted = true;
(async () => {
const { data: session } = await supabase.auth.getSession();
const token = session.session?.access_token || '';
const base = serverUrl || import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
const url = token
? `${base}/api/analytics/stream?token=${encodeURIComponent(token)}`
: `${base}/api/analytics/stream`;
const es = new EventSource(url);
esRef.current = es;
es.addEventListener('log', (event: any) => {
if (!mounted) return;
try {
const entry = JSON.parse(event.data);
try {
const es = await subscribeToAnalyticsStream((entry) => {
if (!mounted) return;
if (!showBots && (entry.isBot || entry.isAI)) return;
if (!showApi && (entry.path || '').startsWith('/api/')) return;
@ -109,10 +97,18 @@ const AnalyticsMapView: React.FC<Props> = ({ serverUrl }) => {
return { ...prev, locations: locs };
});
}
} catch { /* ignore */ }
});
es.onerror = () => { if (es) es.close(); };
}, serverUrl || undefined);
if (!mounted) {
es.close();
return;
}
esRef.current = es;
es.onerror = () => { if (es) es.close(); };
} catch (err) {
console.error('Failed to subscribe to analytics map stream:', err);
}
})();
return () => {

View File

@ -1,8 +1,7 @@
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { fetchAnalyticsOverview } from '@/lib/db';
import { supabase } from '@/integrations/supabase/client';
import { fetchAnalyticsOverview, subscribeToAnalyticsStream } from '@/modules/analytics/client-analytics';
import {
Clock, Users, Bot, BarChart3, Globe, TrendingUp,
Loader2, RefreshCw, Eye, EyeOff
@ -128,20 +127,9 @@ const AnalyticsOverview: React.FC<Props> = ({ serverUrl }) => {
let mounted = true;
(async () => {
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData.session?.access_token;
const base = serverUrl || '';
const url = token
? `${base}/api/analytics/stream?token=${encodeURIComponent(token)}`
: `${base}/api/analytics/stream`;
const es = new EventSource(url);
esRef.current = es;
es.addEventListener('log', (event: any) => {
if (!mounted) return;
try {
const entry = JSON.parse(event.data);
try {
const es = await subscribeToAnalyticsStream((entry) => {
if (!mounted) return;
const isBot = entry.isBot === true || entry.isAI === true;
if (!showBots && isBot) return;
if (!showApi && (entry.path || '').startsWith('/api/')) return;
@ -150,7 +138,6 @@ const AnalyticsOverview: React.FC<Props> = ({ serverUrl }) => {
// Determine bucket key (hourly)
const d = new Date(entry.timestamp);
const bk = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:00`;
const ip: string = entry.ip || 'unknown';
const p: string = entry.path || '';
const content = isContentVisit(p);
@ -173,13 +160,20 @@ const AnalyticsOverview: React.FC<Props> = ({ serverUrl }) => {
// Update KPIs
const kpis = { ...prev.kpis };
if (isBot) kpis.uniqueBots = kpis.uniqueBots; // can't track unique client-side, keep server value
return { ...prev, timeSeries: ts, kpis };
});
} catch (e) { /* ignore */ }
});
es.onerror = () => { if (es) es.close(); };
}, serverUrl || undefined);
if (!mounted) {
es.close();
return;
}
esRef.current = es;
es.onerror = () => { if (es) es.close(); };
} catch (err) {
console.error('Failed to subscribe to analytics overview stream:', err);
}
})();
return () => {

View File

@ -0,0 +1,54 @@
import { apiClient, getAuthHeaders, serverUrl as defaultServerUrl, getAuthToken } from '@/lib/db';
export const fetchAnalytics = async (options: { limit?: number, startDate?: string, endDate?: string, baseUrl?: string } = {}) => {
const params = new URLSearchParams();
if (options.limit) params.append('limit', String(options.limit));
if (options.startDate) params.append('startDate', options.startDate);
if (options.endDate) params.append('endDate', options.endDate);
const query = params.toString() ? `?${params.toString()}` : '';
const base = options.baseUrl && options.baseUrl !== defaultServerUrl ? options.baseUrl : '';
return apiClient<any>(`${base}/api/analytics${query}`);
};
export const fetchAnalyticsOverview = async (options: { period?: string, tracking?: string, showBots?: boolean, showApi?: boolean, baseUrl?: string } = {}) => {
const params = new URLSearchParams();
if (options.period) params.append('period', options.period);
if (options.tracking) params.append('tracking', options.tracking);
if (options.showBots === false) params.append('showBots', 'false');
if (options.showApi === true) params.append('showApi', 'true');
const query = params.toString() ? `?${params.toString()}` : '';
const base = options.baseUrl && options.baseUrl !== defaultServerUrl ? options.baseUrl : '';
return apiClient<any>(`${base}/api/analytics/overview${query}`);
};
export const clearAnalytics = async (baseUrl?: string) => {
const base = baseUrl && baseUrl !== defaultServerUrl ? baseUrl : '';
return apiClient<any>(`${base}/api/analytics`, { method: 'DELETE' });
};
export const subscribeToAnalyticsStream = async (
onLog: (entry: any) => void,
baseUrl?: string
): Promise<EventSource> => {
const token = await getAuthToken();
const base = baseUrl || defaultServerUrl;
const url = token
? `${base}/api/analytics/stream?token=${encodeURIComponent(token)}`
: `${base}/api/analytics/stream`;
const es = new EventSource(url);
es.addEventListener('log', (event: any) => {
try {
const entry = JSON.parse(event.data);
onLog(entry);
} catch { /* ignore */ }
});
return es;
};

View File

@ -17,7 +17,7 @@ import React, { Suspense } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
// Lazy load AnalyticsDashboard
const AnalyticsDashboard = React.lazy(() => import("@/pages/analytics").then(module => ({ default: module.AnalyticsDashboard })));
const AnalyticsDashboard = React.lazy(() => import("@/modules/analytics").then(module => ({ default: module.AnalyticsDashboard })));
const AdminPage = () => {
const { user, session, loading, roles } = useAuth();