Files
studio-admin-fe/src/pages/launcher/LauncherSourcePage.tsx
T
chyoo 1d182e8fc6
ci/woodpecker/push/woodpecker Pipeline was successful
align launcher source and build handling
2026-07-06 10:03:54 +09:00

385 lines
16 KiB
TypeScript

import { Copy, ExternalLink, Link2, 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 type { GiteaRepo } from '../../types/application';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import {
getLauncherSource,
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 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'
}
};
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));
}
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 [commitsLoading, setCommitsLoading] = useState(false);
const [commitsError, setCommitsError] = 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(() => {
const repository = splitRepository(activeForm.repository, activeTargetMeta.defaultOrg);
if (!repository) {
setCommits([]);
setCommitsError('');
return;
}
let canceled = false;
setCommitsLoading(true);
setCommitsError('');
listGiteaCommits(repository.owner, repository.repo, sourceBranch, 5)
.then((nextCommits) => {
if (!canceled) setCommits(nextCommits);
})
.catch((error: Error) => {
if (!canceled) {
setCommits([]);
setCommitsError(error.message);
}
})
.finally(() => {
if (!canceled) setCommitsLoading(false);
});
return () => {
canceled = true;
};
}, [activeForm.repository, activeTargetMeta.defaultOrg, sourceBranch]);
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: '복사되었습니다.' });
};
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 브랜치 기준 최근 5개 커밋입니다.`} />
<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>
</tr>
</thead>
<tbody>
{commitsLoading ? (
<tr>
<td colSpan={4} className="px-5 py-10 text-center text-sm text-muted-foreground">커밋을 불러오는 중입니다.</td>
</tr>
) : null}
{!commitsLoading && commitsError ? (
<tr>
<td colSpan={4} className="px-5 py-10 text-center text-sm text-muted-foreground">커밋 조회 실패: {commitsError}</td>
</tr>
) : null}
{!commitsLoading && !commitsError && commits.length === 0 ? (
<tr>
<td colSpan={4} 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>
</tr>
)) : null}
</tbody>
</table>
</div>
</Card>
<LoadingModal open={loadingVisible} />
</>
);
}