diff --git a/src/App.tsx b/src/App.tsx index 0ed1bb1..7ba3a1f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,10 +22,12 @@ import { BuildManagementPage } from './pages/applications/BuildManagementPage'; import { DeploymentManagementPage } from './pages/applications/DeploymentManagementPage'; import { MigrationManagementPage } from './pages/applications/MigrationManagementPage'; import { ReleaseManagementPage } from './pages/applications/ReleaseManagementPage'; +import { RepositoryManagementPage } from './pages/applications/RepositoryManagementPage'; import { SourceManagementPage as AppSourcesApiPage } from './pages/applications/SourceManagementPage'; import { AdminManagementPage as AdminManagementApiPage } from './pages/operations/AdminManagementPage'; import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage'; import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage'; +import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage'; import { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage'; import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage'; import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage'; @@ -61,6 +63,7 @@ type ViewKey = | 'launcherCommonSettings' | 'notices' | 'appManagement' + | 'appRepositories' | 'appSources' | 'appBuilds' | 'appReleases' @@ -82,6 +85,7 @@ type ViewKey = | 'runtimeLogs' | 'runtimeDeviceControl' | 'adminManagement' + | 'organizations' | 'userSessions' | 'licenses'; @@ -93,6 +97,7 @@ const viewByLabel: Record = { '공통 설정': 'launcherCommonSettings', 공지사항: 'notices', '앱 관리': 'appManagement', + '저장소 관리': 'appRepositories', '소스 관리': 'appSources', '빌드 관리': 'appBuilds', '릴리즈 관리': 'appReleases', @@ -114,6 +119,7 @@ const viewByLabel: Record = { '로그/이벤트': 'runtimeLogs', '장비 제어': 'runtimeDeviceControl', '관리자 관리': 'adminManagement', + '조직 관리': 'organizations', '사용자/세션': 'userSessions', 라이센스: 'licenses' }; @@ -128,6 +134,7 @@ const pathByView: Record = { launcherCommonSettings: '/launcher/common-settings', notices: '/operations/notices', appManagement: '/applications/apps', + appRepositories: '/applications/repositories', appSources: '/applications/sources', appBuilds: '/applications/builds', appReleases: '/applications/releases', @@ -149,6 +156,7 @@ const pathByView: Record = { runtimeLogs: '/runtime/logs', runtimeDeviceControl: '/runtime/devices', adminManagement: '/operations/admins', + organizations: '/operations/organizations', userSessions: '/operations/users', licenses: '/operations/licenses' }; @@ -332,7 +340,7 @@ function loadAppReleases() { function loadUserSessions() { return apiRequest<{ sessions?: UserSessionRow[] }>('/users/sessions') - .then((response) => response.sessions ?? []); + .then((response) => (response.sessions ?? []).filter((session) => session.sessionId)); } function loadNotices() { @@ -1490,12 +1498,16 @@ function App() { case 'adminManagement': if (!isOwner) return null; return ; + case 'organizations': + return ; case 'userSessions': return ; case 'licenses': return ; case 'appManagement': return ; + case 'appRepositories': + return ; case 'appSources': return ; case 'appBuilds': diff --git a/src/data/mock.ts b/src/data/mock.ts index ec8c5f3..8fa38d8 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -35,6 +35,7 @@ export const navGroups = [ icon: Boxes, children: [ { label: '앱 관리' }, + { label: '저장소 관리' }, { label: '소스 관리' }, { label: '빌드 관리' }, { label: '릴리즈 관리' }, @@ -81,6 +82,7 @@ export const navGroups = [ label: '운영', items: [ { label: '관리자 관리', icon: ShieldCheck }, + { label: '조직 관리', icon: Boxes }, { label: '사용자/세션', icon: Users }, { label: '라이센스', icon: FileKey }, { label: '공지사항', icon: Megaphone } diff --git a/src/layout/navigation.ts b/src/layout/navigation.ts index afd591f..a17a0ef 100644 --- a/src/layout/navigation.ts +++ b/src/layout/navigation.ts @@ -51,6 +51,7 @@ export const navGroups: NavGroup[] = [ icon: Boxes, children: [ { label: '앱 관리', path: '/applications/apps' }, + { label: '저장소 관리', path: '/applications/repositories' }, { label: '소스 관리', path: '/applications/sources' }, { label: '빌드 관리', path: '/applications/builds' }, { label: '릴리즈 관리', path: '/applications/releases' }, @@ -97,6 +98,7 @@ export const navGroups: NavGroup[] = [ label: '운영', items: [ { label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck }, + { label: '조직 관리', path: '/operations/organizations', icon: Boxes }, { label: '사용자/세션', path: '/operations/users', icon: Users }, { label: '라이센스', path: '/operations/licenses', icon: FileKey }, { label: '공지사항', path: '/operations/notices', icon: Megaphone } diff --git a/src/pages/applications/RepositoryManagementPage.tsx b/src/pages/applications/RepositoryManagementPage.tsx new file mode 100644 index 0000000..4f658b8 --- /dev/null +++ b/src/pages/applications/RepositoryManagementPage.tsx @@ -0,0 +1,490 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link2, Plus } from 'lucide-react'; +import { Badge, Button, Card, CardHeader } from '../../components/ui'; +import { useToast } from '../../components/toast'; +import { PageTitle } from '../../layout/AppLayout'; +import type { GiteaRepo } from '../../types/application'; +import type { ApiApp, ApiAppComponent } from '../../types/runtime'; +import { LoadingModal } from '../operations/operation-utils'; +import { createGiteaOrgRepo, linkComponentSource, listGiteaRepos, listSources } from './application-api'; +import { AppSelector, useApps } from './ApplicationControls'; + +const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000'; +const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@localhost:2222'; +const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? import.meta.env.GITEA_APP_ORG ?? 'platform'; + +type PlatformRepo = { + id: number; + owner: string; + name: string; + fullName: string; + description?: string; + private: boolean; + cloneUrl: string; + sshUrl: string; + defaultBranch: string; + language?: string; +}; + +type SourceComponent = ApiAppComponent & { + app?: ApiApp; +}; + +function useMinimumLoading(loading: boolean, minimumMs = 500) { + const [visible, setVisible] = useState(loading); + + useEffect(() => { + if (loading) { + setVisible(true); + return; + } + + const timer = window.setTimeout(() => setVisible(false), minimumMs); + return () => window.clearTimeout(timer); + }, [loading, minimumMs]); + + return visible; +} + +function toPlatformRepo(repo: GiteaRepo): PlatformRepo { + const fullName = (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? repo.name; + const owner = + typeof repo.owner === 'string' + ? repo.owner + : repo.owner?.login ?? fullName.split('/')[0] ?? 'platform'; + + return { + id: repo.id ?? Number(`${owner.length}${repo.name.length}`), + owner, + name: repo.name, + fullName, + description: (repo as GiteaRepo & { description?: string }).description, + private: Boolean(repo.private), + cloneUrl: (repo as GiteaRepo & { cloneUrl?: string }).cloneUrl ?? repo.clone_url ?? `${giteaBaseUrl}/${fullName}.git`, + sshUrl: (repo as GiteaRepo & { sshUrl?: string }).sshUrl ?? repo.ssh_url ?? `${giteaSshBaseUrl.replace(/\/$/, '')}/${fullName}.git`, + defaultBranch: (repo as GiteaRepo & { defaultBranch?: string }).defaultBranch ?? 'main', + language: (repo as GiteaRepo & { language?: string }).language + }; +} + +function normalizeRepositoryName(repository: string) { + const trimmed = repository.trim().replace(/^\/+/, '').replace(/\.git$/, ''); + return trimmed.includes('/') ? trimmed : `${giteaAppOrg}/${trimmed}`; +} + +function repositoryKey(repository: string) { + return normalizeRepositoryName(repository).toLowerCase(); +} + +function isSourceLinkComponent(source: SourceComponent) { + return (source.imageSource ?? 'SOURCE') === 'SOURCE' && source.type !== 'DATABASE' && source.type !== 'MIGRATION'; +} + +function isLauncherRepo(repo: PlatformRepo) { + const name = repo.name.toLowerCase(); + const fullName = repo.fullName.toLowerCase(); + return name === 'studio-launcher' || name === 'studio-launcher-fe' || name === 'studio-launcher-be' || fullName.includes('/studio-launcher'); +} + +export function RepositoryManagementPage() { + const { toast } = useToast(); + const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps(); + const [giteaRepos, setGiteaRepos] = useState([]); + const [apiSources, setApiSources] = useState([]); + const [sourcesLoading, setSourcesLoading] = useState(true); + const [giteaStatus, setGiteaStatus] = useState<'loading' | 'ready' | 'error'>('loading'); + const sourcesLoadingVisible = useMinimumLoading(giteaStatus === 'loading' || sourcesLoading); + const [isCreateRepoOpen, setIsCreateRepoOpen] = useState(false); + const [repoName, setRepoName] = useState(''); + const [repoDescription, setRepoDescription] = useState(''); + const [repoPrivate, setRepoPrivate] = useState(true); + const [createDevelopBranch, setCreateDevelopBranch] = useState(true); + const [createReleaseBranch, setCreateReleaseBranch] = useState(true); + const [repoCreateStatus, setRepoCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle'); + const [sourceModalOpen, setSourceModalOpen] = useState(false); + const [sourceTarget, setSourceTarget] = useState(null); + const [sourceRepo, setSourceRepo] = useState(''); + const [sourceBranch, setSourceBranch] = useState('develop'); + const [sourceBuildTarget, setSourceBuildTarget] = useState(''); + const [sourceSaveStatus, setSourceSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle'); + + const refreshRepos = () => { + setGiteaStatus('loading'); + listGiteaRepos(giteaAppOrg, 100) + .then((repos) => { + setGiteaRepos(repos.map(toPlatformRepo)); + setGiteaStatus('ready'); + }) + .catch(() => setGiteaStatus('error')); + }; + + useEffect(() => { + let mounted = true; + + setSourcesLoading(true); + listSources() + .then((sources) => { + if (!mounted) return; + setApiSources(sources); + setSourcesLoading(false); + }) + .catch(() => { + if (!mounted) return; + setApiSources([]); + setSourcesLoading(false); + }); + + listGiteaRepos(giteaAppOrg, 100) + .then((repos) => { + if (!mounted) return; + setGiteaRepos(repos.map(toPlatformRepo)); + setGiteaStatus('ready'); + }) + .catch(() => { + if (!mounted) return; + setGiteaStatus('error'); + }); + + return () => { + mounted = false; + }; + }, []); + + const createRepo = () => { + if (!repoName.trim()) return; + + setRepoCreateStatus('saving'); + createGiteaOrgRepo(giteaAppOrg, { + name: repoName.trim(), + description: repoDescription.trim() || undefined, + private: repoPrivate, + autoInit: true, + createDevelopBranch, + createReleaseBranch + }) + .then(() => { + setRepoName(''); + setRepoDescription(''); + setRepoPrivate(true); + setCreateDevelopBranch(true); + setCreateReleaseBranch(true); + setRepoCreateStatus('idle'); + setIsCreateRepoOpen(false); + refreshRepos(); + toast({ type: 'success', message: '저장소가 생성되었습니다.' }); + }) + .catch(() => setRepoCreateStatus('error')); + }; + + const refreshSources = () => { + setSourcesLoading(true); + listSources() + .then((sources) => { + setApiSources(sources); + setSourcesLoading(false); + }) + .catch(() => { + setApiSources([]); + setSourcesLoading(false); + }); + }; + + const openSourceModal = (source: SourceComponent) => { + setSourceTarget(source); + setSourceRepo(source.repository ?? ''); + setSourceBranch(source.branch ?? 'develop'); + setSourceBuildTarget(source.buildTarget ?? ''); + setSourceSaveStatus('idle'); + setSourceModalOpen(true); + }; + + const closeSourceModal = () => { + setSourceModalOpen(false); + setSourceTarget(null); + setSourceRepo(''); + setSourceBranch('develop'); + setSourceBuildTarget(''); + setSourceSaveStatus('idle'); + }; + + const saveSourceLink = () => { + if (!sourceTarget || !sourceRepo.trim()) return; + + setSourceSaveStatus('saving'); + linkComponentSource(sourceTarget.id, { + repository: sourceRepo, + branch: sourceBranch || undefined, + buildTarget: sourceBuildTarget || undefined + }) + .then(() => { + closeSourceModal(); + refreshSources(); + toast({ type: 'success', message: '소스 연결이 저장되었습니다.' }); + }) + .catch(() => { + setSourceSaveStatus('error'); + toast({ type: 'error', message: '소스 연결 저장에 실패했습니다.' }); + }); + }; + + const filteredSources = apiSources.filter((source) => isSourceLinkComponent(source) && (!selectedAppId || source.appId === selectedAppId)); + const selectedAppRepoKeys = useMemo( + () => new Set(filteredSources.map((source) => source.repository).filter((repository): repository is string => Boolean(repository)).map(repositoryKey)), + [filteredSources] + ); + const visibleRepos = useMemo( + () => giteaRepos + .filter((repo) => !isLauncherRepo(repo)) + .sort((a, b) => { + const aSelected = selectedAppRepoKeys.has(repositoryKey(a.fullName)); + const bSelected = selectedAppRepoKeys.has(repositoryKey(b.fullName)); + if (aSelected !== bSelected) return aSelected ? -1 : 1; + return a.fullName.localeCompare(b.fullName); + }), + [giteaRepos, selectedAppRepoKeys] + ); + + return ( + <> + setIsCreateRepoOpen(true)}> + + 저장소 생성 + + } + /> + + + + + +
+ + + + + + + + + + + + + {giteaStatus === 'error' ? ( + + + + ) : giteaStatus !== 'loading' && visibleRepos.length === 0 ? ( + + + + ) : ( + visibleRepos.map((repo) => { + const isSelectedAppRepo = selectedAppRepoKeys.has(repositoryKey(repo.fullName)); + return ( + + + + + + + + + ); + }) + )} + +
저장소Owner기본 브랜치언어Clone URL상태
백엔드 연결 후 저장소가 표시됩니다.
등록된 저장소가 없습니다.
+
+ {repo.fullName} + {isSelectedAppRepo ? 선택 앱 : null} +
+ {repo.description ?

{repo.description}

: null} +
{repo.owner}{repo.defaultBranch}{repo.language || '-'}{repo.cloneUrl} + {repo.private ? 'Private' : 'Public'} +
+
+
+ + + +
+ + + + + + + + + + + + + + + + {!sourcesLoading && filteredSources.length === 0 ? ( + + + + ) : ( + !sourcesLoading && filteredSources.map((source) => ( + + + + + + + + + + + + )) + )} + +
앱구성요소저장소기본 브랜치Commit유형빌드 대상상태관리
등록된 앱 구성요소가 없습니다.
{source.app?.name ?? '-'}{source.name} + {source.repository || '미연결'} + {source.branch || '-'}-{source.type}{source.buildTarget || '-'}{source.repository ? '연결됨' : '미연결'} +
+ +
+
+
+
+ + {isCreateRepoOpen ? ( +
+
+
+

저장소 생성

+
+
+ +