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