paginate app operations and refine runtime settings layout
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
2026-07-07 15:32:56 +09:00
parent 1de085790d
commit 9cb82249d7
14 changed files with 805 additions and 214 deletions
+129 -22
View File
@@ -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>