feat(gmw): public features #7-14 — scam domains, top channels, hourly heatmap, category drill-down, coverage stats, channel culture glossary, term KB, edit history

ALSO fixes: dashboard.repository still JOINed dropped user_reputations table (listUsers/getUserDetail crash).
This commit is contained in:
asepharyana
2026-08-18 20:45:12 +07:00
parent 2a8f6d9062
commit 00e8d68ce5
38 changed files with 1542 additions and 25 deletions
@@ -24,7 +24,7 @@ export function ActivityHeatmap({
for (const b of buckets) {
const k = `${b.channelId}:${b.hour}`;
byKey.set(k, (byKey.get(k) ?? 0) + b.count);
if (byKey.get(k)! > max) max = byKey.get(k)!;
if ((byKey.get(k) ?? 0) > max) max = byKey.get(k) ?? 0;
}
if (buckets.length === 0) {
@@ -0,0 +1,128 @@
"use client";
import { ChevronRight } from "lucide-react";
import { Badge, GlassPanel } from "@/components/primitives";
import { SectionHeader } from "@/components/shared";
import { formatNumber, formatRelativeTime } from "@/lib/format";
import type { CategoryAction, ModerationTrends } from "@/lib/types";
const SEVERITY_TONE: Record<
string,
"signal" | "amber" | "vermilion" | "neutral"
> = {
critical: "vermilion",
high: "vermilion",
medium: "amber",
low: "signal",
none: "neutral",
};
interface CategoryDrilldownProps {
trends: ModerationTrends;
selected?: string | null;
actions?: CategoryAction[];
loading?: boolean;
onSelect: (category: string | null) => void;
}
export function CategoryDrilldown({
trends,
selected,
actions,
loading,
onSelect,
}: CategoryDrilldownProps) {
const maxCat = trends.categories.reduce((m, c) => Math.max(m, c.count), 0);
return (
<GlassPanel className="lg:col-span-3">
<SectionHeader eyebrow="drill-down" title="Flag Category" />
{selected ? (
<div className="mb-3 flex items-center gap-2">
<button
type="button"
onClick={() => onSelect(null)}
className="text-xs text-ink-soft hover:text-ink"
>
Back to all categories
</button>
<span className="text-xs text-ink-faint">
/ {selected} (
{loading ? "loading…" : formatNumber(actions?.length ?? 0)} actions)
</span>
</div>
) : (
<p className="mb-2 text-xs text-ink-faint">
Click a category to list the underlying moderation actions.
</p>
)}
{!selected ? (
<div className="space-y-2">
{trends.categories.map((c) => {
const pct = maxCat > 0 ? Math.max(2, (c.count / maxCat) * 100) : 0;
return (
<button
type="button"
key={c.name}
onClick={() => onSelect(c.name)}
className="flex w-full items-center gap-3 text-left text-sm"
>
<span className="w-36 shrink-0 truncate text-ink-soft">
{c.name}
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-signal"
style={{ width: `${pct}%` }}
/>
</div>
<span className="mono w-10 text-right text-ink">
{formatNumber(c.count)}
</span>
</button>
);
})}
</div>
) : (
<div className="space-y-2">
{loading && <p className="text-xs text-ink-faint">Loading</p>}
{!loading && actions && actions.length === 0 && (
<p className="text-xs text-ink-faint">
No actions in this category.
</p>
)}
{actions?.slice(0, 12).map((a) => (
<div key={a.id} className="flex items-start gap-2 text-sm">
<Badge tone={SEVERITY_TONE[a.severity ?? "none"]}>
{a.severity ?? "none"}
</Badge>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="font-medium text-ink">{a.action_type}</span>
{a.username && (
<span className="text-ink-soft">@{a.username}</span>
)}
<span className="text-ink-faint mono text-xs">
{a.created_at ? formatRelativeTime(a.created_at) : ""}
</span>
</div>
{a.content && (
<p className="mt-0.5 line-clamp-2 text-ink-faint">
{a.content}
</p>
)}
{a.reason && (
<p className="mt-0.5 line-clamp-1 text-xs text-ink-faint">
Reason: {a.reason}
</p>
)}
<ChevronRight className="mt-1 size-3 text-ink-faint/50" />
</div>
</div>
))}
</div>
)}
</GlassPanel>
);
}
@@ -0,0 +1,71 @@
"use client";
import { GlassPanel } from "@/components/primitives";
import { SectionHeader } from "@/components/shared";
import { downloadCsv } from "@/lib/csv";
import { formatRelativeTime } from "@/lib/format";
import type { ChannelCultureRow } from "@/lib/types";
export function ChannelCultureGlossary({
cultures,
}: {
cultures: ChannelCultureRow[];
}) {
return (
<GlassPanel className="lg:col-span-3">
<SectionHeader
eyebrow="culture"
title="Channel Culture Glossary"
action={
cultures.length > 0 ? (
<button
type="button"
onClick={() =>
downloadCsv(
"channel-cultures.csv",
cultures.map((c) => ({
channel: c.channel_name ?? c.channel_id,
summary: c.culture_summary ?? "",
last_analyzed: c.last_analyzed_at ?? "",
})),
)
}
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
>
CSV
</button>
) : null
}
/>
{cultures.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-faint">
No channel cultures captured yet.
</p>
) : (
<div className="space-y-3">
{cultures.map((c) => (
<div key={c.channel_id} className="text-sm">
<div className="flex items-baseline justify-between">
<span className="font-medium text-ink">
{c.channel_name ?? c.channel_id}
</span>
{c.last_analyzed_at && (
<span className="text-xs text-ink-faint">
{formatRelativeTime(c.last_analyzed_at)}
</span>
)}
</div>
{c.culture_summary ? (
<p className="mt-1 text-ink-faint">{c.culture_summary}</p>
) : (
<span className="text-xs text-ink-faint">
(no summary captured)
</span>
)}
</div>
))}
</div>
)}
</GlassPanel>
);
}
@@ -0,0 +1,46 @@
"use client";
import { AlertCircle, CheckCircle2, XCircle } from "lucide-react";
import { GlassPanel } from "@/components/primitives";
import { MetricTile, SectionHeader } from "@/components/shared";
import { formatNumber } from "@/lib/format";
import type { ModerationCoverage } from "@/lib/types";
export function CoverageTiles({ coverage }: { coverage: ModerationCoverage }) {
const pct = (n: number) => `${n.toFixed(1)}%`;
return (
<GlassPanel className="lg:col-span-5">
<SectionHeader eyebrow="automation" title="Auto-mod Coverage" />
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile
label="Coverage"
value={pct(coverage.coverage_rate)}
tone={coverage.coverage_rate > 90 ? "signal" : "amber"}
icon={<CheckCircle2 className="size-3.5" />}
/>
<MetricTile
label="Completed"
value={formatNumber(coverage.completed)}
tone="signal"
icon={<CheckCircle2 className="size-3.5" />}
/>
<MetricTile
label="Failed"
value={formatNumber(coverage.failed)}
tone={coverage.failed > 0 ? "vermilion" : "neutral"}
icon={<XCircle className="size-3.5" />}
/>
<MetricTile
label="Pending"
value={formatNumber(coverage.pending)}
tone={coverage.pending > 0 ? "amber" : "neutral"}
icon={<AlertCircle className="size-3.5" />}
/>
</div>
<p className="mt-2 text-xs text-ink-faint">
{pct(coverage.failed_rate)} of analysis runs failed. Total runs in
window: {formatNumber(coverage.total)}.
</p>
</GlassPanel>
);
}
@@ -0,0 +1,68 @@
"use client";
import { Download, History } from "lucide-react";
import { GlassPanel } from "@/components/primitives";
import { SectionHeader } from "@/components/shared";
import { downloadCsv } from "@/lib/csv";
import { formatRelativeTime } from "@/lib/format";
import type { EditHistoryRow } from "@/lib/types";
export function EditHistory({ edits }: { edits: EditHistoryRow[] }) {
return (
<GlassPanel className="lg:col-span-4">
<SectionHeader
eyebrow="evasion"
title="Message Edits"
action={
edits.length > 0 ? (
<button
type="button"
onClick={() =>
downloadCsv(
"message-edits.csv",
edits.map((e) => ({
author: e.username ?? "",
channel: e.channel_name ?? "",
old_content: e.old_content,
edited_at: e.edited_at,
})),
)
}
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
>
<Download className="size-3.5" />
CSV
</button>
) : null
}
/>
{edits.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-faint">
No edited messages recorded recently.
</p>
) : (
<div className="space-y-3">
{edits.map((e) => (
<div key={e.id} className="text-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="font-medium text-ink">
{e.username ?? "unknown"}
</span>
<span className="text-xs text-ink-faint">
edited {formatRelativeTime(e.edited_at)} ·{" "}
{e.channel_name ?? e.channel_id ?? "unknown channel"}
</span>
</div>
<div className="mt-1 flex items-start gap-1.5">
<History className="mt-0.5 size-3.5 shrink-0 text-ink-faint/50" />
<pre className="line-clamp-2 whitespace-pre-wrap break-words text-ink-faint/80">
{e.old_content || <em>(content not available)</em>}
</pre>
</div>
</div>
))}
</div>
)}
</GlassPanel>
);
}
@@ -0,0 +1,53 @@
"use client";
import { GlassPanel } from "@/components/primitives";
import { SectionHeader } from "@/components/shared";
import type { HourlyModeration } from "@/lib/types";
import { cn } from "@/lib/utils";
export function ModerationHeatmap({ hours }: { hours: HourlyModeration[] }) {
const max = hours.reduce((m, h) => Math.max(m, h.total), 0);
const intensity = (v: number) => {
if (max <= 0) return "bg-white/5";
const t = Math.max(0, Math.min(1, v / max));
if (t < 0.25) return "bg-white/[0.06]";
if (t < 0.5) return "bg-signal/25";
if (t < 0.75) return "bg-signal/50";
return "bg-vermilion/60";
};
return (
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="timing" title="Flagged by Hour (24h)" />
<p className="mb-3 text-xs text-ink-faint">
Distribution of moderation actions across the day.
</p>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{hours.map((h) => (
<div key={h.hour} className="flex items-center gap-2">
<span className="w-8 text-xs text-ink-faint mono">
{String(h.hour).padStart(2, "0")}:00
</span>
<div className="flex-1">
<div
className={cn(
"h-5 rounded transition-colors",
intensity(h.total),
)}
title={`${h.total} actions`}
/>
</div>
<span
className={cn(
"mono w-8 text-right text-xs",
h.total === 0 ? "text-ink-faint/40" : "text-ink",
)}
>
{h.total}
</span>
</div>
))}
</div>
</GlassPanel>
);
}
@@ -0,0 +1,67 @@
"use client";
import { Download } from "lucide-react";
import { GlassPanel } from "@/components/primitives";
import { SectionHeader } from "@/components/shared";
import { downloadCsv } from "@/lib/csv";
import { formatNumber } from "@/lib/format";
import type { FlaggedDomain } from "@/lib/types";
export function ScamDomains({ domains }: { domains: FlaggedDomain[] }) {
const max = domains.reduce((m, d) => Math.max(m, d.count), 0);
return (
<GlassPanel className="lg:col-span-2">
<SectionHeader
eyebrow="risk"
title="Flagged Link Domains"
action={
domains.length > 0 ? (
<button
type="button"
onClick={() =>
downloadCsv(
"flagged-domains.csv",
domains.map((d) => ({
domain: d.domain,
flagged_count: d.count,
})),
)
}
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
>
<Download className="size-3.5" />
CSV
</button>
) : null
}
/>
{domains.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-faint">
No flagged links captured recently.
</p>
) : (
<div className="space-y-2">
{domains.map((d) => {
const pct = max > 0 ? Math.max(2, (d.count / max) * 100) : 0;
return (
<div key={d.domain} className="flex items-center gap-3 text-sm">
<span className="w-44 shrink-0 truncate font-mono text-ink-soft">
{d.domain}
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-[#8b5cf6]"
style={{ width: `${pct}%` }}
/>
</div>
<span className="mono w-10 shrink-0 text-right text-ink">
{formatNumber(d.count)}
</span>
</div>
);
})}
</div>
)}
</GlassPanel>
);
}
@@ -0,0 +1,71 @@
"use client";
import { Globe } from "lucide-react";
import { GlassPanel } from "@/components/primitives";
import { SectionHeader } from "@/components/shared";
import { downloadCsv } from "@/lib/csv";
import { formatRelativeTime } from "@/lib/format";
import type { GlossaryRow } from "@/lib/types";
export function TermGlossary({ terms }: { terms: GlossaryRow[] }) {
return (
<GlassPanel className="lg:col-span-3">
<SectionHeader
eyebrow="knowledge"
title="Term Knowledge Base"
action={
terms.length > 0 ? (
<button
type="button"
onClick={() =>
downloadCsv(
"glossary.csv",
terms.map((t) => ({
term: t.term,
definition: t.definition,
source: t.source_url,
resolved: t.resolved_at,
hits: t.hit_count,
})),
)
}
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
>
CSV
</button>
) : null
}
/>
{terms.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-faint">
No term resolutions cached yet.
</p>
) : (
<div className="space-y-3">
{terms.map((t) => (
<div key={t.term} className="text-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="font-medium text-ink">{t.term}</span>
<span className="text-xs text-ink-faint">
{t.hit_count} uses · {formatRelativeTime(t.resolved_at)}
</span>
</div>
<p className="mt-1 text-ink-faint">{t.definition}</p>
{t.source_url && (
<a
href={t.source_url}
target="_blank"
rel="noopener noreferrer"
className="mt-0.5 text-xs text-ink-soft hover:text-ink"
>
<Globe className="mr-1 inline size-3" />
{t.source_url}
</a>
)}
</div>
))}
</div>
)}
</GlassPanel>
);
}
@@ -0,0 +1,72 @@
"use client";
import { Download } from "lucide-react";
import { GlassPanel } from "@/components/primitives";
import { SectionHeader } from "@/components/shared";
import { downloadCsv } from "@/lib/csv";
import { formatNumber } from "@/lib/format";
import type { FlaggedChannel } from "@/lib/types";
export function TopChannels({ channels }: { channels: FlaggedChannel[] }) {
const max = channels.reduce((m, c) => Math.max(m, c.flagged_count), 0);
return (
<GlassPanel className="lg:col-span-2">
<SectionHeader
eyebrow="channels"
title="Top Flagged Channels"
action={
channels.length > 0 ? (
<button
type="button"
onClick={() =>
downloadCsv(
"flagged-channels.csv",
channels.map((c) => ({
channel_id: c.channel_id,
channel_name: c.channel_name ?? "",
flagged_count: c.flagged_count,
})),
)
}
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
>
<Download className="size-3.5" />
CSV
</button>
) : null
}
/>
{channels.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-faint">
No flagged activity in the selected period.
</p>
) : (
<div className="space-y-2">
{channels.map((c) => {
const pct =
max > 0 ? Math.max(2, (c.flagged_count / max) * 100) : 0;
return (
<div
key={c.channel_id}
className="flex items-center gap-3 text-sm"
>
<span className="w-40 shrink-0 truncate text-ink-soft">
{c.channel_name ?? c.channel_id}
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-[#f59e0b]"
style={{ width: `${pct}%` }}
/>
</div>
<span className="mono w-10 shrink-0 text-right text-ink">
{formatNumber(c.flagged_count)}
</span>
</div>
);
})}
</div>
)}
</GlassPanel>
);
}