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