replace dashboard launcher summary with common apps
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-06 18:28:43 +09:00
parent 1f716c7719
commit bf833f772c
+140 -78
View File
@@ -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 (
<Card className="xl:col-span-3">
<Card className="min-w-0 overflow-hidden">
<CardHeader
title="런처 버전 관리"
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태를 관리합니다."
action={<DashboardLinkButton onClick={onOpen} label="런처 버전 관리로 이동" />}
/>
<div className="grid gap-4 p-5 lg:grid-cols-[1fr_280px]">
<div className="space-y-4">
{loading ? (
<div className="min-h-48 rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 space-y-2">
<div className="p-5">
{loading ? (
<div className="min-h-48 rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 space-y-2">
<p className="font-medium">Studio Launcher</p>
<SkeletonBlock className="h-3 w-56" />
</div>
<SkeletonBlock className="h-5 w-14 rounded-md" />
</div>
<div className="mt-5 grid gap-3 text-sm">
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">배포 파일</p>
<SkeletonBlock className="mt-3 h-4 w-52" />
</div>
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">다운로드 URL</p>
<SkeletonBlock className="mt-3 h-4 w-full" />
</div>
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
<SkeletonBlock className="mt-3 h-4 w-4/5" />
</div>
</div>
</div>
) : activeVersion ? (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">Studio Launcher</p>
<SkeletonBlock className="h-3 w-56" />
</div>
<SkeletonBlock className="h-5 w-14 rounded-md" />
</div>
<div className="mt-5 grid gap-3 text-sm">
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">배포 파일</p>
<SkeletonBlock className="mt-3 h-4 w-52" />
</div>
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">다운로드 URL</p>
<SkeletonBlock className="mt-3 h-4 w-full" />
</div>
<div className="rounded-md bg-background px-3 py-3">
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
<SkeletonBlock className="mt-3 h-4 w-4/5" />
<Badge tone={activeVersion.isActive ? 'green' : 'neutral'}>{activeVersion.isActive ? '활성' : '비활성'}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
최신 {activeVersion.version} · 최소 지원 {activeVersion.minimumVersion ?? '-'}
</p>
</div>
</div>
) : activeVersion ? (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">Studio Launcher</p>
<Badge tone={activeVersion.isActive ? 'green' : 'neutral'}>{activeVersion.isActive ? '활성' : '비활성'}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
최신 {activeVersion.version} · 최소 지원 {activeVersion.minimumVersion ?? '-'}
</p>
</div>
<div className="mt-4 grid gap-3 text-sm">
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">배포 파일</p>
<p className="mt-1 break-all font-medium leading-5">{activeVersion.artifact}</p>
</div>
<div className="mt-4 grid gap-3 text-sm">
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">배포 파일</p>
<p className="mt-1 break-all font-medium leading-5">{activeVersion.artifact}</p>
</div>
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">다운로드 URL</p>
<p className="mt-1 break-all font-medium leading-5">{activeVersion.downloadUrl}</p>
</div>
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
<p className="mt-1 text-sm leading-6 text-foreground">{activeVersion.releaseNotes || '릴리즈 노트가 없습니다.'}</p>
</div>
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">다운로드 URL</p>
<p className="mt-1 break-all font-medium leading-5">{activeVersion.downloadUrl}</p>
</div>
<div className="rounded-md bg-background px-3 py-2">
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
<p className="mt-1 text-sm leading-6 text-foreground">{activeVersion.releaseNotes || '릴리즈 노트가 없습니다.'}</p>
</div>
</div>
) : (
<div className="flex min-h-48 items-center justify-center rounded-md border border-border bg-muted/20 p-4 text-sm text-muted-foreground">
등록된 런처 버전이 없습니다.
</div>
)}
</div>
<div className="rounded-md border border-border bg-background p-4">
<p className="text-sm font-medium">런처 상태 요약</p>
<div className="mt-4 space-y-3">
{loading ? (
Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="flex gap-3 rounded-md bg-muted/30 p-3">
<SkeletonBlock className="mt-0.5 size-4 shrink-0 rounded-full" />
<SkeletonBlock className="h-4 flex-1" />
</div>
))
) : [
`${versions.length}개 런처 버전 등록`,
activeVersion?.repository ? `소스 연결 ${activeVersion.repository}` : '런처 소스 연결 확인 필요',
activeVersion?.downloadUrl ? '다운로드 URL 등록됨' : '다운로드 URL 확인 필요'
].map((label) => (
<div key={label} className="flex gap-3 rounded-md bg-muted/30 p-3">
<Bell className="mt-0.5 size-4 text-muted-foreground" />
<p className="text-sm leading-5">{label}</p>
</div>
) : (
<div className="flex min-h-48 items-center justify-center rounded-md border border-border bg-muted/20 p-4 text-sm text-muted-foreground">
등록된 런처 버전이 없습니다.
</div>
)}
</div>
</Card>
);
}
function CommonAppsPanel({ pipelines, loading }: { pipelines: CommonAppPipeline[]; loading: boolean }) {
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader title="공통앱" description="Woodpecker의 모든 저장소를 마지막 파이프라인 생성 순으로 표시합니다." />
<div className="max-h-[420px] overflow-y-auto">
{loading ? (
<div className="space-y-3 p-5">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-md border border-border bg-muted/20 p-3">
<SkeletonBlock className="h-4 w-48" />
<SkeletonBlock className="mt-3 h-3 w-64" />
</div>
))}
</div>
</div>
) : pipelines.length === 0 ? (
<div className="flex min-h-36 items-center justify-center px-5 text-sm text-muted-foreground">표시할 저장소가 없습니다.</div>
) : (
<div className="divide-y divide-border">
{pipelines.slice(0, 8).map((pipeline) => (
<div key={pipeline.id} className="px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
<p className="truncate text-sm font-semibold">{pipeline.repository}</p>
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">{pipeline.message}</p>
</div>
{pipeline.url ? (
<a href={pipeline.url} target="_blank" rel="noreferrer" className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Woodpecker 저장소 열기">
<ExternalLink className="size-4" />
</a>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge tone={getTone(pipeline.status)}>{pipeline.status}</Badge>
<span>{pipeline.branch}</span>
<span>{formatRelativeTime(pipeline.createdAt)}</span>
</div>
</div>
))}
</div>
)}
</div>
</Card>
);
@@ -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<ApiApp[]>([]);
const [dashboardReleases, setDashboardReleases] = useState<ApiAppRelease[]>([]);
const [recentSessions, setRecentSessions] = useState<UserSessionRow[]>([]);
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
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({
<MetricCards apps={dashboardApps} sessions={recentSessions} runtimeResources={runtimeResources} loading={dashboardLoading} />
<div className="mt-4 grid gap-4 xl:grid-cols-4">
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<LauncherPanel versions={launcherRows} loading={dashboardLoading} onOpen={onLauncherVersionsOpen} />
<SessionsPanel sessions={recentSessions.slice(0, 5)} loading={dashboardLoading} onMore={onUserSessionsOpen} />
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} />
</div>
<div className="mt-4">