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
@@ -0,0 +1,20 @@
import { PageTransition } from "@/components/shared";
import { getChannelCultures } from "@/lib/api/server";
import type { ChannelCultureRow } from "@/lib/types";
import { ChannelsView } from "./view";
export const dynamic = "force-dynamic";
export default async function ChannelsPage() {
let cultures: ChannelCultureRow[] | undefined;
try {
cultures = await getChannelCultures(100);
} catch {
cultures = undefined;
}
return (
<PageTransition>
<ChannelsView initialCultures={cultures} />
</PageTransition>
);
}
@@ -0,0 +1,23 @@
"use client";
import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary";
import { SkeletonPanel } from "@/components/shared";
import { useChannelCultures } from "@/hooks";
import type { ChannelCultureRow } from "@/lib/types";
export function ChannelsView({
initialCultures,
}: {
initialCultures?: ChannelCultureRow[];
}) {
const { data: cultures } = useChannelCultures(100, initialCultures);
return (
<div className="space-y-5">
{cultures ? (
<ChannelCultureGlossary cultures={cultures} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { PageTransition } from "@/components/shared";
import { getGlossary } from "@/lib/api/server";
import type { GlossaryRow } from "@/lib/types";
import { GlossaryView } from "./view";
export const dynamic = "force-dynamic";
export default async function GlossaryPage() {
let terms: GlossaryRow[] | undefined;
try {
terms = await getGlossary(100);
} catch {
terms = undefined;
}
return (
<PageTransition>
<GlossaryView initialTerms={terms} />
</PageTransition>
);
}
@@ -0,0 +1,15 @@
"use client";
import { SkeletonPanel } from "@/components/shared";
import { TermGlossary } from "@/components/TermGlossary";
import { useGlossary } from "@/hooks";
import type { GlossaryRow } from "@/lib/types";
export function GlossaryView({
initialTerms,
}: {
initialTerms?: GlossaryRow[];
}) {
const { data: terms } = useGlossary(100, initialTerms);
return terms ? <TermGlossary terms={terms} /> : <SkeletonPanel rows={6} />;
}
@@ -1,5 +1,10 @@
import { PageTransition } from "@/components/shared";
import { getConfig, getGuilds, getMessages } from "@/lib/api/server";
import {
getConfig,
getGuilds,
getMessages,
getRecentEdits,
} from "@/lib/api/server";
import { MessagesView } from "./view";
export const dynamic = "force-dynamic";
@@ -11,12 +16,14 @@ export default async function MessagesPage() {
data: import("@/lib/types").MessageRecord[];
nextCursor: string | null;
} | null = null;
let initialEdits: import("@/lib/types").EditHistoryRow[] | undefined;
try {
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
const gid = config?.monitorGuildId;
if (gid) {
initialMessages = await getMessages(gid, undefined, 50);
}
initialEdits = await getRecentEdits(50);
} catch {
/* client hooks surface errors */
}
@@ -26,6 +33,7 @@ export default async function MessagesPage() {
initialGuilds={guilds}
initialGuildId={config?.monitorGuildId ?? null}
initialMessages={initialMessages}
initialEdits={initialEdits}
/>
</PageTransition>
);
@@ -14,6 +14,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
import { useAmbient } from "@/components/ambient/ambient-context";
import { EditHistory } from "@/components/EditHistory";
import {
Avatar,
Badge,
@@ -37,6 +38,7 @@ import {
useMessagesHasMore,
useMessagesStream,
useMessagesWsSync,
useRecentEdits,
useSemanticSearch,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
@@ -48,7 +50,12 @@ import {
renderMessageContent,
safeParseJsonArray,
} from "@/lib/format";
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
import type {
AiStatus,
EditHistoryRow,
Guild,
MessageRecord,
} from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
@@ -56,6 +63,7 @@ export function MessagesView({
initialGuilds,
initialGuildId,
initialMessages,
initialEdits,
}: {
initialGuilds?: Guild[];
initialGuildId?: string | null;
@@ -63,6 +71,7 @@ export function MessagesView({
data: MessageRecord[];
nextCursor: string | null;
} | null;
initialEdits?: EditHistoryRow[];
}) {
const ws = useWebSocket();
const [guildId, setGuildId] = useState<string | null>(
@@ -112,6 +121,7 @@ export function MessagesView({
query.trim().length >= 2 && semanticMode,
);
const activity = useMessageActivity(30);
const edits = useRecentEdits(50, undefined, initialEdits);
const detail = useMessageDetail(selected);
const ambient = useAmbient();
@@ -431,6 +441,8 @@ export function MessagesView({
{activity.data && activity.data.length > 0 && (
<ActivityHeatmap buckets={activity.data} />
)}
{edits.data && <EditHistory edits={edits.data} />}
</div>
);
}
@@ -15,14 +15,18 @@ import {
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { CategoryDrilldown } from "@/components/CategoryDrilldown";
import { CoverageTiles } from "@/components/CoverageTiles";
import { Donut } from "@/components/charts";
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
import {
Badge,
GlassPanel,
Select,
type SelectOption,
} from "@/components/primitives";
import { ScamDomains } from "@/components/ScamDomains";
import {
ErrorState,
MetricTile,
@@ -31,12 +35,18 @@ import {
SkeletonPanel,
SkeletonRows,
} from "@/components/shared";
import { TopChannels } from "@/components/TopChannels";
import { TopicTrends } from "@/components/TopicTrends";
import {
useHourlyModeration,
useLiveModeration,
useModerationActions,
useModerationByCategory,
useModerationCoverage,
useModerationStats,
useModerationTrends,
useTopFlaggedChannels,
useTopFlaggedDomains,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
import { downloadCsv } from "@/lib/csv";
@@ -81,6 +91,13 @@ export function ModerationView({
);
const liveActions = useLiveModeration(initialActions ?? [], 50);
const { data: trends } = useModerationTrends(30);
const { data: domains } = useTopFlaggedDomains(30);
const { data: channels } = useTopFlaggedChannels(30);
const { data: hourly } = useHourlyModeration(30);
const { data: coverage } = useModerationCoverage(30);
const [drilldown, setDrilldown] = useState<string | null>(null);
const { data: categoryActions, isValidating: categoryLoading } =
useModerationByCategory(drilldown ? 30 : 0, drilldown);
const failedRate = stats ? stats.failed_rate * 100 : 0;
@@ -172,6 +189,44 @@ export function ModerationView({
<LiveModerationFeed actions={liveActions} />
</div>
{coverage ? (
<CoverageTiles coverage={coverage} />
) : (
<SkeletonPanel rows={3} className="lg:col-span-5" />
)}
<div className="lg:col-span-2">
{domains ? (
<ScamDomains domains={domains} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
<div className="lg:col-span-2">
{hourly ? (
<ModerationHeatmap hours={hourly} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
<div className="lg:col-span-1">
{channels ? (
<TopChannels channels={channels} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
<div className="lg:col-span-3">
<CategoryDrilldown
trends={trends ?? { categories: [], severities: [], actions: [] }}
selected={drilldown}
actions={categoryActions ?? []}
loading={categoryLoading}
onSelect={setDrilldown}
/>
</div>
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="health" title="Breakdown" />
<div className="flex items-center gap-5">
@@ -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>
);
}
+8 -1
View File
@@ -10,6 +10,7 @@ export {
useUsers,
} from "./use-dashboard";
export { useGuilds } from "./use-guilds";
export { useChannelCultures, useGlossary } from "./use-knowledge";
export {
useMediaLoop,
useMediaQueue,
@@ -21,22 +22,28 @@ export {
export {
useImages,
useLoadMore,
useMessageActivity,
useMessageDetail,
useMessageSearch,
useMessages,
useMessagesHasMore,
useMessagesStream,
useMessagesWsSync,
useRecentEdits,
useReview,
useSemanticSearch,
useTextChannels,
useMessageActivity,
} from "./use-messages";
export {
useHourlyModeration,
useLiveModeration,
useModerationActions,
useModerationByCategory,
useModerationCoverage,
useModerationStats,
useModerationTrends,
useTopFlaggedChannels,
useTopFlaggedDomains,
} from "./use-moderation";
export {
useDeleteRecording,
@@ -0,0 +1,22 @@
import useSWR from "swr";
import { knowledgeApi } from "@/lib/api";
import type { ChannelCultureRow, GlossaryRow } from "@/lib/types";
export function useChannelCultures(
limit = 100,
initialData?: ChannelCultureRow[],
) {
return useSWR<ChannelCultureRow[]>(
["channel-cultures", limit],
() => knowledgeApi.channelCultures(limit),
{ fallbackData: initialData },
);
}
export function useGlossary(limit = 100, initialData?: GlossaryRow[]) {
return useSWR<GlossaryRow[]>(
["glossary", limit],
() => knowledgeApi.glossary(limit),
{ fallbackData: initialData },
);
}
@@ -5,6 +5,7 @@ import { messagesApi, voiceApi } from "@/lib/api";
import type {
AttachmentRecord,
Channel,
EditHistoryRow,
MessageActivityBucket,
MessageRecord,
SemanticSearchResult,
@@ -381,3 +382,15 @@ export function useMessageActivity(days = 30) {
messagesApi.getActivity(days),
);
}
export function useRecentEdits(
limit = 50,
channelId?: string,
initialData?: EditHistoryRow[],
) {
return useSWR<EditHistoryRow[]>(
["recent-edits", limit, channelId ?? null],
() => messagesApi.getRecentEdits(limit, channelId),
{ fallbackData: initialData },
);
}
@@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { moderationApi } from "@/lib/api";
import type {
CategoryAction,
FlaggedChannel,
FlaggedDomain,
HourlyModeration,
ModerationAction,
ModerationCoverage,
ModerationStats,
ModerationTrends,
} from "@/lib/types";
@@ -82,3 +87,35 @@ export function useModerationTrends(days = 30, initialData?: ModerationTrends) {
{ fallbackData: initialData },
);
}
export function useTopFlaggedDomains(days = 30) {
return useSWR<FlaggedDomain[]>(["moderation-domains", days], () =>
moderationApi.getTopDomains(days),
);
}
export function useTopFlaggedChannels(days = 30) {
return useSWR<FlaggedChannel[]>(["moderation-channels", days], () =>
moderationApi.getTopChannels(days),
);
}
export function useHourlyModeration(days = 30) {
return useSWR<HourlyModeration[]>(["moderation-byhour", days], () =>
moderationApi.getHourlyModeration(days),
);
}
export function useModerationByCategory(days = 30, category: string | null) {
return useSWR<CategoryAction[]>(
category ? ["moderation-bycategory", days, category] : null,
() => moderationApi.getByCategory(days, category as string),
{ keepPreviousData: true },
);
}
export function useModerationCoverage(days = 30) {
return useSWR<ModerationCoverage>(["moderation-coverage", days], () =>
moderationApi.getCoverage(days),
);
}
+7
View File
@@ -6,9 +6,16 @@ export { orpc } from "../orpc/client";
export { chatbotApi } from "./chatbot";
export { configApi } from "./config";
export { dashboardApi } from "./dashboard";
export { knowledgeApi } from "./knowledge";
export { mediaApi } from "./media";
export { messagesApi } from "./messages";
export { moderationApi } from "./moderation";
export { recordingsApi } from "./recordings";
// Re-export server-side fetchers for use inside React Server Components.
export {
getChannelCultures,
getGlossary,
getRecentEdits,
} from "./server";
export { uiStateApi } from "./ui-state";
export { voiceApi } from "./voice";
@@ -0,0 +1,16 @@
import { orpc } from "@/lib/orpc/client";
import type { ChannelCultureRow, GlossaryRow } from "@/lib/types";
export const knowledgeApi = {
channelCultures: (limit = 100, search?: string) =>
orpc.knowledge.channelCultures({
limit,
search,
}) as unknown as Promise<ChannelCultureRow[]>,
glossary: (limit = 100, search?: string) =>
orpc.knowledge.glossary({
limit,
search,
}) as unknown as Promise<GlossaryRow[]>,
};
@@ -1,6 +1,7 @@
import { orpc } from "@/lib/orpc/client";
import type {
AttachmentRecord,
EditHistoryRow,
MessageActivityBucket,
MessageRecord,
SemanticSearchResult,
@@ -80,4 +81,10 @@ export const messagesApi = {
orpc.messages.activity({ days }) as unknown as Promise<
MessageActivityBucket[]
>,
// Public, read-only recent message edits (evasion tracker).
getRecentEdits: (limit = 50, channelId?: string) =>
orpc.messages.editHistory({ limit, channelId }) as unknown as Promise<
EditHistoryRow[]
>,
};
@@ -1,5 +1,10 @@
import { orpc } from "@/lib/orpc/client";
import type {
CategoryAction,
FlaggedChannel,
FlaggedDomain,
HourlyModeration,
ModerationCoverage,
ModerationStats,
ModerationTrends,
PaginatedModerationActions,
@@ -24,4 +29,25 @@ export const moderationApi = {
getTrends: (days = 30) =>
orpc.moderation.trends({ days }) as unknown as Promise<ModerationTrends>,
getTopDomains: (days = 30) =>
orpc.moderation.topDomains({ days }) as unknown as Promise<FlaggedDomain[]>,
getTopChannels: (days = 30) =>
orpc.moderation.topChannels({ days }) as unknown as Promise<
FlaggedChannel[]
>,
getHourlyModeration: (days = 30) =>
orpc.moderation.byHour({ days }) as unknown as Promise<HourlyModeration[]>,
getByCategory: (days = 30, category: string) =>
orpc.moderation.byCategory({ days, category }) as unknown as Promise<
CategoryAction[]
>,
getCoverage: (days = 30) =>
orpc.moderation.coverage({
days,
}) as unknown as Promise<ModerationCoverage>,
};
+52
View File
@@ -16,11 +16,19 @@ import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type {
AppConfig,
ChannelCultureRow,
DashboardActivity,
DashboardStats,
EditHistoryRow,
FlaggedChannel,
FlaggedDomain,
GlossaryRow,
Guild,
HourlyModeration,
MediaState,
ModerationCoverage,
ModerationStats,
ModerationTrends,
PaginatedModerationActions,
PaginatedRecordings,
VoiceStatus,
@@ -84,6 +92,33 @@ export async function getModerationActions(limit = 100) {
})) as unknown as PaginatedModerationActions;
return res.data;
}
export async function getModerationTrends(
days = 30,
): Promise<ModerationTrends> {
return serverOrpc().moderation.trends({
days,
}) as unknown as Promise<ModerationTrends>;
}
export async function getTopFlaggedDomains(days = 30) {
return serverOrpc().moderation.topDomains({
days,
}) as unknown as FlaggedDomain[];
}
export async function getTopFlaggedChannels(days = 30) {
return serverOrpc().moderation.topChannels({
days,
}) as unknown as FlaggedChannel[];
}
export async function getHourlyModeration(days = 30) {
return serverOrpc().moderation.byHour({
days,
}) as unknown as HourlyModeration[];
}
export async function getCoverage(days = 30) {
return serverOrpc().moderation.coverage({
days,
}) as unknown as ModerationCoverage;
}
// ---- Voice ----
export async function getGuilds(): Promise<Guild[]> {
@@ -122,3 +157,20 @@ export async function getMessages(
nextCursor: string | null;
}>;
}
// ---- Knowledge (public read-only) ----
export async function getChannelCultures(limit = 100) {
return serverOrpc().knowledge.channelCultures({
limit,
}) as unknown as Promise<ChannelCultureRow[]>;
}
export async function getGlossary(limit = 100) {
return serverOrpc().knowledge.glossary({
limit,
}) as unknown as Promise<GlossaryRow[]>;
}
export async function getRecentEdits(limit = 50): Promise<EditHistoryRow[]> {
return serverOrpc().messages.editHistory({
limit,
}) as unknown as Promise<EditHistoryRow[]>;
}
+1
View File
@@ -1,5 +1,6 @@
export * from "./dashboard";
export * from "./guild";
export * from "./knowledge";
export * from "./media";
export * from "./message";
export * from "./moderation";
@@ -0,0 +1,25 @@
export interface ChannelCultureRow {
channel_id: string;
guild_id: string | null;
channel_name: string | null;
culture_summary: string | null;
last_analyzed_at: number | null;
}
export interface GlossaryRow {
term: string;
definition: string;
source_url: string;
resolved_at: number;
hit_count: number;
}
export interface EditHistoryRow {
id: string;
message_id: string;
old_content: string;
edited_at: number;
channel_id: string | null;
channel_name: string | null;
username: string | null;
}
@@ -50,3 +50,44 @@ export interface ModerationTrends {
severities: { level: string; count: number }[];
actions: { type: string; count: number }[];
}
export interface FlaggedDomain {
domain: string;
count: number;
}
export interface FlaggedChannel {
channel_id: string;
channel_name: string | null;
flagged_count: number;
}
export interface HourlyModeration {
hour: number;
total: number;
}
export interface CategoryAction {
id: string;
message_id: string | null;
user_id: string | null;
guild_id: string;
action_type: ModerationActionType;
reason: string | null;
status: ModerationStatus;
created_at: number | null;
severity: "none" | "low" | "medium" | "high" | "critical" | null;
confidence: number | null;
score: number | null;
username: string | null;
content: string | null;
}
export interface ModerationCoverage {
total: number;
completed: number;
failed: number;
pending: number;
coverage_rate: number;
failed_rate: number;
}