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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user