forked from syntaxbullet/AuroraBot-discord
feat: implement visual analytics and activity charts
This commit is contained in:
126
web/src/components/ActivityChart.tsx
Normal file
126
web/src/components/ActivityChart.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import type { ActivityData } from '../hooks/use-activity-stats';
|
||||
|
||||
interface ActivityChartProps {
|
||||
data: ActivityData[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const date = new Date(label);
|
||||
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return (
|
||||
<div className="glass p-3 rounded-lg border border-white/10 text-sm shadow-xl animate-in fade-in zoom-in duration-200">
|
||||
<p className="font-semibold text-white/90 border-b border-white/10 pb-1 mb-2">
|
||||
{timeStr}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center justify-between gap-4">
|
||||
<span className="text-blue-400">Commands</span>
|
||||
<span className="font-mono">{payload[0].value}</span>
|
||||
</p>
|
||||
<p className="flex items-center justify-between gap-4">
|
||||
<span className="text-emerald-400">Transactions</span>
|
||||
<span className="font-mono">{payload[1].value}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ActivityChart: React.FC<ActivityChartProps> = ({ data, loading }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full h-[300px] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-muted-foreground animate-pulse text-sm">Aggregating stats...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format hour for XAxis (e.g., "HH:00")
|
||||
const chartData = data.map(item => ({
|
||||
...item,
|
||||
displayTime: new Date(item.hour).getHours() + ':00'
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full h-[300px] mt-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorCommands" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-primary)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-primary)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorTransactions" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="oklch(0.7 0.15 160)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="oklch(0.7 0.15 160)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke="rgba(255,255,255,0.05)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
fontSize={10}
|
||||
tickFormatter={(str) => {
|
||||
const date = new Date(str);
|
||||
return date.getHours() % 4 === 0 ? `${date.getHours()}:00` : '';
|
||||
}}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
stroke="var(--muted-foreground)"
|
||||
minTickGap={30}
|
||||
/>
|
||||
<YAxis
|
||||
fontSize={10}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
stroke="var(--muted-foreground)"
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="commands"
|
||||
stroke="var(--color-primary)"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCommands)"
|
||||
animationDuration={1500}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="transactions"
|
||||
stroke="oklch(0.7 0.15 160)"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorTransactions)"
|
||||
animationDuration={1500}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
50
web/src/hooks/use-activity-stats.ts
Normal file
50
web/src/hooks/use-activity-stats.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface ActivityData {
|
||||
hour: string;
|
||||
commands: number;
|
||||
transactions: number;
|
||||
}
|
||||
|
||||
interface UseActivityStatsResult {
|
||||
data: ActivityData[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to fetch hourly activity data for charts.
|
||||
* Data is cached on the server for 5 minutes.
|
||||
*/
|
||||
export function useActivityStats(): UseActivityStatsResult {
|
||||
const [data, setData] = useState<ActivityData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchActivity = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/stats/activity");
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const jsonData = await response.json();
|
||||
setData(jsonData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch activity stats:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch activity");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchActivity();
|
||||
|
||||
// Refresh every 5 minutes to match server cache
|
||||
const interval = setInterval(fetchActivity, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return { data, loading, error, refresh: fetchActivity };
|
||||
}
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Activity, Server, Users, Zap } from "lucide-react";
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
|
||||
import { useActivityStats } from "@/hooks/use-activity-stats";
|
||||
import { ControlPanel } from "@/components/ControlPanel";
|
||||
import { ActivityChart } from "@/components/ActivityChart";
|
||||
|
||||
export function Dashboard() {
|
||||
const { stats, loading, error } = useDashboardStats();
|
||||
const { data: activityData, loading: activityLoading } = useActivityStats();
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
@@ -81,6 +84,20 @@ export function Dashboard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Activity Chart Section */}
|
||||
<Card className="glass border-white/5 overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<div className="h-5 w-1 bg-emerald-500 rounded-full" />
|
||||
Live Activity Analytics
|
||||
</CardTitle>
|
||||
<CardDescription className="text-white/40 font-medium tracking-tight">Hourly command and transaction volume across the network (last 24h)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActivityChart data={activityData} loading={activityLoading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4 glass border-white/5">
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
|
||||
@@ -59,6 +59,10 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
// Interval for broadcasting stats to all connected WS clients
|
||||
let statsBroadcastInterval: Timer | undefined;
|
||||
|
||||
// Cache for activity stats (heavy aggregation)
|
||||
let cachedActivity: { data: any, timestamp: number } | null = null;
|
||||
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
const server = serve({
|
||||
port,
|
||||
hostname,
|
||||
@@ -97,6 +101,27 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/stats/activity") {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (cachedActivity && (now - cachedActivity.timestamp < ACTIVITY_CACHE_TTL)) {
|
||||
return Response.json(cachedActivity.data);
|
||||
}
|
||||
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
const activity = await dashboardService.getActivityAggregation();
|
||||
|
||||
cachedActivity = { data: activity, timestamp: now };
|
||||
return Response.json(activity);
|
||||
} catch (error) {
|
||||
console.error("Error fetching activity stats:", error);
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch activity statistics" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Administrative Actions
|
||||
if (url.pathname.startsWith("/api/actions/") && req.method === "POST") {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user