217 lines
9.8 KiB
TypeScript
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} />
|
|
</>
|
|
);
|
|
}
|