Files
GMW/services/frontend/src/app/(dashboard)/dashboard/view.tsx
T
asepharyana 38b74cc1db feat(fe): enrich dashboard, moderation, messages inspector, channels roster with full backend data
- Dashboard: 6-tile metric deck (flagged today, active users 24h, mod queue),
  pipeline status bar, top channels ranking, enriched reactions with emojis,
  warned counter in gauge, 4 quick-nav links
- Moderation: coverage tile + header rate, top flagged domains, top flagged
  channels, category/severity/action breakdown trends, coverage progress bar
- Messages inspector: severity meter, confidence %, risk score, recommended
  action badge, reply-context chain, mention + role chips
- Channels: roster/culture tabs, rich table (messages, clean, flagged, risk
  bar, last activity) wired to dashboard.channels endpoint
2026-09-01 23:51:39 +07:00

410 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
Activity,
AlertTriangle,
AudioWaveform,
ChevronRight,
Hash,
MessageSquare,
Mic,
Shield,
ShieldAlert,
ShieldCheck,
Users,
Zap,
} from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { AreaActivity, RadialGauge } from "@/components/charts";
import { GlassPanel } from "@/components/primitives";
import {
ErrorState,
MetricTile,
PageTransition,
SectionHeader,
SkeletonHero,
SkeletonMetricRow,
SkeletonPanel,
} from "@/components/shared";
import { useActivity, useStats, useTopReactions } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { formatNumber } from "@/lib/format";
import type { DashboardStats } from "@/lib/types";
function deriveSignal(stats?: DashboardStats) {
if (!stats)
return { tone: "signal" as const, label: "NOMINAL", state: "STABLE" };
const total = stats.total_flagged + stats.total_clean || 1;
const ratio = stats.total_flagged / total;
if (stats.moderation_overview.error > 0)
return { tone: "vermilion" as const, label: "FAULT", state: "MOD_ERR" };
if (ratio > 0.25)
return {
tone: "vermilion" as const,
label: "ELEVATED",
state: "HIGH_RISK",
};
if (ratio > 0.1)
return { tone: "amber" as const, label: "WATCH", state: "ADVISORY" };
return { tone: "signal" as const, label: "NOMINAL", state: "OPTIMAL" };
}
export function DashboardView({
initialStats,
initialActivity,
}: {
initialStats?: DashboardStats;
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
}) {
const {
data: stats,
isLoading,
error,
mutate: mutateStats,
} = useStats(initialStats);
const { data: activity } = useActivity(14, initialActivity);
const { data: reactions } = useTopReactions();
const ambient = useAmbient();
const hudRef = useStaggerReveal<HTMLDivElement>(".linear-tile", {
stagger: 0.035,
y: 8,
dependencies: [stats],
});
useEffect(() => {
const s = deriveSignal(stats);
ambient.set(
s.tone,
0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50),
s.label,
);
}, [stats, ambient]);
if (error && !stats)
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
if (!stats && isLoading)
return (
<div className="space-y-4">
<SkeletonHero />
<SkeletonMetricRow cols={4} />
<SkeletonPanel rows={4} />
</div>
);
if (!stats)
return (
<ErrorState
error={error ?? new Error("No telemetry stream")}
onRetry={() => void mutateStats()}
/>
);
const s = stats;
const total = s.total_flagged + s.total_clean || 1;
const cleanRatio = s.total_clean / total;
const mod = s.moderation_overview;
const hasModQueue = mod.pending > 0 || mod.processing > 0 || mod.error > 0;
return (
<PageTransition>
<div ref={hudRef} className="space-y-4">
{/* Tactical HUD Header Bar */}
<div className="linear-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Telemetry Overview · Node 01
</h1>
</div>
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>SIGNAL:</span>
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
{deriveSignal(stats).label}
</span>
{hasModQueue && (
<span className="flex items-center gap-1 rounded bg-amber/15 px-2 py-0.5 font-medium text-amber border border-amber/30">
<Zap className="size-3" />
{mod.pending + mod.processing + mod.error} IN QUEUE
</span>
)}
</div>
</div>
{/* Primary Metric Deck — 6 tiles */}
<div className="linear-tile grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<MetricTile
label="Total Messages"
value={formatNumber(s.total_messages)}
hint={`${formatNumber(s.today_messages)} today`}
icon={<MessageSquare className="size-4" />}
/>
<MetricTile
label="Clean Filter Ratio"
value={`${Math.round(cleanRatio * 100)}%`}
hint={`${formatNumber(s.total_clean)} verified clean`}
tone="signal"
icon={<Shield className="size-4" />}
/>
<MetricTile
label="Flagged Today"
value={formatNumber(s.today_flagged)}
hint={`${formatNumber(s.total_flagged)} all-time flagged`}
tone={s.today_flagged > 0 ? "vermilion" : "neutral"}
icon={<ShieldAlert className="size-4" />}
/>
<MetricTile
label="Active Users 24h"
value={formatNumber(s.active_users_24h)}
hint={`${formatNumber(s.total_users)} total profiles`}
icon={<Users className="size-4" />}
/>
<MetricTile
label="Voice Recordings"
value={formatNumber(s.total_voice_recordings)}
hint="Archived audio sessions"
icon={<Mic className="size-4" />}
/>
<MetricTile
label="Mod Queue"
value={formatNumber(mod.pending + mod.processing)}
hint={mod.error > 0 ? `${mod.error} errors` : "Processing pipeline"}
tone={
mod.error > 0
? "vermilion"
: mod.pending > 0
? "amber"
: "neutral"
}
icon={<Activity className="size-4" />}
/>
</div>
{/* Moderation Queue Status Bar */}
{hasModQueue && (
<div className="linear-tile flex flex-wrap items-center gap-4 rounded-[8px] border border-amber/20 bg-amber/5 px-4 py-2.5 font-mono text-[10px] text-ink-soft">
<span className="text-amber font-semibold uppercase">
Pipeline Status
</span>
{mod.pending > 0 && (
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-amber" />
{mod.pending} PENDING
</span>
)}
{mod.processing > 0 && (
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-signal animate-pulse" />
{mod.processing} PROCESSING
</span>
)}
{mod.error > 0 && (
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-vermilion" />
{mod.error} ERROR
</span>
)}
</div>
)}
{/* Dynamic Activity Area & Telemetry Gauges */}
<div className="grid gap-3 lg:grid-cols-3">
<GlassPanel className="linear-tile lg:col-span-2">
<SectionHeader
eyebrow="Activity Stream"
title="Message Volume & Signal Density"
/>
<div className="mt-4">
<AreaActivity daily={activity?.daily ?? []} />
</div>
</GlassPanel>
<GlassPanel className="linear-tile flex flex-col justify-between">
<div>
<SectionHeader
eyebrow="Security State"
title="Moderation Accuracy"
/>
<div className="my-6 flex items-center justify-center">
<RadialGauge
value={Math.round(cleanRatio * 100)}
label="Clean Ratio"
tone={cleanRatio > 0.85 ? "signal" : "amber"}
size={140}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-2 border-t border-hairline pt-3 text-center">
<div>
<div className="eyebrow">CLEAN</div>
<div className="font-sans text-sm font-semibold text-success">
{formatNumber(s.total_clean)}
</div>
</div>
<div>
<div className="eyebrow">FLAGGED</div>
<div className="font-sans text-sm font-semibold text-vermilion">
{formatNumber(s.total_flagged)}
</div>
</div>
<div>
<div className="eyebrow">WARNED</div>
<div className="font-sans text-sm font-semibold text-amber">
{formatNumber(s.total_warned)}
</div>
</div>
</div>
</GlassPanel>
</div>
{/* Top Channels + Engagement Row */}
<div className="grid gap-3 lg:grid-cols-2">
{/* Top Channels Ranking */}
{s.top_channels && s.top_channels.length > 0 && (
<GlassPanel className="linear-tile">
<SectionHeader
eyebrow="volume"
title="Top Active Channels"
action={
<Link
href="/analysis"
className="flex items-center gap-1 text-xs text-ink-soft hover:text-ink"
>
View All
<ChevronRight className="size-3" />
</Link>
}
/>
<div className="mt-3 space-y-2">
{s.top_channels.slice(0, 6).map((ch, i) => {
const maxCount = s.top_channels[0]?.message_count || 1;
const pct = Math.max(
4,
Math.round((ch.message_count / maxCount) * 100),
);
return (
<div
key={ch.channel_id}
className="flex items-center gap-3 text-xs"
>
<span className="font-mono w-4 text-[10px] text-ink-faint">
{String(i + 1).padStart(2, "0")}
</span>
<span className="w-28 shrink-0 truncate font-mono text-xs text-ink sm:w-36">
<Hash className="mr-0.5 inline size-3 text-signal" />
{ch.channel_name ?? ch.channel_id.slice(0, 14)}
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
className="h-full rounded-full bg-signal"
style={{ width: `${pct}%` }}
/>
</div>
<span className="font-mono w-16 text-right text-[11px] font-semibold text-signal">
{formatNumber(ch.message_count)}
</span>
</div>
);
})}
</div>
</GlassPanel>
)}
{/* Top Reacted Messages */}
{reactions && reactions.length > 0 && (
<GlassPanel className="linear-tile">
<SectionHeader
eyebrow="Engagement"
title="Top Reacted Messages"
/>
<div className="mt-3 space-y-2">
{reactions.slice(0, 4).map((r) => (
<div
key={r.message_id}
className="hud-card flex items-start justify-between gap-2 p-2.5"
>
<div className="min-w-0 flex-1">
<span className="line-clamp-1 block text-xs text-ink-soft">
{r.content || "[Media/Attachment]"}
</span>
{r.username && (
<span className="mt-0.5 block font-mono text-[10px] text-ink-muted">
@{r.username}
{r.channel_name && ` in #${r.channel_name}`}
</span>
)}
</div>
<div className="shrink-0 text-right">
<span className="font-mono text-xs font-semibold text-signal">
{formatNumber(r.reaction_count)}
</span>
{r.top_emojis && r.top_emojis.length > 0 && (
<div className="mt-0.5 flex gap-0.5">
{r.top_emojis.map((e) => (
<span
key={e.emoji}
className="text-[10px]"
title={`${e.emoji} ×${e.count}`}
>
{e.emoji}
</span>
))}
</div>
)}
</div>
</div>
))}
</div>
</GlassPanel>
)}
</div>
{/* Quick Navigation */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Link
href="/moderation"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<ShieldCheck className="size-3.5 text-signal" />
Live Moderation Queue
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/voice"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<AudioWaveform className="size-3.5 text-success" />
Active Voice Stages
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/analysis"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<AlertTriangle className="size-3.5 text-amber" />
Flagged Content Analysis
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/recordings"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<Mic className="size-3.5 text-vermilion" />
Voice Recording Archive
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
</div>
</div>
</PageTransition>
);
}