feat(dashboard): message activity timeline + moderation donut
Backend: - GET /api/dashboard/activity?days=1..90 — daily buckets (messages, flagged, active_users) + hourly distribution last 24h - clamped days param, reuses existing indexes (idx_messages_created, ai_status_created) Frontend: - ActivityChart: area chart messages+flagged per day, 7/14/30d range - HourlyActivityChart: 24h bars with peak highlight - ModerationDonut: clean/flagged/warned/error breakdown with live summary line (server X% clean) - Dashboard layout: activity 2/3 + donut 1/3, hourly + top channels Verified: endpoint returns real data from prod DB (784/1897/8 msgs per day), tsc clean backend+frontend.
This commit is contained in:
@@ -82,6 +82,52 @@ export class DashboardRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getActivity(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const sinceMs = Date.now() - days * 86400000;
|
||||||
|
const dayAgoMs = Date.now() - 86400000;
|
||||||
|
|
||||||
|
// Daily buckets (last N days)
|
||||||
|
const daily = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
to_char(to_timestamp(created_at / 1000), 'YYYY-MM-DD') AS day,
|
||||||
|
COUNT(*)::int AS messages,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||||
|
COUNT(DISTINCT user_id)::int AS active_users
|
||||||
|
FROM ${pgMessagesTable}
|
||||||
|
WHERE created_at >= ${sinceMs}
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Hourly distribution (last 24h)
|
||||||
|
const hourly = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||||
|
COUNT(*)::int AS messages,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged
|
||||||
|
FROM ${pgMessagesTable}
|
||||||
|
WHERE created_at >= ${dayAgoMs}
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
days,
|
||||||
|
daily: (daily.rows as Record<string, unknown>[]).map((r) => ({
|
||||||
|
day: String(r.day),
|
||||||
|
messages: Number(r.messages),
|
||||||
|
flagged: Number(r.flagged),
|
||||||
|
active_users: Number(r.active_users),
|
||||||
|
})),
|
||||||
|
hourly: (hourly.rows as Record<string, unknown>[]).map((r) => ({
|
||||||
|
hour: Number(r.hour),
|
||||||
|
messages: Number(r.messages),
|
||||||
|
flagged: Number(r.flagged),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async listUsers(query: ListUsersQuery) {
|
async listUsers(query: ListUsersQuery) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const limit = query.limit ?? 20;
|
const limit = query.limit ?? 20;
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export function createDashboardRouter(): Router {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/dashboard/activity?days=14 — message volume over time
|
||||||
|
router.get(
|
||||||
|
"/dashboard/activity",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const days = Math.min(Math.max(Number(req.query.days) || 14, 1), 90);
|
||||||
|
const activity = await dashboardService.getActivity(days);
|
||||||
|
res.json(activity);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/dashboard/users — paginated user list with profiles
|
// GET /api/dashboard/users — paginated user list with profiles
|
||||||
router.get(
|
router.get(
|
||||||
"/dashboard/users",
|
"/dashboard/users",
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export class DashboardService {
|
|||||||
return dashboardRepository.getStats();
|
return dashboardRepository.getStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getActivity(days: number) {
|
||||||
|
logger.debug({ days }, "Fetching dashboard activity");
|
||||||
|
return dashboardRepository.getActivity(days);
|
||||||
|
}
|
||||||
|
|
||||||
async listUsers(query: ListUsersQuery) {
|
async listUsers(query: ListUsersQuery) {
|
||||||
logger.debug({ query }, "Listing dashboard users");
|
logger.debug({ query }, "Listing dashboard users");
|
||||||
return dashboardRepository.listUsers(query);
|
return dashboardRepository.listUsers(query);
|
||||||
|
|||||||
@@ -9,19 +9,34 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { ActivityChart } from "@/components/dashboard/activity-chart";
|
||||||
import { ChannelsSection } from "@/components/dashboard/channels-section";
|
import { ChannelsSection } from "@/components/dashboard/channels-section";
|
||||||
|
import { HourlyActivityChart } from "@/components/dashboard/hourly-activity-chart";
|
||||||
|
import { ModerationDonut } from "@/components/dashboard/moderation-donut";
|
||||||
import { StatCard } from "@/components/dashboard/stat-card";
|
import { StatCard } from "@/components/dashboard/stat-card";
|
||||||
import { TopChannelsChart } from "@/components/dashboard/top-channels-chart";
|
import { TopChannelsChart } from "@/components/dashboard/top-channels-chart";
|
||||||
import { UsersSection } from "@/components/dashboard/users-section";
|
import { UsersSection } from "@/components/dashboard/users-section";
|
||||||
import { SubNav } from "@/components/layout/sub-nav";
|
import { SubNav } from "@/components/layout/sub-nav";
|
||||||
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||||
import { useStats } from "@/hooks";
|
import { useActivity, useStats } from "@/hooks";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type DashboardTab = "stats" | "users" | "channels";
|
type DashboardTab = "stats" | "users" | "channels";
|
||||||
|
|
||||||
|
const DAY_RANGES = [7, 14, 30] as const;
|
||||||
|
|
||||||
|
const MODERATION_COLORS: Record<string, string> = {
|
||||||
|
Clean: "oklch(0.72 0.16 155)",
|
||||||
|
Flagged: "oklch(0.62 0.19 25)",
|
||||||
|
Warned: "oklch(0.78 0.15 80)",
|
||||||
|
Error: "oklch(0.55 0.02 245)",
|
||||||
|
};
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [tab, setTab] = useState<DashboardTab>("stats");
|
const [tab, setTab] = useState<DashboardTab>("stats");
|
||||||
|
const [days, setDays] = useState<number>(14);
|
||||||
const { data: stats, isLoading, error, mutate: refetch } = useStats();
|
const { data: stats, isLoading, error, mutate: refetch } = useStats();
|
||||||
|
const { data: activity, isLoading: activityLoading } = useActivity(days);
|
||||||
|
|
||||||
const subNavTabs = [
|
const subNavTabs = [
|
||||||
{ id: "stats", label: "Stats", icon: <Hash className="size-3" /> },
|
{ id: "stats", label: "Stats", icon: <Hash className="size-3" /> },
|
||||||
@@ -29,6 +44,31 @@ export default function DashboardPage() {
|
|||||||
{ id: "channels", label: "Channels", icon: <Hash className="size-3" /> },
|
{ id: "channels", label: "Channels", icon: <Hash className="size-3" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const moderationData = stats
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "Clean",
|
||||||
|
value: stats.total_clean,
|
||||||
|
color: MODERATION_COLORS.Clean,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Flagged",
|
||||||
|
value: stats.total_flagged,
|
||||||
|
color: MODERATION_COLORS.Flagged,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Warned",
|
||||||
|
value: stats.total_warned,
|
||||||
|
color: MODERATION_COLORS.Warned,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Error",
|
||||||
|
value: stats.total_error,
|
||||||
|
color: MODERATION_COLORS.Error,
|
||||||
|
},
|
||||||
|
].filter((d) => d.value > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in-up">
|
<div className="space-y-4 animate-fade-in-up">
|
||||||
<SubNav
|
<SubNav
|
||||||
@@ -80,12 +120,50 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TopChannelsChart
|
<div className="flex items-center justify-end gap-1">
|
||||||
data={stats.top_channels.map((c) => ({
|
{DAY_RANGES.map((range) => (
|
||||||
name: c.channel_name ?? c.channel_id,
|
<button
|
||||||
count: c.message_count,
|
key={range}
|
||||||
}))}
|
type="button"
|
||||||
/>
|
onClick={() => setDays(range)}
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide rounded-md transition-colors",
|
||||||
|
days === range
|
||||||
|
? "bg-primary/20 text-primary"
|
||||||
|
: "text-text-secondary/60 hover:text-text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{range}d
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
|
<div className="xl:col-span-2">
|
||||||
|
{activityLoading ? (
|
||||||
|
<LoadingSkeleton count={1} height="h-56" />
|
||||||
|
) : (
|
||||||
|
<ActivityChart data={activity?.daily} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ModerationDonut data={moderationData} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
|
<div className="xl:col-span-2">
|
||||||
|
{activityLoading ? (
|
||||||
|
<LoadingSkeleton count={1} height="h-40" />
|
||||||
|
) : (
|
||||||
|
<HourlyActivityChart data={activity?.hourly} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TopChannelsChart
|
||||||
|
data={stats.top_channels.map((c) => ({
|
||||||
|
name: c.channel_name ?? c.channel_id,
|
||||||
|
count: c.message_count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { useMounted } from "@/lib/hooks/use-mounted";
|
||||||
|
|
||||||
|
interface ActivityChartProps {
|
||||||
|
data?: { day: string; messages: number; flagged: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
background: "oklch(0.11 0.02 245 / 0.95)",
|
||||||
|
border: "1px solid oklch(1 0 0 / 0.08)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "oklch(0.93 0.01 245)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function ActivityChart({ data = [] }: ActivityChartProps) {
|
||||||
|
const mounted = useMounted();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
|
||||||
|
Message Activity
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-secondary/50">
|
||||||
|
messages · flagged per day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-56">
|
||||||
|
{mounted ? (
|
||||||
|
<ResponsiveContainer
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
minWidth={0}
|
||||||
|
minHeight={0}
|
||||||
|
>
|
||||||
|
<AreaChart data={data} margin={{ left: -18, right: 4, top: 4 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradMessages" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor="var(--color-primary)"
|
||||||
|
stopOpacity={0.45}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor="var(--color-primary)"
|
||||||
|
stopOpacity={0.02}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradFlagged" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor="oklch(0.62 0.19 25)"
|
||||||
|
stopOpacity={0.5}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor="oklch(0.62 0.19 25)"
|
||||||
|
stopOpacity={0.02}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="oklch(1 0 0 / 0.05)"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||||
|
tickFormatter={(v: string) => {
|
||||||
|
const [, m, d] = v.split("-");
|
||||||
|
return `${Number(m)}/${Number(d)}`;
|
||||||
|
}}
|
||||||
|
minTickGap={24}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={TOOLTIP_STYLE}
|
||||||
|
labelFormatter={(label) => {
|
||||||
|
const [y, m, d] = String(label).split("-");
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="messages"
|
||||||
|
stroke="var(--color-primary)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#gradMessages)"
|
||||||
|
name="Messages"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="flagged"
|
||||||
|
stroke="oklch(0.62 0.19 25)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#gradFlagged)"
|
||||||
|
name="Flagged"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full animate-pulse rounded-md bg-card/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { useMounted } from "@/lib/hooks/use-mounted";
|
||||||
|
|
||||||
|
interface HourlyActivityChartProps {
|
||||||
|
data?: { hour: number; messages: number; flagged: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUR_LABELS = Array.from({ length: 24 }, (_, i) => {
|
||||||
|
const h = i % 12 === 0 ? 12 : i % 12;
|
||||||
|
return `${h}${i < 12 ? "am" : "pm"}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
background: "oklch(0.11 0.02 245 / 0.95)",
|
||||||
|
border: "1px solid oklch(1 0 0 / 0.08)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "oklch(0.93 0.01 245)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function HourlyActivityChart({ data = [] }: HourlyActivityChartProps) {
|
||||||
|
const mounted = useMounted();
|
||||||
|
|
||||||
|
const full = Array.from({ length: 24 }, (_, hour) => {
|
||||||
|
const found = data.find((d) => d.hour === hour);
|
||||||
|
return {
|
||||||
|
hour,
|
||||||
|
label: HOUR_LABELS[hour],
|
||||||
|
messages: found?.messages ?? 0,
|
||||||
|
flagged: found?.flagged ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const peak = Math.max(1, ...full.map((d) => d.messages));
|
||||||
|
const maxMessages = Math.max(...full.map((d) => d.messages));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
|
||||||
|
Hourly Activity
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-secondary/50">
|
||||||
|
last 24h · peak {maxMessages} msgs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-40">
|
||||||
|
{mounted ? (
|
||||||
|
<ResponsiveContainer
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
minWidth={0}
|
||||||
|
minHeight={0}
|
||||||
|
>
|
||||||
|
<BarChart data={full} margin={{ left: -22, right: 4, top: 4 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 9 }}
|
||||||
|
interval={3}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||||
|
allowDecimals={false}
|
||||||
|
domain={[0, (dataMax: number) => Math.max(1, dataMax)]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={TOOLTIP_STYLE}
|
||||||
|
cursor={{ fill: "oklch(1 0 0 / 0.04)" }}
|
||||||
|
labelFormatter={(label) => `Hour ${label}`}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="messages" radius={[3, 3, 0, 0]} name="Messages">
|
||||||
|
{full.map((d) => (
|
||||||
|
<Cell
|
||||||
|
key={d.hour}
|
||||||
|
fill={
|
||||||
|
d.messages === maxMessages && maxMessages > 0
|
||||||
|
? "var(--color-primary)"
|
||||||
|
: d.messages > peak * 0.5
|
||||||
|
? "oklch(0.52 0.13 245 / 0.7)"
|
||||||
|
: "oklch(0.52 0.13 245 / 0.35)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full animate-pulse rounded-md bg-card/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { useMounted } from "@/lib/hooks/use-mounted";
|
||||||
|
|
||||||
|
interface ModerationDonutProps {
|
||||||
|
data?: { name: string; value: number; color: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
background: "oklch(0.11 0.02 245 / 0.95)",
|
||||||
|
border: "1px solid oklch(1 0 0 / 0.08)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "oklch(0.93 0.01 245)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function ModerationDonut({ data = [] }: ModerationDonutProps) {
|
||||||
|
const mounted = useMounted();
|
||||||
|
const total = data.reduce((sum, d) => sum + d.value, 0);
|
||||||
|
const cleanPct =
|
||||||
|
total > 0
|
||||||
|
? Math.round(
|
||||||
|
((data.find((d) => d.name === "Clean")?.value ?? 0) / total) * 100,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
|
||||||
|
Moderation Breakdown
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative h-40 w-40 shrink-0">
|
||||||
|
{mounted ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius={52}
|
||||||
|
outerRadius={72}
|
||||||
|
paddingAngle={2}
|
||||||
|
strokeWidth={0}
|
||||||
|
>
|
||||||
|
{data.map((entry) => (
|
||||||
|
<Cell key={entry.name} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full animate-pulse rounded-full bg-card/40" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-2xl font-bold text-text-primary">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-text-secondary/60">
|
||||||
|
messages
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.name} className="flex items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className="size-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ background: d.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-text-secondary">{d.name}</span>
|
||||||
|
<span className="ml-auto font-mono text-text-primary">
|
||||||
|
{d.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="w-10 text-right font-mono text-text-secondary/50">
|
||||||
|
{total > 0 ? Math.round((d.value / total) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{cleanPct >= 90 && (
|
||||||
|
<p className="pt-1 text-[10px] text-green-500/80">
|
||||||
|
✓ Server is {cleanPct}% clean — moderation is holding up well
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{cleanPct < 90 && cleanPct > 0 && (
|
||||||
|
<p className="pt-1 text-[10px] text-amber-500/80">
|
||||||
|
{100 - cleanPct}% of messages were flagged or warned — review
|
||||||
|
activity in the Analysis tab
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { useConfig } from "./use-config";
|
export { useConfig } from "./use-config";
|
||||||
export {
|
export {
|
||||||
|
useActivity,
|
||||||
useChannelDetail,
|
useChannelDetail,
|
||||||
useChannels,
|
useChannels,
|
||||||
useStats,
|
useStats,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import useSWR from "swr";
|
|||||||
|
|
||||||
import { dashboardApi } from "@/lib/api";
|
import { dashboardApi } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
|
DashboardActivity,
|
||||||
DashboardChannelDetail,
|
DashboardChannelDetail,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
DashboardUserDetail,
|
DashboardUserDetail,
|
||||||
@@ -13,6 +14,12 @@ export function useStats() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useActivity(days = 14) {
|
||||||
|
return useSWR<DashboardActivity>(["dashboard-activity", days], () =>
|
||||||
|
dashboardApi.getActivity(days),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useUsers(search?: string) {
|
export function useUsers(search?: string) {
|
||||||
return useSWR(
|
return useSWR(
|
||||||
["dashboard-users", search ?? ""],
|
["dashboard-users", search ?? ""],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
DashboardActivity,
|
||||||
DashboardChannelDetail,
|
DashboardChannelDetail,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
DashboardUserDetail,
|
DashboardUserDetail,
|
||||||
@@ -10,6 +11,9 @@ import { api } from "./client";
|
|||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
getStats: () => api.get<DashboardStats>("/api/dashboard/stats"),
|
getStats: () => api.get<DashboardStats>("/api/dashboard/stats"),
|
||||||
|
|
||||||
|
getActivity: (days = 14) =>
|
||||||
|
api.get<DashboardActivity>(`/api/dashboard/activity?days=${days}`),
|
||||||
|
|
||||||
listUsers: (limit?: number, cursor?: string, search?: string) => {
|
listUsers: (limit?: number, cursor?: string, search?: string) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (limit) params.set("limit", String(limit));
|
if (limit) params.set("limit", String(limit));
|
||||||
|
|||||||
@@ -28,6 +28,25 @@ export interface ModerationOverview {
|
|||||||
error: number;
|
error: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardActivity {
|
||||||
|
days: number;
|
||||||
|
daily: DailyActivityPoint[];
|
||||||
|
hourly: HourlyActivityPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyActivityPoint {
|
||||||
|
day: string; // YYYY-MM-DD
|
||||||
|
messages: number;
|
||||||
|
flagged: number;
|
||||||
|
active_users: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HourlyActivityPoint {
|
||||||
|
hour: number; // 0-23
|
||||||
|
messages: number;
|
||||||
|
flagged: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardUser {
|
export interface DashboardUser {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user