Browse Source

Add admin connection stats page

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
d5863f3b52
  1. 203
      frontend/src/components/ConnectionTracker/ConnectionTracker.tsx
  2. 15
      frontend/src/pages/AdminPage.tsx
  3. 23
      frontend/src/pages/StatsAdminPage.tsx

203
frontend/src/components/ConnectionTracker/ConnectionTracker.tsx

@ -0,0 +1,203 @@ @@ -0,0 +1,203 @@
import { useState, useEffect } from 'react';
import { settingsApi } from '../../services/apiClient';
const formatTime = (minutes: number): string => {
if (minutes < 1) {
return `${Math.round(minutes * 60)}s`;
}
if (minutes < 60) {
return `${Math.round(minutes)}m`;
}
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
if (mins === 0) {
return `${hours}h`;
}
return `${hours}h ${mins}m`;
};
interface Connection {
sessionId: string;
userId?: number;
username?: string;
route?: string;
videoTitle?: string;
videoChannel?: string;
timeUsed?: number;
dailyLimit?: number;
lastHeartbeat: number;
connectedAt: number;
}
interface ConnectionStats {
total: number;
authenticated: number;
anonymous: number;
connections?: Connection[];
}
export function ConnectionTracker() {
const [stats, setStats] = useState<ConnectionStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStats = async () => {
try {
setIsLoading(true);
setError(null);
const response = await settingsApi.getConnectionStats();
setStats(response.data);
} catch (err: any) {
setError(err.error?.message || 'Failed to load connection stats');
console.error('Error fetching connection stats:', err);
} finally {
setIsLoading(false);
}
};
// Fetch stats on mount and periodically
useEffect(() => {
fetchStats();
// Refresh every 10 seconds
const interval = setInterval(fetchStats, 10000);
return () => clearInterval(interval);
}, []);
if (isLoading && !stats) {
return (
<div className="bg-card rounded-xl p-6 border border-border h-fit">
<div className="mb-6">
<h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Active Connections</h2>
<p className="m-0 text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
return (
<div className="bg-card rounded-xl p-6 border border-border h-fit">
<div className="mb-6">
<h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Active Connections</h2>
<p className="m-0 text-sm text-muted-foreground">
Number of users currently connected to the app
</p>
</div>
{error && (
<div className="px-4 py-3 rounded-md mb-4 text-sm bg-red-50 text-red-800 border border-red-200">
{error}
</div>
)}
{stats && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-4">
<div className="p-5 bg-gradient-to-br from-primary/10 to-secondary/10 rounded-lg border border-primary/20">
<div className="text-center">
<div className="text-4xl font-bold text-primary mb-2">
{stats.total}
</div>
<div className="text-sm text-muted-foreground font-medium">
Total Active Users
</div>
</div>
</div>
<div className="p-5 bg-muted rounded-lg border border-border">
<div className="text-center">
<div className="text-4xl font-bold text-foreground mb-2">
{stats.authenticated}
</div>
<div className="text-sm text-muted-foreground font-medium">
Authenticated
</div>
</div>
</div>
<div className="p-5 bg-muted rounded-lg border border-border">
<div className="text-center">
<div className="text-4xl font-bold text-foreground mb-2">
{stats.anonymous}
</div>
<div className="text-sm text-muted-foreground font-medium">
Anonymous
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={fetchStats}
disabled={isLoading}
className="px-4 py-2 bg-transparent text-primary border border-primary rounded-md text-sm font-medium cursor-pointer transition-colors hover:bg-primary hover:text-primary-foreground disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
{stats.connections && stats.connections.length > 0 && (
<div className="mt-6 pt-6 border-t border-border">
<h3 className="m-0 mb-4 text-base font-semibold text-foreground">Connection Details</h3>
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{stats.connections.map((conn) => {
const timeAgo = Math.floor((Date.now() - conn.lastHeartbeat) / 1000);
const timeAgoText = timeAgo < 60 ? `${timeAgo}s ago` : `${Math.floor(timeAgo / 60)}m ago`;
return (
<div
key={conn.sessionId}
className="p-3 bg-muted rounded-lg border border-border text-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{conn.username ? (
<span className="font-semibold text-foreground">
{conn.username}
</span>
) : (
<span className="text-muted-foreground italic">
Anonymous
</span>
)}
<span className="text-xs text-muted-foreground">
({conn.sessionId.substring(0, 8)}...)
</span>
</div>
<div className="text-xs text-muted-foreground">
<span className="font-medium">Route:</span> {conn.route || '/'}
</div>
{conn.videoTitle && (
<div className="text-xs text-muted-foreground mt-1">
<span className="font-medium">Video:</span> {conn.videoTitle}
</div>
)}
{conn.videoChannel && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">Channel:</span> {conn.videoChannel}
</div>
)}
{conn.timeUsed !== undefined && conn.dailyLimit !== undefined && (conn.route === '/videos' || conn.videoTitle) && (
<div className="text-xs text-muted-foreground mt-1">
<span className="font-medium">Video Time Used:</span> {formatTime(conn.timeUsed)} / {formatTime(conn.dailyLimit)} ({Math.round((conn.timeUsed / conn.dailyLimit) * 100)}%)
</div>
)}
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{timeAgoText}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
);
}

15
frontend/src/pages/AdminPage.tsx

@ -42,6 +42,21 @@ export function AdminPage() { @@ -42,6 +42,21 @@ export function AdminPage() {
Manage word groups for speech sound practice
</p>
</Link>
<Link
to="/admin/stats"
className="bg-blue-100 hover:bg-blue-200 w-full p-6 rounded-3xl font-semibold text-foreground transition-all active:scale-95 hover:shadow-lg flex flex-col items-center text-center no-underline"
>
<div className="mb-3">
<div className="w-20 h-20 flex items-center justify-center text-4xl">
📊
</div>
</div>
<h2 className="text-xl font-bold mb-1">Connection Stats</h2>
<p className="text-sm opacity-75">
View active user connections and routes
</p>
</Link>
</div>
</div>
);

23
frontend/src/pages/StatsAdminPage.tsx

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
import { Link } from 'react-router-dom';
import { ConnectionTracker } from '../components/ConnectionTracker/ConnectionTracker';
export function StatsAdminPage() {
return (
<div className="min-h-[calc(100vh-60px)] bg-background">
<div className="bg-card border-b border-border py-8 px-6 text-center">
<Link
to="/admin"
className="inline-block mb-4 px-4 py-2 bg-transparent border border-border rounded-md text-foreground text-sm cursor-pointer transition-colors no-underline hover:bg-muted"
>
Back to Admin
</Link>
<h1 className="m-0 mb-2 text-[28px] font-medium text-foreground">Connection Statistics</h1>
<p className="m-0 text-sm text-muted-foreground">View active user connections and their current routes</p>
</div>
<div className="max-w-[1200px] mx-auto p-6">
<ConnectionTracker />
</div>
</div>
);
}
Loading…
Cancel
Save