feat: real guild/channel names from Discord + remove selectors in Messages/Analytics
- discord-gateway: add redis command handlers for guilds:list, guilds:text-channels, voice:channels - backend: replace postgres synthetic names with redis commands to gateway (with fallback) - frontend: remove guild/channel dropdowns from messages and analytics tabs - frontend: auto-load all channels from monitor guild via guildId query param - frontend: show guild name in messages/analytics headers instead of selector - live tab: keeps guild/channel selectors with real discord names Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f1ddca5eee
commit
5f419a6f0f
@@ -1,5 +1,4 @@
|
||||
import { Activity, BarChart3 } from "lucide-react";
|
||||
import type { Channel, Guild } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Button,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
const TIME_RANGES = [
|
||||
@@ -22,27 +20,17 @@ const TIME_RANGES = [
|
||||
];
|
||||
|
||||
interface ControlBarProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
guildName: string | null;
|
||||
hours: number;
|
||||
isFetching: boolean;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
onHoursChange: (hours: number) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function ControlBar({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
guildName,
|
||||
hours,
|
||||
isFetching,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onHoursChange,
|
||||
onRefresh,
|
||||
}: ControlBarProps) {
|
||||
@@ -54,28 +42,19 @@ export function ControlBar({
|
||||
Analisis Moderasi
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Pantau statistik, tren topik, dan aktivitas user.
|
||||
{guildName ? (
|
||||
<>
|
||||
Pantau statistik, tren topik, dan aktivitas user di seluruh
|
||||
channel{" "}
|
||||
<span className="font-medium text-foreground">{guildName}</span>.
|
||||
</>
|
||||
) : (
|
||||
"Pantau statistik, tren topik, dan aktivitas user."
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={(e) => onGuildChange(e.target.value)}
|
||||
placeholder="Pilih guild"
|
||||
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
|
||||
className="min-w-[180px]"
|
||||
/>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => onChannelChange(e.target.value)}
|
||||
placeholder="Semua channel"
|
||||
options={[
|
||||
{ value: "", label: "Semua channel" },
|
||||
...channels.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
className="min-w-[160px]"
|
||||
/>
|
||||
<div className="flex items-center gap-1 rounded-md bg-muted p-0.5">
|
||||
{TIME_RANGES.map((tr) => (
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import type { Channel, Guild } from "../../shared/api/client";
|
||||
import { ActivityChart } from "./components/ActivityChart";
|
||||
import { ControlBar } from "./components/ControlBar";
|
||||
import { Heatmap } from "./components/Heatmap";
|
||||
@@ -11,26 +10,16 @@ import { ViolatorTable } from "./components/ViolatorTable";
|
||||
import { useAnalytics } from "./hooks/useAnalytics";
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
guildId: string;
|
||||
guildName: string | null;
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
}: AnalyticsPanelProps) {
|
||||
export function AnalyticsPanel({ guildId, guildName }: AnalyticsPanelProps) {
|
||||
const [hours, setHours] = useState(24);
|
||||
const analytics = useAnalytics({
|
||||
guildId: selectedGuild,
|
||||
channelId: selectedChannel || undefined,
|
||||
guildId,
|
||||
// No channelId — analytics for all channels in the guild
|
||||
channelId: undefined,
|
||||
hours,
|
||||
});
|
||||
|
||||
@@ -60,11 +49,11 @@ export function AnalyticsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedGuild) {
|
||||
if (!guildId) {
|
||||
return (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pilih guild untuk melihat analitik.
|
||||
Menunggu konfigurasi guild...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -73,14 +62,9 @@ export function AnalyticsPanel({
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlBar
|
||||
guilds={guilds}
|
||||
channels={channels}
|
||||
selectedGuild={selectedGuild}
|
||||
selectedChannel={selectedChannel}
|
||||
guildName={guildName}
|
||||
hours={hours}
|
||||
isFetching={isFetching}
|
||||
onGuildChange={onGuildChange}
|
||||
onChannelChange={onChannelChange}
|
||||
onHoursChange={setHours}
|
||||
onRefresh={() => {
|
||||
refresh();
|
||||
|
||||
@@ -12,8 +12,6 @@ export function mergeMessages(
|
||||
for (const message of incoming) {
|
||||
byId.set(message.id, { ...byId.get(message.id), ...message });
|
||||
}
|
||||
// Removed .slice(0, 200) cap — let the message list grow unbounded.
|
||||
// Infinite scroll handles the data volume via cursor pagination.
|
||||
return Array.from(byId.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id),
|
||||
);
|
||||
@@ -26,26 +24,25 @@ export function useMessages() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const currentChannel = useRef<string | null>(null);
|
||||
const currentGuild = useRef<string | null>(null);
|
||||
|
||||
const fetchMessages = useCallback(async (channelId?: string) => {
|
||||
if (!channelId) {
|
||||
const fetchMessages = useCallback(async (guildId?: string) => {
|
||||
if (!guildId) {
|
||||
setMessages([]);
|
||||
setCursor(null);
|
||||
setHasMore(false);
|
||||
return [];
|
||||
}
|
||||
currentChannel.current = channelId;
|
||||
currentGuild.current = guildId;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
channelId,
|
||||
guildId,
|
||||
});
|
||||
const result = await listMessages(params);
|
||||
// Only update state if we're still on the same channel (avoid race conditions)
|
||||
if (currentChannel.current === channelId) {
|
||||
if (currentGuild.current === guildId) {
|
||||
setMessages(result.data);
|
||||
setCursor(result.nextCursor);
|
||||
setHasMore(!!result.nextCursor);
|
||||
@@ -61,30 +58,23 @@ export function useMessages() {
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!cursor || !currentChannel.current || loadingMore) return;
|
||||
if (!cursor || !currentGuild.current || loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
channelId: currentChannel.current,
|
||||
guildId: currentGuild.current,
|
||||
cursor,
|
||||
});
|
||||
const result = await listMessages(params);
|
||||
// Only update if still on the same channel
|
||||
if (
|
||||
currentChannel.current === result.data[0]?.channel_id ||
|
||||
currentChannel.current
|
||||
) {
|
||||
setMessages((prev) => [...prev, ...result.data]);
|
||||
setCursor(result.nextCursor);
|
||||
setHasMore(!!result.nextCursor);
|
||||
}
|
||||
setMessages((prev) => [...prev, ...result.data]);
|
||||
setCursor(result.nextCursor);
|
||||
setHasMore(!!result.nextCursor);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [cursor, loadingMore]);
|
||||
|
||||
// BUG 5 FIX: reanalyze returns Promise<void> so callers can await it
|
||||
const reanalyze = useCallback(async (id: string): Promise<void> => {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Filter, Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Channel, Guild, MessageRecord } from "../../shared/api/client";
|
||||
import type { MessageRecord } from "../../shared/api/client";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Select,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
@@ -20,13 +18,8 @@ import { ImageGrid } from "./components/ImageGrid";
|
||||
import { MessageFeed } from "./components/MessageFeed";
|
||||
|
||||
interface MessagesPanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
guildName: string | null;
|
||||
messages: MessageRecord[];
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
onReanalyze: (id: string) => Promise<void>;
|
||||
onLoadMore?: () => void;
|
||||
hasMore?: boolean;
|
||||
@@ -36,13 +29,8 @@ interface MessagesPanelProps {
|
||||
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
||||
|
||||
export function MessagesPanel({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
guildName,
|
||||
messages,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onReanalyze,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
@@ -63,11 +51,7 @@ export function MessagesPanel({
|
||||
}
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: searchQuery,
|
||||
...(selectedChannel && { channelId: selectedChannel }),
|
||||
limit: "50",
|
||||
});
|
||||
const params = new URLSearchParams({ q: searchQuery, limit: "50" });
|
||||
const response = await fetch(`/api/analysis/search?${params}`);
|
||||
if (!response.ok) throw new Error("Search failed");
|
||||
const data = await response.json();
|
||||
@@ -110,24 +94,19 @@ export function MessagesPanel({
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Message Source</CardTitle>
|
||||
<CardDescription>
|
||||
Pick a guild and channel/thread to inspect captures.
|
||||
</CardDescription>
|
||||
<CardTitle>Messages</CardTitle>
|
||||
{guildName && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Monitoring all text channels in{" "}
|
||||
<span className="font-medium text-foreground">{guildName}</span>
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={(e) => onGuildChange(e.target.value)}
|
||||
placeholder="Select text guild"
|
||||
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
|
||||
/>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => onChannelChange(e.target.value)}
|
||||
placeholder="Select channel or thread"
|
||||
options={channels.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Messages are automatically captured from all text channels in the
|
||||
monitored guild. Real-time updates arrive via WebSocket.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -257,9 +236,7 @@ export function MessagesPanel({
|
||||
emptyText={
|
||||
showSearch
|
||||
? "No messages found matching your search."
|
||||
: selectedChannel
|
||||
? "No captures yet."
|
||||
: "Select a channel to view captures."
|
||||
: "No captures yet."
|
||||
}
|
||||
onLoadMore={showSearch ? undefined : onLoadMore}
|
||||
hasMore={showSearch ? false : hasMore}
|
||||
|
||||
Reference in New Issue
Block a user