Compare commits
4 Commits
99cfef1004
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 4873020bfa | |||
| c847e321a7 | |||
| 7c0bdf02a2 | |||
| b98339d299 |
+60
-16
@@ -16,7 +16,7 @@ import CourseSelectionScreen from './features/curriculum/CourseSelectionScreen'
|
|||||||
import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen'
|
import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen'
|
||||||
import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal'
|
import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal'
|
||||||
import MaterialsModal from './features/curriculum/MaterialsModal'
|
import MaterialsModal from './features/curriculum/MaterialsModal'
|
||||||
import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi'
|
import { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } from './features/curriculum/curriculumApi'
|
||||||
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
|
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
|
||||||
import { getContextHelpSteps } from './features/help/contextHelp'
|
import { getContextHelpSteps } from './features/help/contextHelp'
|
||||||
import AiGuideView from '../features/AiGuideView'
|
import AiGuideView from '../features/AiGuideView'
|
||||||
@@ -84,6 +84,11 @@ type IdeActivity = WorkspaceActivity
|
|||||||
|
|
||||||
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'
|
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'
|
||||||
|
|
||||||
|
function isRemoteEnvironmentProfile(profile: EnvironmentProfile): boolean {
|
||||||
|
const target = profile.config?.target
|
||||||
|
return target === 'remote-jetson' || target === 'platform'
|
||||||
|
}
|
||||||
|
|
||||||
function resolveModuleRuntimeImages(
|
function resolveModuleRuntimeImages(
|
||||||
profiles: EnvironmentProfile[],
|
profiles: EnvironmentProfile[],
|
||||||
remoteWorkspace: boolean,
|
remoteWorkspace: boolean,
|
||||||
@@ -92,7 +97,7 @@ function resolveModuleRuntimeImages(
|
|||||||
if (!remoteWorkspace) return activeRuntimeImages
|
if (!remoteWorkspace) return activeRuntimeImages
|
||||||
|
|
||||||
const images = profiles
|
const images = profiles
|
||||||
.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
|
.filter(isRemoteEnvironmentProfile)
|
||||||
.flatMap((profile) => Array.isArray(profile.config.runtimeImages) ? profile.config.runtimeImages : [])
|
.flatMap((profile) => Array.isArray(profile.config.runtimeImages) ? profile.config.runtimeImages : [])
|
||||||
.filter((image): image is RuntimeImageArtifact => (
|
.filter((image): image is RuntimeImageArtifact => (
|
||||||
typeof image === 'object'
|
typeof image === 'object'
|
||||||
@@ -408,8 +413,10 @@ function LocalPcWorkspaceMockup({
|
|||||||
weekNumber,
|
weekNumber,
|
||||||
courseKey,
|
courseKey,
|
||||||
curriculumWeek,
|
curriculumWeek,
|
||||||
|
publicGuideCourse,
|
||||||
activeLearningStep,
|
activeLearningStep,
|
||||||
allowFutureLearningSteps,
|
allowFutureLearningSteps,
|
||||||
|
teacherQaDisabled,
|
||||||
onLearningStepSelect,
|
onLearningStepSelect,
|
||||||
onSubmissionRecorded
|
onSubmissionRecorded
|
||||||
}: {
|
}: {
|
||||||
@@ -436,8 +443,10 @@ function LocalPcWorkspaceMockup({
|
|||||||
weekNumber: number
|
weekNumber: number
|
||||||
courseKey: string
|
courseKey: string
|
||||||
curriculumWeek: CurriculumWeek
|
curriculumWeek: CurriculumWeek
|
||||||
|
publicGuideCourse: boolean
|
||||||
activeLearningStep: string | null
|
activeLearningStep: string | null
|
||||||
allowFutureLearningSteps: boolean
|
allowFutureLearningSteps: boolean
|
||||||
|
teacherQaDisabled?: boolean
|
||||||
onLearningStepSelect?: (step: string) => void
|
onLearningStepSelect?: (step: string) => void
|
||||||
onSubmissionRecorded: () => void
|
onSubmissionRecorded: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
@@ -736,7 +745,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
const runtimeModules = useMemo(() => resolveRuntimeModules(
|
const runtimeModules = useMemo(() => resolveRuntimeModules(
|
||||||
curriculumWeek.modules,
|
curriculumWeek.modules,
|
||||||
remoteWorkspace
|
remoteWorkspace
|
||||||
? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
|
? visualizationProfiles.filter(isRemoteEnvironmentProfile)
|
||||||
: activeEnvironmentProfile,
|
: activeEnvironmentProfile,
|
||||||
remoteWorkspace ? 'platform' : 'local'
|
remoteWorkspace ? 'platform' : 'local'
|
||||||
), [activeEnvironmentProfile, curriculumWeek.modules, remoteWorkspace, visualizationProfiles])
|
), [activeEnvironmentProfile, curriculumWeek.modules, remoteWorkspace, visualizationProfiles])
|
||||||
@@ -778,15 +787,18 @@ function LocalPcWorkspaceMockup({
|
|||||||
const storedParameters = readStoredModuleParameters()
|
const storedParameters = readStoredModuleParameters()
|
||||||
return modules.map((module) => {
|
return modules.map((module) => {
|
||||||
const override = storedParameters[getModuleParameterStorageKey(module)]
|
const override = storedParameters[getModuleParameterStorageKey(module)]
|
||||||
if (!override) return module
|
const defaultParameters = runtimeModules.find((item) => item.folderName === module.folderName)?.defaultParameters
|
||||||
|
?.filter((parameter): parameter is string => typeof parameter === 'string') ?? []
|
||||||
return {
|
return {
|
||||||
...module,
|
...module,
|
||||||
parameters: serializeModuleParameters(override).split(/\s+/).filter(Boolean)
|
parameters: override
|
||||||
|
? serializeModuleParameters(override).split(/\s+/).filter(Boolean)
|
||||||
|
: defaultParameters.length ? defaultParameters : module.parameters
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [courseKey, weekNumber])
|
}, [courseKey, runtimeModules, weekNumber])
|
||||||
const curriculumModuleKey = runtimeModules
|
const curriculumModuleKey = runtimeModules
|
||||||
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}`)
|
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}:${JSON.stringify(module.defaultParameters ?? [])}`)
|
||||||
.join('|')
|
.join('|')
|
||||||
const {
|
const {
|
||||||
availableModules,
|
availableModules,
|
||||||
@@ -2781,7 +2793,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
controllingModuleNames={controllingModuleNames}
|
controllingModuleNames={controllingModuleNames}
|
||||||
listError={moduleListError}
|
listError={moduleListError}
|
||||||
installError={moduleInstallError}
|
installError={moduleInstallError}
|
||||||
allowRepositoryDeletion={!remoteWorkspace}
|
allowRepositoryDeletion={!remoteWorkspace && !publicGuideCourse}
|
||||||
onAddModuleTab={addModuleTabOnly}
|
onAddModuleTab={addModuleTabOnly}
|
||||||
onOpenSource={openModuleSource}
|
onOpenSource={openModuleSource}
|
||||||
onInstallModule={(module) => void installModule(module)}
|
onInstallModule={(module) => void installModule(module)}
|
||||||
@@ -2828,6 +2840,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
iconButtonClass={iconButtonClass}
|
iconButtonClass={iconButtonClass}
|
||||||
accessToken={accessToken}
|
accessToken={accessToken}
|
||||||
qaMode={qaMode}
|
qaMode={qaMode}
|
||||||
|
teacherQaDisabled={teacherQaDisabled}
|
||||||
teacherChatFlash={teacherChatFlash}
|
teacherChatFlash={teacherChatFlash}
|
||||||
qaMessagesLoading={qaMessagesLoading}
|
qaMessagesLoading={qaMessagesLoading}
|
||||||
visibleAiMessages={visibleAiMessages}
|
visibleAiMessages={visibleAiMessages}
|
||||||
@@ -2881,6 +2894,7 @@ export default function App(): React.JSX.Element {
|
|||||||
const [curriculumWeeks, setCurriculumWeeks] = useState<CurriculumWeek[]>([])
|
const [curriculumWeeks, setCurriculumWeeks] = useState<CurriculumWeek[]>([])
|
||||||
const [courseKey, setCourseKey] = useState('default-course')
|
const [courseKey, setCourseKey] = useState('default-course')
|
||||||
const [studentCourses, setStudentCourses] = useState<StudentCourseOption[]>([])
|
const [studentCourses, setStudentCourses] = useState<StudentCourseOption[]>([])
|
||||||
|
const [trialCourse, setTrialCourse] = useState<StudentCourseOption | null>(null)
|
||||||
const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false)
|
const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false)
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) ?? '')
|
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) ?? '')
|
||||||
const [courseSelectionVisible, setCourseSelectionVisible] = useState(false)
|
const [courseSelectionVisible, setCourseSelectionVisible] = useState(false)
|
||||||
@@ -2929,6 +2943,7 @@ export default function App(): React.JSX.Element {
|
|||||||
const connectedPlatformRef = useRef<PlatformOption | null>(null)
|
const connectedPlatformRef = useRef<PlatformOption | null>(null)
|
||||||
const runningPlatformsRef = useRef<PlatformOption[]>([])
|
const runningPlatformsRef = useRef<PlatformOption[]>([])
|
||||||
const platformStatusRequestSequenceRef = useRef(0)
|
const platformStatusRequestSequenceRef = useRef(0)
|
||||||
|
const [checkedRemotePlatformStatusIds, setCheckedRemotePlatformStatusIds] = useState<Set<string>>(() => new Set())
|
||||||
const forcedPlatformTerminationHandlerRef = useRef<(payload: PlatformControlTerminatedPayload) => void>(() => undefined)
|
const forcedPlatformTerminationHandlerRef = useRef<(payload: PlatformControlTerminatedPayload) => void>(() => undefined)
|
||||||
const [workspaceWeekState, setWorkspaceWeekState] = useState<CurriculumWeek | null>(null)
|
const [workspaceWeekState, setWorkspaceWeekState] = useState<CurriculumWeek | null>(null)
|
||||||
const [sessionConfirmAction, setSessionConfirmAction] = useState<'disconnect' | 'release' | null>(null)
|
const [sessionConfirmAction, setSessionConfirmAction] = useState<'disconnect' | 'release' | null>(null)
|
||||||
@@ -2964,7 +2979,8 @@ export default function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
const selectedWeek = selectedWeekState ?? EMPTY_WEEK
|
const selectedWeek = selectedWeekState ?? EMPTY_WEEK
|
||||||
const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK
|
const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK
|
||||||
const selectedCourse = studentCourses.find((course) => course.id === selectedCourseId) ?? null
|
const selectedCourse = studentCourses.find((course) => course.id === selectedCourseId)
|
||||||
|
?? (trialCourse?.id === selectedCourseId ? trialCourse : null)
|
||||||
const publicGuideCourseActive = selectedCourse?.slug === PUBLIC_GUIDE_COURSE_SLUG
|
const publicGuideCourseActive = selectedCourse?.slug === PUBLIC_GUIDE_COURSE_SLUG
|
||||||
const studioDisabled = !selectedWeekState
|
const studioDisabled = !selectedWeekState
|
||||||
const connectedEnvironmentProfile = connectedPlatform
|
const connectedEnvironmentProfile = connectedPlatform
|
||||||
@@ -3201,17 +3217,19 @@ export default function App(): React.JSX.Element {
|
|||||||
|
|
||||||
setStudentCoursesLoading(true)
|
setStudentCoursesLoading(true)
|
||||||
try {
|
try {
|
||||||
const courses = await fetchStudentCourses(fetchWithFreshAuth)
|
const { courses, guideCourse } = await fetchStudentCourses(fetchWithFreshAuth)
|
||||||
|
const selectableCourses = guideCourse ? [...courses, guideCourse] : courses
|
||||||
|
|
||||||
setStudentCourses(courses)
|
setStudentCourses(courses)
|
||||||
|
setTrialCourse(guideCourse)
|
||||||
setStudentCoursesLoaded(true)
|
setStudentCoursesLoaded(true)
|
||||||
setSelectedCourseId((current) => {
|
setSelectedCourseId((current) => {
|
||||||
if (current && courses.some((course) => course.id === current)) {
|
if (current && selectableCourses.some((course) => course.id === current)) {
|
||||||
if (options.autoSelectSingle) setCourseSelectionVisible(false)
|
if (options.autoSelectSingle) setCourseSelectionVisible(false)
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
const stored = window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY)
|
const stored = window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY)
|
||||||
if (stored && courses.some((course) => course.id === stored)) {
|
if (stored && selectableCourses.some((course) => course.id === stored)) {
|
||||||
if (options.autoSelectSingle) setCourseSelectionVisible(false)
|
if (options.autoSelectSingle) setCourseSelectionVisible(false)
|
||||||
return stored
|
return stored
|
||||||
}
|
}
|
||||||
@@ -3226,6 +3244,7 @@ export default function App(): React.JSX.Element {
|
|||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setStudentCourses([])
|
setStudentCourses([])
|
||||||
|
setTrialCourse(null)
|
||||||
setStudentCoursesLoaded(true)
|
setStudentCoursesLoaded(true)
|
||||||
setCourseSelectionVisible(true)
|
setCourseSelectionVisible(true)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3329,6 +3348,11 @@ export default function App(): React.JSX.Element {
|
|||||||
return status ? { ...platform, ...status } : platform
|
return status ? { ...platform, ...status } : platform
|
||||||
}
|
}
|
||||||
setPlatformOptions((current) => current.map(mergePlatformStatus))
|
setPlatformOptions((current) => current.map(mergePlatformStatus))
|
||||||
|
setCheckedRemotePlatformStatusIds((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
platforms.forEach((platform) => next.add(platform.id))
|
||||||
|
return next
|
||||||
|
})
|
||||||
const lostControlPlatformIds = new Set(
|
const lostControlPlatformIds = new Set(
|
||||||
runningPlatformsRef.current
|
runningPlatformsRef.current
|
||||||
.filter((platform) => {
|
.filter((platform) => {
|
||||||
@@ -3499,15 +3523,19 @@ export default function App(): React.JSX.Element {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [authReady, authSession?.accessToken, fetchWithFreshAuth])
|
}, [authReady, authSession?.accessToken, fetchWithFreshAuth])
|
||||||
|
const isRemotePlatformStatusPending = (platform: PlatformOption): boolean => (
|
||||||
|
platform.platformKey !== 'futuredrive-local-pc' && !checkedRemotePlatformStatusIds.has(platform.id)
|
||||||
|
)
|
||||||
const filteredPlatformOptions = platformOptions.filter((platform) => {
|
const filteredPlatformOptions = platformOptions.filter((platform) => {
|
||||||
if (platformFilter === 'available') return platform.state === 'ready'
|
const pending = isRemotePlatformStatusPending(platform)
|
||||||
if (platformFilter === 'unavailable') return platform.state !== 'ready'
|
if (platformFilter === 'available') return platform.state === 'ready' && !pending
|
||||||
|
if (platformFilter === 'unavailable') return platform.state !== 'ready' && !pending
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
const platformFilterCounts = {
|
const platformFilterCounts = {
|
||||||
all: platformOptions.length,
|
all: platformOptions.length,
|
||||||
available: platformOptions.filter((platform) => platform.state === 'ready').length,
|
available: platformOptions.filter((platform) => platform.state === 'ready' && !isRemotePlatformStatusPending(platform)).length,
|
||||||
unavailable: platformOptions.filter((platform) => platform.state !== 'ready').length
|
unavailable: platformOptions.filter((platform) => platform.state !== 'ready' && !isRemotePlatformStatusPending(platform)).length
|
||||||
}
|
}
|
||||||
const localPcPlatform = platformOptions.find((platform) => platform.platformKey === 'futuredrive-local-pc')
|
const localPcPlatform = platformOptions.find((platform) => platform.platformKey === 'futuredrive-local-pc')
|
||||||
const openPlatformSettings = (platform: PlatformOption): void => {
|
const openPlatformSettings = (platform: PlatformOption): void => {
|
||||||
@@ -3663,6 +3691,14 @@ export default function App(): React.JSX.Element {
|
|||||||
setLocalPcLayoutOpen(false)
|
setLocalPcLayoutOpen(false)
|
||||||
setCurriculumRefreshSignal((current) => current + 1)
|
setCurriculumRefreshSignal((current) => current + 1)
|
||||||
}
|
}
|
||||||
|
const searchStudentCourseCatalog = useCallback(
|
||||||
|
(query: string) => searchCourseCatalog(fetchWithFreshAuth, query),
|
||||||
|
[fetchWithFreshAuth],
|
||||||
|
)
|
||||||
|
const enrollStudentInCourse = useCallback(async (courseId: string): Promise<void> => {
|
||||||
|
await enrollInCourse(fetchWithFreshAuth, courseId)
|
||||||
|
await loadStudentCourses({ autoSelectSingle: false })
|
||||||
|
}, [fetchWithFreshAuth, loadStudentCourses])
|
||||||
const closeLocalPcWorkspace = (): void => {
|
const closeLocalPcWorkspace = (): void => {
|
||||||
setLocalPcLayoutOpen(false)
|
setLocalPcLayoutOpen(false)
|
||||||
if (!userSidebarCollapsed) setSidebarCollapsed(false)
|
if (!userSidebarCollapsed) setSidebarCollapsed(false)
|
||||||
@@ -3992,6 +4028,8 @@ export default function App(): React.JSX.Element {
|
|||||||
setLocalPcLayoutOpen(false)
|
setLocalPcLayoutOpen(false)
|
||||||
setRecentSessionModalOpen(false)
|
setRecentSessionModalOpen(false)
|
||||||
setPlatformModalMode('select')
|
setPlatformModalMode('select')
|
||||||
|
platformStatusRequestSequenceRef.current += 1
|
||||||
|
setCheckedRemotePlatformStatusIds(new Set())
|
||||||
await loadCurriculum()
|
await loadCurriculum()
|
||||||
setPlatformModalOpen(true)
|
setPlatformModalOpen(true)
|
||||||
}
|
}
|
||||||
@@ -4405,8 +4443,10 @@ export default function App(): React.JSX.Element {
|
|||||||
weekNumber={workspaceWeek.week}
|
weekNumber={workspaceWeek.week}
|
||||||
courseKey={courseKey}
|
courseKey={courseKey}
|
||||||
curriculumWeek={workspaceWeek}
|
curriculumWeek={workspaceWeek}
|
||||||
|
publicGuideCourse={publicGuideCourseActive}
|
||||||
activeLearningStep={activeLearningStep}
|
activeLearningStep={activeLearningStep}
|
||||||
allowFutureLearningSteps={publicGuideCourseActive}
|
allowFutureLearningSteps={publicGuideCourseActive}
|
||||||
|
teacherQaDisabled={publicGuideCourseActive}
|
||||||
onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined}
|
onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined}
|
||||||
onSubmissionRecorded={() => undefined}
|
onSubmissionRecorded={() => undefined}
|
||||||
/>
|
/>
|
||||||
@@ -4441,9 +4481,12 @@ export default function App(): React.JSX.Element {
|
|||||||
) : courseSelectionVisible ? (
|
) : courseSelectionVisible ? (
|
||||||
<CourseSelectionScreen
|
<CourseSelectionScreen
|
||||||
courses={studentCourses}
|
courses={studentCourses}
|
||||||
|
trialCourse={trialCourse}
|
||||||
selectedCourseId={selectedCourseId}
|
selectedCourseId={selectedCourseId}
|
||||||
hasSelectedCourse={Boolean(selectedCourse)}
|
hasSelectedCourse={Boolean(selectedCourse)}
|
||||||
onSelectCourse={selectStudentCourse}
|
onSelectCourse={selectStudentCourse}
|
||||||
|
onSearchCourses={searchStudentCourseCatalog}
|
||||||
|
onEnrollCourse={enrollStudentInCourse}
|
||||||
onBack={() => setCourseSelectionVisible(false)}
|
onBack={() => setCourseSelectionVisible(false)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -4507,6 +4550,7 @@ export default function App(): React.JSX.Element {
|
|||||||
counts={platformFilterCounts}
|
counts={platformFilterCounts}
|
||||||
workspaceWeekTitle={workspaceWeek.title}
|
workspaceWeekTitle={workspaceWeek.title}
|
||||||
platformLoading={platformLoading}
|
platformLoading={platformLoading}
|
||||||
|
checkedRemotePlatformStatusIds={checkedRemotePlatformStatusIds}
|
||||||
isPlatformUnavailable={isPlatformUnavailable}
|
isPlatformUnavailable={isPlatformUnavailable}
|
||||||
getPlatformDotClass={getPlatformDotClass}
|
getPlatformDotClass={getPlatformDotClass}
|
||||||
onFilterChange={setPlatformFilter}
|
onFilterChange={setPlatformFilter}
|
||||||
|
|||||||
Vendored
+5
-4
@@ -65,7 +65,7 @@ interface Window {
|
|||||||
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||||
}
|
}
|
||||||
modules: {
|
modules: {
|
||||||
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{
|
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null; dockerImage?: string | null }>) => Promise<{
|
||||||
cacheRoot: string
|
cacheRoot: string
|
||||||
workspaceRoot: string | null
|
workspaceRoot: string | null
|
||||||
modules: Array<{
|
modules: Array<{
|
||||||
@@ -77,6 +77,7 @@ interface Window {
|
|||||||
sourcePath: string
|
sourcePath: string
|
||||||
workspacePath: string | null
|
workspacePath: string | null
|
||||||
templateRepositoryUrl?: string | null
|
templateRepositoryUrl?: string | null
|
||||||
|
dockerImage?: string | null
|
||||||
parameters: string[]
|
parameters: string[]
|
||||||
cpu: number | null
|
cpu: number | null
|
||||||
memory: number | null
|
memory: number | null
|
||||||
@@ -101,12 +102,12 @@ interface Window {
|
|||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
|
||||||
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null, rosParams?: string) => Promise<{ ok: boolean; message: string }>
|
||||||
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
||||||
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
|
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
|
||||||
stopRunning: () => Promise<{ ok: boolean; message: string }>
|
stopRunning: () => Promise<{ ok: boolean; message: string }>
|
||||||
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
|
||||||
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
|
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
|
||||||
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }>
|
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { CheckCircle2 } from 'lucide-react'
|
import { CheckCircle2, LoaderCircle, Search, Sparkles, UserPlus } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
|
import type { CourseCatalogOption } from './curriculumApi'
|
||||||
|
|
||||||
type CourseOption = {
|
type CourseOption = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -15,72 +18,131 @@ type CourseOption = {
|
|||||||
|
|
||||||
type CourseSelectionScreenProps = {
|
type CourseSelectionScreenProps = {
|
||||||
courses: CourseOption[]
|
courses: CourseOption[]
|
||||||
|
trialCourse?: CourseOption | null
|
||||||
selectedCourseId: string | null
|
selectedCourseId: string | null
|
||||||
hasSelectedCourse: boolean
|
hasSelectedCourse: boolean
|
||||||
onSelectCourse: (courseId: string) => void
|
onSelectCourse: (courseId: string) => void
|
||||||
|
onSearchCourses: (query: string) => Promise<CourseCatalogOption[]>
|
||||||
|
onEnrollCourse: (courseId: string) => Promise<void>
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function teacherLabel(course: Pick<CourseOption, 'teacher' | 'organizationName'>): string {
|
||||||
|
return `${course.teacher?.name ?? course.teacher?.email ?? '강사 미지정'} / ${course.teacher?.primaryOrganizationName ?? course.organizationName ?? '소속 미지정'}`
|
||||||
|
}
|
||||||
|
|
||||||
export default function CourseSelectionScreen({
|
export default function CourseSelectionScreen({
|
||||||
courses,
|
courses,
|
||||||
|
trialCourse,
|
||||||
selectedCourseId,
|
selectedCourseId,
|
||||||
hasSelectedCourse,
|
hasSelectedCourse,
|
||||||
onSelectCourse,
|
onSelectCourse,
|
||||||
onBack
|
onSearchCourses,
|
||||||
|
onEnrollCourse,
|
||||||
|
onBack,
|
||||||
}: CourseSelectionScreenProps): React.JSX.Element {
|
}: CourseSelectionScreenProps): React.JSX.Element {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<CourseCatalogOption[]>([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [searchError, setSearchError] = useState('')
|
||||||
|
const [selectedCatalogCourseId, setSelectedCatalogCourseId] = useState<string | null>(null)
|
||||||
|
const [enrolling, setEnrolling] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const keyword = query.trim()
|
||||||
|
if (!keyword) {
|
||||||
|
setSearchResults([])
|
||||||
|
setSelectedCatalogCourseId(null)
|
||||||
|
setSearchError('')
|
||||||
|
setSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setSearching(true)
|
||||||
|
setSearchError('')
|
||||||
|
void onSearchCourses(keyword)
|
||||||
|
.then((nextResults) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setSearchResults(nextResults)
|
||||||
|
setSelectedCatalogCourseId((current) => nextResults.some((course) => course.id === current) ? current : null)
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setSearchResults([])
|
||||||
|
setSelectedCatalogCourseId(null)
|
||||||
|
setSearchError(error instanceof Error ? error.message : '강좌 검색에 실패했습니다.')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setSearching(false)
|
||||||
|
})
|
||||||
|
}, 250)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [onSearchCourses, query])
|
||||||
|
|
||||||
|
const selectedCatalogCourse = searchResults.find((course) => course.id === selectedCatalogCourseId) ?? null
|
||||||
|
const submitEnrollment = async (): Promise<void> => {
|
||||||
|
if (!selectedCatalogCourse || enrolling) return
|
||||||
|
setEnrolling(true)
|
||||||
|
setSearchError('')
|
||||||
|
try {
|
||||||
|
await onEnrollCourse(selectedCatalogCourse.id)
|
||||||
|
onSelectCourse(selectedCatalogCourse.id)
|
||||||
|
} catch (error) {
|
||||||
|
setSearchError(error instanceof Error ? error.message : '수강 신청에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setEnrolling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto grid min-h-[calc(100vh-120px)] max-w-[900px] place-items-center px-4">
|
<section className="mx-auto grid min-h-[calc(100vh-120px)] max-w-[900px] place-items-center px-4">
|
||||||
<div className="w-full rounded-xl border border-[#d5dbea] bg-white p-6 shadow-[0_18px_42px_rgba(20,27,44,0.12)]">
|
<div className="w-full rounded-xl border border-[#d5dbea] bg-white p-6 shadow-[0_18px_42px_rgba(20,27,44,0.12)]">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-[24px] font-black text-[#111520]">강좌 선택</h1>
|
<h1 className="text-[24px] font-black text-[#111520]">강좌 선택</h1>
|
||||||
<p className="mt-2 text-[13px] font-semibold text-[#64748b]">학습을 진행할 수강중인 강좌를 선택해주세요.</p>
|
<p className="mt-2 text-[13px] font-semibold text-[#64748b]">수강 중인 강좌를 선택하거나 새 강좌를 검색해 수강 신청하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
{hasSelectedCourse ? (
|
{hasSelectedCourse ? (
|
||||||
<button
|
<button type="button" onClick={onBack} className="shrink-0 cursor-pointer border-0 bg-transparent px-0 py-1 text-[13px] font-black text-[#6f4df6] transition hover:text-[#4f35c8]">뒤로가기</button>
|
||||||
type="button"
|
|
||||||
onClick={onBack}
|
|
||||||
className="shrink-0 cursor-pointer border-0 bg-transparent px-0 py-1 text-[13px] font-black text-[#6f4df6] transition hover:text-[#4f35c8]"
|
|
||||||
>
|
|
||||||
뒤로가기
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3">
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<label className="relative block">
|
||||||
|
<Search size={18} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[#738096]" />
|
||||||
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="강좌명 또는 강사명 검색" className="h-12 w-full rounded-lg border border-[#cfd8e6] bg-[#fbfcff] pl-11 pr-4 text-[14px] font-semibold text-[#172033] outline-none transition focus:border-[#6f4df6] focus:bg-white focus:ring-2 focus:ring-[#6f4df6]/15" />
|
||||||
|
</label>
|
||||||
|
{query.trim() ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
{searching ? <div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-[13px] font-bold text-[#64748b]"><LoaderCircle size={17} className="animate-spin" /> 검색 중입니다.</div>
|
||||||
|
: searchResults.length ? <div className="grid gap-2">{searchResults.map((course) => {
|
||||||
|
const selected = course.id === selectedCatalogCourseId
|
||||||
|
return <button key={course.id} type="button" disabled={course.enrolled || enrolling} onClick={() => setSelectedCatalogCourseId(course.id)} className={['rounded-lg border p-3.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35', course.enrolled ? 'cursor-default border-[#dce3ee] bg-[#f8fafc] opacity-70' : selected ? 'cursor-pointer border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)]' : 'cursor-pointer border-[#dce3ee] bg-white hover:border-[#6f4df6] hover:bg-[#fbfaff]'].join(' ')}>
|
||||||
|
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h2 className="truncate text-[15px] font-black text-[#111520]">{course.title}</h2><p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{teacherLabel(course)}</p></div>{course.enrolled ? <span className="shrink-0 rounded-md bg-[#edf7f2] px-2 py-1 text-[11px] font-black text-[#16845b]">수강 중</span> : selected ? <CheckCircle2 size={18} className="shrink-0 text-[#6f4df6]" /> : null}</div>
|
||||||
|
</button>
|
||||||
|
})}</div>
|
||||||
|
: <div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-center text-[13px] font-bold text-[#64748b]">검색 결과가 없습니다.</div>}
|
||||||
|
{searchError ? <p className="mt-2 text-[12px] font-bold text-[#d74d54]">{searchError}</p> : null}
|
||||||
|
{selectedCatalogCourse ? <div className="mt-3 flex justify-end"><button type="button" onClick={() => void submitEnrollment()} disabled={enrolling} className="inline-flex h-10 items-center gap-2 rounded-md bg-[#6f4df6] px-4 text-[13px] font-black text-white transition hover:bg-[#5f42d7] disabled:cursor-wait disabled:opacity-60">{enrolling ? <LoaderCircle size={16} className="animate-spin" /> : <UserPlus size={16} />} 수강 신청</button></div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t border-[#edf0f5] pt-5"><p className="text-[12px] font-black text-[#64748b]">수강 중인 강좌</p><div className="mt-3 grid gap-3">
|
||||||
{courses.length ? courses.map((course) => {
|
{courses.length ? courses.map((course) => {
|
||||||
const selected = course.id === selectedCourseId
|
const selected = course.id === selectedCourseId
|
||||||
return (
|
return <button key={course.id} type="button" className={['cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35', selected ? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)] hover:border-[#5f42d7] hover:bg-[#f2efff] hover:shadow-[0_12px_28px_rgba(111,77,246,0.18)]' : 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]'].join(' ')} onClick={() => onSelectCourse(course.id)}>
|
||||||
<button
|
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h2 className="flex min-w-0 items-center gap-1.5 text-[16px] font-black text-[#111520]"><span className="min-w-0 truncate">{course.title}</span>{selected ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}</h2><p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{teacherLabel(course)}</p></div><span className="shrink-0 rounded-md bg-[#f0efff] px-2.5 py-1 text-[11px] font-black text-[#6f4df6]">{course.progress ?? 0}%</span></div>
|
||||||
key={course.id}
|
</button>
|
||||||
type="button"
|
}) : <div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-10 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.</div>}
|
||||||
className={[
|
</div></div>
|
||||||
'cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35',
|
{trialCourse ? <div className="mt-6 border-t border-[#edf0f5] pt-5"><p className="text-[12px] font-black text-[#64748b]">둘러보기</p><button type="button" onClick={() => onSelectCourse(trialCourse.id)} className={['mt-3 w-full cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35', selectedCourseId === trialCourse.id ? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)]' : 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]'].join(' ')}><div className="flex items-start justify-between gap-3"><div className="min-w-0"><h2 className="flex min-w-0 items-center gap-1.5 text-[16px] font-black text-[#111520]"><span className="min-w-0 truncate">{trialCourse.title}</span>{selectedCourseId === trialCourse.id ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}</h2><p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{trialCourse.teacher?.primaryOrganizationName ?? trialCourse.organizationName ?? 'Autonomy Studio'}</p></div><span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-[#ded4ff] bg-[#f4f0ff] px-2.5 py-1 text-[11px] font-black text-[#6944d8]"><Sparkles size={12} strokeWidth={2.5} />체험</span></div></button></div> : null}
|
||||||
selected
|
|
||||||
? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)] hover:border-[#5f42d7] hover:bg-[#f2efff] hover:shadow-[0_12px_28px_rgba(111,77,246,0.18)]'
|
|
||||||
: 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]'
|
|
||||||
].join(' ')}
|
|
||||||
onClick={() => onSelectCourse(course.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="flex min-w-0 items-center gap-1.5 text-[16px] font-black text-[#111520]">
|
|
||||||
<span className="min-w-0 truncate">{course.title}</span>
|
|
||||||
{selected ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
|
|
||||||
{course.teacher?.name ?? course.teacher?.email ?? '강사 미지정'} / {course.teacher?.primaryOrganizationName ?? course.organizationName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<span className="rounded-md bg-[#f0efff] px-2.5 py-1 text-[11px] font-black text-[#6f4df6]">{course.progress ?? 0}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}) : (
|
|
||||||
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-12 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ type LearningInfoWeek = {
|
|||||||
title: string
|
title: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
progress: number
|
progress: number
|
||||||
topics: string[]
|
|
||||||
duration: string
|
duration: string
|
||||||
detail: {
|
detail: {
|
||||||
requiresSourceSubmission?: boolean
|
requiresSourceSubmission?: boolean
|
||||||
@@ -60,21 +59,7 @@ export default function LearningInfoCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff]">
|
<div className="mb-[17px] mt-4 overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff]">
|
||||||
<div className="border-b border-[#edf0f5] px-3.5 py-3">
|
|
||||||
<span className="flex items-center gap-2 text-[12px] font-bold text-[#151827]">
|
|
||||||
<BookOpen size={14} className="text-[#6d7788]" />
|
|
||||||
학습 주제
|
|
||||||
</span>
|
|
||||||
<div className="mt-2 grid gap-1.5">
|
|
||||||
{week.topics.map((topic, index) => (
|
|
||||||
<div key={topic} className="flex items-center gap-2 rounded-md px-1 py-0.5 text-[12px] font-medium text-[#4f596b]">
|
|
||||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-[#ede9ff] text-[10px] font-bold text-[#714ef6]">{index + 1}</span>
|
|
||||||
<span className="min-w-0 truncate">{topic}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{[
|
{[
|
||||||
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 },
|
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 },
|
||||||
...(week.detail.requiresSourceSubmission
|
...(week.detail.requiresSourceSubmission
|
||||||
|
|||||||
@@ -3,10 +3,56 @@ import type { CurriculumResponse, DownloadResource, StudentCourseOption } from '
|
|||||||
|
|
||||||
type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise<Response>
|
type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise<Response>
|
||||||
|
|
||||||
export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise<StudentCourseOption[]> {
|
export type CourseCatalogOption = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string | null
|
||||||
|
organizationName?: string | null
|
||||||
|
enrolled: boolean
|
||||||
|
teacher?: {
|
||||||
|
email?: string | null
|
||||||
|
name?: string | null
|
||||||
|
primaryOrganizationName?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StudentCourseList = {
|
||||||
|
courses: StudentCourseOption[]
|
||||||
|
guideCourse: StudentCourseOption | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise<StudentCourseList> {
|
||||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`)
|
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`)
|
||||||
const payload = response.ok ? await response.json() as { courses?: StudentCourseOption[] } : null
|
const payload = response.ok ? await response.json() as Partial<StudentCourseList> : null
|
||||||
return payload?.courses ?? []
|
return {
|
||||||
|
courses: payload?.courses ?? [],
|
||||||
|
guideCourse: payload?.guideCourse ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseMessage(response: Response, fallback: string): Promise<string> {
|
||||||
|
const payload = await response.json().catch(() => null) as { message?: string | string[] } | null
|
||||||
|
if (Array.isArray(payload?.message)) return payload.message.join(' ')
|
||||||
|
return payload?.message || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCourseCatalog(
|
||||||
|
fetchWithFreshAuth: AuthFetch,
|
||||||
|
query: string,
|
||||||
|
): Promise<CourseCatalogOption[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (query.trim()) params.set('query', query.trim())
|
||||||
|
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/course-catalog?${params.toString()}`)
|
||||||
|
if (!response.ok) throw new Error(await responseMessage(response, '강좌 검색에 실패했습니다.'))
|
||||||
|
const payload = await response.json() as { courses?: CourseCatalogOption[] }
|
||||||
|
return payload.courses ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrollInCourse(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<void> {
|
||||||
|
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/course-catalog/${encodeURIComponent(courseId)}/enroll`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(await responseMessage(response, '수강 신청에 실패했습니다.'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCurriculum(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<CurriculumResponse | null> {
|
export async function fetchCurriculum(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<CurriculumResponse | null> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AlertCircle, CheckCircle2, Link2, Lock, Power, Settings } from 'lucide-react'
|
import { AlertCircle, CheckCircle2, Link2, LoaderCircle, Lock, Power, Settings } from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import type { PlatformOption } from './types'
|
import type { PlatformOption } from './types'
|
||||||
@@ -60,6 +60,7 @@ type PlatformOptionCardProps = {
|
|||||||
Icon: LucideIcon
|
Icon: LucideIcon
|
||||||
dotClassName: string
|
dotClassName: string
|
||||||
unavailable: boolean
|
unavailable: boolean
|
||||||
|
statusPending: boolean
|
||||||
displayStatus: string
|
displayStatus: string
|
||||||
primaryAction: string
|
primaryAction: string
|
||||||
actionDisabled: boolean
|
actionDisabled: boolean
|
||||||
@@ -73,6 +74,7 @@ export function PlatformOptionCard({
|
|||||||
Icon,
|
Icon,
|
||||||
dotClassName,
|
dotClassName,
|
||||||
unavailable,
|
unavailable,
|
||||||
|
statusPending,
|
||||||
displayStatus,
|
displayStatus,
|
||||||
primaryAction,
|
primaryAction,
|
||||||
actionDisabled,
|
actionDisabled,
|
||||||
@@ -106,7 +108,9 @@ export function PlatformOptionCard({
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">{platform.description}</p>
|
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">{platform.description}</p>
|
||||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-[#5f6878]">
|
<div className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-[#5f6878]">
|
||||||
{unavailable
|
{statusPending
|
||||||
|
? <LoaderCircle size={13} className="animate-spin text-[#7a879a]" />
|
||||||
|
: unavailable
|
||||||
? <AlertCircle size={13} className="text-[#dc2626]" />
|
? <AlertCircle size={13} className="text-[#dc2626]" />
|
||||||
: ready
|
: ready
|
||||||
? <CheckCircle2 size={13} className="text-[#2fbda0]" />
|
? <CheckCircle2 size={13} className="text-[#2fbda0]" />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type PlatformSelectionPanelProps = {
|
|||||||
counts: Record<PlatformFilter, number>
|
counts: Record<PlatformFilter, number>
|
||||||
workspaceWeekTitle: string
|
workspaceWeekTitle: string
|
||||||
platformLoading: boolean
|
platformLoading: boolean
|
||||||
|
checkedRemotePlatformStatusIds: ReadonlySet<string>
|
||||||
isPlatformUnavailable: (platform: PlatformOption) => boolean
|
isPlatformUnavailable: (platform: PlatformOption) => boolean
|
||||||
getPlatformDotClass: (platform: PlatformOption) => string
|
getPlatformDotClass: (platform: PlatformOption) => string
|
||||||
onFilterChange: (filter: PlatformFilter) => void
|
onFilterChange: (filter: PlatformFilter) => void
|
||||||
@@ -36,6 +37,7 @@ export default function PlatformSelectionPanel({
|
|||||||
counts,
|
counts,
|
||||||
workspaceWeekTitle,
|
workspaceWeekTitle,
|
||||||
platformLoading,
|
platformLoading,
|
||||||
|
checkedRemotePlatformStatusIds,
|
||||||
isPlatformUnavailable,
|
isPlatformUnavailable,
|
||||||
getPlatformDotClass,
|
getPlatformDotClass,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
@@ -81,14 +83,17 @@ export default function PlatformSelectionPanel({
|
|||||||
const { id, status, action, state, iconKey } = platform
|
const { id, status, action, state, iconKey } = platform
|
||||||
const Icon = PLATFORM_ICONS[iconKey] ?? Monitor
|
const Icon = PLATFORM_ICONS[iconKey] ?? Monitor
|
||||||
const locked = state === 'locked'
|
const locked = state === 'locked'
|
||||||
const unavailable = isPlatformUnavailable(platform)
|
const statusPending = platform.platformKey !== 'futuredrive-local-pc' && !checkedRemotePlatformStatusIds.has(id)
|
||||||
const actionDisabled = locked || unavailable
|
const unavailable = !statusPending && isPlatformUnavailable(platform)
|
||||||
|
const actionDisabled = statusPending || locked || unavailable
|
||||||
const runningInSelectionList = runningPlatforms.some((runningPlatform) => runningPlatform.id === platform.id)
|
const runningInSelectionList = runningPlatforms.some((runningPlatform) => runningPlatform.id === platform.id)
|
||||||
const displayStatus = unavailable && !status.includes('연결 끊김') && !status.includes('연결 불가')
|
const displayStatus = statusPending
|
||||||
|
? '확인 중'
|
||||||
|
: unavailable && !status.includes('연결 끊김') && !status.includes('연결 불가')
|
||||||
? '연결 끊김'
|
? '연결 끊김'
|
||||||
: status
|
: status
|
||||||
const hasControl = platform.control?.status === 'ACTIVE'
|
const hasControl = platform.control?.status === 'ACTIVE'
|
||||||
const primaryAction = hasControl ? '연결' : action
|
const primaryAction = statusPending ? '확인 중' : hasControl ? '연결' : action
|
||||||
const platformActionDisabled = runningInSelectionList || actionDisabled
|
const platformActionDisabled = runningInSelectionList || actionDisabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,8 +101,9 @@ export default function PlatformSelectionPanel({
|
|||||||
key={id}
|
key={id}
|
||||||
platform={platform}
|
platform={platform}
|
||||||
Icon={Icon}
|
Icon={Icon}
|
||||||
dotClassName={getPlatformDotClass(platform)}
|
dotClassName={statusPending ? 'bg-[#94a3b8] shadow-[0_0_6px_rgba(148,163,184,0.45)]' : getPlatformDotClass(platform)}
|
||||||
unavailable={unavailable}
|
unavailable={unavailable}
|
||||||
|
statusPending={statusPending}
|
||||||
displayStatus={displayStatus}
|
displayStatus={displayStatus}
|
||||||
primaryAction={primaryAction}
|
primaryAction={primaryAction}
|
||||||
actionDisabled={platformActionDisabled}
|
actionDisabled={platformActionDisabled}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type WorkspaceQaPanelProps = {
|
|||||||
iconButtonClass: string
|
iconButtonClass: string
|
||||||
accessToken: string
|
accessToken: string
|
||||||
qaMode: QaMode
|
qaMode: QaMode
|
||||||
|
teacherQaDisabled?: boolean
|
||||||
teacherChatFlash: boolean
|
teacherChatFlash: boolean
|
||||||
qaMessagesLoading: boolean
|
qaMessagesLoading: boolean
|
||||||
visibleAiMessages: WorkspaceQaMessage[]
|
visibleAiMessages: WorkspaceQaMessage[]
|
||||||
@@ -53,6 +54,7 @@ export default function WorkspaceQaPanel({
|
|||||||
iconButtonClass,
|
iconButtonClass,
|
||||||
accessToken,
|
accessToken,
|
||||||
qaMode,
|
qaMode,
|
||||||
|
teacherQaDisabled = false,
|
||||||
teacherChatFlash,
|
teacherChatFlash,
|
||||||
qaMessagesLoading,
|
qaMessagesLoading,
|
||||||
visibleAiMessages,
|
visibleAiMessages,
|
||||||
@@ -73,6 +75,10 @@ export default function WorkspaceQaPanel({
|
|||||||
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages
|
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages
|
||||||
const lastMessage = activeMessages[activeMessages.length - 1]
|
const lastMessage = activeMessages[activeMessages.length - 1]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (teacherQaDisabled && qaMode === 'teacher') onModeChange('ai')
|
||||||
|
}, [onModeChange, qaMode, teacherQaDisabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contextMenu) return
|
if (!contextMenu) return
|
||||||
const closeContextMenu = (): void => setContextMenu(null)
|
const closeContextMenu = (): void => setContextMenu(null)
|
||||||
@@ -161,16 +167,24 @@ export default function WorkspaceQaPanel({
|
|||||||
{ id: 'ai' as const, label: 'AI', Icon: Bot },
|
{ id: 'ai' as const, label: 'AI', Icon: Bot },
|
||||||
{ id: 'teacher' as const, label: '강사', Icon: User }
|
{ id: 'teacher' as const, label: '강사', Icon: User }
|
||||||
].map(({ id, label, Icon }) => {
|
].map(({ id, label, Icon }) => {
|
||||||
|
const disabled = id === 'teacher' && teacherQaDisabled
|
||||||
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
|
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onModeChange(id)}
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) onModeChange(id)
|
||||||
|
}}
|
||||||
className={[
|
className={[
|
||||||
'relative flex h-8 cursor-pointer items-center justify-center gap-1.5 text-[12px] font-bold transition',
|
'relative flex h-8 items-center justify-center gap-1.5 text-[12px] font-bold transition',
|
||||||
teacherTabFlash
|
disabled
|
||||||
|
? dark
|
||||||
|
? 'cursor-not-allowed bg-[#202020] text-white/25'
|
||||||
|
: 'cursor-not-allowed bg-[#f3f5f8] text-[#aab3c1]'
|
||||||
|
: teacherTabFlash
|
||||||
? dark
|
? dark
|
||||||
? 'animate-pulse bg-[#2563eb]/28 text-[#9ec2ff] shadow-[inset_0_0_0_1px_rgba(96,165,250,0.28)]'
|
? 'animate-pulse bg-[#2563eb]/28 text-[#9ec2ff] shadow-[inset_0_0_0_1px_rgba(96,165,250,0.28)]'
|
||||||
: 'animate-pulse bg-[#dbeafe] text-[#1d4ed8] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.18)]'
|
: 'animate-pulse bg-[#dbeafe] text-[#1d4ed8] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.18)]'
|
||||||
@@ -234,7 +248,7 @@ export default function WorkspaceQaPanel({
|
|||||||
<QaInputBox
|
<QaInputBox
|
||||||
dark={dark}
|
dark={dark}
|
||||||
busy={inputBusy}
|
busy={inputBusy}
|
||||||
disabled={inputDisabled}
|
disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export default function WorkspaceVisualizationPanel({
|
|||||||
{!running && (
|
{!running && (
|
||||||
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-black/34">
|
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-black/34">
|
||||||
<div className="rounded-lg border border-white/10 bg-[#101624]/88 px-5 py-4 text-center shadow-[0_18px_48px_rgba(0,0,0,0.35)]">
|
<div className="rounded-lg border border-white/10 bg-[#101624]/88 px-5 py-4 text-center shadow-[0_18px_48px_rgba(0,0,0,0.35)]">
|
||||||
<p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '해당 주차는 시각화를 지원하지 않습니다'}</p>
|
<p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '시각화 준비 중입니다.'}</p>
|
||||||
<p className="mt-2 text-[11px] font-semibold text-white/50">{stacks.length ? '지도와 차량 상태가 표시됩니다.' : '강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.'}</p>
|
<p className="mt-2 text-[11px] font-semibold text-white/50">{stacks.length ? '지도와 차량 상태가 표시됩니다.' : '강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function useWorkspaceModuleControl({
|
|||||||
for (const module of targets) {
|
for (const module of targets) {
|
||||||
const result = remoteWorkspace
|
const result = remoteWorkspace
|
||||||
? await remoteModuleStart(module)
|
? await remoteModuleStart(module)
|
||||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage)
|
||||||
if (!result.ok) throw new Error(result.message)
|
if (!result.ok) throw new Error(result.message)
|
||||||
}
|
}
|
||||||
await loadAvailableModules({ silent: true })
|
await loadAvailableModules({ silent: true })
|
||||||
@@ -256,14 +256,14 @@ export function useWorkspaceModuleControl({
|
|||||||
: action === 'build'
|
: action === 'build'
|
||||||
? remoteWorkspace
|
? remoteWorkspace
|
||||||
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||||
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage, getModuleRosParams(module))
|
||||||
: action === 'restart'
|
: action === 'restart'
|
||||||
? remoteWorkspace
|
? remoteWorkspace
|
||||||
? await remoteModuleRestart(module)
|
? await remoteModuleRestart(module)
|
||||||
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage)
|
||||||
: remoteWorkspace
|
: remoteWorkspace
|
||||||
? await remoteModuleStart(module)
|
? await remoteModuleStart(module)
|
||||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage)
|
||||||
if (!result.ok) throw new Error(result.message)
|
if (!result.ok) throw new Error(result.message)
|
||||||
await loadAvailableModules({ silent: true })
|
await loadAvailableModules({ silent: true })
|
||||||
const actionLabel = action === 'stop'
|
const actionLabel = action === 'stop'
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ export function useWorkspaceModuleList({
|
|||||||
const [modulesLoading, setModulesLoading] = useState(false)
|
const [modulesLoading, setModulesLoading] = useState(false)
|
||||||
const [moduleListError, setModuleListError] = useState<string | null>(null)
|
const [moduleListError, setModuleListError] = useState<string | null>(null)
|
||||||
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
|
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
|
||||||
|
const moduleInputsRef = useRef({ curriculumModules, applyModuleParameterOverrides })
|
||||||
const loadSequenceRef = useRef(0)
|
const loadSequenceRef = useRef(0)
|
||||||
|
const moduleLoadInFlightRef = useRef(false)
|
||||||
const pendingModuleStatesRef = useRef(new Map<string, { state: ModuleRow['state']; expiresAt: number }>())
|
const pendingModuleStatesRef = useRef(new Map<string, { state: ModuleRow['state']; expiresAt: number }>())
|
||||||
|
|
||||||
const moduleStateKey = (module: ModuleRow): string => module.folderName ?? module.name
|
const moduleStateKey = (module: ModuleRow): string => module.folderName ?? module.name
|
||||||
@@ -89,9 +91,13 @@ export function useWorkspaceModuleList({
|
|||||||
}))
|
}))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
|
||||||
|
}, [applyModuleParameterOverrides, curriculumModuleKey, curriculumModules])
|
||||||
|
|
||||||
const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => {
|
const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => {
|
||||||
if (!remoteTarget) return { modules: [] }
|
if (!remoteTarget) throw new Error('원격 플랫폼 SSH 주소를 확인하지 못했습니다.')
|
||||||
const result = await window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? [])
|
const result = await window.remoteWorkspace.modules.list(remoteTarget, moduleInputsRef.current.curriculumModules ?? [])
|
||||||
return {
|
return {
|
||||||
modules: result.modules.map((module) => {
|
modules: result.modules.map((module) => {
|
||||||
const rawModule = module as ModuleRow & { defaultParameters?: unknown }
|
const rawModule = module as ModuleRow & { defaultParameters?: unknown }
|
||||||
@@ -106,38 +112,42 @@ export function useWorkspaceModuleList({
|
|||||||
}, [curriculumModuleKey, remoteTarget])
|
}, [curriculumModuleKey, remoteTarget])
|
||||||
|
|
||||||
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
|
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
|
||||||
|
// Remote inventory probes open SSH sessions. Keep one request in flight so
|
||||||
|
// polling cannot continuously replace a slow response with a newer one.
|
||||||
|
if (moduleLoadInFlightRef.current) return
|
||||||
|
moduleLoadInFlightRef.current = true
|
||||||
const requestSequence = ++loadSequenceRef.current
|
const requestSequence = ++loadSequenceRef.current
|
||||||
if (!silent) setModulesLoading(true)
|
if (!silent) setModulesLoading(true)
|
||||||
try {
|
try {
|
||||||
|
const { curriculumModules: currentCurriculumModules, applyModuleParameterOverrides: applyOverrides } = moduleInputsRef.current
|
||||||
if (remoteWorkspace) {
|
if (remoteWorkspace) {
|
||||||
const result = await loadRemoteModules()
|
const result = await loadRemoteModules()
|
||||||
if (requestSequence !== loadSequenceRef.current) return
|
if (requestSequence !== loadSequenceRef.current) return
|
||||||
setModuleListError(null)
|
setModuleListError(null)
|
||||||
setAvailableModules(applyPendingModuleStates(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)))
|
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? [])
|
const result = await window.localWorkspace.modules.list(clonePathPreview, currentCurriculumModules ?? [])
|
||||||
if (requestSequence !== loadSequenceRef.current) return
|
if (requestSequence !== loadSequenceRef.current) return
|
||||||
setModuleListError(null)
|
setModuleListError(null)
|
||||||
setAvailableModules(applyPendingModuleStates(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)))
|
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestSequence !== loadSequenceRef.current) return
|
if (requestSequence !== loadSequenceRef.current) return
|
||||||
console.error('[modules] list failed', error)
|
console.error('[modules] list failed', error)
|
||||||
const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.'
|
const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.'
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setModuleListError(message)
|
setModuleListError(message)
|
||||||
setAvailableModules(makeUninstalledModuleRows(curriculumModules))
|
setAvailableModules(makeUninstalledModuleRows(moduleInputsRef.current.curriculumModules))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
moduleLoadInFlightRef.current = false
|
||||||
if (!silent && requestSequence === loadSequenceRef.current) setModulesLoading(false)
|
if (!silent && requestSequence === loadSequenceRef.current) setModulesLoading(false)
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
applyModuleParameterOverrides,
|
|
||||||
applyPendingModuleStates,
|
applyPendingModuleStates,
|
||||||
clonePathPreview,
|
clonePathPreview,
|
||||||
curriculumModuleKey,
|
curriculumModuleKey,
|
||||||
curriculumModules,
|
|
||||||
loadRemoteModules,
|
loadRemoteModules,
|
||||||
remoteWorkspace
|
remoteWorkspace
|
||||||
])
|
])
|
||||||
@@ -151,10 +161,10 @@ export function useWorkspaceModuleList({
|
|||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
void loadAvailableModules({ silent: true })
|
void loadAvailableModules({ silent: true })
|
||||||
}, 5000)
|
}, remoteWorkspace ? 10_000 : 5_000)
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId)
|
return () => window.clearInterval(intervalId)
|
||||||
}, [loadAvailableModules, pollingActive])
|
}, [loadAvailableModules, pollingActive, remoteWorkspace])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
availableModules,
|
availableModules,
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ import type { VisualizationStack } from './WorkspaceVisualizationPanel'
|
|||||||
|
|
||||||
export type Nav2VisualizationMode = 'slam' | 'navigation'
|
export type Nav2VisualizationMode = 'slam' | 'navigation'
|
||||||
|
|
||||||
|
const DEFAULT_NAV2_VISUALIZATION_PROFILE: EnvironmentProfile = {
|
||||||
|
id: 'default-nav2-visualization',
|
||||||
|
week: 0,
|
||||||
|
platformKey: 'futuredrive-local-pc',
|
||||||
|
profileType: 'NAV2',
|
||||||
|
name: 'NAV2',
|
||||||
|
config: {
|
||||||
|
type: 'NAV2',
|
||||||
|
target: 'local',
|
||||||
|
viewer: { stack: 'nav2' },
|
||||||
|
bridge: {
|
||||||
|
name: 'nav2-ros-bridge',
|
||||||
|
port: 8765,
|
||||||
|
image: 'slam-ros-bridge:latest',
|
||||||
|
buildContext: 'resources/slam-ros-bridge',
|
||||||
|
env: { ROBOT_STACK: 'nav2' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
|
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
|
||||||
const type = profileType.toLowerCase()
|
const type = profileType.toLowerCase()
|
||||||
if (type === 'real_slam') return 'slam'
|
if (type === 'real_slam') return 'slam'
|
||||||
@@ -16,14 +36,14 @@ function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationM
|
|||||||
return profile?.profileType.toLowerCase() === 'real_navigation' ? 'navigation' : 'slam'
|
return profile?.profileType.toLowerCase() === 'real_navigation' ? 'navigation' : 'slam'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
|
function isRemoteEnvironmentProfile(profile: EnvironmentProfile | null): boolean {
|
||||||
const type = profile?.profileType.toLowerCase()
|
const target = profile?.config?.target
|
||||||
return type === 'real_slam' || type === 'real_navigation'
|
return target === 'remote-jetson' || target === 'platform'
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
|
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
|
||||||
const matchingProfiles = profiles.filter((profile) => (
|
const matchingProfiles = profiles.filter((profile) => (
|
||||||
remoteWorkspace ? isRealPlatformProfile(profile) : !isRealPlatformProfile(profile)
|
remoteWorkspace ? isRemoteEnvironmentProfile(profile) : !isRemoteEnvironmentProfile(profile)
|
||||||
))
|
))
|
||||||
return matchingProfiles.length ? matchingProfiles : profiles
|
return matchingProfiles.length ? matchingProfiles : profiles
|
||||||
}
|
}
|
||||||
@@ -77,7 +97,11 @@ export function useWorkspaceVisualization({
|
|||||||
const [, setVisualizationStatusMessage] = useState('대기')
|
const [, setVisualizationStatusMessage] = useState('대기')
|
||||||
const [visualizationBusy, setVisualizationBusy] = useState(false)
|
const [visualizationBusy, setVisualizationBusy] = useState(false)
|
||||||
|
|
||||||
const runtimeProfiles = useMemo(() => selectRuntimeProfiles(profiles, remoteWorkspace), [profiles, remoteWorkspace])
|
const runtimeProfiles = useMemo(() => {
|
||||||
|
const candidates = selectRuntimeProfiles(profiles, remoteWorkspace)
|
||||||
|
const nav2Profiles = candidates.filter((profile) => resolveVisualizationStack(profile.profileType) !== 'autoware')
|
||||||
|
return nav2Profiles.length ? nav2Profiles : [DEFAULT_NAV2_VISUALIZATION_PROFILE]
|
||||||
|
}, [profiles, remoteWorkspace])
|
||||||
const visualizationStacks = useMemo(() => (
|
const visualizationStacks = useMemo(() => (
|
||||||
runtimeProfiles
|
runtimeProfiles
|
||||||
.map((profile) => resolveVisualizationStack(profile.profileType))
|
.map((profile) => resolveVisualizationStack(profile.profileType))
|
||||||
@@ -97,7 +121,7 @@ export function useWorkspaceVisualization({
|
|||||||
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string'
|
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string'
|
||||||
? activeEnvironmentAgent.url.replace(/\/+$/, '')
|
? activeEnvironmentAgent.url.replace(/\/+$/, '')
|
||||||
: ''
|
: ''
|
||||||
const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && activeEnvironmentAgentUrl
|
const visualizationRemote = isRemoteEnvironmentProfile(activeEnvironmentProfile) && activeEnvironmentAgentUrl
|
||||||
const visualizationRemoteBridgeHttpUrl = (() => {
|
const visualizationRemoteBridgeHttpUrl = (() => {
|
||||||
if (!visualizationRemote) return ''
|
if (!visualizationRemote) return ''
|
||||||
try {
|
try {
|
||||||
@@ -114,7 +138,7 @@ export function useWorkspaceVisualization({
|
|||||||
: 'ws://localhost:8765'
|
: 'ws://localhost:8765'
|
||||||
const visualizationBackendUrl = visualizationBridgeHttpUrl
|
const visualizationBackendUrl = visualizationBridgeHttpUrl
|
||||||
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'nav2'
|
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'nav2'
|
||||||
? `/slam-viewer/index.html?directBridge=1&mode=${nav2VisualizationMode}&runtime=${isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation'}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}`
|
? `/slam-viewer/index.html?directBridge=1&mode=${nav2VisualizationMode}&runtime=${isRemoteEnvironmentProfile(activeEnvironmentProfile) ? 'real' : 'simulation'}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}`
|
||||||
: `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
|
: `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,11 +165,6 @@ export function useWorkspaceVisualization({
|
|||||||
|
|
||||||
const startVisualizationBridge = async (): Promise<void> => {
|
const startVisualizationBridge = async (): Promise<void> => {
|
||||||
if (visualizationBusy) return
|
if (visualizationBusy) return
|
||||||
if (!activeEnvironmentProfile) {
|
|
||||||
setVisualizationStatusMessage('지원 안함')
|
|
||||||
onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setVisualizationBusy(true)
|
setVisualizationBusy(true)
|
||||||
setVisualizationStatusMessage('시작 중')
|
setVisualizationStatusMessage('시작 중')
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['mod
|
|||||||
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
||||||
sourceType: module.sourceType ?? null,
|
sourceType: module.sourceType ?? null,
|
||||||
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
|
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
|
||||||
parameters: [],
|
parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [],
|
||||||
cpu: null,
|
cpu: null,
|
||||||
memory: null,
|
memory: null,
|
||||||
runtime: '-'
|
runtime: '-'
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ const localModules = {
|
|||||||
sourcePath: module.sourcePath ?? module.workspacePath ?? '',
|
sourcePath: module.sourcePath ?? module.workspacePath ?? '',
|
||||||
workspacePath: module.workspacePath ?? null,
|
workspacePath: module.workspacePath ?? null,
|
||||||
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
||||||
|
dockerImage: module.dockerImage ?? null,
|
||||||
parameters: Array.isArray(module.parameters) ? module.parameters : [],
|
parameters: Array.isArray(module.parameters) ? module.parameters : [],
|
||||||
cpu: running ? cpu : null,
|
cpu: running ? cpu : null,
|
||||||
memory: running ? memory : null,
|
memory: running ? memory : null,
|
||||||
@@ -173,10 +174,12 @@ const localModules = {
|
|||||||
folderName?: string,
|
folderName?: string,
|
||||||
templateRepositoryUrl?: string | null,
|
templateRepositoryUrl?: string | null,
|
||||||
studentRepositoryUrl?: string | null,
|
studentRepositoryUrl?: string | null,
|
||||||
|
options?: { runtimeImages?: unknown },
|
||||||
) {
|
) {
|
||||||
const result = await bridgeRequest<{ ok: boolean; workspacePath?: string }>('/api/modules/install', {
|
const result = await bridgeRequest<{ ok: boolean; workspacePath?: string }>('/api/modules/install', {
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
module: moduleInput(name, folderName, { templateRepositoryUrl, studentRepositoryUrl }),
|
module: moduleInput(name, folderName, { templateRepositoryUrl, studentRepositoryUrl }),
|
||||||
|
runtimeImages: options?.runtimeImages,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
ok: result.ok,
|
ok: result.ok,
|
||||||
@@ -195,21 +198,24 @@ const localModules = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async start(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) {
|
async start(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) {
|
||||||
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/start', {
|
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/start', {
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
module: moduleInput(name, folderName),
|
module: moduleInput(name, folderName, { dockerImage }),
|
||||||
rosParams,
|
rosParams,
|
||||||
gpuEnabled,
|
gpuEnabled,
|
||||||
rosDomainId,
|
rosDomainId,
|
||||||
|
runtimeImages,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async build(name: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string) {
|
async build(name: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null, rosParams?: string) {
|
||||||
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/build', {
|
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/build', {
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
module: moduleInput(name, folderName),
|
module: moduleInput(name, folderName, { dockerImage }),
|
||||||
gpuEnabled,
|
gpuEnabled,
|
||||||
rosDomainId,
|
rosDomainId,
|
||||||
|
runtimeImages,
|
||||||
|
rosParams,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async stop(name: string, workspaceRoot?: string, folderName?: string) {
|
async stop(name: string, workspaceRoot?: string, folderName?: string) {
|
||||||
@@ -225,9 +231,9 @@ const localModules = {
|
|||||||
async stopRunning() {
|
async stopRunning() {
|
||||||
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/stop-running', {})
|
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/stop-running', {})
|
||||||
},
|
},
|
||||||
async restart(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) {
|
async restart(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) {
|
||||||
await localModules.stop(name, workspaceRoot, folderName)
|
await localModules.stop(name, workspaceRoot, folderName)
|
||||||
return localModules.start(name, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId)
|
return localModules.start(name, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, dockerImage)
|
||||||
},
|
},
|
||||||
async logs(name: string, workspaceRoot?: string, folderName?: string) {
|
async logs(name: string, workspaceRoot?: string, folderName?: string) {
|
||||||
return bridgeRequest<{ ok: boolean; logs: string; message?: string }>('/api/modules/logs', {
|
return bridgeRequest<{ ok: boolean; logs: string; message?: string }>('/api/modules/logs', {
|
||||||
|
|||||||
Reference in New Issue
Block a user