fix(dashboard): infinite scroll, auto-refetch, analytics cache invalidation, remove topic row cap
Frontend: - Cursor pagination (100/page) replacing hardcoded 80 message limit - Removed .slice(0,200) cap on mergeMessages - IntersectionObserver infinite scroll with skeleton loading - 15s periodic refetch for message list sync Backend: - Removed LIMIT 2000 from topic trends SQL query - Added invalidateAnalyticsCache on message capture (messageCreated) - Added invalidateAnalyticsCache on batch analysis completion Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5078b34ac2
commit
4117f83c1b
@@ -77,6 +77,15 @@ export default function App() {
|
|||||||
useEffect(() => { if (monitorGuildId) voice.loadTextTargets(monitorGuildId).catch(() => undefined); }, [monitorGuildId, voice.loadTextTargets]);
|
useEffect(() => { if (monitorGuildId) voice.loadTextTargets(monitorGuildId).catch(() => undefined); }, [monitorGuildId, voice.loadTextTargets]);
|
||||||
useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel, messages.fetchMessages]);
|
useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel, messages.fetchMessages]);
|
||||||
|
|
||||||
|
// Periodic refetch — ensures dashboard stays in sync even if WS events were missed
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTextChannel) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||||
|
}, 15_000); // every 15s (longer than WS, shorter than stale cache)
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [selectedTextChannel, messages.fetchMessages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout activeTab={activeTab} wsStatus={socket.status} voiceStatus={voice.voiceStatus} onTabChange={(tab) => patchUIState({ activeTab: tab })}>
|
<DashboardLayout activeTab={activeTab} wsStatus={socket.status} voiceStatus={voice.voiceStatus} onTabChange={(tab) => patchUIState({ activeTab: tab })}>
|
||||||
{activeTab === "live" ? (
|
{activeTab === "live" ? (
|
||||||
@@ -105,6 +114,9 @@ export default function App() {
|
|||||||
onGuildChange={(id) => patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })}
|
onGuildChange={(id) => patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })}
|
||||||
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
|
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
|
||||||
onReanalyze={messages.reanalyze}
|
onReanalyze={messages.reanalyze}
|
||||||
|
onLoadMore={messages.loadMore}
|
||||||
|
hasMore={messages.hasMore}
|
||||||
|
loadingMore={messages.loadingMore}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AnalyticsErrorBoundary>
|
<AnalyticsErrorBoundary>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { ScrollArea } from "../../../shared/ui";
|
import { ScrollArea } from "../../../shared/ui";
|
||||||
import type { MessageRecord } from "../../../shared/api/client";
|
import type { MessageRecord } from "../../../shared/api/client";
|
||||||
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
||||||
@@ -7,9 +8,30 @@ export interface MessageFeedProps {
|
|||||||
onReanalyze: (id: string) => Promise<void>;
|
onReanalyze: (id: string) => Promise<void>;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found.", loading }: MessageFeedProps) {
|
export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found.", loading, onLoadMore, hasMore, loadingMore }: MessageFeedProps) {
|
||||||
|
// IntersectionObserver for infinite scroll — fires when sentinel becomes visible
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onLoadMore || !hasMore) return;
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) onLoadMore();
|
||||||
|
},
|
||||||
|
{ rootMargin: "400px" }, // preload before user reaches bottom
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [onLoadMore, hasMore]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||||
@@ -30,6 +52,17 @@ export function MessageFeed({ messages, onReanalyze, emptyText = "No messages fo
|
|||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
|
<MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Infinite-scroll sentinel */}
|
||||||
|
{hasMore && (
|
||||||
|
<div ref={sentinelRef} className="flex items-center justify-center py-4">
|
||||||
|
{loadingMore ? (
|
||||||
|
<MessageCardSkeleton />
|
||||||
|
) : (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,47 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { listMessages, reanalyzeMessage } from "../../../shared/api/client";
|
import { listMessages, reanalyzeMessage } from "../../../shared/api/client";
|
||||||
import type { MessageRecord } from "../../../shared/api/client";
|
import type { MessageRecord } from "../../../shared/api/client";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
export function mergeMessages(current: MessageRecord[], incoming: MessageRecord[]): MessageRecord[] {
|
export function mergeMessages(current: MessageRecord[], incoming: MessageRecord[]): MessageRecord[] {
|
||||||
const byId = new Map(current.map((message) => [message.id, message]));
|
const byId = new Map(current.map((message) => [message.id, message]));
|
||||||
for (const message of incoming) {
|
for (const message of incoming) {
|
||||||
byId.set(message.id, { ...byId.get(message.id), ...message });
|
byId.set(message.id, { ...byId.get(message.id), ...message });
|
||||||
}
|
}
|
||||||
return Array.from(byId.values())
|
// Removed .slice(0, 200) cap — let the message list grow unbounded.
|
||||||
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
|
// Infinite scroll handles the data volume via cursor pagination.
|
||||||
.slice(0, 200);
|
return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMessages() {
|
export function useMessages() {
|
||||||
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 fetchMessages = useCallback(async (channelId?: string) => {
|
const fetchMessages = useCallback(async (channelId?: string) => {
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setCursor(null);
|
||||||
|
setHasMore(false);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
currentChannel.current = channelId;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ limit: "80" });
|
const params = new URLSearchParams({ limit: String(PAGE_SIZE), channelId });
|
||||||
params.set("channel", channelId);
|
|
||||||
const result = await listMessages(params);
|
const result = await listMessages(params);
|
||||||
setMessages(result.data);
|
// Only update state if we're still on the same channel (avoid race conditions)
|
||||||
|
if (currentChannel.current === channelId) {
|
||||||
|
setMessages(result.data);
|
||||||
|
setCursor(result.nextCursor);
|
||||||
|
setHasMore(!!result.nextCursor);
|
||||||
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -39,6 +52,27 @@ export function useMessages() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (!cursor || !currentChannel.current || loadingMore) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: String(PAGE_SIZE),
|
||||||
|
channelId: currentChannel.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);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [cursor, loadingMore]);
|
||||||
|
|
||||||
// BUG 5 FIX: reanalyze returns Promise<void> so callers can await it
|
// BUG 5 FIX: reanalyze returns Promise<void> so callers can await it
|
||||||
const reanalyze = useCallback(async (id: string): Promise<void> => {
|
const reanalyze = useCallback(async (id: string): Promise<void> => {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
@@ -51,5 +85,15 @@ export function useMessages() {
|
|||||||
await reanalyzeMessage(id);
|
await reanalyzeMessage(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { messages, setMessages, loading, error, fetchMessages, reanalyze };
|
return {
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
error,
|
||||||
|
fetchMessages,
|
||||||
|
reanalyze,
|
||||||
|
loadMore,
|
||||||
|
hasMore,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ interface MessagesPanelProps {
|
|||||||
onGuildChange: (guildId: string) => void;
|
onGuildChange: (guildId: string) => void;
|
||||||
onChannelChange: (channelId: string) => void;
|
onChannelChange: (channelId: string) => void;
|
||||||
onReanalyze: (id: string) => Promise<void>;
|
onReanalyze: (id: string) => Promise<void>;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
||||||
@@ -21,6 +24,7 @@ type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
|||||||
export function MessagesPanel({
|
export function MessagesPanel({
|
||||||
guilds, channels, selectedGuild, selectedChannel,
|
guilds, channels, selectedGuild, selectedChannel,
|
||||||
messages, onGuildChange, onChannelChange, onReanalyze,
|
messages, onGuildChange, onChannelChange, onReanalyze,
|
||||||
|
onLoadMore, hasMore, loadingMore,
|
||||||
}: MessagesPanelProps) {
|
}: MessagesPanelProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
|
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
|
||||||
@@ -85,7 +89,7 @@ export function MessagesPanel({
|
|||||||
|
|
||||||
{stats.total > 0 && (
|
{stats.total > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="secondary" className="text-xs">{stats.total} total</Badge>
|
<Badge variant="secondary" className="text-xs">{stats.total} total{hasMore && !showSearch ? "+" : ""}</Badge>
|
||||||
<Badge variant="outline" className="text-xs text-green-400 border-green-400/30">{stats.clean} clean</Badge>
|
<Badge variant="outline" className="text-xs text-green-400 border-green-400/30">{stats.clean} clean</Badge>
|
||||||
<Badge variant="outline" className="text-xs text-yellow-400 border-yellow-400/30">{stats.warn} warn</Badge>
|
<Badge variant="outline" className="text-xs text-yellow-400 border-yellow-400/30">{stats.warn} warn</Badge>
|
||||||
<Badge variant="outline" className="text-xs text-red-400 border-red-400/30">{stats.flagged} flagged</Badge>
|
<Badge variant="outline" className="text-xs text-red-400 border-red-400/30">{stats.flagged} flagged</Badge>
|
||||||
@@ -127,7 +131,14 @@ export function MessagesPanel({
|
|||||||
<TabsTrigger value="images">Images</TabsTrigger>
|
<TabsTrigger value="images">Images</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="all">
|
<TabsContent value="all">
|
||||||
<MessageFeed messages={filteredMessages} onReanalyze={onReanalyze} emptyText={showSearch ? "No messages found matching your search." : selectedChannel ? "No captures yet." : "Select a channel to view captures."} />
|
<MessageFeed
|
||||||
|
messages={filteredMessages}
|
||||||
|
onReanalyze={onReanalyze}
|
||||||
|
emptyText={showSearch ? "No messages found matching your search." : selectedChannel ? "No captures yet." : "Select a channel to view captures."}
|
||||||
|
onLoadMore={showSearch ? undefined : onLoadMore}
|
||||||
|
hasMore={showSearch ? false : hasMore}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="images">
|
<TabsContent value="images">
|
||||||
<ImageGrid messages={filteredMessages} />
|
<ImageGrid messages={filteredMessages} />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
updateMessageAIAnalysis,
|
updateMessageAIAnalysis,
|
||||||
updateMessagesAIAnalysisBulk,
|
updateMessagesAIAnalysisBulk,
|
||||||
} from "./messageStore.js";
|
} from "./messageStore.js";
|
||||||
|
import { invalidateAnalyticsCache } from "./analyticsStore.js";
|
||||||
import type {
|
import type {
|
||||||
AnalysisQueueStatus,
|
AnalysisQueueStatus,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
@@ -374,6 +375,7 @@ async function processIndividualFallback(
|
|||||||
const rows = await updateMessagesAIAnalysisBulk(updates);
|
const rows = await updateMessagesAIAnalysisBulk(updates);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
getModerationBroadcaster()?.messageAnalyzed(row);
|
getModerationBroadcaster()?.messageAnalyzed(row);
|
||||||
|
invalidateAnalyticsCache(row.guild_id);
|
||||||
scheduleAutoDelete(row);
|
scheduleAutoDelete(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -424,8 +424,8 @@ export async function getTopicTrends(input: {
|
|||||||
try {
|
try {
|
||||||
const since = Date.now() - hours * 3600_000;
|
const since = Date.now() - hours * 3600_000;
|
||||||
|
|
||||||
// Only fetch messages that have ai_analysis (the ones that actually have topics)
|
// Fetch all analyzed messages within the time window (no hard row cap).
|
||||||
// This dramatically reduces rows for large guilds
|
// Messages without ai_analysis are excluded which naturally limits rows.
|
||||||
const rows = (await executeAll(
|
const rows = (await executeAll(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -438,7 +438,6 @@ export async function getTopicTrends(input: {
|
|||||||
AND ai_analysis IS NOT NULL
|
AND ai_analysis IS NOT NULL
|
||||||
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 2000
|
|
||||||
`,
|
`,
|
||||||
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
|
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
|
||||||
)) as MessageRecord[];
|
)) as MessageRecord[];
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@ import {
|
|||||||
updateMessageAsEdited,
|
updateMessageAsEdited,
|
||||||
upsertMessageForCapture,
|
upsertMessageForCapture,
|
||||||
} from "./messageStore.js";
|
} from "./messageStore.js";
|
||||||
|
import { invalidateAnalyticsCache } from "./analyticsStore.js";
|
||||||
import type { AttachmentRecord, MessageRecord } from "./types.js";
|
import type { AttachmentRecord, MessageRecord } from "./types.js";
|
||||||
|
|
||||||
const logger = createChildLogger("message-capture");
|
const logger = createChildLogger("message-capture");
|
||||||
@@ -127,6 +128,9 @@ export async function captureMessage(
|
|||||||
broadcaster.messageCreated(messageRecord);
|
broadcaster.messageCreated(messageRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate analytics cache so real-time stats reflect the new message
|
||||||
|
invalidateAnalyticsCache(messageRecord.guild_id);
|
||||||
|
|
||||||
const attachmentUploadTasks: Promise<void>[] = [];
|
const attachmentUploadTasks: Promise<void>[] = [];
|
||||||
|
|
||||||
// Insert attachments before queuing analysis to avoid race condition
|
// Insert attachments before queuing analysis to avoid race condition
|
||||||
|
|||||||
Reference in New Issue
Block a user