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

This commit is contained in:
2026-07-07 09:43:22 +09:00
7 changed files with 182 additions and 81 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

+4 -2
View File
@@ -110,7 +110,8 @@ export function Modal({
children,
footer,
onClose,
className
className,
bodyClassName
}: {
open: boolean;
title: ReactNode;
@@ -118,6 +119,7 @@ export function Modal({
footer?: ReactNode;
onClose: () => void;
className?: string;
bodyClassName?: string;
}) {
if (!open) return null;
@@ -135,7 +137,7 @@ export function Modal({
<X className="size-4" />
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-5 py-5">{children}</div>
<div className={cn('max-h-[70vh] overflow-y-auto px-5 py-5', bodyClassName)}>{children}</div>
{footer ? <div className="flex justify-end gap-2 border-t border-border px-5 py-4">{footer}</div> : null}
</div>
</div>
+6 -6
View File
@@ -272,10 +272,16 @@ export function LauncherBuildPage() {
title="빌드 관리"
description="런처 클라이언트 설치 파일과 백엔드 컨테이너 이미지를 구성요소별로 빌드합니다."
actions={
<div className="flex items-center gap-2">
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
새로고침
</Button>
<Button className="h-8 px-3 text-[13px]" onClick={() => setConfirmOpen(true)}>
<Play className="size-4" />
실행
</Button>
</div>
}
/>
@@ -381,12 +387,6 @@ export function LauncherBuildPage() {
</div>
}
/>
<div className="flex justify-end px-4 pt-4">
<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>
<div className="min-h-[638px] space-y-3 p-4">
{jobs.length === 0 ? (
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
@@ -232,7 +232,7 @@ export function LauncherComponentsPage() {
title="구성요소"
description="런처 클라이언트, 백엔드, DB의 배포 기준과 런타임 상태를 한 화면에서 확인합니다."
actions={
<Button className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
새로고침
</Button>
+106 -17
View File
@@ -50,6 +50,14 @@ const commitRangeLabels: Record<CommitRange, string> = {
const commitPageSize = 5;
type DiffFile = {
id: string;
path: string;
lines: string[];
added: number;
removed: number;
};
function repoFullName(repo: GiteaRepo) {
return (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? normalizeRepository(repo.name);
}
@@ -130,38 +138,118 @@ function commitRangeSince(range: CommitRange) {
return date.toISOString();
}
function normalizeDiffPath(value?: string) {
if (!value) return '';
const path = value.trim();
if (path === '/dev/null') return path;
return path.replace(/^[ab]\//, '');
}
function diffPathFromHeader(line: string) {
const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
return normalizeDiffPath(match?.[2] ?? match?.[1]);
}
function parseDiffFiles(diff: string): DiffFile[] {
if (!diff.trim()) {
return [{ id: 'empty', path: '변경 내역', lines: ['표시할 변경 내역이 없습니다.'], added: 0, removed: 0 }];
}
const files: DiffFile[] = [];
let current: DiffFile | null = null;
const pushCurrent = () => {
if (current) files.push(current);
};
diff.split(/\r?\n/).forEach((line) => {
if (line.startsWith('diff --git ')) {
pushCurrent();
current = {
id: `file-${files.length}`,
path: diffPathFromHeader(line) || '변경 파일',
lines: [line],
added: 0,
removed: 0
};
return;
}
if (!current) {
current = {
id: 'file-0',
path: '변경 내역',
lines: [],
added: 0,
removed: 0
};
}
if (line.startsWith('+++ ')) {
const nextPath = normalizeDiffPath(line.slice(4));
if (nextPath && nextPath !== '/dev/null') current.path = nextPath;
}
if (line.startsWith('+') && !line.startsWith('+++')) current.added += 1;
if (line.startsWith('-') && !line.startsWith('---')) current.removed += 1;
current.lines.push(line);
});
pushCurrent();
return files;
}
function diffLineClass(line: string) {
if (line.startsWith('+++') || line.startsWith('---')) {
return 'bg-sky-500/10 text-sky-200';
return 'bg-[#111827] text-sky-200';
}
if (line.startsWith('@@')) {
return 'bg-violet-500/15 text-violet-200';
return 'bg-[#1e1b4b] text-violet-100';
}
if (line.startsWith('+')) {
return 'bg-emerald-500/15 text-emerald-100';
return 'bg-[#123524] text-emerald-50';
}
if (line.startsWith('-')) {
return 'bg-rose-500/15 text-rose-100';
return 'bg-[#3a1418] text-rose-50';
}
if (line.startsWith('diff --git') || line.startsWith('index ')) {
return 'bg-muted/40 text-foreground';
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-muted-foreground';
return 'text-slate-300';
}
function DiffViewer({ diff }: { diff: string }) {
const lines = diff ? diff.split(/\r?\n/) : ['표시할 변경 내역이 없습니다.'];
const files = parseDiffFiles(diff);
return (
<div className="max-h-[70vh] overflow-auto rounded-md border border-border bg-[#0b0f14] font-mono text-xs leading-5">
{lines.map((line, index) => (
<div key={`${index}-${line}`} className={`grid min-w-max 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-[11px] text-muted-foreground">
<div className="h-[72vh] overflow-auto rounded-md border border-border bg-[#080b10] p-3 font-mono text-xs leading-5">
<div className="space-y-4">
{files.map((file) => (
<section key={file.id} className="min-w-max overflow-hidden rounded-md border border-slate-700/80 bg-[#0b0f14]">
<div className="sticky top-0 z-10 flex items-center justify-between gap-4 border-b border-slate-700/80 bg-[#111827] px-4 py-2">
<div className="min-w-0">
<p className="truncate text-[13px] font-semibold text-slate-100">{file.path}</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px]">
<span className="rounded bg-emerald-500/15 px-2 py-0.5 font-semibold text-emerald-100">+{file.added}</span>
<span className="rounded bg-rose-500/15 px-2 py-0.5 font-semibold text-rose-100">-{file.removed}</span>
</div>
</div>
{file.lines.map((line, index) => (
<div key={`${file.id}-${index}-${line}`} className={`grid grid-cols-[72px_minmax(0,1fr)] ${diffLineClass(line)}`}>
<span className="select-none border-r border-white/10 px-3 py-0.5 text-right text-[11px] text-slate-500">
{index + 1}
</span>
<pre className="whitespace-pre px-3 py-0.5">{line || ' '}</pre>
</div>
))}
</section>
))}
</div>
</div>
);
}
@@ -451,7 +539,7 @@ export function LauncherSourcePage() {
</div>
}
/>
<div className="overflow-x-auto">
<div className="min-h-[284px] 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>
@@ -465,17 +553,17 @@ export function LauncherSourcePage() {
<tbody>
{commitsLoading ? (
<tr>
<td colSpan={5} className="px-5 py-10 text-center text-sm text-muted-foreground">커밋을 불러오는 중입니다.</td>
<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="px-5 py-10 text-center text-sm text-muted-foreground">커밋 조회 실패: {commitsError}</td>
<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="px-5 py-10 text-center text-sm text-muted-foreground">표시할 커밋이 없습니다.</td>
<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) => (
@@ -529,7 +617,8 @@ export function LauncherSourcePage() {
open={Boolean(diffTarget)}
title="커밋 내역 상세"
onClose={() => setDiffTarget(null)}
className="max-w-[min(96vw,1400px)]"
className="max-w-[min(98vw,1800px)]"
bodyClassName="max-h-[84vh] px-4 py-4"
footer={<Button variant="outline" onClick={() => setDiffTarget(null)}>닫기</Button>}
>
{diffTarget ? (
+55 -45
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Play, Plus, RotateCcw, Square, Trash2 } from 'lucide-react';
import { Plus, RotateCcw, Square, Trash2 } from 'lucide-react';
import { Badge, Button, Card, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
@@ -161,7 +161,7 @@ export function RuntimeConfigPage() {
setProfileNamespace(nextApp?.namespace ?? '');
setVariableComponentId(nextApp?.components?.[0]?.id ?? '');
setIngressComponentId(nextApp?.components?.[0]?.id ?? '');
setIngressPort(nextApp?.components?.[0]?.port ? String(nextApp.components[0].port) : '');
setIngressPort('');
return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]);
})
.then((nextProfiles) => applyProfiles(nextProfiles))
@@ -176,7 +176,7 @@ export function RuntimeConfigPage() {
setProfileNamespace(nextApp?.namespace ?? '');
setVariableComponentId(nextApp?.components?.[0]?.id ?? '');
setIngressComponentId(nextApp?.components?.[0]?.id ?? '');
setIngressPort(nextApp?.components?.[0]?.port ? String(nextApp.components[0].port) : '');
setIngressPort('');
refreshProfiles(nextAppId).catch((loadError: Error) => setError(loadError.message));
};
@@ -209,14 +209,17 @@ export function RuntimeConfigPage() {
const addVariable = () => {
if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return;
const profile = selectedProfile;
const key = variableKey.trim();
setError('');
setActionKey(`variable-add-${profile.id}`);
createRuntimeVariable(selectedProfile.id, {
componentId: variableComponentId,
key,
value: variableValue.trim(),
isSecret: variableIsSecret
})
.then(() => applyRuntimeProfile(profile.id))
.then(() => {
setVariableKey('');
setVariableValue('');
@@ -224,58 +227,80 @@ export function RuntimeConfigPage() {
toast({
type: 'success',
title: '설정값 등록',
message: `${key} 설정값을 추가했습니다. 실행 중인 파드에는 적용 버튼을 눌러야 반영됩니다.`
message: `${key} 설정값을 추가하고 클러스터 설정에 반영했습니다.`
});
refreshProfiles(appId, selectedProfile.id);
refreshResources(profile.namespace);
refreshProfiles(appId, profile.id);
})
.catch((createError: Error) => {
setError(createError.message);
toast({ type: 'error', title: '설정값 등록 실패', message: createError.message });
});
})
.finally(() => setActionKey(''));
};
const removeVariable = (variableId: string) => {
if (!selectedProfile) return;
deleteRuntimeVariable(selectedProfile.id, variableId)
.then(() => refreshProfiles(appId, selectedProfile.id))
.catch((deleteError: Error) => setError(deleteError.message));
const profile = selectedProfile;
setActionKey(`variable-delete-${variableId}`);
deleteRuntimeVariable(profile.id, variableId)
.then(() => applyRuntimeProfile(profile.id))
.then(() => {
toast({ type: 'success', title: '설정값 삭제', message: '설정값을 삭제하고 클러스터 설정에 반영했습니다.' });
refreshResources(profile.namespace);
refreshProfiles(appId, profile.id);
})
.catch((deleteError: Error) => {
setError(deleteError.message);
toast({ type: 'error', title: '설정값 삭제 실패', message: deleteError.message });
})
.finally(() => setActionKey(''));
};
const addIngressRule = () => {
if (!selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()) return;
createRuntimeIngressRule(selectedProfile.id, {
const profile = selectedProfile;
const host = ingressHost.trim();
setActionKey(`ingress-add-${profile.id}`);
createRuntimeIngressRule(profile.id, {
componentId: ingressComponentId,
host: ingressHost.trim(),
host,
path: ingressPath.trim() || '/',
servicePort: Number(ingressPort),
stripPrefix: ingressStripPrefix,
tlsEnabled: false
})
.then(() => applyRuntimeProfile(profile.id))
.then(() => {
setIngressHost('');
setIngressPath('/');
setIngressStripPrefix(false);
refreshProfiles(appId, selectedProfile.id);
toast({ type: 'success', title: '접속 설정 등록', message: `${host} 접속 설정을 추가하고 클러스터 설정에 반영했습니다.` });
refreshResources(profile.namespace);
refreshProfiles(appId, profile.id);
})
.catch((createError: Error) => setError(createError.message));
.catch((createError: Error) => {
setError(createError.message);
toast({ type: 'error', title: '접속 설정 등록 실패', message: createError.message });
})
.finally(() => setActionKey(''));
};
const removeIngressRule = (ruleId: string) => {
if (!selectedProfile) return;
deleteRuntimeIngressRule(selectedProfile.id, ruleId)
.then(() => refreshProfiles(appId, selectedProfile.id))
.catch((deleteError: Error) => setError(deleteError.message));
};
const applyProfile = (profile = selectedProfile) => {
if (!profile) return;
setActionKey(`profile-${profile.id}`);
applyRuntimeProfile(profile.id)
const profile = selectedProfile;
setActionKey(`ingress-delete-${ruleId}`);
deleteRuntimeIngressRule(profile.id, ruleId)
.then(() => applyRuntimeProfile(profile.id))
.then(() => {
toast({ type: 'success', title: '접속 설정 삭제', message: '접속 설정을 삭제하고 클러스터 설정에 반영했습니다.' });
refreshResources(profile.namespace);
refreshProfiles(appId, profile.id);
})
.catch((applyError: Error) => setError(applyError.message))
.catch((deleteError: Error) => {
setError(deleteError.message);
toast({ type: 'error', title: '접속 설정 삭제 실패', message: deleteError.message });
})
.finally(() => setActionKey(''));
};
@@ -422,20 +447,6 @@ export function RuntimeConfigPage() {
<span>수정 <b className="text-foreground">{formatDate(profile.updatedAt ?? profile.createdAt)}</b></span>
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-2">
<Button
variant="outline"
className="h-8 px-3 text-[13px]"
disabled={actionKey === `profile-${profile.id}`}
onClick={(event) => {
event.stopPropagation();
applyProfile(profile);
}}
>
<Play className="size-4" />
적용
</Button>
</div>
</div>
</div>
))}
@@ -478,7 +489,7 @@ export function RuntimeConfigPage() {
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{variable.isSecret ? '********' : variable.value}</td>
<td className="px-5 py-3 text-xs text-muted-foreground">{formatDate(variable.createdAt)}</td>
<td className="px-5 py-3 text-right">
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${variable.key} 삭제`} onClick={() => removeVariable(variable.id)}>
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${variable.key} 삭제`} onClick={() => removeVariable(variable.id)} disabled={Boolean(actionKey)}>
<Trash2 className="size-4" />
</Button>
</td>
@@ -524,9 +535,9 @@ export function RuntimeConfigPage() {
placeholder="값 입력"
/>
</label>
<Button className="h-9 w-full text-[13px]" onClick={addVariable} disabled={!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()}>
<Button className="h-9 w-full text-[13px]" onClick={addVariable} disabled={Boolean(actionKey) || !selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()}>
<Plus className="size-4" />
추가
{actionKey.startsWith('variable-add-') ? '저장 중' : '추가'}
</Button>
</div>
</Card>
@@ -570,7 +581,7 @@ export function RuntimeConfigPage() {
<td className="px-5 py-3 text-muted-foreground">{rule.servicePort}</td>
<td className="px-5 py-3 text-muted-foreground">{rule.stripPrefix ? '제거' : '유지'}</td>
<td className="px-5 py-3 text-right">
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${rule.host} 삭제`} onClick={() => removeIngressRule(rule.id)}>
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${rule.host} 삭제`} onClick={() => removeIngressRule(rule.id)} disabled={Boolean(actionKey)}>
<Trash2 className="size-4" />
</Button>
</td>
@@ -592,9 +603,8 @@ export function RuntimeConfigPage() {
value={ingressComponentId}
onChange={(event) => {
const nextId = event.target.value;
const component = components.find((item) => item.id === nextId);
setIngressComponentId(nextId);
setIngressPort(component?.port ? String(component.port) : '');
setIngressPort('');
}}
>
{components.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
@@ -624,9 +634,9 @@ export function RuntimeConfigPage() {
/>
Path prefix 제거
</label>
<Button className="h-9 w-full text-[13px]" onClick={addIngressRule} disabled={!selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()}>
<Button className="h-9 w-full text-[13px]" onClick={addIngressRule} disabled={Boolean(actionKey) || !selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()}>
<Plus className="size-4" />
추가
{actionKey.startsWith('ingress-add-') ? '저장 중' : '추가'}
</Button>
</div>
</Card>
+1 -1
View File
@@ -230,7 +230,7 @@ export function RuntimeLogsPage() {
title="로그/이벤트"
description="네임스페이스 기준으로 워크로드 로그와 이벤트를 확인합니다."
actions={(
<Button className="h-8 px-3 text-[13px]" onClick={() => refresh()} disabled={loading || !namespace.trim()}>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => refresh()} disabled={loading || !namespace.trim()}>
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
새로고침
</Button>