diff --git a/src/App.tsx b/src/App.tsx index 03e0489..ae617fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,11 +2,11 @@ import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'rea import { useLocation, useNavigate } from 'react-router-dom'; import { Activity, - Bell, ChevronDown, ChevronRight, CircleHelp, ExternalLink, + GitBranch, Copy, KeyRound, Lock, @@ -285,6 +285,16 @@ type ApiAppRelease = { app?: ApiApp; }; +type CommonAppPipeline = { + id: string; + repository: string; + message: string; + branch: string; + status: string; + createdAt: number; + url?: string | null; +}; + type ApiNotice = { id: string; appKey: string; @@ -402,6 +412,19 @@ function getTone(status: string) { return statusTone[status as keyof typeof statusTone] ?? 'neutral'; } +function formatRelativeTime(timestamp: number) { + if (!timestamp) return '-'; + const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (seconds < 60) return '방금 전'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}분 전`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}시간 전`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}일 전`; + return new Intl.DateTimeFormat('ko-KR', { month: 'short', day: 'numeric' }).format(new Date(timestamp)); +} + type NavGroup = (typeof navGroups)[number]; type NavItem = NavGroup['items'][number]; type NavChild = { label: string; view?: string }; @@ -922,94 +945,118 @@ function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVer const activeVersion = versions.find((version) => version.isActive) ?? versions[0]; return ( - + } /> -
-
- {loading ? ( -
-
-
+
+ {loading ? ( +
+
+
+

Studio Launcher

+ +
+ +
+
+
+

배포 파일

+ +
+
+

다운로드 URL

+ +
+
+

릴리즈 노트

+ +
+
+
+ ) : activeVersion ? ( +
+
+
+

Studio Launcher

- -
- -
-
-
-

배포 파일

- -
-
-

다운로드 URL

- -
-
-

릴리즈 노트

- + {activeVersion.isActive ? '활성' : '비활성'}
+

+ 최신 {activeVersion.version} · 최소 지원 {activeVersion.minimumVersion ?? '-'} +

- ) : activeVersion ? ( -
-
-
-
-

Studio Launcher

- {activeVersion.isActive ? '활성' : '비활성'} -
-

- 최신 {activeVersion.version} · 최소 지원 {activeVersion.minimumVersion ?? '-'} -

-
+
+
+

배포 파일

+

{activeVersion.artifact}

-
-
-

배포 파일

-

{activeVersion.artifact}

-
-
-

다운로드 URL

-

{activeVersion.downloadUrl}

-
-
-

릴리즈 노트

-

{activeVersion.releaseNotes || '릴리즈 노트가 없습니다.'}

-
+
+

다운로드 URL

+

{activeVersion.downloadUrl}

+
+
+

릴리즈 노트

+

{activeVersion.releaseNotes || '릴리즈 노트가 없습니다.'}

- ) : ( -
- 등록된 런처 버전이 없습니다. -
- )} -
-
-

런처 상태 요약

-
- {loading ? ( - Array.from({ length: 3 }).map((_, index) => ( -
- - -
- )) - ) : [ - `${versions.length}개 런처 버전 등록`, - activeVersion?.repository ? `소스 연결 ${activeVersion.repository}` : '런처 소스 연결 확인 필요', - activeVersion?.downloadUrl ? '다운로드 URL 등록됨' : '다운로드 URL 확인 필요' - ].map((label) => ( -
- -

{label}

+
+ ) : ( +
+ 등록된 런처 버전이 없습니다. +
+ )} +
+ + ); +} + +function CommonAppsPanel({ pipelines, loading }: { pipelines: CommonAppPipeline[]; loading: boolean }) { + return ( + + +
+ {loading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ +
))}
-
+ ) : pipelines.length === 0 ? ( +
표시할 저장소가 없습니다.
+ ) : ( +
+ {pipelines.slice(0, 8).map((pipeline) => ( +
+
+
+
+ +

{pipeline.repository}

+
+

{pipeline.message}

+
+ {pipeline.url ? ( + + + + ) : null} +
+
+ {pipeline.status} + {pipeline.branch} + {formatRelativeTime(pipeline.createdAt)} +
+
+ ))} +
+ )}
); @@ -2343,6 +2390,17 @@ function loadNotices() { .then((data) => data.notices ?? []); } +function loadCommonAppPipelines() { + return apiFetch(`${apiBaseUrl}/woodpecker/repos`, { headers: authHeaders() }) + .then((response) => { + if (!response.ok) { + throw new Error('Woodpecker repositories request failed'); + } + return response.json() as Promise<{ repos: CommonAppPipeline[] }>; + }) + .then((data) => data.repos ?? []); +} + function loadRuntimeNamespaceResources(namespace: string) { return apiFetch(`${apiBaseUrl}/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`, { headers: authHeaders() }) .then((response) => { @@ -3203,6 +3261,7 @@ function DashboardPage({ const [dashboardApps, setDashboardApps] = useState([]); const [dashboardReleases, setDashboardReleases] = useState([]); const [recentSessions, setRecentSessions] = useState([]); + const [commonAppPipelines, setCommonAppPipelines] = useState([]); const [dashboardNotices, setDashboardNotices] = useState([]); const [runtimeResources, setRuntimeResources] = useState([]); const [dashboardLoading, setDashboardLoading] = useState(true); @@ -3212,12 +3271,13 @@ function DashboardPage({ async function loadDashboard() { setDashboardLoading(true); - const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult] = await Promise.allSettled([ + const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult] = await Promise.allSettled([ loadLauncherVersions(), loadApps(), loadAppReleases(), loadUserSessions(), - loadNotices() + loadNotices(), + loadCommonAppPipelines() ]); const nextVersions = versionsResult.status === 'fulfilled' ? versionsResult.value : []; @@ -3225,6 +3285,7 @@ function DashboardPage({ const nextReleases = releasesResult.status === 'fulfilled' ? releasesResult.value : []; const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : []; const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : []; + const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : []; const namespaceApps = nextApps.filter((app) => app.namespace); const [runtimeResults, metricResults] = await Promise.all([ Promise.allSettled( @@ -3261,6 +3322,7 @@ function DashboardPage({ setDashboardApps(nextApps); setDashboardReleases(nextReleases); setRecentSessions(nextSessions); + setCommonAppPipelines(nextCommonApps); setDashboardNotices(nextNotices); setRuntimeResources(nextRuntimeResources); setDashboardLoading(false); @@ -3286,9 +3348,9 @@ function DashboardPage({ -
+
- +
diff --git a/src/pages/applications/MigrationManagementPage.tsx b/src/pages/applications/MigrationManagementPage.tsx index 17f8114..b575e70 100644 --- a/src/pages/applications/MigrationManagementPage.tsx +++ b/src/pages/applications/MigrationManagementPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react'; import { Button, Card, Modal } from '../../components/ui'; import { useToast } from '../../components/toast'; @@ -42,6 +42,18 @@ function migrationTime(job: RuntimeControlJob) { return Number.isNaN(value) ? 0 : value; } +function sanitizeK8sName(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function uniqueValues(values: string[]) { + return [...new Set(values.filter(Boolean))]; +} + const migrationPageSize = 5; export function MigrationManagementPage() { @@ -64,6 +76,18 @@ export function MigrationManagementPage() { const selectedRelease = releases.find((release) => release.id === releaseId); const migrationComponents = selectedApp?.components?.filter((component) => component.type === 'MIGRATION') ?? []; const images = imageEntries(selectedRelease?.imageTags); + const namespaceOptions = useMemo( + () => uniqueValues([selectedApp?.namespace || '', namespace]), + [namespace, selectedApp?.namespace] + ); + const envSecretOptions = useMemo( + () => uniqueValues([ + ...images.map(([name]) => `${sanitizeK8sName(name)}-secret`), + `${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`, + envSecretName + ]), + [envSecretName, images, selectedApp?.key] + ); const selectedImage = selectedImageTag(selectedRelease, imageComponentName); const sortedJobs = [...jobs].sort((a, b) => migrationTime(b) - migrationTime(a)); const totalPages = Math.max(1, Math.ceil(sortedJobs.length / migrationPageSize)); @@ -83,7 +107,7 @@ export function MigrationManagementPage() { setMigrationComponentId((prev) => ( migrationComponents.some((component) => component.id === prev) ? prev : migrationComponents[0]?.id || '' )); - setEnvSecretName((prev) => prev || `${selectedApp?.key ?? 'app'}-be-secret`); + setEnvSecretName((prev) => prev || `${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`); } finally { setLoading(false); } @@ -98,10 +122,14 @@ export function MigrationManagementPage() { setImageComponentName((prev) => (nextImages.some(([name]) => name === prev) ? prev : nextImages[0]?.[0] || '')); }, [selectedRelease?.id]); + useEffect(() => { + if (imageComponentName) setEnvSecretName(`${sanitizeK8sName(imageComponentName)}-secret`); + }, [imageComponentName]); + useEffect(() => { setNamespace(selectedApp?.namespace || ''); setMigrationComponentId(migrationComponents[0]?.id || ''); - setEnvSecretName(`${selectedApp?.key ?? 'app'}-be-secret`); + setEnvSecretName(`${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`); setPage(1); }, [selectedAppId]); @@ -117,7 +145,7 @@ export function MigrationManagementPage() { } }; - const canRun = Boolean(releaseId && migrationComponentId && imageComponentName && namespace && command.trim()); + const canRun = Boolean(releaseId && migrationComponentId && imageComponentName && namespace && envSecretName && command.trim()); return ( <> @@ -182,19 +210,25 @@ export function MigrationManagementPage() {