520 lines
21 KiB
TypeScript
520 lines
21 KiB
TypeScript
import { Copy, ExternalLink, FileText, Link2, RotateCcw } from 'lucide-react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
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 { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
|
import {
|
|
getLauncherSource,
|
|
getGiteaCommitDiff,
|
|
listGiteaCommits,
|
|
listLauncherVersions,
|
|
listPlatformRepos,
|
|
updateLauncherSource,
|
|
type GiteaCommit,
|
|
type LauncherVersion
|
|
} from './launcher-api';
|
|
|
|
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
|
const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@localhost:2222';
|
|
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? 'platform';
|
|
|
|
type SourceTarget = 'launcher-client' | 'launcher-backend';
|
|
type CommitRange = 'week' | 'month' | 'all';
|
|
|
|
type SourceForm = {
|
|
repository: string;
|
|
image: string;
|
|
};
|
|
|
|
const targetLabels: Record<SourceTarget, { title: string; description: string; defaultOrg: string }> = {
|
|
'launcher-client': {
|
|
title: '클라이언트',
|
|
description: '사용자 PC에 설치되는 Electron .deb 빌드 소스입니다.',
|
|
defaultOrg: 'platform'
|
|
},
|
|
'launcher-backend': {
|
|
title: '백엔드',
|
|
description: 'portal-api 런처 백엔드 컨테이너 이미지 빌드 소스입니다.',
|
|
defaultOrg: 'platform'
|
|
}
|
|
};
|
|
|
|
const commitRangeLabels: Record<CommitRange, string> = {
|
|
week: '일주일',
|
|
month: '한달',
|
|
all: '전체'
|
|
};
|
|
|
|
const commitPageSize = 5;
|
|
|
|
function repoFullName(repo: GiteaRepo) {
|
|
return (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? normalizeRepository(repo.name);
|
|
}
|
|
|
|
function normalizeRepository(repository?: string | null, fallbackOrg = giteaAppOrg) {
|
|
if (!repository) return '';
|
|
const value = repository.replace(/^\/+|\/+$/g, '');
|
|
return value.includes('/') ? value : `${fallbackOrg}/${value}`;
|
|
}
|
|
|
|
function splitRepository(repository?: string | null, fallbackOrg = giteaAppOrg) {
|
|
const normalizedRepository = normalizeRepository(repository, fallbackOrg);
|
|
const [owner, ...repoParts] = normalizedRepository.split('/');
|
|
const repo = repoParts.join('/');
|
|
return owner && repo ? { owner, repo } : null;
|
|
}
|
|
|
|
function httpCloneUrl(repository?: string | null, fallbackOrg?: string) {
|
|
const normalizedRepository = normalizeRepository(repository, fallbackOrg);
|
|
return normalizedRepository ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalizedRepository}.git` : '-';
|
|
}
|
|
|
|
function sshCloneUrl(repository?: string | null, fallbackOrg?: string) {
|
|
const normalizedRepository = normalizeRepository(repository, fallbackOrg);
|
|
return normalizedRepository ? `${giteaSshBaseUrl.replace(/\/$/, '')}/${normalizedRepository}.git` : '-';
|
|
}
|
|
|
|
function emptyForm(): SourceForm {
|
|
return {
|
|
repository: '',
|
|
image: 'localhost:5000/studio-launcher-be'
|
|
};
|
|
}
|
|
|
|
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 === 2) 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();
|
|
}
|
|
|
|
export function LauncherSourcePage() {
|
|
const { toast } = useToast();
|
|
const [activeTarget, setActiveTarget] = useState<SourceTarget>('launcher-client');
|
|
const [versions, setVersions] = useState<LauncherVersion[]>([]);
|
|
const [platformRepos, setPlatformRepos] = useState<GiteaRepo[]>([]);
|
|
const [repos, setRepos] = useState<GiteaRepo[]>([]);
|
|
const [forms, setForms] = useState<Record<SourceTarget, SourceForm>>({
|
|
'launcher-client': emptyForm(),
|
|
'launcher-backend': emptyForm()
|
|
});
|
|
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
|
const [commitRange, setCommitRange] = useState<CommitRange>('week');
|
|
const [commitPage, setCommitPage] = useState(1);
|
|
const [hasNextCommitPage, setHasNextCommitPage] = useState(false);
|
|
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 [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const loadingVisible = useMinimumLoading(loading);
|
|
const sourceBranch = 'develop';
|
|
|
|
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
|
const activeForm = forms[activeTarget];
|
|
const activeTargetMeta = targetLabels[activeTarget];
|
|
|
|
const updateForm = (patch: Partial<SourceForm>) => {
|
|
setForms((prev) => ({
|
|
...prev,
|
|
[activeTarget]: {
|
|
...prev[activeTarget],
|
|
...patch
|
|
}
|
|
}));
|
|
};
|
|
|
|
const refresh = () => {
|
|
setLoading(true);
|
|
Promise.all([listLauncherVersions(), listPlatformRepos(), getLauncherSource()])
|
|
.then(([nextVersions, platformRepos, sourceResult]) => {
|
|
const nextVersion = nextVersions.find((version) => version.isActive) ?? nextVersions[0];
|
|
const clientRepository = sourceResult.source.client?.repository ?? sourceResult.source.repository ?? nextVersion?.repository ?? (platformRepos[0] ? repoFullName(platformRepos[0]) : '');
|
|
const backendRepository = sourceResult.source.backend?.repository ?? (platformRepos[0] ? repoFullName(platformRepos[0]) : '');
|
|
|
|
setVersions(nextVersions);
|
|
setPlatformRepos(platformRepos);
|
|
setRepos(platformRepos);
|
|
setForms({
|
|
'launcher-client': {
|
|
repository: clientRepository,
|
|
image: ''
|
|
},
|
|
'launcher-backend': {
|
|
repository: backendRepository,
|
|
image: sourceResult.source.backend?.image ?? 'localhost:5000/studio-launcher-be'
|
|
}
|
|
});
|
|
})
|
|
.catch((error: Error) => {
|
|
toast({ type: 'error', title: '소스 정보 조회 실패', message: error.message });
|
|
})
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setRepos(platformRepos);
|
|
}, [platformRepos]);
|
|
|
|
useEffect(() => {
|
|
setCommitPage(1);
|
|
}, [activeForm.repository, activeTarget, commitRange]);
|
|
|
|
useEffect(() => {
|
|
const repository = splitRepository(activeForm.repository, activeTargetMeta.defaultOrg);
|
|
if (!repository) {
|
|
setCommits([]);
|
|
setCommitsError('');
|
|
setHasNextCommitPage(false);
|
|
return;
|
|
}
|
|
|
|
let canceled = false;
|
|
setCommitsLoading(true);
|
|
setCommitsError('');
|
|
listGiteaCommits(repository.owner, repository.repo, sourceBranch, {
|
|
page: commitPage,
|
|
limit: commitPageSize + 1,
|
|
since: commitRangeSince(commitRange)
|
|
})
|
|
.then((nextCommits) => {
|
|
if (!canceled) {
|
|
setCommits(nextCommits.slice(0, commitPageSize));
|
|
setHasNextCommitPage(nextCommits.length > commitPageSize);
|
|
}
|
|
})
|
|
.catch((error: Error) => {
|
|
if (!canceled) {
|
|
setCommits([]);
|
|
setHasNextCommitPage(false);
|
|
setCommitsError(error.message);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!canceled) setCommitsLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
canceled = true;
|
|
};
|
|
}, [activeForm.repository, activeTargetMeta.defaultOrg, sourceBranch, commitPage, commitRange]);
|
|
|
|
const save = () => {
|
|
if (!activeForm.repository.trim()) return;
|
|
setSaving(true);
|
|
updateLauncherSource({
|
|
target: activeTarget,
|
|
repository: activeForm.repository.trim(),
|
|
branch: sourceBranch,
|
|
commitSha: '',
|
|
image: activeTarget === 'launcher-backend' ? activeForm.image.trim() || undefined : undefined,
|
|
dockerfile: activeTarget === 'launcher-backend' ? 'Dockerfile' : undefined
|
|
})
|
|
.then(() => {
|
|
toast({ type: 'success', title: '소스 연결', message: `${activeTargetMeta.title} 소스 연결을 저장했습니다.` });
|
|
refresh();
|
|
})
|
|
.catch((error: Error) => {
|
|
toast({ type: 'error', title: '소스 연결 실패', message: error.message });
|
|
})
|
|
.finally(() => setSaving(false));
|
|
};
|
|
|
|
const copy = (value: string) => {
|
|
navigator.clipboard?.writeText(value);
|
|
toast({ type: 'success', message: '복사되었습니다.' });
|
|
};
|
|
|
|
const openCommitDiff = (commit: GiteaCommit) => {
|
|
const repository = splitRepository(activeForm.repository, activeTargetMeta.defaultOrg);
|
|
if (!repository) {
|
|
toast({ type: 'error', title: 'Diff 조회 실패', message: '저장소가 연결되어 있지 않습니다.' });
|
|
return;
|
|
}
|
|
|
|
setDiffTarget(commit);
|
|
setDiffContent('');
|
|
setDiffError('');
|
|
setDiffLoading(true);
|
|
getGiteaCommitDiff(repository.owner, repository.repo, commit.sha)
|
|
.then((diff) => setDiffContent(diff))
|
|
.catch((error: Error) => setDiffError(error.message))
|
|
.finally(() => setDiffLoading(false));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="런처 운영"
|
|
title="소스 관리"
|
|
description="런처 클라이언트와 백엔드 소스 저장소를 구성요소별로 연결합니다."
|
|
actions={
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
|
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
|
새로고침
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="mb-4 flex flex-wrap gap-2">
|
|
{(Object.keys(targetLabels) as SourceTarget[]).map((target) => (
|
|
<Button
|
|
key={target}
|
|
variant={activeTarget === target ? 'default' : 'outline'}
|
|
onClick={() => setActiveTarget(target)}
|
|
>
|
|
{targetLabels[target].title}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,620px)_minmax(0,1fr)]">
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title={`${activeTargetMeta.title} 소스 연결`} description={activeTargetMeta.description} />
|
|
<div className="grid gap-4 p-5 md:grid-cols-3">
|
|
<label className="block md:col-span-3">
|
|
<span className="text-xs font-medium text-muted-foreground">저장소</span>
|
|
<select
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
value={activeForm.repository}
|
|
onChange={(event) => updateForm({ repository: event.target.value })}
|
|
>
|
|
<option value="">저장소 선택</option>
|
|
{repos.map((repo) => <option key={repoFullName(repo)} value={repoFullName(repo)}>{repoFullName(repo)}</option>)}
|
|
</select>
|
|
</label>
|
|
{activeTarget === 'launcher-backend' ? (
|
|
<>
|
|
<label className="block md:col-span-3">
|
|
<span className="text-xs font-medium text-muted-foreground">이미지 이름</span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
value={activeForm.image}
|
|
onChange={(event) => updateForm({ image: event.target.value })}
|
|
placeholder="localhost:5000/studio-launcher-be"
|
|
/>
|
|
</label>
|
|
</>
|
|
) : null}
|
|
<div className="flex justify-end md:col-span-3">
|
|
<Button onClick={save} disabled={!activeForm.repository.trim() || saving}>
|
|
<Link2 className="size-4" />
|
|
저장소 연결
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="현재 연결" description={`${activeTargetMeta.title} 빌드 기준입니다.`} />
|
|
<div className="space-y-4 p-5 text-sm">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="text-muted-foreground">기준</span>
|
|
<Badge tone={activeTarget === 'launcher-client' && activeVersion?.isActive ? 'green' : 'neutral'}>
|
|
{activeTarget === 'launcher-client' ? activeVersion?.version ?? '-' : activeForm.image || '-'}
|
|
</Badge>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">HTTP Clone URL</p>
|
|
<div className="mt-2 flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
|
<p className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">{httpCloneUrl(activeForm.repository, activeTargetMeta.defaultOrg)}</p>
|
|
<Button variant="ghost" className="size-8 px-0" onClick={() => copy(httpCloneUrl(activeForm.repository, activeTargetMeta.defaultOrg))} aria-label="HTTP clone URL 복사">
|
|
<Copy className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">SSH Clone URL</p>
|
|
<div className="mt-2 flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
|
<p className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">{sshCloneUrl(activeForm.repository, activeTargetMeta.defaultOrg)}</p>
|
|
<Button variant="ghost" className="size-8 px-0" onClick={() => copy(sshCloneUrl(activeForm.repository, activeTargetMeta.defaultOrg))} aria-label="SSH clone URL 복사">
|
|
<Copy className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{activeForm.repository ? (
|
|
<a className="inline-flex items-center gap-1.5 text-xs text-primary" href={`${giteaBaseUrl.replace(/\/$/, '')}/${normalizeRepository(activeForm.repository, activeTargetMeta.defaultOrg)}`} target="_blank" rel="noreferrer">
|
|
저장소 열기
|
|
<ExternalLink className="size-3.5" />
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card className="mt-4 min-w-0 overflow-hidden">
|
|
<CardHeader
|
|
title="커밋 목록"
|
|
description={`${activeTargetMeta.title} 저장소의 develop 브랜치 기준 커밋 내역입니다.`}
|
|
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="overflow-x-auto">
|
|
<table className="min-w-[760px] w-full 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="px-5 py-10 text-center text-sm text-muted-foreground">커밋을 불러오는 중입니다.</td>
|
|
</tr>
|
|
) : null}
|
|
{!commitsLoading && commitsError ? (
|
|
<tr>
|
|
<td colSpan={5} className="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="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">페이지 {commitPage}</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={!hasNextCommitPage || commitsLoading}
|
|
onClick={() => setCommitPage((page) => page + 1)}
|
|
>
|
|
다음
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Modal
|
|
open={Boolean(diffTarget)}
|
|
title="커밋 내역 상세"
|
|
onClose={() => setDiffTarget(null)}
|
|
className="max-w-5xl"
|
|
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>
|
|
) : (
|
|
<pre className="max-h-[58vh] overflow-auto rounded-md border border-border bg-background p-4 font-mono text-xs leading-5 text-muted-foreground">
|
|
{diffContent || '표시할 변경 내역이 없습니다.'}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
|
|
<LoadingModal open={loadingVisible} />
|
|
</>
|
|
);
|
|
}
|