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
This commit is contained in:
@@ -1,39 +1,234 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Hash } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary";
|
import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary";
|
||||||
import { SkeletonPanel } from "@/components/shared";
|
import { GlassPanel } from "@/components/primitives";
|
||||||
import { useChannelCultures } from "@/hooks";
|
import {
|
||||||
import type { ChannelCultureRow } from "@/lib/types";
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
PageTransition,
|
||||||
|
SectionHeader,
|
||||||
|
SkeletonPanel,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { useChannelCultures, useChannels } from "@/hooks";
|
||||||
|
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||||
|
import { formatNumber } from "@/lib/format";
|
||||||
|
import type { DashboardChannel } from "@/lib/types";
|
||||||
|
|
||||||
|
type ViewMode = "roster" | "culture";
|
||||||
|
|
||||||
export function ChannelsView({
|
export function ChannelsView({
|
||||||
initialCultures,
|
initialCultures,
|
||||||
}: {
|
}: {
|
||||||
initialCultures?: ChannelCultureRow[];
|
initialCultures?: import("@/lib/types").ChannelCultureRow[];
|
||||||
}) {
|
}) {
|
||||||
const { data: cultures } = useChannelCultures(100, initialCultures);
|
const [viewMode, setViewMode] = useState<ViewMode>("roster");
|
||||||
return (
|
const { data: channels, isLoading, error } = useChannels();
|
||||||
<div className="space-y-4">
|
const ambient = useAmbient();
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="relative flex size-3 items-center justify-center">
|
|
||||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-signal opacity-75" />
|
|
||||||
<span className="relative inline-flex size-2 rounded-full bg-signal" />
|
|
||||||
</div>
|
|
||||||
<h1 className="font-mono text-xs font-semibold tracking-widest text-ink uppercase">
|
|
||||||
CHANNEL ROSTER · SIGNAL_MAP
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-1.5 rounded-md bg-surface px-2 py-0.5 font-mono text-[11px] text-ink-soft">
|
|
||||||
<span className="text-ink-faint">NODES:</span>
|
|
||||||
<span className="font-bold text-signal">{cultures?.length ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cultures ? (
|
const containerRef = useStaggerReveal<HTMLDivElement>(
|
||||||
<ChannelCultureGlossary cultures={cultures} />
|
".channel-roster-card",
|
||||||
) : (
|
{
|
||||||
<SkeletonPanel rows={6} />
|
stagger: 0.03,
|
||||||
)}
|
y: 8,
|
||||||
</div>
|
dependencies: [channels?.length],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ambient.set("signal", 0.25, "channels");
|
||||||
|
}, [ambient]);
|
||||||
|
|
||||||
|
if (error && !channels) return <ErrorState error={error} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageTransition>
|
||||||
|
<div ref={containerRef} className="space-y-4">
|
||||||
|
{/* Tactical HUD Header Bar */}
|
||||||
|
<div className="channel-roster-card flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex size-3 items-center justify-center">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-signal opacity-75" />
|
||||||
|
<span className="relative inline-flex size-2 rounded-full bg-signal" />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-mono text-xs font-semibold tracking-widest text-ink uppercase">
|
||||||
|
Channel Roster · Signal Map
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode("roster")}
|
||||||
|
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
|
||||||
|
viewMode === "roster"
|
||||||
|
? "bg-surface text-ink border border-hairline-focus shadow-xs"
|
||||||
|
: "text-ink-muted hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ROSTER
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode("culture")}
|
||||||
|
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
|
||||||
|
viewMode === "culture"
|
||||||
|
? "bg-surface text-ink border border-hairline-focus shadow-xs"
|
||||||
|
: "text-ink-muted hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
CULTURE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center gap-1.5 rounded-md bg-surface px-2 py-0.5 font-mono text-[11px] text-ink-soft">
|
||||||
|
<span className="text-ink-faint">NODES:</span>
|
||||||
|
<span className="font-bold text-signal">
|
||||||
|
{channels?.length ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === "culture" ? (
|
||||||
|
<ChannelsCultureView initialCultures={initialCultures} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isLoading && !channels ? (
|
||||||
|
<SkeletonPanel rows={8} />
|
||||||
|
) : !channels || channels.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Hash className="size-7" />}
|
||||||
|
title="No channels discovered"
|
||||||
|
description="Channels appear here once the Discord gateway captures messages."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GlassPanel className="channel-roster-card">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="registry"
|
||||||
|
title="Channel Roster"
|
||||||
|
action={
|
||||||
|
<span className="mono text-xs text-[#8a8f98]">
|
||||||
|
{channels.length} channels
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-hairline font-mono text-[10px] text-ink-muted uppercase">
|
||||||
|
<th className="px-3 py-2">Channel</th>
|
||||||
|
<th className="px-3 py-2 text-right">Messages</th>
|
||||||
|
<th className="px-3 py-2 text-right">Clean</th>
|
||||||
|
<th className="px-3 py-2 text-right">Flagged</th>
|
||||||
|
<th className="px-3 py-2">Risk</th>
|
||||||
|
<th className="px-3 py-2 text-right">Last Activity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<ChannelRow key={ch.channel_id} channel={ch} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageTransition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelRow({ channel }: { channel: DashboardChannel }) {
|
||||||
|
const total = channel.total_messages || 1;
|
||||||
|
const flaggedPct = channel.flagged_count
|
||||||
|
? (channel.flagged_count / total) * 100
|
||||||
|
: 0;
|
||||||
|
const riskTone =
|
||||||
|
flaggedPct > 10 ? "vermilion" : flaggedPct > 3 ? "amber" : "signal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-hairline/50 transition-colors hover:bg-surface-2/50">
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Hash className="size-3.5 text-signal" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-ink">
|
||||||
|
{channel.channel_name ?? channel.channel_id.slice(0, 20)}
|
||||||
|
</div>
|
||||||
|
{channel.guild_id && (
|
||||||
|
<div className="font-mono text-[9px] text-ink-faint">
|
||||||
|
guild: {channel.guild_id.slice(0, 12)}…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-mono text-[11px] font-semibold text-ink">
|
||||||
|
{formatNumber(channel.total_messages)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right">
|
||||||
|
<span className="font-mono text-[11px] text-success">
|
||||||
|
{formatNumber(channel.total_messages - channel.flagged_count)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right">
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[11px] ${
|
||||||
|
channel.flagged_count > 0 ? "text-vermilion" : "text-ink-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatNumber(channel.flagged_count)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1 w-16 overflow-hidden rounded-full bg-surface-2">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
riskTone === "vermilion"
|
||||||
|
? "bg-vermilion"
|
||||||
|
: riskTone === "amber"
|
||||||
|
? "bg-amber"
|
||||||
|
: "bg-signal"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(100, flaggedPct * 3)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[9px] text-ink-faint">
|
||||||
|
{flaggedPct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right">
|
||||||
|
{channel.last_message_at ? (
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] text-ink-muted"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
{new Date(channel.last_message_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-ink-faint">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelsCultureView({
|
||||||
|
initialCultures,
|
||||||
|
}: {
|
||||||
|
initialCultures?: import("@/lib/types").ChannelCultureRow[];
|
||||||
|
}) {
|
||||||
|
const { data: cultures } = useChannelCultures(100, initialCultures);
|
||||||
|
|
||||||
|
return cultures ? (
|
||||||
|
<ChannelCultureGlossary cultures={cultures} />
|
||||||
|
) : (
|
||||||
|
<SkeletonPanel rows={6} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
AudioWaveform,
|
AudioWaveform,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Hash,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Mic,
|
Mic,
|
||||||
Shield,
|
Shield,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -98,6 +104,8 @@ export function DashboardView({
|
|||||||
const s = stats;
|
const s = stats;
|
||||||
const total = s.total_flagged + s.total_clean || 1;
|
const total = s.total_flagged + s.total_clean || 1;
|
||||||
const cleanRatio = s.total_clean / total;
|
const cleanRatio = s.total_clean / total;
|
||||||
|
const mod = s.moderation_overview;
|
||||||
|
const hasModQueue = mod.pending > 0 || mod.processing > 0 || mod.error > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageTransition>
|
<PageTransition>
|
||||||
@@ -113,13 +121,19 @@ export function DashboardView({
|
|||||||
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
|
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
|
||||||
<span>SIGNAL:</span>
|
<span>SIGNAL:</span>
|
||||||
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
|
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
|
||||||
STABLE_STREAM
|
{deriveSignal(stats).label}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary Metric Deck */}
|
{/* Primary Metric Deck — 6 tiles */}
|
||||||
<div className="linear-tile grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="linear-tile grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Total Messages"
|
label="Total Messages"
|
||||||
value={formatNumber(s.total_messages)}
|
value={formatNumber(s.total_messages)}
|
||||||
@@ -133,6 +147,19 @@ export function DashboardView({
|
|||||||
tone="signal"
|
tone="signal"
|
||||||
icon={<Shield className="size-4" />}
|
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
|
<MetricTile
|
||||||
label="Voice Recordings"
|
label="Voice Recordings"
|
||||||
value={formatNumber(s.total_voice_recordings)}
|
value={formatNumber(s.total_voice_recordings)}
|
||||||
@@ -140,13 +167,47 @@ export function DashboardView({
|
|||||||
icon={<Mic className="size-4" />}
|
icon={<Mic className="size-4" />}
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Total Profiles"
|
label="Mod Queue"
|
||||||
value={formatNumber(s.total_profiles)}
|
value={formatNumber(mod.pending + mod.processing)}
|
||||||
hint={`${formatNumber(s.active_users_24h)} active 24h`}
|
hint={mod.error > 0 ? `${mod.error} errors` : "Processing pipeline"}
|
||||||
icon={<Users className="size-4" />}
|
tone={
|
||||||
|
mod.error > 0
|
||||||
|
? "vermilion"
|
||||||
|
: mod.pending > 0
|
||||||
|
? "amber"
|
||||||
|
: "neutral"
|
||||||
|
}
|
||||||
|
icon={<Activity className="size-4" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Dynamic Activity Area & Telemetry Gauges */}
|
||||||
<div className="grid gap-3 lg:grid-cols-3">
|
<div className="grid gap-3 lg:grid-cols-3">
|
||||||
<GlassPanel className="linear-tile lg:col-span-2">
|
<GlassPanel className="linear-tile lg:col-span-2">
|
||||||
@@ -174,7 +235,7 @@ export function DashboardView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 border-t border-hairline pt-3 text-center">
|
<div className="grid grid-cols-3 gap-2 border-t border-hairline pt-3 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow">CLEAN</div>
|
<div className="eyebrow">CLEAN</div>
|
||||||
<div className="font-sans text-sm font-semibold text-success">
|
<div className="font-sans text-sm font-semibold text-success">
|
||||||
@@ -187,12 +248,70 @@ export function DashboardView({
|
|||||||
{formatNumber(s.total_flagged)}
|
{formatNumber(s.total_flagged)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secondary Insights Deck */}
|
{/* Top Channels + Engagement Row */}
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
<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 && (
|
{reactions && reactions.length > 0 && (
|
||||||
<GlassPanel className="linear-tile">
|
<GlassPanel className="linear-tile">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -203,47 +322,86 @@ export function DashboardView({
|
|||||||
{reactions.slice(0, 4).map((r) => (
|
{reactions.slice(0, 4).map((r) => (
|
||||||
<div
|
<div
|
||||||
key={r.message_id}
|
key={r.message_id}
|
||||||
className="hud-card flex items-center justify-between p-2.5"
|
className="hud-card flex items-start justify-between gap-2 p-2.5"
|
||||||
>
|
>
|
||||||
<span className="truncate pr-2 text-xs text-ink-soft">
|
<div className="min-w-0 flex-1">
|
||||||
{r.content || "[Media/Attachment]"}
|
<span className="line-clamp-1 block text-xs text-ink-soft">
|
||||||
</span>
|
{r.content || "[Media/Attachment]"}
|
||||||
<span className="font-mono text-xs font-semibold text-signal shrink-0">
|
</span>
|
||||||
{formatNumber(r.reaction_count)} reactions
|
{r.username && (
|
||||||
</span>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<GlassPanel className="linear-tile flex flex-col justify-between">
|
{/* Quick Navigation */}
|
||||||
<div>
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<SectionHeader eyebrow="Navigation" title="Quick Diagnostics" />
|
<Link
|
||||||
<div className="mt-2 space-y-1.5">
|
href="/moderation"
|
||||||
<Link
|
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
|
||||||
href="/moderation"
|
>
|
||||||
className="hud-card flex items-center justify-between p-2.5 text-xs text-ink-soft transition-colors hover:text-ink"
|
<span className="flex items-center gap-2">
|
||||||
>
|
<ShieldCheck className="size-3.5 text-signal" />
|
||||||
<span className="flex items-center gap-2">
|
Live Moderation Queue
|
||||||
<Shield className="size-3.5 text-signal" />
|
</span>
|
||||||
Review Live Moderation Queue
|
<ChevronRight className="size-3.5 text-ink-muted" />
|
||||||
</span>
|
</Link>
|
||||||
<ChevronRight className="size-3.5 text-ink-muted" />
|
<Link
|
||||||
</Link>
|
href="/voice"
|
||||||
<Link
|
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
|
||||||
href="/voice"
|
>
|
||||||
className="hud-card flex items-center justify-between p-2.5 text-xs text-ink-soft transition-colors hover:text-ink"
|
<span className="flex items-center gap-2">
|
||||||
>
|
<AudioWaveform className="size-3.5 text-success" />
|
||||||
<span className="flex items-center gap-2">
|
Active Voice Stages
|
||||||
<AudioWaveform className="size-3.5 text-success" />
|
</span>
|
||||||
Inspect Active Voice Stages
|
<ChevronRight className="size-3.5 text-ink-muted" />
|
||||||
</span>
|
</Link>
|
||||||
<ChevronRight className="size-3.5 text-ink-muted" />
|
<Link
|
||||||
</Link>
|
href="/analysis"
|
||||||
</div>
|
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
|
||||||
</div>
|
>
|
||||||
</GlassPanel>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</PageTransition>
|
</PageTransition>
|
||||||
|
|||||||
@@ -572,6 +572,34 @@ function AiBadge({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function severityTone(
|
||||||
|
sev: import("@/lib/types").AiSeverity | null | undefined,
|
||||||
|
): "signal" | "amber" | "vermilion" | "neutral" {
|
||||||
|
if (sev === "critical" || sev === "high") return "vermilion";
|
||||||
|
if (sev === "medium") return "amber";
|
||||||
|
if (sev === "low") return "neutral";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityWidth(
|
||||||
|
sev: import("@/lib/types").AiSeverity | null | undefined,
|
||||||
|
): number {
|
||||||
|
if (sev === "critical") return 100;
|
||||||
|
if (sev === "high") return 75;
|
||||||
|
if (sev === "medium") return 50;
|
||||||
|
if (sev === "low") return 25;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECOMMEND_LABELS: Record<string, string> = {
|
||||||
|
none: "No Action",
|
||||||
|
monitor: "Monitor",
|
||||||
|
warn: "Warn User",
|
||||||
|
review: "Manual Review",
|
||||||
|
delete: "Delete Message",
|
||||||
|
escalate: "Escalate",
|
||||||
|
};
|
||||||
|
|
||||||
function MessageDetail({
|
function MessageDetail({
|
||||||
m,
|
m,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -585,6 +613,14 @@ function MessageDetail({
|
|||||||
(m as { edit_history?: Array<{ old_content: string; edited_at: number }> })
|
(m as { edit_history?: Array<{ old_content: string; edited_at: number }> })
|
||||||
.edit_history ?? [];
|
.edit_history ?? [];
|
||||||
const editCount = (m as { edit_count?: number }).edit_count ?? 0;
|
const editCount = (m as { edit_count?: number }).edit_count ?? 0;
|
||||||
|
const ref = parseMeta(m.metadata)?.reference;
|
||||||
|
const mentions = parseMeta(m.metadata)?.mentionedUsers ?? [];
|
||||||
|
const mentionRoles = parseMeta(m.metadata)?.mentionedRoles ?? [];
|
||||||
|
const sev = m.ai_severity;
|
||||||
|
const conf = m.ai_confidence;
|
||||||
|
const recAction = m.ai_recommended_action;
|
||||||
|
const score = m.ai_moderation_score;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -615,11 +651,89 @@ function MessageDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reply context chain */}
|
||||||
|
{ref && (ref.content || ref.repliedUsername) && (
|
||||||
|
<div className="rounded-[6px] border-l-2 border-signal/40 bg-signal/5 px-3 py-2 text-[11px]">
|
||||||
|
<span className="font-semibold text-ink-muted">
|
||||||
|
Replying to {ref.repliedUsername ?? "message"}
|
||||||
|
</span>
|
||||||
|
{ref.content && (
|
||||||
|
<div className="mt-0.5 line-clamp-2 text-ink-faint">
|
||||||
|
{ref.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="hud-card p-3 text-xs text-ink-soft leading-relaxed">
|
<div className="hud-card p-3 text-xs text-ink-soft leading-relaxed">
|
||||||
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
||||||
"(no text content)"}
|
"(no text content)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI Analysis Metrics — severity, confidence, score, recommended action */}
|
||||||
|
{(sev || conf != null || score != null || recAction) && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{sev && sev !== "none" && (
|
||||||
|
<div className="hud-card px-2.5 py-2">
|
||||||
|
<div className="eyebrow mb-1">Severity</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
severityTone(sev) === "vermilion"
|
||||||
|
? "bg-vermilion"
|
||||||
|
: severityTone(sev) === "amber"
|
||||||
|
? "bg-amber"
|
||||||
|
: "bg-signal"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${severityWidth(sev)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
tone={severityTone(sev)}
|
||||||
|
className="font-mono text-[9px]"
|
||||||
|
>
|
||||||
|
{sev.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{conf != null && (
|
||||||
|
<div className="hud-card px-2.5 py-2">
|
||||||
|
<div className="eyebrow mb-1">Confidence</div>
|
||||||
|
<div className="font-mono text-xs font-semibold text-ink">
|
||||||
|
{Math.round(conf * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{score != null && (
|
||||||
|
<div className="hud-card px-2.5 py-2">
|
||||||
|
<div className="eyebrow mb-1">Risk Score</div>
|
||||||
|
<div className="font-mono text-xs font-semibold text-ink">
|
||||||
|
{score.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recAction && recAction !== "none" && (
|
||||||
|
<div className="hud-card px-2.5 py-2">
|
||||||
|
<div className="eyebrow mb-1">Recommended Action</div>
|
||||||
|
<Badge
|
||||||
|
tone={
|
||||||
|
recAction === "delete" || recAction === "escalate"
|
||||||
|
? "vermilion"
|
||||||
|
: recAction === "warn" || recAction === "review"
|
||||||
|
? "amber"
|
||||||
|
: "neutral"
|
||||||
|
}
|
||||||
|
className="font-mono text-[9px]"
|
||||||
|
>
|
||||||
|
{RECOMMEND_LABELS[recAction] ?? recAction}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{m.ai_analysis && (
|
{m.ai_analysis && (
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow mb-1">AI heuristic reasoning</div>
|
<div className="eyebrow mb-1">AI heuristic reasoning</div>
|
||||||
@@ -629,18 +743,52 @@ function MessageDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(flags.length > 0 || cats.length > 0) && (
|
{/* Flags / Categories / Mentions */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
{(flags.length > 0 ||
|
||||||
{flags.map((f) => (
|
cats.length > 0 ||
|
||||||
<Badge key={f} tone="vermilion">
|
mentions.length > 0 ||
|
||||||
{f}
|
mentionRoles.length > 0) && (
|
||||||
</Badge>
|
<div className="space-y-1.5">
|
||||||
))}
|
{(flags.length > 0 || cats.length > 0) && (
|
||||||
{cats.map((c) => (
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<Badge key={c} tone="amber">
|
{flags.map((f) => (
|
||||||
{c}
|
<Badge key={f} tone="vermilion">
|
||||||
</Badge>
|
{f}
|
||||||
))}
|
</Badge>
|
||||||
|
))}
|
||||||
|
{cats.map((c) => (
|
||||||
|
<Badge key={c} tone="amber">
|
||||||
|
{c}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mentions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<span className="text-[10px] text-ink-muted">Mentions:</span>
|
||||||
|
{mentions.map((u) => (
|
||||||
|
<span
|
||||||
|
key={u.id}
|
||||||
|
className="rounded bg-signal/10 px-1.5 py-0.5 text-[10px] text-signal"
|
||||||
|
>
|
||||||
|
@{u.username}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mentionRoles.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<span className="text-[10px] text-ink-muted">Roles:</span>
|
||||||
|
{mentionRoles.map((r) => (
|
||||||
|
<span
|
||||||
|
key={r.id}
|
||||||
|
className="rounded bg-amber/10 px-1.5 py-0.5 text-[10px] text-amber"
|
||||||
|
>
|
||||||
|
@{r.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, CheckCircle2, Shield } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
BarChart3,
|
||||||
|
CheckCircle2,
|
||||||
|
Globe,
|
||||||
|
Hash,
|
||||||
|
Shield,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
|
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
|
||||||
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
|
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
|
||||||
import { GlassPanel } from "@/components/primitives";
|
import { GlassPanel } from "@/components/primitives";
|
||||||
|
import { ScamDomains } from "@/components/ScamDomains";
|
||||||
import {
|
import {
|
||||||
ErrorState,
|
ErrorState,
|
||||||
MetricTile,
|
MetricTile,
|
||||||
@@ -14,10 +23,15 @@ import {
|
|||||||
SkeletonMetricRow,
|
SkeletonMetricRow,
|
||||||
SkeletonPanel,
|
SkeletonPanel,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
|
import { TopChannels } from "@/components/TopChannels";
|
||||||
import {
|
import {
|
||||||
useHourlyModeration,
|
useHourlyModeration,
|
||||||
useModerationActions,
|
useModerationActions,
|
||||||
|
useModerationCoverage,
|
||||||
useModerationStats,
|
useModerationStats,
|
||||||
|
useModerationTrends,
|
||||||
|
useTopFlaggedChannels,
|
||||||
|
useTopFlaggedDomains,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
@@ -48,6 +62,10 @@ export function ModerationView({
|
|||||||
initialActions,
|
initialActions,
|
||||||
);
|
);
|
||||||
const { data: hourly } = useHourlyModeration();
|
const { data: hourly } = useHourlyModeration();
|
||||||
|
const { data: domains } = useTopFlaggedDomains(30);
|
||||||
|
const { data: channels } = useTopFlaggedChannels(30);
|
||||||
|
const { data: trends } = useModerationTrends(30);
|
||||||
|
const { data: coverage } = useModerationCoverage(30);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
|
|
||||||
const [filterMode, setFilterMode] = useState<"all" | "flagged" | "clean">(
|
const [filterMode, setFilterMode] = useState<"all" | "flagged" | "clean">(
|
||||||
@@ -99,6 +117,26 @@ export function ModerationView({
|
|||||||
Moderation Intelligence Console
|
Moderation Intelligence Console
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
|
||||||
|
<span>EXECUTION:</span>
|
||||||
|
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
|
||||||
|
{Math.round((executed / total) * 100)}% RATE
|
||||||
|
</span>
|
||||||
|
{coverage && (
|
||||||
|
<>
|
||||||
|
<span>COVERAGE:</span>
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-0.5 font-medium border ${
|
||||||
|
coverage.coverage_rate >= 90
|
||||||
|
? "bg-signal/15 text-signal border-signal/30"
|
||||||
|
: "bg-amber/15 text-amber border-amber/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{Math.round(coverage.coverage_rate)}%
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
|
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
|
||||||
{(["all", "flagged", "clean"] as const).map((mode) => (
|
{(["all", "flagged", "clean"] as const).map((mode) => (
|
||||||
<button
|
<button
|
||||||
@@ -118,7 +156,7 @@ export function ModerationView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary Metric Grid */}
|
{/* Primary Metric Grid */}
|
||||||
<div className="mod-tile grid gap-3 sm:grid-cols-3">
|
<div className="mod-tile grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Total Audited"
|
label="Total Audited"
|
||||||
value={formatNumber(stats.total)}
|
value={formatNumber(stats.total)}
|
||||||
@@ -139,6 +177,15 @@ export function ModerationView({
|
|||||||
tone={failedRatio > 0.05 ? "vermilion" : "neutral"}
|
tone={failedRatio > 0.05 ? "vermilion" : "neutral"}
|
||||||
icon={<AlertTriangle className="size-4" />}
|
icon={<AlertTriangle className="size-4" />}
|
||||||
/>
|
/>
|
||||||
|
{coverage && (
|
||||||
|
<MetricTile
|
||||||
|
label="Analysis Coverage"
|
||||||
|
value={`${Math.round(coverage.coverage_rate)}%`}
|
||||||
|
hint={`${formatNumber(coverage.completed)}/${formatNumber(coverage.total)} analyzed`}
|
||||||
|
tone={coverage.coverage_rate >= 90 ? "signal" : "amber"}
|
||||||
|
icon={<BarChart3 className="size-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live Moderation Stream */}
|
{/* Live Moderation Stream */}
|
||||||
@@ -176,6 +223,157 @@ export function ModerationView({
|
|||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Domains, Channels, and Trends — 3-column row */}
|
||||||
|
<div className="grid gap-3 lg:grid-cols-3">
|
||||||
|
{/* Top Flagged Domains */}
|
||||||
|
{domains && domains.length > 0 && (
|
||||||
|
<div className="mod-tile">
|
||||||
|
<ScamDomains domains={domains} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Flagged Channels */}
|
||||||
|
{channels && channels.length > 0 && (
|
||||||
|
<div className="mod-tile">
|
||||||
|
<TopChannels channels={channels} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Moderation Trends */}
|
||||||
|
{trends && (
|
||||||
|
<div className="mod-tile">
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader eyebrow="trends" title="Category Breakdown" />
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{/* Categories */}
|
||||||
|
{trends.categories.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow mb-1.5">By Category</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{trends.categories.slice(0, 8).map((cat) => (
|
||||||
|
<span
|
||||||
|
key={cat.name}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-hairline bg-surface-2 px-2 py-1 text-[10px] font-medium text-ink-soft"
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
<span className="font-mono text-ink-muted">
|
||||||
|
{cat.count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Severities */}
|
||||||
|
{trends.severities.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow mb-1.5">By Severity</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{trends.severities.map((sev) => {
|
||||||
|
const maxSev = Math.max(
|
||||||
|
...trends.severities.map((s) => s.count),
|
||||||
|
);
|
||||||
|
const pct = maxSev
|
||||||
|
? Math.round((sev.count / maxSev) * 100)
|
||||||
|
: 0;
|
||||||
|
const tone =
|
||||||
|
sev.level === "critical" || sev.level === "high"
|
||||||
|
? "bg-vermilion"
|
||||||
|
: sev.level === "medium"
|
||||||
|
? "bg-amber"
|
||||||
|
: "bg-signal";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sev.level}
|
||||||
|
className="flex items-center gap-2 text-[10px]"
|
||||||
|
>
|
||||||
|
<span className="w-16 shrink-0 font-mono text-ink-muted">
|
||||||
|
{sev.level}
|
||||||
|
</span>
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${tone}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-8 text-right font-mono text-ink-faint">
|
||||||
|
{sev.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Types */}
|
||||||
|
{trends.actions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow mb-1.5">By Action Type</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{trends.actions.map((act) => (
|
||||||
|
<span
|
||||||
|
key={act.type}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-vermilion/20 bg-vermilion/5 px-2 py-1 text-[10px] font-medium text-vermilion/80"
|
||||||
|
>
|
||||||
|
{act.type.replace(/_/g, " ")}
|
||||||
|
<span className="font-mono text-ink-muted">
|
||||||
|
{act.count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coverage Progress Bar */}
|
||||||
|
{coverage && (
|
||||||
|
<div className="mod-tile">
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="pipeline"
|
||||||
|
title="Analysis Coverage"
|
||||||
|
action={
|
||||||
|
<span className="font-mono text-[11px] text-ink-muted">
|
||||||
|
{formatNumber(coverage.completed)}/
|
||||||
|
{formatNumber(coverage.total)} (
|
||||||
|
{Math.round(coverage.coverage_rate)}%)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="h-2.5 w-full overflow-hidden rounded-full bg-surface-2">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
coverage.coverage_rate >= 90
|
||||||
|
? "bg-signal"
|
||||||
|
: coverage.coverage_rate >= 70
|
||||||
|
? "bg-amber"
|
||||||
|
: "bg-vermilion"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, coverage.coverage_rate)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex justify-between font-mono text-[10px] text-ink-muted">
|
||||||
|
<span>{formatNumber(coverage.completed)} analyzed</span>
|
||||||
|
<span>
|
||||||
|
{formatNumber(coverage.pending)} pending ·{" "}
|
||||||
|
{formatNumber(coverage.failed)} failed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageTransition>
|
</PageTransition>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user