improve admin release and deployment safeguards
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user