improve launcher history and runtime config UX
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 376 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user