3 changed files with 241 additions and 0 deletions
@ -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> |
||||
); |
||||
} |
||||
@ -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…
Reference in new issue