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,6 +1,6 @@
|
|||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import { getPool } from "../../shared/database/index.js";
|
|
||||||
import { config } from "../../shared/config/index.js";
|
import { config } from "../../shared/config/index.js";
|
||||||
|
import { getPool } from "../../shared/database/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("voice.service");
|
const logger = createChildLogger("voice.service");
|
||||||
@@ -100,7 +100,10 @@ async function sendCommand<T = unknown>(
|
|||||||
redis.on("message", handler);
|
redis.on("message", handler);
|
||||||
|
|
||||||
redis
|
redis
|
||||||
.publish("backend:command", JSON.stringify({ id, type, payload, replyChannel }))
|
.publish(
|
||||||
|
"backend:command",
|
||||||
|
JSON.stringify({ id, type, payload, replyChannel }),
|
||||||
|
)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
@@ -118,9 +121,17 @@ async function readStatus<T>(key: string): Promise<T | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get guilds from database (distinct guild_id from messages).
|
* Get guilds — query from discord-gateway via Redis command for real names.
|
||||||
|
* Falls back to database (distinct guild_id from messages) if gateway unreachable.
|
||||||
*/
|
*/
|
||||||
export async function getGuilds(): Promise<Guild[]> {
|
export async function getGuilds(): Promise<Guild[]> {
|
||||||
|
const fromGateway = await sendCommand<Guild[]>("guilds:list", {});
|
||||||
|
if (fromGateway && fromGateway.length > 0) return fromGateway;
|
||||||
|
|
||||||
|
// Fallback: Postgres with synthetic names
|
||||||
|
logger.warn(
|
||||||
|
"discord-gateway unreachable, falling back to Postgres for guilds",
|
||||||
|
);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT DISTINCT guild_id FROM messages ORDER BY guild_id`,
|
`SELECT DISTINCT guild_id FROM messages ORDER BY guild_id`,
|
||||||
@@ -134,9 +145,20 @@ export async function getGuilds(): Promise<Guild[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get text channels from database (distinct channel_id for a guild).
|
* Get text channels — query from discord-gateway via Redis command for real names.
|
||||||
|
* Falls back to database if gateway unreachable.
|
||||||
*/
|
*/
|
||||||
export async function getTextChannels(guildId: string): Promise<Channel[]> {
|
export async function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||||
|
const fromGateway = await sendCommand<Channel[]>("guilds:text-channels", {
|
||||||
|
guildId,
|
||||||
|
});
|
||||||
|
if (fromGateway && fromGateway.length > 0) return fromGateway;
|
||||||
|
|
||||||
|
// Fallback: Postgres with synthetic names
|
||||||
|
logger.warn(
|
||||||
|
{ guildId },
|
||||||
|
"discord-gateway unreachable, falling back to Postgres for text channels",
|
||||||
|
);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT DISTINCT channel_id FROM messages WHERE guild_id = $1 ORDER BY channel_id`,
|
`SELECT DISTINCT channel_id FROM messages WHERE guild_id = $1 ORDER BY channel_id`,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Redis from "ioredis";
|
|
||||||
import type { Client } from "discord.js-selfbot-v13";
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
import type { VoiceController } from "../voice-recording/voiceController.js";
|
import Redis from "ioredis";
|
||||||
import { discordPlayer } from "../voice-recording/player.js";
|
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||||
|
import { discordPlayer } from "../voice-recording/player.js";
|
||||||
|
import type { VoiceController } from "../voice-recording/voiceController.js";
|
||||||
|
|
||||||
const logger = createChildLogger("command-handler");
|
const logger = createChildLogger("command-handler");
|
||||||
|
|
||||||
@@ -125,6 +125,15 @@ export class CommandHandler {
|
|||||||
case "voice:disconnect":
|
case "voice:disconnect":
|
||||||
reply = await this.handleVoiceDisconnect(cmd);
|
reply = await this.handleVoiceDisconnect(cmd);
|
||||||
break;
|
break;
|
||||||
|
case "voice:channels":
|
||||||
|
reply = await this.handleVoiceChannels(cmd);
|
||||||
|
break;
|
||||||
|
case "guilds:list":
|
||||||
|
reply = await this.handleListGuilds(cmd);
|
||||||
|
break;
|
||||||
|
case "guilds:text-channels":
|
||||||
|
reply = await this.handleTextChannels(cmd);
|
||||||
|
break;
|
||||||
case "media:queue":
|
case "media:queue":
|
||||||
reply = await this.handleMediaQueue(cmd);
|
reply = await this.handleMediaQueue(cmd);
|
||||||
break;
|
break;
|
||||||
@@ -148,7 +157,10 @@ export class CommandHandler {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
logger.error({ commandId: cmd.id, error: message }, "Command execution failed");
|
logger.error(
|
||||||
|
{ commandId: cmd.id, error: message },
|
||||||
|
"Command execution failed",
|
||||||
|
);
|
||||||
reply = {
|
reply = {
|
||||||
id: cmd.id,
|
id: cmd.id,
|
||||||
success: false,
|
success: false,
|
||||||
@@ -175,7 +187,12 @@ export class CommandHandler {
|
|||||||
|
|
||||||
private async handleVoiceConnect(cmd: BackendCommand): Promise<CommandReply> {
|
private async handleVoiceConnect(cmd: BackendCommand): Promise<CommandReply> {
|
||||||
if (!this.client || !this.voiceController) {
|
if (!this.client || !this.voiceController) {
|
||||||
return { id: cmd.id, success: false, data: null, error: "Gateway not initialized" };
|
return {
|
||||||
|
id: cmd.id,
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: "Gateway not initialized",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const guildId = String(cmd.payload.guildId ?? "");
|
const guildId = String(cmd.payload.guildId ?? "");
|
||||||
@@ -194,15 +211,124 @@ export class CommandHandler {
|
|||||||
return { id: cmd.id, success: true, data: status };
|
return { id: cmd.id, success: true, data: status };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleVoiceDisconnect(cmd: BackendCommand): Promise<CommandReply> {
|
private async handleVoiceDisconnect(
|
||||||
|
cmd: BackendCommand,
|
||||||
|
): Promise<CommandReply> {
|
||||||
if (!this.voiceController) {
|
if (!this.voiceController) {
|
||||||
return { id: cmd.id, success: false, data: null, error: "Gateway not initialized" };
|
return {
|
||||||
|
id: cmd.id,
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: "Gateway not initialized",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await this.voiceController.disconnect();
|
const status = await this.voiceController.disconnect();
|
||||||
return { id: cmd.id, success: true, data: status };
|
return { id: cmd.id, success: true, data: status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleVoiceChannels(
|
||||||
|
cmd: BackendCommand,
|
||||||
|
): Promise<CommandReply> {
|
||||||
|
if (!this.client) {
|
||||||
|
return {
|
||||||
|
id: cmd.id,
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: "Gateway not initialized",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildId = String(cmd.payload.guildId ?? "");
|
||||||
|
if (!guildId) {
|
||||||
|
return {
|
||||||
|
id: cmd.id,
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: "guildId is required",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const guild = await this.client.guilds.fetch(guildId);
|
||||||
|
const channels = await guild.channels.fetch();
|
||||||
|
const voiceChannels = channels
|
||||||
|
.filter((c) => c?.type === "GUILD_VOICE")
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
type: "voice" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { id: cmd.id, success: true, data: voiceChannels };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id: cmd.id, success: false, data: null, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleListGuilds(cmd: BackendCommand): Promise<CommandReply> {
|
||||||
|
if (!this.client) {
|
||||||
|
return {
|
||||||
|
id: cmd.id,
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: "Gateway not initialized",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const guilds = this.client.guilds.cache.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
icon: g.iconURL() ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { id: cmd.id, success: true, data: guilds };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id: cmd.id, success: false, data: null, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTextChannels(cmd: BackendCommand): Promise<CommandReply> {
|
||||||
|
if (!this.client) {
|
||||||
|
return {
|
||||||
|
id: cmd.id,
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: "Gateway not initialized",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildId = String(cmd.payload.guildId ?? "");
|
||||||
|
if (!guildId) {
|
||||||
|
return {
|
||||||
|
id: cmd.id,
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: "guildId is required",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const guild = await this.client.guilds.fetch(guildId);
|
||||||
|
const channels = await guild.channels.fetch();
|
||||||
|
const textChannels = channels
|
||||||
|
.filter((c) => c?.type === "GUILD_TEXT")
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
type: "text" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { id: cmd.id, success: true, data: textChannels };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id: cmd.id, success: false, data: null, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleMediaQueue(_cmd: BackendCommand): Promise<CommandReply> {
|
private async handleMediaQueue(_cmd: BackendCommand): Promise<CommandReply> {
|
||||||
// Media queueing is handled at a higher level (frontend / backend streams
|
// Media queueing is handled at a higher level (frontend / backend streams
|
||||||
// audio directly). Log the request for now.
|
// audio directly). Log the request for now.
|
||||||
@@ -235,7 +361,11 @@ export class CommandHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
discordPlayer.setMusicVolume(volume);
|
discordPlayer.setMusicVolume(volume);
|
||||||
return { id: cmd.id, success: true, data: { volume: discordPlayer.getMusicVolume() } };
|
return {
|
||||||
|
id: cmd.id,
|
||||||
|
success: true,
|
||||||
|
data: { volume: discordPlayer.getMusicVolume() },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Status publishing ----
|
// ---- Status publishing ----
|
||||||
@@ -243,7 +373,12 @@ export class CommandHandler {
|
|||||||
private publishVoiceStatus(): void {
|
private publishVoiceStatus(): void {
|
||||||
const status: VoiceStatusPayload = this.voiceController
|
const status: VoiceStatusPayload = this.voiceController
|
||||||
? this.voiceController.getStatus()
|
? this.voiceController.getStatus()
|
||||||
: { connected: false, activeGuildId: null, activeChannelId: null, activeChannelName: null };
|
: {
|
||||||
|
connected: false,
|
||||||
|
activeGuildId: null,
|
||||||
|
activeChannelId: null,
|
||||||
|
activeChannelName: null,
|
||||||
|
};
|
||||||
|
|
||||||
this.setKey(VOICE_STATUS_KEY, JSON.stringify(status));
|
this.setKey(VOICE_STATUS_KEY, JSON.stringify(status));
|
||||||
}
|
}
|
||||||
@@ -265,7 +400,8 @@ export class CommandHandler {
|
|||||||
*/
|
*/
|
||||||
private setKey(key: string, value: string): void {
|
private setKey(key: string, value: string): void {
|
||||||
const redis = new Redis(config.REDIS_URL);
|
const redis = new Redis(config.REDIS_URL);
|
||||||
redis.set(key, value)
|
redis
|
||||||
|
.set(key, value)
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
logger.warn({ key, error: msg }, "Failed to update Redis status key");
|
logger.warn({ key, error: msg }, "Failed to update Redis status key");
|
||||||
|
|||||||
@@ -64,14 +64,13 @@ export default function App() {
|
|||||||
const activeTab = uiState.activeTab || "live";
|
const activeTab = uiState.activeTab || "live";
|
||||||
const selectedVoiceGuild =
|
const selectedVoiceGuild =
|
||||||
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||||
const selectedTextGuild =
|
|
||||||
monitorGuildId || uiState.selectedTextGuild || uiState.selectedGuild || "";
|
// Resolve monitor guild name from the full guild list (has real names now)
|
||||||
const selectedTextChannel = uiState.selectedTextChannel || "";
|
const monitorGuildName = useMemo(
|
||||||
const monitorGuild = useMemo(
|
|
||||||
() =>
|
() =>
|
||||||
monitorGuildId
|
monitorGuildId
|
||||||
? voice.guilds.find((g) => g.id === monitorGuildId)
|
? (voice.guilds.find((g) => g.id === monitorGuildId)?.name ?? null)
|
||||||
: undefined,
|
: null,
|
||||||
[monitorGuildId, voice.guilds],
|
[monitorGuildId, voice.guilds],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,7 +96,9 @@ export default function App() {
|
|||||||
onMessageAnalyzed: (m) =>
|
onMessageAnalyzed: (m) =>
|
||||||
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
||||||
onAttachmentUploaded: () =>
|
onAttachmentUploaded: () =>
|
||||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined),
|
messages
|
||||||
|
.fetchMessages(monitorGuildId || undefined)
|
||||||
|
.catch(() => undefined),
|
||||||
onMediaState: (state) => media.setMediaState(state as MediaState),
|
onMediaState: (state) => media.setMediaState(state as MediaState),
|
||||||
onVoiceRecordingUploaded: (d) =>
|
onVoiceRecordingUploaded: (d) =>
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
@@ -107,43 +108,37 @@ export default function App() {
|
|||||||
|
|
||||||
const transmit = useAudioTransmit(socket.socketRef);
|
const transmit = useAudioTransmit(socket.socketRef);
|
||||||
|
|
||||||
|
// Load app config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAppConfig()
|
getAppConfig()
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
if (c.monitorGuildId) {
|
if (c.monitorGuildId) {
|
||||||
setMonitorGuildId(c.monitorGuildId);
|
setMonitorGuildId(c.monitorGuildId);
|
||||||
patchUIState({
|
|
||||||
selectedTextGuild: c.monitorGuildId,
|
|
||||||
selectedAnalyticsGuild: c.monitorGuildId,
|
|
||||||
selectedTextChannel: "",
|
|
||||||
selectedAnalyticsChannel: "",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, [patchUIState]);
|
}, []);
|
||||||
|
|
||||||
|
// Load voice channels when guild changes (Live tab)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedVoiceGuild)
|
if (selectedVoiceGuild)
|
||||||
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||||
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
||||||
|
|
||||||
|
// Auto-fetch messages for the monitor guild (all channels)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (monitorGuildId)
|
if (monitorGuildId)
|
||||||
voice.loadTextTargets(monitorGuildId).catch(() => undefined);
|
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
||||||
}, [monitorGuildId, voice.loadTextTargets]);
|
}, [monitorGuildId, 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
|
// Periodic refetch — keeps dashboard in sync even if WS events missed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedTextChannel) return;
|
if (!monitorGuildId) return;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
||||||
}, 15_000); // every 15s (longer than WS, shorter than stale cache)
|
}, 15_000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [selectedTextChannel, messages.fetchMessages]);
|
}, [monitorGuildId, messages.fetchMessages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
@@ -191,15 +186,8 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
) : activeTab === "messages" ? (
|
) : activeTab === "messages" ? (
|
||||||
<MessagesPanel
|
<MessagesPanel
|
||||||
guilds={monitorGuild ? [monitorGuild] : []}
|
guildName={monitorGuildName}
|
||||||
channels={voice.textChannels}
|
|
||||||
selectedGuild={selectedTextGuild}
|
|
||||||
selectedChannel={selectedTextChannel}
|
|
||||||
messages={messages.messages}
|
messages={messages.messages}
|
||||||
onGuildChange={(id) =>
|
|
||||||
patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })
|
|
||||||
}
|
|
||||||
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
|
|
||||||
onReanalyze={messages.reanalyze}
|
onReanalyze={messages.reanalyze}
|
||||||
onLoadMore={messages.loadMore}
|
onLoadMore={messages.loadMore}
|
||||||
hasMore={messages.hasMore}
|
hasMore={messages.hasMore}
|
||||||
@@ -218,23 +206,8 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AnalyticsPanel
|
<AnalyticsPanel
|
||||||
guilds={monitorGuild ? [monitorGuild] : []}
|
guildId={monitorGuildId}
|
||||||
channels={voice.textChannels}
|
guildName={monitorGuildName}
|
||||||
selectedGuild={
|
|
||||||
uiState.selectedAnalyticsGuild || selectedTextGuild || ""
|
|
||||||
}
|
|
||||||
selectedChannel={
|
|
||||||
uiState.selectedAnalyticsChannel || selectedTextChannel || ""
|
|
||||||
}
|
|
||||||
onGuildChange={(id) =>
|
|
||||||
patchUIState({
|
|
||||||
selectedAnalyticsGuild: id,
|
|
||||||
selectedAnalyticsChannel: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onChannelChange={(id) =>
|
|
||||||
patchUIState({ selectedAnalyticsChannel: id })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AnalyticsErrorBoundary>
|
</AnalyticsErrorBoundary>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Activity, BarChart3 } from "lucide-react";
|
import { Activity, BarChart3 } from "lucide-react";
|
||||||
import type { Channel, Guild } from "../../../shared/api/client";
|
|
||||||
import { cn } from "../../../shared/lib/utils";
|
import { cn } from "../../../shared/lib/utils";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -8,7 +7,6 @@ import {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Select,
|
|
||||||
} from "../../../shared/ui";
|
} from "../../../shared/ui";
|
||||||
|
|
||||||
const TIME_RANGES = [
|
const TIME_RANGES = [
|
||||||
@@ -22,27 +20,17 @@ const TIME_RANGES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface ControlBarProps {
|
interface ControlBarProps {
|
||||||
guilds: Guild[];
|
guildName: string | null;
|
||||||
channels: Channel[];
|
|
||||||
selectedGuild: string;
|
|
||||||
selectedChannel: string;
|
|
||||||
hours: number;
|
hours: number;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
onGuildChange: (guildId: string) => void;
|
|
||||||
onChannelChange: (channelId: string) => void;
|
|
||||||
onHoursChange: (hours: number) => void;
|
onHoursChange: (hours: number) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ControlBar({
|
export function ControlBar({
|
||||||
guilds,
|
guildName,
|
||||||
channels,
|
|
||||||
selectedGuild,
|
|
||||||
selectedChannel,
|
|
||||||
hours,
|
hours,
|
||||||
isFetching,
|
isFetching,
|
||||||
onGuildChange,
|
|
||||||
onChannelChange,
|
|
||||||
onHoursChange,
|
onHoursChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: ControlBarProps) {
|
}: ControlBarProps) {
|
||||||
@@ -54,28 +42,19 @@ export function ControlBar({
|
|||||||
Analisis Moderasi
|
Analisis Moderasi
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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">
|
<div className="flex items-center gap-1 rounded-md bg-muted p-0.5">
|
||||||
{TIME_RANGES.map((tr) => (
|
{TIME_RANGES.map((tr) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Channel, Guild } from "../../shared/api/client";
|
|
||||||
import { ActivityChart } from "./components/ActivityChart";
|
import { ActivityChart } from "./components/ActivityChart";
|
||||||
import { ControlBar } from "./components/ControlBar";
|
import { ControlBar } from "./components/ControlBar";
|
||||||
import { Heatmap } from "./components/Heatmap";
|
import { Heatmap } from "./components/Heatmap";
|
||||||
@@ -11,26 +10,16 @@ import { ViolatorTable } from "./components/ViolatorTable";
|
|||||||
import { useAnalytics } from "./hooks/useAnalytics";
|
import { useAnalytics } from "./hooks/useAnalytics";
|
||||||
|
|
||||||
interface AnalyticsPanelProps {
|
interface AnalyticsPanelProps {
|
||||||
guilds: Guild[];
|
guildId: string;
|
||||||
channels: Channel[];
|
guildName: string | null;
|
||||||
selectedGuild: string;
|
|
||||||
selectedChannel: string;
|
|
||||||
onGuildChange: (guildId: string) => void;
|
|
||||||
onChannelChange: (channelId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnalyticsPanel({
|
export function AnalyticsPanel({ guildId, guildName }: AnalyticsPanelProps) {
|
||||||
guilds,
|
|
||||||
channels,
|
|
||||||
selectedGuild,
|
|
||||||
selectedChannel,
|
|
||||||
onGuildChange,
|
|
||||||
onChannelChange,
|
|
||||||
}: AnalyticsPanelProps) {
|
|
||||||
const [hours, setHours] = useState(24);
|
const [hours, setHours] = useState(24);
|
||||||
const analytics = useAnalytics({
|
const analytics = useAnalytics({
|
||||||
guildId: selectedGuild,
|
guildId,
|
||||||
channelId: selectedChannel || undefined,
|
// No channelId — analytics for all channels in the guild
|
||||||
|
channelId: undefined,
|
||||||
hours,
|
hours,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,11 +49,11 @@ export function AnalyticsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedGuild) {
|
if (!guildId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8">
|
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
Pilih guild untuk melihat analitik.
|
Menunggu konfigurasi guild...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -73,14 +62,9 @@ export function AnalyticsPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<ControlBar
|
<ControlBar
|
||||||
guilds={guilds}
|
guildName={guildName}
|
||||||
channels={channels}
|
|
||||||
selectedGuild={selectedGuild}
|
|
||||||
selectedChannel={selectedChannel}
|
|
||||||
hours={hours}
|
hours={hours}
|
||||||
isFetching={isFetching}
|
isFetching={isFetching}
|
||||||
onGuildChange={onGuildChange}
|
|
||||||
onChannelChange={onChannelChange}
|
|
||||||
onHoursChange={setHours}
|
onHoursChange={setHours}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ export function mergeMessages(
|
|||||||
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 });
|
||||||
}
|
}
|
||||||
// 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(
|
return Array.from(byId.values()).sort(
|
||||||
(a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id),
|
(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 [error, setError] = useState<string | null>(null);
|
||||||
const [cursor, setCursor] = useState<string | null>(null);
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const currentChannel = useRef<string | null>(null);
|
const currentGuild = useRef<string | null>(null);
|
||||||
|
|
||||||
const fetchMessages = useCallback(async (channelId?: string) => {
|
const fetchMessages = useCallback(async (guildId?: string) => {
|
||||||
if (!channelId) {
|
if (!guildId) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setCursor(null);
|
setCursor(null);
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
currentChannel.current = channelId;
|
currentGuild.current = guildId;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: String(PAGE_SIZE),
|
limit: String(PAGE_SIZE),
|
||||||
channelId,
|
guildId,
|
||||||
});
|
});
|
||||||
const result = await listMessages(params);
|
const result = await listMessages(params);
|
||||||
// Only update state if we're still on the same channel (avoid race conditions)
|
if (currentGuild.current === guildId) {
|
||||||
if (currentChannel.current === channelId) {
|
|
||||||
setMessages(result.data);
|
setMessages(result.data);
|
||||||
setCursor(result.nextCursor);
|
setCursor(result.nextCursor);
|
||||||
setHasMore(!!result.nextCursor);
|
setHasMore(!!result.nextCursor);
|
||||||
@@ -61,30 +58,23 @@ export function useMessages() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (!cursor || !currentChannel.current || loadingMore) return;
|
if (!cursor || !currentGuild.current || loadingMore) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: String(PAGE_SIZE),
|
limit: String(PAGE_SIZE),
|
||||||
channelId: currentChannel.current,
|
guildId: currentGuild.current,
|
||||||
cursor,
|
cursor,
|
||||||
});
|
});
|
||||||
const result = await listMessages(params);
|
const result = await listMessages(params);
|
||||||
// Only update if still on the same channel
|
setMessages((prev) => [...prev, ...result.data]);
|
||||||
if (
|
setCursor(result.nextCursor);
|
||||||
currentChannel.current === result.data[0]?.channel_id ||
|
setHasMore(!!result.nextCursor);
|
||||||
currentChannel.current
|
|
||||||
) {
|
|
||||||
setMessages((prev) => [...prev, ...result.data]);
|
|
||||||
setCursor(result.nextCursor);
|
|
||||||
setHasMore(!!result.nextCursor);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}, [cursor, loadingMore]);
|
}, [cursor, loadingMore]);
|
||||||
|
|
||||||
// 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) =>
|
||||||
prev.map((message) =>
|
prev.map((message) =>
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { Filter, Search, X } from "lucide-react";
|
import { Filter, Search, X } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { Channel, Guild, MessageRecord } from "../../shared/api/client";
|
import type { MessageRecord } from "../../shared/api/client";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
@@ -20,13 +18,8 @@ import { ImageGrid } from "./components/ImageGrid";
|
|||||||
import { MessageFeed } from "./components/MessageFeed";
|
import { MessageFeed } from "./components/MessageFeed";
|
||||||
|
|
||||||
interface MessagesPanelProps {
|
interface MessagesPanelProps {
|
||||||
guilds: Guild[];
|
guildName: string | null;
|
||||||
channels: Channel[];
|
|
||||||
selectedGuild: string;
|
|
||||||
selectedChannel: string;
|
|
||||||
messages: MessageRecord[];
|
messages: MessageRecord[];
|
||||||
onGuildChange: (guildId: string) => void;
|
|
||||||
onChannelChange: (channelId: string) => void;
|
|
||||||
onReanalyze: (id: string) => Promise<void>;
|
onReanalyze: (id: string) => Promise<void>;
|
||||||
onLoadMore?: () => void;
|
onLoadMore?: () => void;
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
@@ -36,13 +29,8 @@ interface MessagesPanelProps {
|
|||||||
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
||||||
|
|
||||||
export function MessagesPanel({
|
export function MessagesPanel({
|
||||||
guilds,
|
guildName,
|
||||||
channels,
|
|
||||||
selectedGuild,
|
|
||||||
selectedChannel,
|
|
||||||
messages,
|
messages,
|
||||||
onGuildChange,
|
|
||||||
onChannelChange,
|
|
||||||
onReanalyze,
|
onReanalyze,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
@@ -63,11 +51,7 @@ export function MessagesPanel({
|
|||||||
}
|
}
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({ q: searchQuery, limit: "50" });
|
||||||
q: searchQuery,
|
|
||||||
...(selectedChannel && { channelId: selectedChannel }),
|
|
||||||
limit: "50",
|
|
||||||
});
|
|
||||||
const response = await fetch(`/api/analysis/search?${params}`);
|
const response = await fetch(`/api/analysis/search?${params}`);
|
||||||
if (!response.ok) throw new Error("Search failed");
|
if (!response.ok) throw new Error("Search failed");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -110,24 +94,19 @@ export function MessagesPanel({
|
|||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Message Source</CardTitle>
|
<CardTitle>Messages</CardTitle>
|
||||||
<CardDescription>
|
{guildName && (
|
||||||
Pick a guild and channel/thread to inspect captures.
|
<p className="text-sm text-muted-foreground">
|
||||||
</CardDescription>
|
Monitoring all text channels in{" "}
|
||||||
|
<span className="font-medium text-foreground">{guildName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
<CardContent>
|
||||||
<Select
|
<p className="text-xs text-muted-foreground">
|
||||||
value={selectedGuild}
|
Messages are automatically captured from all text channels in the
|
||||||
onChange={(e) => onGuildChange(e.target.value)}
|
monitored guild. Real-time updates arrive via WebSocket.
|
||||||
placeholder="Select text guild"
|
</p>
|
||||||
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -257,9 +236,7 @@ export function MessagesPanel({
|
|||||||
emptyText={
|
emptyText={
|
||||||
showSearch
|
showSearch
|
||||||
? "No messages found matching your search."
|
? "No messages found matching your search."
|
||||||
: selectedChannel
|
: "No captures yet."
|
||||||
? "No captures yet."
|
|
||||||
: "Select a channel to view captures."
|
|
||||||
}
|
}
|
||||||
onLoadMore={showSearch ? undefined : onLoadMore}
|
onLoadMore={showSearch ? undefined : onLoadMore}
|
||||||
hasMore={showSearch ? false : hasMore}
|
hasMore={showSearch ? false : hasMore}
|
||||||
|
|||||||
Reference in New Issue
Block a user