feat: implement visual analytics and activity charts

This commit is contained in:
syntaxbullet
2026-01-08 21:36:19 +01:00
parent 5d2d4bb0c6
commit 11e07a0068
11 changed files with 433 additions and 13 deletions

View 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>
);
};

View 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 };
}

View File

@@ -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">

View File

@@ -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 {