merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-09 13:50:25 +09:00
5 changed files with 58 additions and 30 deletions
+8 -8
View File
@@ -73,12 +73,12 @@ function getTone(status: string) {
return 'neutral'; return 'neutral';
} }
function formatStatus(status?: string | null) { function formatStatus(status?: string | null, launcherLinked?: boolean) {
if (status === 'DISABLED' || status === 'INACTIVE') return '비활성';
if (launcherLinked) return '활성';
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
ACTIVE: '준비됨', ACTIVE: '활성',
READY: '준비됨', READY: '준비됨',
INACTIVE: '비활성',
DISABLED: '비활성',
ERROR: '오류' ERROR: '오류'
}; };
@@ -906,7 +906,9 @@ export function AppManagementPage() {
<td className="px-5 py-3">{app.name}</td> <td className="px-5 py-3">{app.name}</td>
<td className="px-5 py-3 text-muted-foreground">{app.namespace || '-'}</td> <td className="px-5 py-3 text-muted-foreground">{app.namespace || '-'}</td>
<td className="px-5 py-3">{app.components?.length ?? 0}</td> <td className="px-5 py-3">{app.components?.length ?? 0}</td>
<td className="px-5 py-3"><Badge tone={getTone(app.status)}>{formatStatus(app.status)}</Badge></td> <td className="px-5 py-3">
<Badge tone={getTone(formatStatus(app.status, app.launcherLinked))}>{formatStatus(app.status, app.launcherLinked)}</Badge>
</td>
<td className="px-5 py-3"> <td className="px-5 py-3">
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
@@ -952,7 +954,7 @@ export function AppManagementPage() {
['네임스페이스', selectedApp.namespace], ['네임스페이스', selectedApp.namespace],
['연결 소스', `${linkedComponents}개`], ['연결 소스', `${linkedComponents}개`],
['구성요소', `${totalComponents}개`], ['구성요소', `${totalComponents}개`],
['상태', formatStatus(selectedApp.status)] ['상태', formatStatus(selectedApp.status, selectedApp.launcherLinked)]
].map(([label, value]) => ( ].map(([label, value]) => (
<div key={label} className="rounded-md border border-border bg-muted/20 p-4"> <div key={label} className="rounded-md border border-border bg-muted/20 p-4">
<p className="text-xs text-muted-foreground">{label}</p> <p className="text-xs text-muted-foreground">{label}</p>
@@ -1008,7 +1010,6 @@ export function AppManagementPage() {
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<span className={cn('size-2.5 shrink-0 rounded-full', running ? statusDotToneClass('green') : component.repository ? statusDotToneClass('blue') : statusDotToneClass('neutral'))} /> <span className={cn('size-2.5 shrink-0 rounded-full', running ? statusDotToneClass('green') : component.repository ? statusDotToneClass('blue') : statusDotToneClass('neutral'))} />
{component.name} {component.name}
{running ? <Badge tone="green">실행 중</Badge> : null}
</span> </span>
</td> </td>
<td className="px-5 py-3 text-muted-foreground">{imageSourceLabel}</td> <td className="px-5 py-3 text-muted-foreground">{imageSourceLabel}</td>
@@ -1091,7 +1092,6 @@ export function AppManagementPage() {
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<span className={cn('block size-2.5 shrink-0 rounded-full', stopped ? statusDotToneClass('neutral') : runtimeStatusDotClass(resource.status))} /> <span className={cn('block size-2.5 shrink-0 rounded-full', stopped ? statusDotToneClass('neutral') : runtimeStatusDotClass(resource.status))} />
<span className="text-sm text-foreground">{resource.name}</span> <span className="text-sm text-foreground">{resource.name}</span>
<Badge tone={runtimeStatusTone(resource, stopped)}>{runtimeStatusLabel(resource, stopped)}</Badge>
</span> </span>
</td> </td>
<td className="px-5 py-3 text-foreground">{resource.kind}</td> <td className="px-5 py-3 text-foreground">{resource.kind}</td>
@@ -389,7 +389,6 @@ export function RepositoryManagementPage() {
<input <input
value={repoName} value={repoName}
onChange={(event) => handleRepoNameChange(event.target.value)} onChange={(event) => handleRepoNameChange(event.target.value)}
placeholder="lms-fe"
className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground" className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground"
/> />
</div> </div>
+29 -20
View File
@@ -62,11 +62,19 @@ function splitRepository(repository?: string | null) {
return owner && repo ? { owner, repo } : null; return owner && repo ? { owner, repo } : null;
} }
function repositoryGitUrl(repository?: string | null) { function httpCloneUrl(repository?: string | null) {
const normalized = normalizeRepository(repository); const normalized = normalizeRepository(repository);
return normalized ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalized}.git` : '-'; return normalized ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalized}.git` : '-';
} }
function sshCloneUrl(repository?: string | null) {
const normalized = normalizeRepository(repository);
if (!normalized) return '-';
const base = giteaBaseUrl.replace(/\/$/, '');
const host = base.replace(/^https?:\/\//, '').split('/')[0]?.split(':')[0] || 'localhost';
return `ssh://git@${host}:2222/${normalized}.git`;
}
function commitAuthor(commit: GiteaCommit) { function commitAuthor(commit: GiteaCommit) {
return commit.author?.login return commit.author?.login
|| commit.author?.username || commit.author?.username
@@ -321,13 +329,13 @@ export function SourceManagementPage() {
const selectedSource = filteredSources.find((source) => source.id === selectedSourceId) ?? filteredSources[0] ?? null; const selectedSource = filteredSources.find((source) => source.id === selectedSourceId) ?? filteredSources[0] ?? null;
const repository = splitRepository(selectedSource?.repository); const repository = splitRepository(selectedSource?.repository);
const branch = selectedBranch || 'develop'; const branch = selectedBranch || 'develop';
const latestBranchSha = commits.length > 0 ? commits[0]?.sha ?? '' : '';
const branchOptions = useMemo( const branchOptions = useMemo(
() => [...new Set(['develop', 'release', 'main', selectedSource?.branch].filter((item): item is string => Boolean(item)))], () => [...new Set(['develop', 'release', 'main', selectedSource?.branch].filter((item): item is string => Boolean(item)))],
[selectedSource?.branch] [selectedSource?.branch]
); );
const totalCommitPages = totalCommits === null ? null : Math.max(1, Math.ceil(totalCommits / commitPageSize)); const totalCommitPages = totalCommits === null ? null : Math.max(1, Math.ceil(totalCommits / commitPageSize));
const selectedRepositoryGitUrl = repositoryGitUrl(selectedSource?.repository); const selectedHttpCloneUrl = httpCloneUrl(selectedSource?.repository);
const selectedSshCloneUrl = sshCloneUrl(selectedSource?.repository);
const refreshSources = () => { const refreshSources = () => {
setSourcesLoading(true); setSourcesLoading(true);
@@ -426,11 +434,11 @@ export function SourceManagementPage() {
.finally(() => setDiffLoading(false)); .finally(() => setDiffLoading(false));
}; };
const copyGitUrl = () => { const copyCloneUrl = (value: string, label: string) => {
if (!selectedSource?.repository || selectedRepositoryGitUrl === '-') return; if (!selectedSource?.repository || value === '-') return;
navigator.clipboard?.writeText(selectedRepositoryGitUrl) navigator.clipboard?.writeText(value)
.then(() => toast({ type: 'success', message: 'Git URL을 복사했습니다.' })) .then(() => toast({ type: 'success', message: `${label}을 복사했습니다.` }))
.catch(() => toast({ type: 'error', message: 'Git URL 복사에 실패했습니다.' })); .catch(() => toast({ type: 'error', message: `${label} 복사에 실패했습니다.` }));
}; };
return ( return (
@@ -472,7 +480,7 @@ export function SourceManagementPage() {
</select> </select>
</label> </label>
</div> </div>
<div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 text-sm md:grid-cols-2"> <div className="space-y-3 rounded-md border border-border bg-muted/20 p-3 text-sm">
<div> <div>
<p className="text-xs text-muted-foreground">조회 브랜치</p> <p className="text-xs text-muted-foreground">조회 브랜치</p>
<select <select
@@ -485,27 +493,28 @@ export function SourceManagementPage() {
))} ))}
</select> </select>
</div> </div>
<div className="min-w-0 md:col-span-2"> <div className="min-w-0">
<p className="text-xs text-muted-foreground">저장소</p> <p className="text-xs text-muted-foreground">저장소</p>
<div className="mt-2 rounded-md border border-border bg-background px-3 py-2"> <div className="mt-2 rounded-md border border-border bg-background px-3 py-2">
<p className="truncate text-sm font-medium">{normalizeRepository(selectedSource?.repository) || '-'}</p> <p className="truncate text-sm font-medium">{normalizeRepository(selectedSource?.repository) || '-'}</p>
</div> </div>
</div> </div>
<div className="min-w-0 md:col-span-2"> <div className="min-w-0">
<p className="text-xs text-muted-foreground">Git URL</p> <p className="text-xs text-muted-foreground">HTTP Clone URL</p>
<div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2"> <div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium" title={selectedRepositoryGitUrl}>{selectedRepositoryGitUrl}</p> <p className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground" title={selectedHttpCloneUrl}>{selectedHttpCloneUrl}</p>
<Button variant="ghost" className="size-8 shrink-0 px-0" title="Git URL 복사" aria-label="Git URL 복사" onClick={copyGitUrl} disabled={!selectedSource?.repository}> <Button variant="ghost" className="size-8 shrink-0 px-0" title="HTTP Clone URL 복사" aria-label="HTTP Clone URL 복사" onClick={() => copyCloneUrl(selectedHttpCloneUrl, 'HTTP Clone URL')} disabled={!selectedSource?.repository}>
<Copy className="size-4" /> <Copy className="size-4" />
</Button> </Button>
</div> </div>
</div> </div>
<div className="min-w-0 md:col-span-2"> <div className="min-w-0">
<p className="text-xs text-muted-foreground">SHA1</p> <p className="text-xs text-muted-foreground">SSH Clone URL</p>
<div className="mt-2 rounded-md border border-border bg-background px-3 py-2"> <div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
<p className="truncate font-mono text-sm font-medium" title={latestBranchSha || '-'}> <p className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground" title={selectedSshCloneUrl}>{selectedSshCloneUrl}</p>
{latestBranchSha || '-'} <Button variant="ghost" className="size-8 shrink-0 px-0" title="SSH Clone URL 복사" aria-label="SSH Clone URL 복사" onClick={() => copyCloneUrl(selectedSshCloneUrl, 'SSH Clone URL')} disabled={!selectedSource?.repository}>
</p> <Copy className="size-4" />
</Button>
</div> </div>
</div> </div>
</div> </div>
+20 -1
View File
@@ -64,6 +64,23 @@ function statusTone(status?: string): 'green' | 'amber' | 'red' | 'neutral' {
return 'neutral'; return 'neutral';
} }
function buildStatusText(status?: string) {
if (status === 'SUCCEEDED') return '성공';
if (status === 'FAILED') return '실패';
if (status === 'RUNNING') return '진행 중';
if (status === 'PENDING') return '대기';
if (status === 'CANCELED') return '취소';
return status ?? '빌드 이력 없음';
}
function buildStatusTone(status?: string): 'green' | 'amber' | 'red' | 'blue' | 'neutral' {
if (status === 'SUCCEEDED') return 'green';
if (status === 'FAILED' || status === 'CANCELED') return 'red';
if (status === 'RUNNING') return 'blue';
if (status === 'PENDING') return 'amber';
return 'neutral';
}
function componentIcon(type: LauncherComponentDefinition['type']) { function componentIcon(type: LauncherComponentDefinition['type']) {
if (type === 'client') return FileArchive; if (type === 'client') return FileArchive;
if (type === 'database') return Database; if (type === 'database') return Database;
@@ -249,7 +266,9 @@ export function LauncherComponentsPage() {
<Card className="p-5"> <Card className="p-5">
<p className="text-sm text-muted-foreground">최근 클라이언트 빌드</p> <p className="text-sm text-muted-foreground">최근 클라이언트 빌드</p>
<p className="mt-3 text-2xl font-semibold">{latestBuild?.version ?? '-'}</p> <p className="mt-3 text-2xl font-semibold">{latestBuild?.version ?? '-'}</p>
<p className="mt-1 text-xs text-muted-foreground">{latestBuild?.status ?? '빌드 이력 없음'}</p> <div className="mt-2">
<Badge tone={buildStatusTone(latestBuild?.status)}>{buildStatusText(latestBuild?.status)}</Badge>
</div>
</Card> </Card>
<Card className="p-5"> <Card className="p-5">
<p className="text-sm text-muted-foreground">Namespace</p> <p className="text-sm text-muted-foreground">Namespace</p>
+1
View File
@@ -40,6 +40,7 @@ export type ApiApp = {
description?: string | null; description?: string | null;
namespace?: string | null; namespace?: string | null;
status: string; status: string;
launcherLinked?: boolean;
components?: ApiAppComponent[]; components?: ApiAppComponent[];
}; };