paginate app operations and refine runtime settings layout
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type MouseEvent } from 'react';
|
||||
import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
@@ -53,6 +53,17 @@ function useMinimumLoading(loading: boolean, minimumMs = 500) {
|
||||
return visible;
|
||||
}
|
||||
|
||||
function isPageAgentClick(event: MouseEvent<HTMLElement>) {
|
||||
return !event.nativeEvent.isTrusted;
|
||||
}
|
||||
|
||||
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
||||
if (!isPageAgentClick(event)) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
function getTone(status: string) {
|
||||
if (['활성', '완료', '게시됨', '실행 중', '배포됨', 'ACTIVE', 'READY'].includes(status)) return 'green';
|
||||
if (['주의', '예약됨', '대기', '준비중', '카나리'].includes(status)) return 'amber';
|
||||
@@ -304,6 +315,14 @@ export function AppManagementPage() {
|
||||
const [buildEnvVariables, setBuildEnvVariables] = useState<BuildEnvVariableForm[]>([emptyBuildEnvVariable()]);
|
||||
const [buildEnvStatus, setBuildEnvStatus] = useState<'idle' | 'loading' | 'saving' | 'error'>('idle');
|
||||
const [deletingApp, setDeletingApp] = useState<ApiApp | null>(null);
|
||||
const [appDeleteStatus, setAppDeleteStatus] = useState<'idle' | 'deleting' | 'error'>('idle');
|
||||
const [appDeleteOptions, setAppDeleteOptions] = useState({
|
||||
unlinkSources: true,
|
||||
deleteRuntimeSettings: true,
|
||||
scaleDownWorkloads: true,
|
||||
deleteKubernetesResources: false,
|
||||
deletePersistentVolumes: false
|
||||
});
|
||||
const [deletingComponent, setDeletingComponent] = useState<ApiAppComponent | null>(null);
|
||||
const [runtimeResources, setRuntimeResources] = useState<RuntimeResource[]>([]);
|
||||
const [workloadActionKey, setWorkloadActionKey] = useState('');
|
||||
@@ -806,7 +825,15 @@ export function AppManagementPage() {
|
||||
const deleteApp = () => {
|
||||
if (!deletingApp) return;
|
||||
|
||||
apiFetch(`${apiBaseUrl}/apps/${deletingApp.id}`, { method: 'DELETE', headers: authHeaders() })
|
||||
setAppDeleteStatus('deleting');
|
||||
apiFetch(`${apiBaseUrl}/apps/${deletingApp.id}/decommission`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders()
|
||||
},
|
||||
body: JSON.stringify(appDeleteOptions)
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error('App delete failed');
|
||||
return response.json().catch(() => null);
|
||||
@@ -815,6 +842,7 @@ export function AppManagementPage() {
|
||||
.then((apps) => {
|
||||
setApiApps(apps);
|
||||
setDeletingApp(null);
|
||||
setAppDeleteStatus('idle');
|
||||
setSelectedAppId((current) => {
|
||||
if (current !== deletingApp.id) return current;
|
||||
const nextAppId = apps[0]?.id ?? '';
|
||||
@@ -824,10 +852,19 @@ export function AppManagementPage() {
|
||||
toast({ type: 'success', title: '앱 삭제', message: '앱 그룹이 삭제되었습니다.' });
|
||||
})
|
||||
.catch(() => {
|
||||
setAppDeleteStatus('error');
|
||||
toast({ type: 'error', title: '앱 삭제 실패', message: '참조 이력 또는 백엔드 API 상태를 확인하세요.' });
|
||||
});
|
||||
};
|
||||
|
||||
const updateAppDeleteOption = (key: keyof typeof appDeleteOptions, value: boolean) => {
|
||||
setAppDeleteOptions((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
...(key === 'deleteKubernetesResources' && !value ? { deletePersistentVolumes: false } : {})
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -883,10 +920,19 @@ export function AppManagementPage() {
|
||||
className="size-8 border-rose-500/50 px-0 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||
title="삭제"
|
||||
aria-label="삭제"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setDeletingApp(app);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
event.stopPropagation();
|
||||
setAppDeleteStatus('idle');
|
||||
setAppDeleteOptions({
|
||||
unlinkSources: true,
|
||||
deleteRuntimeSettings: true,
|
||||
scaleDownWorkloads: true,
|
||||
deleteKubernetesResources: false,
|
||||
deletePersistentVolumes: false
|
||||
});
|
||||
setDeletingApp(app);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
@@ -994,7 +1040,10 @@ export function AppManagementPage() {
|
||||
className="size-8 border-rose-500/50 px-0 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||
title="삭제"
|
||||
aria-label="삭제"
|
||||
onClick={() => setDeletingComponent(component)}
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
setDeletingComponent(component);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
@@ -1084,7 +1133,10 @@ export function AppManagementPage() {
|
||||
title="삭제"
|
||||
aria-label={`${resource.name} 삭제`}
|
||||
disabled={pending}
|
||||
onClick={() => requestDeleteWorkload(resource)}
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
requestDeleteWorkload(resource);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
@@ -1095,7 +1147,10 @@ export function AppManagementPage() {
|
||||
title={stopped ? '데이터 볼륨 삭제' : '중지 후 데이터 볼륨 삭제 가능'}
|
||||
aria-label={`${resource.name} 데이터 볼륨 삭제`}
|
||||
disabled={pending || !stopped}
|
||||
onClick={() => requestDeleteVolume(resource)}
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
requestDeleteVolume(resource);
|
||||
}}
|
||||
>
|
||||
<Database className="size-4" />
|
||||
</Button>
|
||||
@@ -1330,7 +1385,10 @@ export function AppManagementPage() {
|
||||
variant="ghost"
|
||||
className="size-9 justify-self-end px-0 text-rose-300"
|
||||
disabled={componentPorts.length === 1}
|
||||
onClick={() => setComponentPorts((prev) => prev.filter((_, portIndex) => portIndex !== index))}
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
setComponentPorts((prev) => prev.filter((_, portIndex) => portIndex !== index));
|
||||
}}
|
||||
title="포트 삭제"
|
||||
aria-label="포트 삭제"
|
||||
>
|
||||
@@ -1472,7 +1530,10 @@ export function AppManagementPage() {
|
||||
variant="ghost"
|
||||
className="size-9 justify-self-end px-0 text-rose-300"
|
||||
disabled={buildEnvVariables.length === 1}
|
||||
onClick={() => setBuildEnvVariables((prev) => prev.filter((_, itemIndex) => itemIndex !== index))}
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
setBuildEnvVariables((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
|
||||
}}
|
||||
title="환경변수 삭제"
|
||||
aria-label="환경변수 삭제"
|
||||
>
|
||||
@@ -1514,7 +1575,15 @@ export function AppManagementPage() {
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<Button variant="ghost" onClick={() => setDeletingComponent(null)}>취소</Button>
|
||||
<Button variant="secondary" onClick={deleteComponent}>삭제</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
deleteComponent();
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1522,27 +1591,59 @@ export function AppManagementPage() {
|
||||
|
||||
{deletingApp ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card shadow-xl">
|
||||
<div className="w-full max-w-xl rounded-lg border border-border bg-card shadow-xl">
|
||||
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
|
||||
<Trash2 className="size-4 text-rose-300" />
|
||||
<h2 className="text-sm font-semibold">앱 삭제</h2>
|
||||
<h2 className="text-sm font-semibold">앱 폐기</h2>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-6 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{deletingApp.name}</span> 앱 그룹을 삭제합니다.
|
||||
<span className="font-semibold text-foreground">{deletingApp.name}</span> 앱을 목록에서 삭제하고 선택한 운영 리소스를 정리합니다.
|
||||
</p>
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-200">
|
||||
앱 관리 데이터만 삭제합니다. 네임스페이스, 워크로드, 볼륨은 자동 삭제하지 않습니다.
|
||||
<div className="space-y-2 rounded-md border border-border bg-muted/20 p-3">
|
||||
{[
|
||||
['unlinkSources', '저장소 연결 정보 삭제'],
|
||||
['deleteRuntimeSettings', '실행 설정 삭제'],
|
||||
['scaleDownWorkloads', 'Kubernetes 워크로드 중지'],
|
||||
['deleteKubernetesResources', 'Kubernetes 리소스 삭제'],
|
||||
['deletePersistentVolumes', 'PVC/데이터 볼륨 삭제']
|
||||
].map(([key, label]) => {
|
||||
const optionKey = key as keyof typeof appDeleteOptions;
|
||||
const disabled = optionKey === 'deletePersistentVolumes' && !appDeleteOptions.deleteKubernetesResources;
|
||||
|
||||
return (
|
||||
<label key={key} className={cn('flex items-center gap-2 text-sm', disabled && 'opacity-50')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appDeleteOptions[optionKey]}
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateAppDeleteOption(optionKey, event.target.checked)}
|
||||
className="size-4 accent-primary"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-200">
|
||||
PVC/데이터 볼륨 삭제는 되돌릴 수 없습니다. DB 데이터 백업을 확인한 경우에만 선택하세요.
|
||||
</div>
|
||||
{appDeleteStatus === 'error' ? (
|
||||
<p className="text-xs text-rose-300">앱 폐기에 실패했습니다. 백엔드 로그와 Kubernetes 권한을 확인하세요.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<Button variant="ghost" onClick={() => setDeletingApp(null)}>취소</Button>
|
||||
<Button variant="ghost" onClick={() => setDeletingApp(null)} disabled={appDeleteStatus === 'deleting'}>취소</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-rose-500/50 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||
onClick={deleteApp}
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
deleteApp();
|
||||
}}
|
||||
disabled={appDeleteStatus === 'deleting'}
|
||||
>
|
||||
삭제
|
||||
{appDeleteStatus === 'deleting' ? '폐기 중' : '폐기'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1598,7 +1699,10 @@ export function AppManagementPage() {
|
||||
variant="outline"
|
||||
className="border-rose-500/50 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||
disabled={workloadActionKey === `delete-${deletingWorkload.kind}-${deletingWorkload.name}`}
|
||||
onClick={confirmDeleteWorkload}
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
confirmDeleteWorkload();
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
@@ -1713,7 +1817,10 @@ export function AppManagementPage() {
|
||||
variant="outline"
|
||||
className="border-rose-500/50 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||
disabled={!canDeleteVolume}
|
||||
onClick={confirmDeleteVolume}
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
confirmDeleteVolume();
|
||||
}}
|
||||
>
|
||||
{volumeDeleteStatus === 'deleting' ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user