improve launcher history and runtime config UX
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-07 09:43:18 +09:00
parent ff23415818
commit a48055bf65
7 changed files with 182 additions and 81 deletions
+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>