diff --git a/src/pages/common-apps/CommonAppBuildPage.tsx b/src/pages/common-apps/CommonAppBuildPage.tsx index ea56715..4afdee0 100644 --- a/src/pages/common-apps/CommonAppBuildPage.tsx +++ b/src/pages/common-apps/CommonAppBuildPage.tsx @@ -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([]); const [loading, setLoading] = useState(true); + const [lastLoadedAt, setLastLoadedAt] = useState(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 ( <> refresh()} disabled={loading}> @@ -54,53 +58,57 @@ export function CommonAppBuildPage() { } /> -
- -

진행 중

-

{runningCount}

-
- -

연결 저장소

-

{pipelines.length}

-
- -

갱신 주기

-

{runningCount > 0 ? '3초' : '10초'}

-
-
- - - -
- - - - - - - - - - - - {pipelines.length === 0 && !loading ? ( - - - - ) : null} - {pipelines.map((pipeline) => ( - - - - - - - - ))} - -
저장소브랜치상태메시지생성시각
표시할 파이프라인이 없습니다.
{pipeline.repository}{pipeline.branch || '-'} - {pipeline.status || '-'} - {pipeline.message || '-'}{formatPipelineTime(pipeline.createdAt)}
+ + + + {runningCount > 0 ? `진행 중 ${runningCount}개` : lastLoadedAt ? `${formatPipelineRelativeTime(lastLoadedAt)} 갱신` : '5초마다 갱신'} + + 0 ? 'blue' : 'neutral'}>{pipelines.length}개 +
+ } + /> +
+ {pipelines.length === 0 && !loading ? ( +
표시할 저장소가 없습니다.
+ ) : null} +
+ {pipelines.map((pipeline) => ( +
+
+
+
+ + +

{formatRepositoryName(pipeline.repository)}

+ {isActivePipelineStatus(pipeline.status) ? ( + + ) : null} +
+

{pipeline.message || '-'}

+
+ + + {isActivePipelineStatus(pipeline.status) ? : null} + {pipeline.status || '-'} + + + {pipeline.branch || '-'} + {formatPipelineRelativeTime(pipeline.createdAt)} +
+
+ {pipeline.url ? ( + + + + ) : null} +
+
+ ))} +
diff --git a/src/pages/common-apps/CommonAppSourcePage.tsx b/src/pages/common-apps/CommonAppSourcePage.tsx index 643ad5a..3b9c3c8 100644 --- a/src/pages/common-apps/CommonAppSourcePage.tsx +++ b/src/pages/common-apps/CommonAppSourcePage.tsx @@ -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 = { + 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 ( +
+ {lines.map((line, index) => ( +
+ {index + 1} + {line || ' '} +
+ ))} +
+ ); +} export function CommonAppSourcePage() { const { toast } = useToast(); + const [repos, setRepos] = useState([]); const [pipelines, setPipelines] = useState([]); + const [selectedRepository, setSelectedRepository] = useState(commonAppComponents[0]?.repository ?? ''); + const [selectedBranch, setSelectedBranch] = useState('develop'); + const [commitRange, setCommitRange] = useState('week'); + const [commitPage, setCommitPage] = useState(1); + const [commits, setCommits] = useState([]); + const [totalCommits, setTotalCommits] = useState(null); + const [hasNextCommitPage, setHasNextCommitPage] = useState(false); const [loading, setLoading] = useState(true); + const [commitsLoading, setCommitsLoading] = useState(false); + const [commitsError, setCommitsError] = useState(''); + const [diffTarget, setDiffTarget] = useState(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 ( <> @@ -44,51 +230,194 @@ export function CommonAppSourcePage() { } /> - - -
- - - - - - - - - - - - - - {commonAppComponents.map((component) => { - const pipeline = pipelineByRepo.get(component.repository); +
+ + +
+ {sortedRepos.length === 0 && !loading ? ( +
표시할 저장소가 없습니다.
+ ) : null} +
+ {sortedRepos.map((repo) => { + const fullName = repoFullName(repo); + const pipeline = pipelineByRepo.get(fullName); + const selected = fullName === selectedRepository; return ( -
- - - - - - - - + ); })} + + + + + + +
+
+

저장소

+

{selectedRepository ? formatRepositoryName(selectedRepository) : '-'}

+
+
+

Woodpecker

+
+ {pipelineByRepo.has(selectedRepository) ? '연결됨' : '미연결'} +
+
+
+

조회 브랜치

+ +
+
+

Gitea URL

+
+

{selectedRepo?.html_url ?? '-'}

+ {selectedRepo?.html_url ? ( + + ) : null} +
+
+
+
+ + + + + {(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => ( + + ))} + + } + /> +
+
저장소구성요소브랜치최근 파이프라인상태생성시각관리
{component.repository}{component.name}{pipeline?.branch || '-'}{pipeline?.message || '-'} - {pipeline?.status || '-'} - {formatPipelineTime(pipeline?.createdAt)} - {pipeline?.url ? ( - - ) : ( - '-' - )} -
+ + + + + + + + + + + {commitsLoading ? ( + + + + ) : null} + {!commitsLoading && commitsError ? ( + + + + ) : null} + {!commitsLoading && !commitsError && commits.length === 0 ? ( + + + + ) : null} + {!commitsLoading && !commitsError ? commits.map((commit) => ( + + + + + + + + )) : null}
작성자SHA1메시지날짜상세
커밋을 불러오는 중입니다.
커밋 조회 실패: {commitsError}
표시할 커밋이 없습니다.
{commitAuthor(commit)}{commit.sha.slice(0, 10)}{commitMessage(commit)}{relativeTime(commitDate(commit))} + +
+
+ + {totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`} + +
+ + +
+
+ setDiffTarget(null)} + className="!max-w-[1400px]" + bodyClassName="max-h-[70vh] px-4 py-4" + footer={} + > + {diffTarget ? ( +
+
+
+ {diffTarget.sha.slice(0, 10)} + {commitMessage(diffTarget)} +
+

+ {commitAuthor(diffTarget)} · {relativeTime(commitDate(diffTarget))} +

+
+ {diffLoading ? ( +
Diff를 불러오는 중입니다.
+ ) : diffError ? ( +
Diff 조회 실패: {diffError}
+ ) : ( + + )} +
+ ) : null} +
+ ); diff --git a/src/pages/common-apps/common-app-utils.ts b/src/pages/common-apps/common-app-utils.ts index ed9daa8..f1f8fca 100644 --- a/src/pages/common-apps/common-app-utils.ts +++ b/src/pages/common-apps/common-app-utils.ts @@ -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);