connect tailscale node status through platform gateway
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { ExternalLink, GitBranch, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
@@ -6,12 +6,13 @@ import { PageTitle } from '../../layout/AppLayout';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { listCommonAppPipelines, type CommonAppPipeline } from './common-app-api';
|
||||
import { formatPipelineTime, isActivePipelineStatus, pipelineStatusTone } from './common-app-utils';
|
||||
import { formatPipelineRelativeTime, formatRepositoryName, isActivePipelineStatus, pipelineStatusDotClass, pipelineStatusTone } from './common-app-utils';
|
||||
|
||||
export function CommonAppBuildPage() {
|
||||
const { toast } = useToast();
|
||||
const [pipelines, setPipelines] = useState<CommonAppPipeline[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastLoadedAt, setLastLoadedAt] = useState<number | null>(null);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const runningCount = useMemo(
|
||||
@@ -22,7 +23,10 @@ export function CommonAppBuildPage() {
|
||||
const refresh = (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
listCommonAppPipelines()
|
||||
.then(setPipelines)
|
||||
.then((nextPipelines) => {
|
||||
setPipelines(nextPipelines);
|
||||
setLastLoadedAt(Date.now());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (!silent) toast({ type: 'error', title: '공통앱 빌드 조회 실패', message: error.message });
|
||||
})
|
||||
@@ -36,16 +40,16 @@ export function CommonAppBuildPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => refresh(true), runningCount > 0 ? 3000 : 10000);
|
||||
const timer = window.setInterval(() => refresh(true), 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [runningCount]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="공통앱"
|
||||
title="빌드 관리"
|
||||
description="공통앱 저장소의 Woodpecker 파이프라인 진행 상태입니다."
|
||||
description="공통앱 저장소의 최근 파이프라인 상태입니다."
|
||||
actions={
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => refresh()} disabled={loading}>
|
||||
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||
@@ -54,53 +58,57 @@ export function CommonAppBuildPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 grid gap-4 md:grid-cols-3">
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">진행 중</p>
|
||||
<p className="mt-3 text-2xl font-semibold">{runningCount}</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">연결 저장소</p>
|
||||
<p className="mt-3 text-2xl font-semibold">{pipelines.length}</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">갱신 주기</p>
|
||||
<p className="mt-3 text-2xl font-semibold">{runningCount > 0 ? '3초' : '10초'}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="파이프라인" description="최근 파이프라인 생성 순으로 표시합니다." />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[920px] whitespace-nowrap text-left text-sm">
|
||||
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">저장소</th>
|
||||
<th className="px-5 py-3 font-medium">브랜치</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
<th className="px-5 py-3 font-medium">메시지</th>
|
||||
<th className="px-5 py-3 font-medium">생성시각</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{pipelines.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-10 text-center text-muted-foreground">표시할 파이프라인이 없습니다.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{pipelines.map((pipeline) => (
|
||||
<tr key={pipeline.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-4 font-medium">{pipeline.repository}</td>
|
||||
<td className="px-5 py-4">{pipeline.branch || '-'}</td>
|
||||
<td className="px-5 py-4">
|
||||
<Badge tone={pipelineStatusTone(pipeline.status)}>{pipeline.status || '-'}</Badge>
|
||||
</td>
|
||||
<td className="max-w-[420px] truncate px-5 py-4">{pipeline.message || '-'}</td>
|
||||
<td className="px-5 py-4 text-muted-foreground">{formatPipelineTime(pipeline.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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) => (
|
||||
<div key={pipeline.id} className="px-5 py-4 transition-colors hover:bg-muted/20">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn('size-2.5 shrink-0 rounded-full', pipelineStatusDotClass(pipeline.status))} />
|
||||
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<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">{pipeline.message || '-'}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<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>
|
||||
<span>{pipeline.branch || '-'}</span>
|
||||
<span>{formatPipelineRelativeTime(pipeline.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{pipeline.url ? (
|
||||
<a href={pipeline.url} target="_blank" rel="noreferrer" className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground" aria-label="저장소 열기">
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,25 +1,157 @@
|
||||
import { ExternalLink, RotateCcw } from 'lucide-react';
|
||||
import { ExternalLink, FileText, GitBranch, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { GiteaRepo } from '../../types/application';
|
||||
import {
|
||||
getGiteaCommitDiff,
|
||||
listGiteaCommitsPage,
|
||||
listStudioRepos,
|
||||
type GiteaCommit
|
||||
} from '../launcher/launcher-api';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { listCommonAppPipelines, type CommonAppPipeline } from './common-app-api';
|
||||
import { commonAppComponents, formatPipelineTime, pipelineStatusTone } from './common-app-utils';
|
||||
import { commonAppComponents, formatRepositoryName } from './common-app-utils';
|
||||
|
||||
type CommitRange = 'week' | 'month' | 'all';
|
||||
|
||||
const commitRangeLabels: Record<CommitRange, string> = {
|
||||
week: '일주일',
|
||||
month: '한달',
|
||||
all: '전체'
|
||||
};
|
||||
|
||||
const commitPageSize = 5;
|
||||
const branchOptions = ['develop', 'release', 'main'];
|
||||
|
||||
function repoFullName(repo: GiteaRepo) {
|
||||
return (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? repo.name;
|
||||
}
|
||||
|
||||
function splitRepository(repository: string) {
|
||||
const [owner, ...repoParts] = repository.split('/');
|
||||
const repo = repoParts.join('/');
|
||||
return owner && repo ? { owner, repo } : null;
|
||||
}
|
||||
|
||||
function commitAuthor(commit: GiteaCommit) {
|
||||
return commit.author?.login
|
||||
|| commit.author?.username
|
||||
|| commit.author?.full_name
|
||||
|| commit.author?.name
|
||||
|| commit.commit?.author?.name
|
||||
|| commit.committer?.login
|
||||
|| '-';
|
||||
}
|
||||
|
||||
function commitMessage(commit: GiteaCommit) {
|
||||
return (commit.commit?.message || '').split(/\r?\n/)[0]?.trim() || '-';
|
||||
}
|
||||
|
||||
function commitDate(commit: GiteaCommit) {
|
||||
return commit.commit?.author?.date
|
||||
|| commit.commit?.committer?.date
|
||||
|| commit.created
|
||||
|| '';
|
||||
}
|
||||
|
||||
function relativeTime(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
const time = new Date(value).getTime();
|
||||
if (Number.isNaN(time)) return '-';
|
||||
const seconds = Math.max(1, Math.floor((Date.now() - time) / 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 === 1) return '어제';
|
||||
if (days < 7) return `${days}일 전`;
|
||||
return new Intl.DateTimeFormat('ko-KR', { month: '2-digit', day: '2-digit' }).format(new Date(value));
|
||||
}
|
||||
|
||||
function commitRangeSince(range: CommitRange) {
|
||||
if (range === 'all') return undefined;
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function diffLineClass(line: string) {
|
||||
if (line.startsWith('+++') || line.startsWith('---')) return 'bg-[#111827] text-sky-200';
|
||||
if (line.startsWith('@@')) return 'bg-[#1e1b4b] text-violet-100';
|
||||
if (line.startsWith('+')) return 'bg-[#123524] text-emerald-50';
|
||||
if (line.startsWith('-')) return 'bg-[#3a1418] text-rose-50';
|
||||
if (line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('new file mode') || line.startsWith('deleted file mode')) {
|
||||
return 'bg-[#0f172a] text-slate-200';
|
||||
}
|
||||
return 'text-slate-300';
|
||||
}
|
||||
|
||||
function DiffViewer({ diff }: { diff: string }) {
|
||||
const lines = diff.trim() ? diff.split(/\r?\n/) : ['표시할 변경 내역이 없습니다.'];
|
||||
return (
|
||||
<div className="max-h-[64vh] overflow-auto rounded-md border border-border bg-[#020617] font-mono text-xs">
|
||||
{lines.map((line, index) => (
|
||||
<div key={`${index}-${line}`} className={`grid grid-cols-[64px_minmax(0,1fr)] ${diffLineClass(line)}`}>
|
||||
<span className="select-none border-r border-white/10 px-3 py-0.5 text-right text-slate-500">{index + 1}</span>
|
||||
<span className="whitespace-pre px-3 py-0.5">{line || ' '}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommonAppSourcePage() {
|
||||
const { toast } = useToast();
|
||||
const [repos, setRepos] = useState<GiteaRepo[]>([]);
|
||||
const [pipelines, setPipelines] = useState<CommonAppPipeline[]>([]);
|
||||
const [selectedRepository, setSelectedRepository] = useState(commonAppComponents[0]?.repository ?? '');
|
||||
const [selectedBranch, setSelectedBranch] = useState('develop');
|
||||
const [commitRange, setCommitRange] = useState<CommitRange>('week');
|
||||
const [commitPage, setCommitPage] = useState(1);
|
||||
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
||||
const [totalCommits, setTotalCommits] = useState<number | null>(null);
|
||||
const [hasNextCommitPage, setHasNextCommitPage] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [commitsLoading, setCommitsLoading] = useState(false);
|
||||
const [commitsError, setCommitsError] = useState('');
|
||||
const [diffTarget, setDiffTarget] = useState<GiteaCommit | null>(null);
|
||||
const [diffContent, setDiffContent] = useState('');
|
||||
const [diffLoading, setDiffLoading] = useState(false);
|
||||
const [diffError, setDiffError] = useState('');
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const pipelineByRepo = useMemo(() => new Map(pipelines.map((pipeline) => [pipeline.repository, pipeline])), [pipelines]);
|
||||
const knownCommonRepos = useMemo(() => new Set(commonAppComponents.map((component) => component.repository)), []);
|
||||
const sortedRepos = useMemo(() => {
|
||||
return [...repos].sort((a, b) => {
|
||||
const aKnown = knownCommonRepos.has(repoFullName(a)) ? 0 : 1;
|
||||
const bKnown = knownCommonRepos.has(repoFullName(b)) ? 0 : 1;
|
||||
if (aKnown !== bKnown) return aKnown - bKnown;
|
||||
return repoFullName(a).localeCompare(repoFullName(b));
|
||||
});
|
||||
}, [knownCommonRepos, repos]);
|
||||
const selectedRepo = useMemo(() => repos.find((repo) => repoFullName(repo) === selectedRepository), [repos, selectedRepository]);
|
||||
const repositoryParts = useMemo(() => splitRepository(selectedRepository), [selectedRepository]);
|
||||
const totalCommitPages = totalCommits === null ? null : Math.max(1, Math.ceil(totalCommits / commitPageSize));
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
listCommonAppPipelines()
|
||||
.then(setPipelines)
|
||||
Promise.all([
|
||||
listStudioRepos(100),
|
||||
listCommonAppPipelines()
|
||||
])
|
||||
.then(([nextRepos, nextPipelines]) => {
|
||||
setRepos(nextRepos);
|
||||
setPipelines(nextPipelines);
|
||||
if (!nextRepos.some((repo) => repoFullName(repo) === selectedRepository)) {
|
||||
setSelectedRepository(repoFullName(nextRepos.find((repo) => knownCommonRepos.has(repoFullName(repo))) ?? nextRepos[0]) || '');
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '공통앱 소스 조회 실패', message: error.message });
|
||||
})
|
||||
@@ -30,12 +162,66 @@ export function CommonAppSourcePage() {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCommitPage(1);
|
||||
}, [selectedRepository, selectedBranch, commitRange]);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
if (!repositoryParts) {
|
||||
setCommits([]);
|
||||
setTotalCommits(null);
|
||||
setHasNextCommitPage(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommitsLoading(true);
|
||||
setCommitsError('');
|
||||
listGiteaCommitsPage(repositoryParts.owner, repositoryParts.repo, selectedBranch, {
|
||||
page: commitPage,
|
||||
limit: commitPageSize,
|
||||
since: commitRangeSince(commitRange)
|
||||
})
|
||||
.then((result) => {
|
||||
if (canceled) return;
|
||||
setCommits(result.commits);
|
||||
setTotalCommits(result.total);
|
||||
setHasNextCommitPage(result.total === null ? result.commits.length === commitPageSize : commitPage < Math.max(1, Math.ceil(result.total / commitPageSize)));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (canceled) return;
|
||||
setCommits([]);
|
||||
setTotalCommits(null);
|
||||
setHasNextCommitPage(false);
|
||||
setCommitsError(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!canceled) setCommitsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [commitPage, commitRange, repositoryParts?.owner, repositoryParts?.repo, selectedBranch]);
|
||||
|
||||
const openCommitDiff = (commit: GiteaCommit) => {
|
||||
if (!repositoryParts) return;
|
||||
setDiffTarget(commit);
|
||||
setDiffContent('');
|
||||
setDiffError('');
|
||||
setDiffLoading(true);
|
||||
getGiteaCommitDiff(repositoryParts.owner, repositoryParts.repo, commit.sha)
|
||||
.then((diff) => setDiffContent(diff))
|
||||
.catch((error: Error) => setDiffError(error.message))
|
||||
.finally(() => setDiffLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="공통앱"
|
||||
title="소스 관리"
|
||||
description="플랫폼 공통앱 저장소와 최근 파이프라인 기준 상태입니다."
|
||||
description="Gitea 저장소와 브랜치별 커밋 내역을 확인합니다."
|
||||
actions={
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||
@@ -44,51 +230,194 @@ export function CommonAppSourcePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="저장소" description="Woodpecker에 연결된 공통앱 저장소입니다." />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[920px] whitespace-nowrap text-left text-sm">
|
||||
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">저장소</th>
|
||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||
<th className="px-5 py-3 font-medium">브랜치</th>
|
||||
<th className="px-5 py-3 font-medium">최근 파이프라인</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
<th className="px-5 py-3 font-medium">생성시각</th>
|
||||
<th className="px-5 py-3 font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{commonAppComponents.map((component) => {
|
||||
const pipeline = pipelineByRepo.get(component.repository);
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(320px,0.8fr)_minmax(0,1.2fr)]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="Gitea 저장소" description="studio 조직 저장소 기준입니다." />
|
||||
<div className="max-h-[520px] overflow-y-auto">
|
||||
{sortedRepos.length === 0 && !loading ? (
|
||||
<div className="flex min-h-36 items-center justify-center px-5 text-sm text-muted-foreground">표시할 저장소가 없습니다.</div>
|
||||
) : null}
|
||||
<div className="divide-y divide-border">
|
||||
{sortedRepos.map((repo) => {
|
||||
const fullName = repoFullName(repo);
|
||||
const pipeline = pipelineByRepo.get(fullName);
|
||||
const selected = fullName === selectedRepository;
|
||||
return (
|
||||
<tr key={component.key} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-4 font-medium">{component.repository}</td>
|
||||
<td className="px-5 py-4 text-muted-foreground">{component.name}</td>
|
||||
<td className="px-5 py-4">{pipeline?.branch || '-'}</td>
|
||||
<td className="max-w-[340px] truncate px-5 py-4">{pipeline?.message || '-'}</td>
|
||||
<td className="px-5 py-4">
|
||||
<Badge tone={pipelineStatusTone(pipeline?.status)}>{pipeline?.status || '-'}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-muted-foreground">{formatPipelineTime(pipeline?.createdAt)}</td>
|
||||
<td className="px-5 py-4">
|
||||
{pipeline?.url ? (
|
||||
<Button variant="outline" className="size-8 px-0" aria-label="저장소 열기" onClick={() => window.open(pipeline.url ?? undefined, '_blank', 'noopener,noreferrer')}>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<button
|
||||
key={fullName}
|
||||
type="button"
|
||||
className={cn('block w-full px-5 py-4 text-left transition-colors hover:bg-muted/20', selected && 'bg-muted/30')}
|
||||
onClick={() => setSelectedRepository(fullName)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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">{formatRepositoryName(fullName)}</p>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{repo.private ? 'Private' : 'Public'}</p>
|
||||
</div>
|
||||
<Badge tone={pipeline ? 'green' : 'neutral'}>{pipeline ? 'Woodpecker 연결됨' : '미연결'}</Badge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="소스 기준" description="커밋 내역을 확인할 저장소와 브랜치입니다." />
|
||||
<div className="grid gap-4 p-5 md:grid-cols-2">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||
<p className="text-xs text-muted-foreground">저장소</p>
|
||||
<p className="mt-2 truncate text-sm font-semibold">{selectedRepository ? formatRepositoryName(selectedRepository) : '-'}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||
<p className="text-xs text-muted-foreground">Woodpecker</p>
|
||||
<div className="mt-2">
|
||||
<Badge tone={pipelineByRepo.has(selectedRepository) ? 'green' : 'neutral'}>{pipelineByRepo.has(selectedRepository) ? '연결됨' : '미연결'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">조회 브랜치</p>
|
||||
<select
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={selectedBranch}
|
||||
onChange={(event) => setSelectedBranch(event.target.value)}
|
||||
>
|
||||
{branchOptions.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Gitea URL</p>
|
||||
<div className="mt-2 flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3">
|
||||
<p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{selectedRepo?.html_url ?? '-'}</p>
|
||||
{selectedRepo?.html_url ? (
|
||||
<Button variant="ghost" className="size-8 px-0" onClick={() => window.open(selectedRepo.html_url, '_blank', 'noopener,noreferrer')} aria-label="Gitea 저장소 열기">
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-4 min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title="커밋 목록"
|
||||
description={`${selectedRepository ? formatRepositoryName(selectedRepository) : '-'} 저장소의 ${selectedBranch} 브랜치 기준 커밋 내역입니다.`}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
{(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
variant={commitRange === range ? 'default' : 'outline'}
|
||||
className="h-8 px-3 text-[13px]"
|
||||
onClick={() => setCommitRange(range)}
|
||||
>
|
||||
{commitRangeLabels[range]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="min-h-[284px] overflow-x-auto">
|
||||
<table className="w-full min-w-[760px] text-left text-sm">
|
||||
<thead className="border-y border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">작성자</th>
|
||||
<th className="px-5 py-3 font-medium">SHA1</th>
|
||||
<th className="px-5 py-3 font-medium">메시지</th>
|
||||
<th className="px-5 py-3 font-medium">날짜</th>
|
||||
<th className="w-16 px-5 py-3 text-right font-medium">상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{commitsLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="h-[240px] px-5 py-10 text-center text-sm text-muted-foreground">커밋을 불러오는 중입니다.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{!commitsLoading && commitsError ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="h-[240px] px-5 py-10 text-center text-sm text-muted-foreground">커밋 조회 실패: {commitsError}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{!commitsLoading && !commitsError && commits.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="h-[240px] px-5 py-10 text-center text-sm text-muted-foreground">표시할 커밋이 없습니다.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||
<tr key={commit.sha} className="border-b border-border last:border-0">
|
||||
<td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
||||
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-right">
|
||||
<Button variant="outline" className="size-8 px-0" title="내역 상세" aria-label="내역 상세" onClick={() => openCommitDiff(commit)}>
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={commitPage <= 1 || commitsLoading} onClick={() => setCommitPage((page) => Math.max(1, page - 1))}>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={commitsLoading || (totalCommitPages !== null ? commitPage >= totalCommitPages : !hasNextCommitPage)}
|
||||
onClick={() => setCommitPage((page) => totalCommitPages === null ? page + 1 : Math.min(totalCommitPages, page + 1))}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={Boolean(diffTarget)}
|
||||
title="커밋 내역 상세"
|
||||
onClose={() => setDiffTarget(null)}
|
||||
className="!max-w-[1400px]"
|
||||
bodyClassName="max-h-[70vh] px-4 py-4"
|
||||
footer={<Button variant="outline" onClick={() => setDiffTarget(null)}>닫기</Button>}
|
||||
>
|
||||
{diffTarget ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="font-mono text-xs text-muted-foreground">{diffTarget.sha.slice(0, 10)}</span>
|
||||
<span className="font-medium">{commitMessage(diffTarget)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{commitAuthor(diffTarget)} · {relativeTime(commitDate(diffTarget))}
|
||||
</p>
|
||||
</div>
|
||||
{diffLoading ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-sm text-muted-foreground">Diff를 불러오는 중입니다.</div>
|
||||
) : diffError ? (
|
||||
<div className="rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">Diff 조회 실패: {diffError}</div>
|
||||
) : (
|
||||
<DiffViewer diff={diffContent} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -55,6 +55,24 @@ export function formatPipelineTime(value?: number | null) {
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatPipelineRelativeTime(value?: number | null) {
|
||||
if (!value) return '-';
|
||||
const ms = value < 10_000_000_000 ? value * 1000 : value;
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - ms) / 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(ms));
|
||||
}
|
||||
|
||||
export function formatRepositoryName(repository: string) {
|
||||
return repository.replace('/', ' / ');
|
||||
}
|
||||
|
||||
export function pipelineStatusTone(status?: string | null): 'green' | 'amber' | 'red' | 'blue' | 'neutral' {
|
||||
const value = (status ?? '').toLowerCase();
|
||||
if (['success', 'succeeded', 'passed'].includes(value)) return 'green';
|
||||
@@ -64,6 +82,15 @@ export function pipelineStatusTone(status?: string | null): 'green' | 'amber' |
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function pipelineStatusDotClass(status?: string | null) {
|
||||
const value = (status ?? '').toLowerCase();
|
||||
if (['success', 'succeeded', 'passed'].includes(value)) return 'bg-emerald-400 shadow-[0_0_0_3px_rgba(52,211,153,0.15)]';
|
||||
if (['running', 'started'].includes(value)) return 'bg-sky-400 shadow-[0_0_0_3px_rgba(56,189,248,0.15)]';
|
||||
if (['pending', 'queued', 'blocked', 'created'].includes(value)) return 'bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.15)]';
|
||||
if (['failure', 'failed', 'error', 'killed', 'declined'].includes(value)) return 'bg-rose-400 shadow-[0_0_0_3px_rgba(251,113,133,0.15)]';
|
||||
return 'bg-muted-foreground/60';
|
||||
}
|
||||
|
||||
export function isActivePipelineStatus(status?: string | null) {
|
||||
const value = (status ?? '').toLowerCase();
|
||||
return ['running', 'started', 'pending', 'queued', 'blocked', 'created'].includes(value);
|
||||
|
||||
Reference in New Issue
Block a user