init
This commit is contained in:
93
components/dashboard/activity-timeline.tsx
Normal file
93
components/dashboard/activity-timeline.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface HourlyData {
|
||||
hour: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function ActivityTimeline() {
|
||||
const [data, setData] = useState<HourlyData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/stats");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.hourlyActivity ?? []);
|
||||
}
|
||||
} catch {
|
||||
// retry on next interval
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>24h Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[160px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="activityGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#00f0ff" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#00f0ff" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tick={{ fill: "#64748b", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1a1f2e",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "8px",
|
||||
color: "#e2e8f0",
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: "#94a3b8" }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#00f0ff"
|
||||
strokeWidth={2}
|
||||
fill="url(#activityGradient)"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
fill: "#00f0ff",
|
||||
stroke: "#0a0e1a",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
151
components/dashboard/lobster-feed.tsx
Normal file
151
components/dashboard/lobster-feed.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useSSE } from "@/hooks/use-sse";
|
||||
|
||||
interface FeedItem {
|
||||
id: string;
|
||||
type: "task" | "online" | "offline";
|
||||
lobsterName: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
summary?: string;
|
||||
durationMs?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function LobsterFeed() {
|
||||
const [items, setItems] = useState<FeedItem[]>([]);
|
||||
|
||||
const handleEvent = useCallback((event: { type: string; data: Record<string, unknown> }) => {
|
||||
if (event.type === "task" || event.type === "online" || event.type === "offline") {
|
||||
const newItem: FeedItem = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
type: event.type as FeedItem["type"],
|
||||
lobsterName: (event.data.lobsterName as string) ?? "Unknown",
|
||||
city: event.data.city as string | undefined,
|
||||
country: event.data.country as string | undefined,
|
||||
summary: event.data.summary as string | undefined,
|
||||
durationMs: event.data.durationMs as number | undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setItems((prev) => [newItem, ...prev].slice(0, 50));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useSSE({
|
||||
url: "/api/v1/stream",
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
|
||||
// Load initial recent tasks
|
||||
useEffect(() => {
|
||||
const fetchRecent = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/lobsters?limit=10");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const feedItems: FeedItem[] = (data.lobsters ?? [])
|
||||
.filter((l: Record<string, unknown>) => l.lastTask)
|
||||
.map((l: Record<string, unknown>) => {
|
||||
const task = l.lastTask as Record<string, unknown>;
|
||||
return {
|
||||
id: `init-${l.id}`,
|
||||
type: "task" as const,
|
||||
lobsterName: l.name as string,
|
||||
city: l.city as string,
|
||||
country: l.country as string,
|
||||
summary: task.summary as string,
|
||||
durationMs: task.durationMs as number,
|
||||
timestamp: new Date(task.timestamp as string).getTime(),
|
||||
};
|
||||
});
|
||||
setItems(feedItems);
|
||||
}
|
||||
} catch {
|
||||
// will populate via SSE
|
||||
}
|
||||
};
|
||||
fetchRecent();
|
||||
}, []);
|
||||
|
||||
const formatDuration = (ms?: number) => {
|
||||
if (!ms) return "";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "task":
|
||||
return "⚡";
|
||||
case "online":
|
||||
return "🟢";
|
||||
case "offline":
|
||||
return "⭕";
|
||||
default:
|
||||
return "🦞";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>Live Feed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[400px] overflow-y-auto p-4 pt-0">
|
||||
<AnimatePresence initial={false}>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-[var(--text-muted)]">
|
||||
Waiting for lobster activity...
|
||||
</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||
animate={{ opacity: 1, height: "auto", y: 0 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border-b border-white/5 py-2.5 last:border-0"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 text-sm">{getIcon(item.type)}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium text-[var(--accent-cyan)]">
|
||||
{item.lobsterName}
|
||||
</span>
|
||||
{item.city && (
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{item.city}, {item.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.summary && (
|
||||
<p className="mt-0.5 truncate text-xs text-[var(--text-secondary)]">
|
||||
{item.summary}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{item.durationMs && (
|
||||
<Badge variant="secondary">{formatDuration(item.durationMs)}</Badge>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--text-muted)]">
|
||||
{new Date(item.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
91
components/dashboard/region-ranking.tsx
Normal file
91
components/dashboard/region-ranking.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface RegionData {
|
||||
name: string;
|
||||
count: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const regionColors: Record<string, string> = {
|
||||
Asia: "var(--accent-cyan)",
|
||||
Europe: "var(--accent-purple)",
|
||||
Americas: "var(--accent-pink)",
|
||||
Africa: "var(--accent-orange)",
|
||||
Oceania: "var(--accent-green)",
|
||||
};
|
||||
|
||||
export function RegionRanking() {
|
||||
const [regions, setRegions] = useState<RegionData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/stats");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const breakdown = (data.regionBreakdown ?? {}) as Record<string, number>;
|
||||
const sorted = Object.entries(breakdown)
|
||||
.map(([name, count]) => ({
|
||||
name,
|
||||
count,
|
||||
color: regionColors[name] ?? "var(--accent-cyan)",
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
setRegions(sorted);
|
||||
}
|
||||
} catch {
|
||||
// retry on interval
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const maxCount = Math.max(...regions.map((r) => r.count), 1);
|
||||
|
||||
return (
|
||||
<Card className="border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>Region Ranking</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
{regions.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-[var(--text-muted)]">No data yet</p>
|
||||
) : (
|
||||
regions.map((region, i) => (
|
||||
<div key={region.name} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-[var(--text-muted)]">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: region.color }}>
|
||||
{region.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-[var(--text-secondary)]">
|
||||
{region.count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: region.color }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(region.count / maxCount) * 100}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
150
components/dashboard/stats-panel.tsx
Normal file
150
components/dashboard/stats-panel.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Users, Zap, Clock, Activity } from "lucide-react";
|
||||
|
||||
interface Stats {
|
||||
totalLobsters: number;
|
||||
activeLobsters: number;
|
||||
tasksToday: number;
|
||||
tasksTotal: number;
|
||||
avgTaskDuration: number;
|
||||
}
|
||||
|
||||
function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string }) {
|
||||
const [display, setDisplay] = useState(0);
|
||||
const prevRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const start = prevRef.current;
|
||||
const diff = value - start;
|
||||
if (diff === 0) return;
|
||||
|
||||
const duration = 800;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const current = start + diff * eased;
|
||||
|
||||
setDisplay(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
prevRef.current = value;
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [value]);
|
||||
|
||||
const formatted = Number.isInteger(value)
|
||||
? Math.round(display).toLocaleString()
|
||||
: display.toFixed(1);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{formatted}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
key: "activeLobsters" as const,
|
||||
label: "Online Now",
|
||||
icon: Activity,
|
||||
color: "var(--accent-green)",
|
||||
glow: "0 0 20px rgba(16, 185, 129, 0.3)",
|
||||
},
|
||||
{
|
||||
key: "totalLobsters" as const,
|
||||
label: "Total Lobsters",
|
||||
icon: Users,
|
||||
color: "var(--accent-cyan)",
|
||||
glow: "var(--glow-cyan)",
|
||||
},
|
||||
{
|
||||
key: "tasksToday" as const,
|
||||
label: "Tasks Today",
|
||||
icon: Zap,
|
||||
color: "var(--accent-purple)",
|
||||
glow: "var(--glow-purple)",
|
||||
},
|
||||
{
|
||||
key: "avgTaskDuration" as const,
|
||||
label: "Avg Duration",
|
||||
icon: Clock,
|
||||
color: "var(--accent-orange)",
|
||||
glow: "0 0 20px rgba(245, 158, 11, 0.3)",
|
||||
suffix: "s",
|
||||
transform: (v: number) => v / 1000,
|
||||
},
|
||||
];
|
||||
|
||||
export function StatsPanel() {
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
totalLobsters: 0,
|
||||
activeLobsters: 0,
|
||||
tasksToday: 0,
|
||||
tasksTotal: 0,
|
||||
avgTaskDuration: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/stats");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch {
|
||||
// will retry on next interval
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{statCards.map(({ key, label, icon: Icon, color, glow, suffix, transform }) => {
|
||||
const raw = stats[key];
|
||||
const value = transform ? transform(raw) : raw;
|
||||
return (
|
||||
<Card
|
||||
key={key}
|
||||
className="overflow-hidden border-white/5 transition-all hover:border-white/10"
|
||||
style={{ boxShadow: glow }}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: `${color}15` }}
|
||||
>
|
||||
<Icon className="h-5 w-5" style={{ color }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-[var(--text-muted)]">{label}</p>
|
||||
<p
|
||||
className="font-mono text-xl font-bold"
|
||||
style={{ color, textShadow: glow }}
|
||||
>
|
||||
<AnimatedNumber value={value} suffix={suffix} />
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user