diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 02b883f..272b2ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,7 @@ "react-slick": "^0.30.2", "recharts": "^2.12.7", "slick-carousel": "^1.8.1", + "sweetalert2": "^11.14.1", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "use-image": "^1.1.1", @@ -13843,6 +13844,15 @@ "dev": true, "license": "MIT" }, + "node_modules/sweetalert2": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.14.1.tgz", + "integrity": "sha512-xadhfcA4STGMh8nC5zHFFWURhRpWc4zyI3GdMDFH/m3hGWZeQQNWhX9xcG4lI9gZYsi/IlazKbwvvje3juL3Xg==", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/limonte" + } + }, "node_modules/tailwind-merge": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6edce64..a4bb4ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "react-slick": "^0.30.2", "recharts": "^2.12.7", "slick-carousel": "^1.8.1", + "sweetalert2": "^11.14.1", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "use-image": "^1.1.1", diff --git a/frontend/src/components/ModelManage/TrainingGraph.tsx b/frontend/src/components/ModelManage/TrainingGraph.tsx index 28fafb4..487b030 100644 --- a/frontend/src/components/ModelManage/TrainingGraph.tsx +++ b/frontend/src/components/ModelManage/TrainingGraph.tsx @@ -1,44 +1,29 @@ -import { useEffect } from 'react'; import ModelLineChart from './ModelLineChart'; import usePollingTrainingModelReport from '@/hooks/usePollingTrainingModelReport'; -import { useQueryClient } from '@tanstack/react-query'; import { ModelResponse } from '@/types'; interface TrainingGraphProps { projectId: number | null; selectedModel: ModelResponse | null; + isTraining: boolean; + onTrainingEnd: () => void; className?: string; } -export default function TrainingGraph({ projectId, selectedModel, className }: TrainingGraphProps) { - const queryClient = useQueryClient(); - - const isTrainingEnabled = Boolean(selectedModel?.isTrain); - - const handleTrainingEnd = () => { - queryClient.resetQueries({ - queryKey: ['modelReports', projectId, selectedModel?.id], - exact: true, - }); - alert('학습이 완료되었습니다.'); - }; - - const { data: trainingDataList } = usePollingTrainingModelReport({ +export default function TrainingGraph({ + projectId, + selectedModel, + isTraining, + onTrainingEnd, + className, +}: TrainingGraphProps) { + const { reportData: trainingDataList } = usePollingTrainingModelReport({ projectId: projectId as number, modelId: selectedModel?.id as number, - enabled: isTrainingEnabled, - onTrainingEnd: handleTrainingEnd, + enabled: isTraining, + onTrainingEnd, }); - useEffect(() => { - if (!selectedModel || !selectedModel.isTrain) { - queryClient.resetQueries({ - queryKey: ['modelReports', projectId, selectedModel?.id], - exact: true, - }); - } - }, [selectedModel, queryClient, projectId]); - return ( void; handleTrainingStart: (trainData: ModelTrainRequest) => void; handleTrainingStop: () => void; - isPolling: boolean; + isWaiting: boolean; + isTraining: boolean; className?: string; } @@ -22,7 +23,8 @@ export default function TrainingSettings({ setSelectedModel, handleTrainingStart, handleTrainingStop, - isPolling, + isWaiting, + isTraining, className, }: TrainingSettingsProps) { const { data: models } = useProjectModelsQuery(projectId ?? 0); @@ -48,9 +50,6 @@ export default function TrainingSettings({ } }; - const isTraining = selectedModel?.isTrain; - const isWaiting = isPolling && !isTraining; - return (
모델 설정 @@ -73,7 +72,8 @@ export default function TrainingSettings({ }} /> - {!isPolling && !isTraining && ( + + {!isWaiting && !isTraining && ( <>
(null); - const [isPolling, setIsPolling] = useState(false); + const [isWaiting, setIsWaiting] = useState<{ [modelId: number]: boolean }>({}); + const [isTraining, setIsTraining] = useState<{ [modelId: number]: boolean }>({}); const queryClient = useQueryClient(); + const prevModelRef = useRef(null); const { mutate: startTraining } = useTrainModelQuery(numericProjectId as number); - - const handleTrainingStart = (trainData: ModelTrainRequest) => { - if (numericProjectId !== null) { - startTraining(trainData); - setIsPolling(true); - } - }; - - const handleTrainingEnd = () => { - setIsPolling(false); - setSelectedModel((prevModel) => (prevModel ? { ...prevModel, isTrain: false } : null)); - }; + const { data: models } = useProjectModelsQuery(numericProjectId ?? 0); useEffect(() => { - if (!selectedModel || !numericProjectId || !isPolling) return; + if (models) { + const trainingModels = models.filter((model) => model.isTrain); + const newIsTraining = trainingModels.reduce( + (acc, model) => { + acc[model.id] = true; + return acc; + }, + {} as { [modelId: number]: boolean } + ); + setIsTraining(newIsTraining); - const intervalId = setInterval(async () => { - await queryClient.invalidateQueries({ queryKey: ['projectModels', numericProjectId] }); - - const models = await queryClient.getQueryData(['projectModels', numericProjectId]); - - const updatedModel = models?.find((model) => model.id === selectedModel.id); + if (selectedModel && trainingModels.some((model) => model.id === selectedModel.id)) { + setSelectedModel(selectedModel); + } else { + setSelectedModel(null); + } + } + }, [models, selectedModel]); + useEffect(() => { + if (models && selectedModel) { + const updatedModel = models.find((model) => model.id === selectedModel.id); if (updatedModel) { setSelectedModel(updatedModel); - if (updatedModel.isTrain) { - setIsPolling(false); - } else { - setIsPolling(false); - setSelectedModel({ ...updatedModel, isTrain: false }); + if (isWaiting[selectedModel.id] && updatedModel.isTrain) { + setIsWaiting((prev) => ({ ...prev, [selectedModel.id]: false })); + setIsTraining((prev) => ({ ...prev, [selectedModel.id]: true })); } } - }, 2000); + } + }, [models, selectedModel, isWaiting]); + + useEffect(() => { + let intervalId: NodeJS.Timeout | null = null; + + if (selectedModel && isWaiting[selectedModel.id]) { + intervalId = setInterval(async () => { + await queryClient.invalidateQueries({ queryKey: ['projectModels', numericProjectId] }); + }, 2000); + } return () => { - clearInterval(intervalId); + if (intervalId) { + clearInterval(intervalId); + } }; - }, [selectedModel, numericProjectId, queryClient, isPolling]); + }, [isWaiting, selectedModel, queryClient, numericProjectId]); - usePollingTrainingModelReport({ - projectId: numericProjectId as number, - modelId: selectedModel?.id as number, - enabled: selectedModel?.isTrain || false, - onTrainingEnd: handleTrainingEnd, - }); + const handleTrainingStart = (trainData: ModelTrainRequest) => { + if (numericProjectId !== null && selectedModel) { + startTraining(trainData); + setIsWaiting((prev) => ({ ...prev, [selectedModel.id]: true })); + } + }; + + const handleTrainingEnd = (modelId: number) => { + if (prevModelRef.current && prevModelRef.current.id === modelId) { + Swal.fire({ + title: '학습 완료', + text: `모델 "${prevModelRef.current.name}"의 학습이 완료되었습니다.`, + icon: 'success', + confirmButtonText: '확인', + }); + } + + setIsTraining((prev) => ({ ...prev, [modelId]: false })); + + if (selectedModel && selectedModel.id === modelId) { + setSelectedModel(null); + } + }; const handleTrainingStop = () => { - setIsPolling(false); - setSelectedModel((prevModel) => (prevModel ? { ...prevModel, isTrain: false } : null)); - //todo: 중단 함수 연결 + if (selectedModel) { + setIsWaiting((prev) => ({ ...prev, [selectedModel.id]: false })); + setIsTraining((prev) => ({ ...prev, [selectedModel.id]: false })); + setSelectedModel(null); + // TODO: 학습 중단 기능 구현 + } }; + useEffect(() => { + if (selectedModel) { + prevModelRef.current = selectedModel; + } + }, [selectedModel]); + return (
selectedModel && handleTrainingEnd(selectedModel.id)} className="h-full" />
diff --git a/frontend/src/components/WorkspaceSidebar/index.tsx b/frontend/src/components/WorkspaceSidebar/index.tsx index d59daa9..31c7081 100644 --- a/frontend/src/components/WorkspaceSidebar/index.tsx +++ b/frontend/src/components/WorkspaceSidebar/index.tsx @@ -5,37 +5,71 @@ import { Project } from '@/types'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '../ui/select'; import useCanvasStore from '@/stores/useCanvasStore'; import { webPath } from '@/router'; -import { Suspense, useEffect } from 'react'; +import { useState } from 'react'; +import useUploadImageFileQuery from '@/queries/projects/useUploadImageFileQuery'; +import useAuthStore from '@/stores/useAuthStore'; export default function WorkspaceSidebar({ workspaceName, projects }: { workspaceName: string; projects: Project[] }) { - const { setImage } = useCanvasStore(); - const { projectId: selectedProjectId, workspaceId } = useParams<{ projectId: string; workspaceId: string }>(); - const selectedProject = projects.find((project) => project.id.toString() === selectedProjectId) || null; + const { projectId: selectedProjectId } = useParams<{ projectId: string }>(); + const selectedProject = projects.find((project) => project.id.toString() === selectedProjectId); const setSidebarSize = useCanvasStore((state) => state.setSidebarSize); const navigate = useNavigate(); + const { workspaceId } = useParams<{ workspaceId: string }>(); + const [isDragging, setIsDragging] = useState(false); + const uploadImageFileMutation = useUploadImageFileQuery(); + const { profile } = useAuthStore(); + const handleSelectProject = (projectId: string) => { - setImage(null); - navigate(`${webPath.workspace()}/${workspaceId}/${projectId}`); + navigate(`${webPath.workspace()}/${workspaceId}/project/${projectId}`); }; - useEffect(() => { - if (!selectedProject) { - setImage(null); - } - }, [selectedProject, setImage]); + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + if (!selectedProjectId || !profile) return; + + const files = Array.from(e.dataTransfer.files); + const memberId = profile.id; + const projectId = parseInt(selectedProjectId); + const folderId = 0; + + uploadImageFileMutation.mutate({ + memberId, + projectId, + folderId, + files, + progressCallback: (progress) => { + console.log(`업로드 진행률: ${progress}%`); + }, + }); + }; return ( <> setSidebarSize(size)} + onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e as unknown as React.DragEvent)} > -
-
-

{workspaceName}

-
+
+

{workspaceName}

+
+
-
}> - {selectedProject && } -
+ {selectedProject && }
diff --git a/frontend/src/components/ui/resizable.tsx b/frontend/src/components/ui/resizable.tsx index a3bba1c..22cf252 100644 --- a/frontend/src/components/ui/resizable.tsx +++ b/frontend/src/components/ui/resizable.tsx @@ -1,33 +1,36 @@ -import { GripVertical } from "lucide-react" -import * as ResizablePrimitive from "react-resizable-panels" +import { GripVertical } from 'lucide-react'; +import * as ResizablePrimitive from 'react-resizable-panels'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; -const ResizablePanelGroup = ({ - className, - ...props -}: React.ComponentProps) => ( +type PanelGroupProps = React.ComponentProps; + +const ResizablePanelGroup = ({ className, ...props }: PanelGroupProps) => ( -) +); -const ResizablePanel = ResizablePrimitive.Panel +type PanelProps = React.ComponentProps; + +const ResizablePanel = ({ className, ...props }: PanelProps) => ( + +); const ResizableHandle = ({ withHandle, className, ...props }: React.ComponentProps & { - withHandle?: boolean + withHandle?: boolean; }) => ( div]:rotate-90 dark:bg-gray-800 dark:focus-visible:ring-gray-300", + 'relative flex w-px items-center justify-center bg-gray-200 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-950 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 dark:bg-gray-800 dark:focus-visible:ring-gray-300 [&[data-panel-group-direction=vertical]>div]:rotate-90', className )} {...props} @@ -38,6 +41,6 @@ const ResizableHandle = ({
)} -) +); -export { ResizablePanelGroup, ResizablePanel, ResizableHandle } +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/frontend/src/hooks/usePollingTrainingModelReport.ts b/frontend/src/hooks/usePollingTrainingModelReport.ts index cdb9142..2305691 100644 --- a/frontend/src/hooks/usePollingTrainingModelReport.ts +++ b/frontend/src/hooks/usePollingTrainingModelReport.ts @@ -1,7 +1,8 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { getTrainingModelReport } from '@/api/reportApi'; -import { ReportResponse } from '@/types'; +import { getProjectModels } from '@/api/modelApi'; +import { ReportResponse, ProjectModelsResponse } from '@/types'; interface UsePollingTrainingModelReportProps { projectId: number; @@ -16,7 +17,7 @@ export default function usePollingTrainingModelReport({ enabled, onTrainingEnd, }: UsePollingTrainingModelReportProps) { - const query = useQuery({ + const reportQuery = useQuery({ queryKey: ['modelReports', projectId, modelId], queryFn: () => getTrainingModelReport(projectId, modelId), enabled, @@ -30,14 +31,30 @@ export default function usePollingTrainingModelReport({ }, }); + const modelQuery = useQuery({ + queryKey: ['projectModels', projectId], + queryFn: () => getProjectModels(projectId), + enabled, + refetchInterval: 2000, + }); + + const prevIsTrainRef = useRef(null); + useEffect(() => { - if (query.data && query.data.length > 0) { - const lastReport = query.data[query.data.length - 1]; - if (lastReport.epoch >= lastReport.totalEpochs) { - onTrainingEnd(); + if (modelQuery.data) { + const model = modelQuery.data.find((m) => m.id === modelId); + if (model) { + const currentIsTrain = model.isTrain; + const prevIsTrain = prevIsTrainRef.current; + + if (prevIsTrain === true && currentIsTrain === false) { + onTrainingEnd(); + } + + prevIsTrainRef.current = currentIsTrain; } } - }, [query.data, onTrainingEnd]); + }, [modelQuery.data, modelId, onTrainingEnd]); - return query; + return { reportData: reportQuery.data, modelData: modelQuery.data }; }