refactor: split monolith into 3 microservices (frontend, backend, discord-gateway)
- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bda8304bb9
commit
c48a0c5e3b
@@ -0,0 +1,248 @@
|
||||
import { Component, lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { AuthOverlay } from "./features/auth";
|
||||
import { LivePanel } from "./features/live";
|
||||
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
||||
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
|
||||
import { MessagesPanel } from "./features/messages";
|
||||
import {
|
||||
mergeMessages,
|
||||
useMessages,
|
||||
} from "./features/messages/hooks/useMessages";
|
||||
import {
|
||||
type ActiveSpeaker,
|
||||
getAppConfig,
|
||||
type MediaState,
|
||||
type MessageRecord,
|
||||
} from "./shared/api/client";
|
||||
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
||||
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
||||
import { useUIState } from "./shared/hooks/useUIState";
|
||||
import { Skeleton } from "./shared/ui";
|
||||
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
||||
import { useDashboardSocket } from "./shared/ws/socket";
|
||||
import { DashboardLayout } from "./widgets/DashboardLayout";
|
||||
|
||||
const AnalyticsPanel = lazy(() =>
|
||||
import("./features/analytics").then((module) => ({
|
||||
default: module.AnalyticsPanel,
|
||||
})),
|
||||
);
|
||||
|
||||
class AnalyticsErrorBoundary extends Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 p-6 text-sm text-destructive">
|
||||
Analytics failed to load. The rest of the dashboard is still
|
||||
available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { uiState, patchUIState } = useUIState();
|
||||
const voice = useVoiceControl();
|
||||
const media = useMediaControl();
|
||||
const messages = useMessages();
|
||||
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(
|
||||
!!localStorage.getItem("admin-password"),
|
||||
);
|
||||
const [monitorGuildId, setMonitorGuildId] = useState("");
|
||||
|
||||
const audio = useAudioPlayback();
|
||||
const activeTab = uiState.activeTab || "live";
|
||||
const selectedVoiceGuild =
|
||||
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||
const selectedTextGuild =
|
||||
monitorGuildId || uiState.selectedTextGuild || uiState.selectedGuild || "";
|
||||
const selectedTextChannel = uiState.selectedTextChannel || "";
|
||||
const monitorGuild = useMemo(
|
||||
() =>
|
||||
monitorGuildId
|
||||
? voice.guilds.find((g) => g.id === monitorGuildId)
|
||||
: undefined,
|
||||
[monitorGuildId, voice.guilds],
|
||||
);
|
||||
|
||||
const socket = useDashboardSocket({
|
||||
onBinary: audio.handleIncomingPcm,
|
||||
onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]),
|
||||
onMessageCreated: (m) =>
|
||||
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
||||
onMessageUpdated: (m) => {
|
||||
const d = m as Partial<MessageRecord> & { id: string };
|
||||
messages.setMessages((prev) =>
|
||||
prev.map((i) => (i.id === d.id ? { ...i, ...d } : i)),
|
||||
);
|
||||
},
|
||||
onMessageDeleted: (m) => {
|
||||
const d = m as { id: string };
|
||||
messages.setMessages((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === d.id ? { ...i, type: "deleted" as const } : i,
|
||||
),
|
||||
);
|
||||
},
|
||||
onMessageAnalyzed: (m) =>
|
||||
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
||||
onAttachmentUploaded: () =>
|
||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined),
|
||||
onMediaState: (state) => media.setMediaState(state as MediaState),
|
||||
onVoiceRecordingUploaded: (d) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("voice_recording_uploaded", { detail: d }),
|
||||
),
|
||||
});
|
||||
|
||||
const transmit = useAudioTransmit(socket.socketRef);
|
||||
|
||||
useEffect(() => {
|
||||
getAppConfig()
|
||||
.then((c) => {
|
||||
if (c.monitorGuildId) {
|
||||
setMonitorGuildId(c.monitorGuildId);
|
||||
patchUIState({
|
||||
selectedTextGuild: c.monitorGuildId,
|
||||
selectedAnalyticsGuild: c.monitorGuildId,
|
||||
selectedTextChannel: "",
|
||||
selectedAnalyticsChannel: "",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [patchUIState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedVoiceGuild)
|
||||
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
||||
useEffect(() => {
|
||||
if (monitorGuildId)
|
||||
voice.loadTextTargets(monitorGuildId).catch(() => undefined);
|
||||
}, [monitorGuildId, voice.loadTextTargets]);
|
||||
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 (
|
||||
<DashboardLayout
|
||||
activeTab={activeTab}
|
||||
wsStatus={socket.status}
|
||||
voiceStatus={voice.voiceStatus}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
>
|
||||
{activeTab === "live" ? (
|
||||
!isAuthenticated ? (
|
||||
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<LivePanel
|
||||
guilds={voice.guilds}
|
||||
voiceChannels={voice.voiceChannels}
|
||||
selectedGuild={selectedVoiceGuild}
|
||||
selectedChannel={uiState.selectedVoiceChannel || ""}
|
||||
status={voice.voiceStatus}
|
||||
voiceLoading={voice.loading}
|
||||
activeSpeakers={activeSpeakers}
|
||||
levels={audio.levels}
|
||||
isListening={audio.isListening}
|
||||
isStreaming={transmit.isStreaming}
|
||||
mediaState={media.mediaState}
|
||||
mediaLoading={media.loading}
|
||||
onGuildChange={(id) =>
|
||||
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
|
||||
}
|
||||
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
|
||||
onJoin={() =>
|
||||
voice.joinVoice(
|
||||
selectedVoiceGuild,
|
||||
uiState.selectedVoiceChannel || "",
|
||||
)
|
||||
}
|
||||
onDisconnect={() => voice.leaveVoice()}
|
||||
onListenToggle={audio.toggleListening}
|
||||
onStreamingToggle={transmit.toggle}
|
||||
onQueueMusic={(s) => media.enqueue(s, "music")}
|
||||
onStartScreen={(s) => media.enqueue(s, "screen")}
|
||||
onSkip={media.skip}
|
||||
onStop={media.stop}
|
||||
onVolumeChange={media.setVolume}
|
||||
/>
|
||||
)
|
||||
) : activeTab === "messages" ? (
|
||||
<MessagesPanel
|
||||
guilds={monitorGuild ? [monitorGuild] : []}
|
||||
channels={voice.textChannels}
|
||||
selectedGuild={selectedTextGuild}
|
||||
selectedChannel={selectedTextChannel}
|
||||
messages={messages.messages}
|
||||
onGuildChange={(id) =>
|
||||
patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })
|
||||
}
|
||||
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
|
||||
onReanalyze={messages.reanalyze}
|
||||
onLoadMore={messages.loadMore}
|
||||
hasMore={messages.hasMore}
|
||||
loadingMore={messages.loadingMore}
|
||||
/>
|
||||
) : (
|
||||
<AnalyticsErrorBoundary>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalyticsPanel
|
||||
guilds={monitorGuild ? [monitorGuild] : []}
|
||||
channels={voice.textChannels}
|
||||
selectedGuild={
|
||||
uiState.selectedAnalyticsGuild || selectedTextGuild || ""
|
||||
}
|
||||
selectedChannel={
|
||||
uiState.selectedAnalyticsChannel || selectedTextChannel || ""
|
||||
}
|
||||
onGuildChange={(id) =>
|
||||
patchUIState({
|
||||
selectedAnalyticsGuild: id,
|
||||
selectedAnalyticsChannel: "",
|
||||
})
|
||||
}
|
||||
onChannelChange={(id) =>
|
||||
patchUIState({ selectedAnalyticsChannel: id })
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</AnalyticsErrorBoundary>
|
||||
)}
|
||||
<MobileTabBar
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user