Files
studio-admin-fe/src/pages/common-apps/CommonAppBuildPage.tsx
T
chyoo c06acdda8d
ci/woodpecker/push/woodpecker Pipeline was successful
improve common app pipeline history layout
2026-07-09 11:05:01 +09:00

217 lines
9.8 KiB
TypeScript

import { Loader2, RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Badge, Button, Card, CardHeader } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { cn } from '../../lib/utils';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import { listCommonAppPipelineHistory, listCommonAppPipelines, type CommonAppPipeline } from './common-app-api';
import { formatDuration, formatPipelineRelativeTime, formatRepositoryName, formatShortSha, isActivePipelineStatus, pipelineStatusDotClass, pipelineStatusTone } from './common-app-utils';
const historyPageSize = 6;
export function CommonAppBuildPage() {
const { toast } = useToast();
const [pipelines, setPipelines] = useState<CommonAppPipeline[]>([]);
const [selectedRepoId, setSelectedRepoId] = useState('');
const [history, setHistory] = useState<CommonAppPipeline[]>([]);
const [historyPage, setHistoryPage] = useState(1);
const [historyTotal, setHistoryTotal] = useState<number | null>(null);
const [historyTotalPages, setHistoryTotalPages] = useState<number | null>(null);
const [hasNextHistoryPage, setHasNextHistoryPage] = useState(false);
const [loading, setLoading] = useState(true);
const [historyLoading, setHistoryLoading] = useState(false);
const [lastLoadedAt, setLastLoadedAt] = useState<number | null>(null);
const loadingVisible = useMinimumLoading(loading || historyLoading);
const runningCount = useMemo(
() => pipelines.filter((pipeline) => isActivePipelineStatus(pipeline.status)).length,
[pipelines]
);
const selectedPipeline = pipelines.find((pipeline) => pipeline.repoId === selectedRepoId) ?? pipelines[0];
const refresh = (silent = false) => {
if (!silent) setLoading(true);
listCommonAppPipelines()
.then((nextPipelines) => {
setPipelines(nextPipelines);
setLastLoadedAt(Date.now());
setSelectedRepoId((current) => {
if (current && nextPipelines.some((pipeline) => pipeline.repoId === current)) return current;
return nextPipelines.find((pipeline) => pipeline.repoId)?.repoId ?? '';
});
})
.catch((error: Error) => {
if (!silent) toast({ type: 'error', title: '공통앱 빌드 조회 실패', message: error.message });
})
.finally(() => {
if (!silent) setLoading(false);
});
};
useEffect(() => {
refresh();
}, []);
useEffect(() => {
const timer = window.setInterval(() => refresh(true), 5000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
setHistoryPage(1);
}, [selectedRepoId]);
useEffect(() => {
let canceled = false;
if (!selectedRepoId) {
setHistory([]);
setHistoryTotal(null);
setHistoryTotalPages(null);
setHasNextHistoryPage(false);
return;
}
setHistoryLoading(true);
listCommonAppPipelineHistory(selectedRepoId, historyPage, historyPageSize)
.then((result) => {
if (canceled) return;
setHistory(result.pipelines ?? []);
setHistoryTotal(result.total ?? null);
setHistoryTotalPages(result.totalPages ?? null);
setHasNextHistoryPage(Boolean(result.hasNext));
})
.catch((error: Error) => {
if (canceled) return;
setHistory([]);
setHistoryTotal(null);
setHistoryTotalPages(null);
setHasNextHistoryPage(false);
toast({ type: 'error', title: '파이프라인 이력 조회 실패', message: error.message });
})
.finally(() => {
if (!canceled) setHistoryLoading(false);
});
return () => {
canceled = true;
};
}, [historyPage, selectedRepoId, toast]);
return (
<>
<PageTitle
eyebrow="공통앱"
title="빌드 관리"
description="공통앱 저장소의 최근 파이프라인 상태입니다."
actions={
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => refresh()} disabled={loading}>
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
새로고침
</Button>
}
/>
<div className="grid gap-4 xl:grid-cols-[minmax(320px,0.75fr)_minmax(0,1.25fr)]">
<Card className="min-w-0 overflow-hidden">
<CardHeader
title="모든 저장소"
description="마지막 파이프라인 생성 순으로 정렬된 저장소"
action={
<div className="flex items-center gap-3">
<span className="hidden text-xs text-muted-foreground sm:inline">
{runningCount > 0 ? `진행 중 ${runningCount}개` : lastLoadedAt ? `${formatPipelineRelativeTime(lastLoadedAt)} 갱신` : '5초마다 갱신'}
</span>
<Badge tone={runningCount > 0 ? 'blue' : 'neutral'}>{pipelines.length}개</Badge>
</div>
}
/>
<div className="max-h-[680px] overflow-y-auto">
{pipelines.length === 0 && !loading ? (
<div className="flex min-h-40 items-center justify-center px-5 text-sm text-muted-foreground">표시할 저장소가 없습니다.</div>
) : null}
<div className="divide-y divide-border">
{pipelines.map((pipeline) => {
const selected = pipeline.repoId === selectedRepoId;
return (
<button
key={pipeline.id}
type="button"
className={cn(
'block w-full px-5 py-3 text-left transition-colors hover:bg-muted/20',
selected && 'bg-primary/20 shadow-[inset_4px_0_0_var(--color-primary)] hover:bg-primary/25'
)}
onClick={() => pipeline.repoId && setSelectedRepoId(pipeline.repoId)}
>
<div className="flex min-w-0 items-center gap-2">
<span className={cn('size-2.5 shrink-0 rounded-full', pipelineStatusDotClass(pipeline.status))} />
<p className="truncate text-sm font-semibold">{formatRepositoryName(pipeline.repository)}</p>
{isActivePipelineStatus(pipeline.status) ? <Loader2 className="size-3.5 shrink-0 animate-spin text-sky-300" /> : null}
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">
<span className="mr-1 font-mono text-xs">#{pipeline.number ?? '-'}</span>
{pipeline.message || '-'}
</p>
</button>
);
})}
</div>
</div>
</Card>
<Card className="min-w-0 overflow-hidden">
<CardHeader
title="파이프라인 이력"
description={selectedPipeline ? formatRepositoryName(selectedPipeline.repository) : '저장소를 선택하세요'}
/>
<div className="min-h-[430px] divide-y divide-border">
{history.length === 0 && !historyLoading ? (
<div className="flex min-h-40 items-center justify-center px-5 text-sm text-muted-foreground">표시할 파이프라인 이력이 없습니다.</div>
) : null}
{history.map((pipeline) => (
<div key={pipeline.id} className="px-5 py-3 transition-colors hover:bg-muted/20">
<div className="flex min-w-0 items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('size-2.5 shrink-0 rounded-full', pipelineStatusDotClass(pipeline.status))} />
<span className="shrink-0 font-mono text-xs text-muted-foreground">#{pipeline.number ?? pipeline.id}</span>
<p className="truncate text-sm font-semibold">{pipeline.message || '-'}</p>
</div>
<Badge tone={pipelineStatusTone(pipeline.status)}>
<span className="inline-flex items-center gap-1.5">
{isActivePipelineStatus(pipeline.status) ? <Loader2 className="size-3 animate-spin" /> : null}
{pipeline.status || '-'}
</span>
</Badge>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 pl-5 text-xs text-muted-foreground">
<span className="rounded-md bg-muted/50 px-2 py-1">{pipeline.branch || '-'}</span>
<span className="rounded-md bg-muted/50 px-2 py-1 font-mono">SHA1 {formatShortSha(pipeline.commitSha)}</span>
<span className="rounded-md bg-muted/50 px-2 py-1">소요 {formatDuration(pipeline.durationSeconds)}</span>
<span className="rounded-md bg-muted/50 px-2 py-1">{formatPipelineRelativeTime(pipeline.createdAt)}</span>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between border-t border-border px-5 py-3 text-xs text-muted-foreground">
<span>
{historyTotal !== null && historyTotalPages !== null
? `총 ${historyTotal}건 · ${historyPage} / ${historyTotalPages}`
: `현재 ${historyPage}페이지`}
</span>
<div className="flex gap-2">
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={historyLoading || historyPage <= 1} onClick={() => setHistoryPage((page) => Math.max(1, page - 1))}>
이전
</Button>
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={historyLoading || !hasNextHistoryPage} onClick={() => setHistoryPage((page) => page + 1)}>
다음
</Button>
</div>
</div>
</Card>
</div>
<LoadingModal open={loadingVisible} />
</>
);
}