improve admin release and deployment safeguards
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-09 09:46:36 +09:00
parent fd590540bc
commit f67017623f
9 changed files with 394 additions and 45 deletions
+116 -9
View File
@@ -4,8 +4,8 @@ import { Badge, Button, Card, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { formatDateTime } from '../../lib/date-format';
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
import type { ApiApp, RuntimeEnvProfile, RuntimeResource } from '../../types/runtime';
import { getStoredSelectedAppId, getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedAppId, setStoredSelectedComponentId } from '../../lib/selected-app';
import type { ApiApp, ApiAppComponent, RuntimeEnvProfile, RuntimeEnvVariable, RuntimeIngressRule, RuntimeResource } from '../../types/runtime';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import {
applyRuntimeProfile,
@@ -67,6 +67,13 @@ function runtimeStatusTone(resource: RuntimeResource, stopped: boolean): 'neutra
return 'blue';
}
function preferredComponentId(appId: string, components: ApiAppComponent[]) {
const storedComponentId = getStoredSelectedComponentId(appId);
return components.some((component) => component.id === storedComponentId)
? storedComponentId
: components[0]?.id ?? '';
}
export function RuntimeConfigPage() {
const { toast } = useToast();
const [apps, setApps] = useState<ApiApp[]>([]);
@@ -79,6 +86,8 @@ export function RuntimeConfigPage() {
const [actionKey, setActionKey] = useState('');
const [error, setError] = useState('');
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [deleteVariableTarget, setDeleteVariableTarget] = useState<RuntimeEnvVariable | null>(null);
const [deleteIngressTarget, setDeleteIngressTarget] = useState<RuntimeIngressRule | null>(null);
const loadingVisible = useMinimumLoading(loading);
const [profileName, setProfileName] = useState('');
@@ -156,11 +165,13 @@ export function RuntimeConfigPage() {
const storedAppId = getStoredSelectedAppId();
const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
const nextApp = nextApps.find((app) => app.id === nextAppId);
const nextComponentId = preferredComponentId(nextAppId, nextApp?.components ?? []);
setApps(nextApps);
setAppId(nextAppId);
setProfileNamespace(nextApp?.namespace ?? '');
setVariableComponentId(nextApp?.components?.[0]?.id ?? '');
setIngressComponentId(nextApp?.components?.[0]?.id ?? '');
setVariableComponentId(nextComponentId);
setIngressComponentId(nextComponentId);
if (nextComponentId) setStoredSelectedComponentId(nextAppId, nextComponentId);
setIngressPort('');
return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]);
})
@@ -171,15 +182,38 @@ export function RuntimeConfigPage() {
const changeApp = (nextAppId: string) => {
const nextApp = apps.find((app) => app.id === nextAppId);
const nextComponentId = preferredComponentId(nextAppId, nextApp?.components ?? []);
setStoredSelectedAppId(nextAppId);
setAppId(nextAppId);
setProfileNamespace(nextApp?.namespace ?? '');
setVariableComponentId(nextApp?.components?.[0]?.id ?? '');
setIngressComponentId(nextApp?.components?.[0]?.id ?? '');
setVariableComponentId(nextComponentId);
setIngressComponentId(nextComponentId);
if (nextComponentId) setStoredSelectedComponentId(nextAppId, nextComponentId);
setIngressPort('');
refreshProfiles(nextAppId).catch((loadError: Error) => setError(loadError.message));
};
useEffect(() => {
const storedComponentId = preferredComponentId(appId, components);
if (!storedComponentId) return;
if (variableComponentId !== storedComponentId) setVariableComponentId(storedComponentId);
if (ingressComponentId !== storedComponentId) setIngressComponentId(storedComponentId);
}, [appId, components, ingressComponentId, variableComponentId]);
useEffect(() => {
const handleSelectedComponentChange = (event: Event) => {
const detail = (event as CustomEvent<{ appId?: string; componentId?: string }>).detail;
if (detail?.appId !== appId || !detail.componentId) return;
if (!components.some((component) => component.id === detail.componentId)) return;
setVariableComponentId(detail.componentId);
setIngressComponentId(detail.componentId);
setIngressPort('');
};
window.addEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
return () => window.removeEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
}, [appId, components]);
const addProfile = () => {
if (!appId || !profileName.trim() || !profileNamespace.trim()) return;
if (duplicateProfile) {
@@ -493,7 +527,7 @@ export function RuntimeConfigPage() {
<td className="break-all px-4 py-3 font-mono text-xs font-medium text-foreground">{variable.key}</td>
<td className="break-all px-4 py-3 font-mono text-xs text-muted-foreground">{variable.isSecret ? '********' : variable.value}</td>
<td className="px-3 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)} disabled={Boolean(actionKey)}>
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${variable.key} 삭제`} onClick={() => setDeleteVariableTarget(variable)} disabled={Boolean(actionKey)}>
<Trash2 className="size-4" />
</Button>
</td>
@@ -514,7 +548,15 @@ export function RuntimeConfigPage() {
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={variableComponentId} onChange={(event) => setVariableComponentId(event.target.value)}>
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={variableComponentId}
onChange={(event) => {
const nextId = event.target.value;
setVariableComponentId(nextId);
setStoredSelectedComponentId(appId, nextId);
}}
>
{components.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
{components.length === 0 ? <option value="">구성요소 없음</option> : null}
</select>
@@ -593,7 +635,7 @@ export function RuntimeConfigPage() {
<td className="px-4 py-3 text-muted-foreground">{rule.servicePort}</td>
<td className="px-4 py-3 text-muted-foreground">{rule.stripPrefix ? '제거' : '유지'}</td>
<td className="px-3 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)} disabled={Boolean(actionKey)}>
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${rule.host} 삭제`} onClick={() => setDeleteIngressTarget(rule)} disabled={Boolean(actionKey)}>
<Trash2 className="size-4" />
</Button>
</td>
@@ -616,6 +658,7 @@ export function RuntimeConfigPage() {
onChange={(event) => {
const nextId = event.target.value;
setIngressComponentId(nextId);
setStoredSelectedComponentId(appId, nextId);
setIngressPort('');
}}
>
@@ -698,6 +741,70 @@ export function RuntimeConfigPage() {
) : null}
</div>
</Modal>
<Modal
open={Boolean(deleteVariableTarget)}
title="설정값 삭제"
onClose={() => {
if (!actionKey) setDeleteVariableTarget(null);
}}
footer={(
<>
<Button variant="outline" disabled={Boolean(actionKey)} onClick={() => setDeleteVariableTarget(null)}>취소</Button>
<Button
className="bg-rose-600 text-white hover:bg-rose-500"
disabled={Boolean(actionKey)}
onClick={() => {
if (!deleteVariableTarget) return;
removeVariable(deleteVariableTarget.id);
setDeleteVariableTarget(null);
}}
>
삭제
</Button>
</>
)}
>
<div className="space-y-3 text-sm">
<p>
<span className="font-semibold text-foreground">{deleteVariableTarget?.key ?? '-'}</span>
{' '}설정값을 삭제합니다.
</p>
<p className="text-muted-foreground">삭제 후 클러스터 설정에 바로 반영됩니다.</p>
</div>
</Modal>
<Modal
open={Boolean(deleteIngressTarget)}
title="접속 설정 삭제"
onClose={() => {
if (!actionKey) setDeleteIngressTarget(null);
}}
footer={(
<>
<Button variant="outline" disabled={Boolean(actionKey)} onClick={() => setDeleteIngressTarget(null)}>취소</Button>
<Button
className="bg-rose-600 text-white hover:bg-rose-500"
disabled={Boolean(actionKey)}
onClick={() => {
if (!deleteIngressTarget) return;
removeIngressRule(deleteIngressTarget.id);
setDeleteIngressTarget(null);
}}
>
삭제
</Button>
</>
)}
>
<div className="space-y-3 text-sm">
<p>
<span className="font-semibold text-foreground">{deleteIngressTarget?.host ?? '-'}</span>
{' '}
<span className="font-semibold text-foreground">{deleteIngressTarget?.path ?? ''}</span>
{' '}접속 설정을 삭제합니다.
</p>
<p className="text-muted-foreground">삭제 후 클러스터 설정에 바로 반영됩니다.</p>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);