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({ -
+
- +