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
@@ -1,12 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus } from 'lucide-react';
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import type { AppRelease, BuildJob } from '../../types/application';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import { AppSelector, formatDate, useApps } from './ApplicationControls';
import { createRelease, listBuildJobsPage, listReleasesPage } from './application-api';
import { createRelease, hideReleaseCandidate, listBuildJobsPage, listReleasesPage } from './application-api';
function statusLabel(status: string) {
return {
@@ -165,7 +165,9 @@ export function ReleaseManagementPage() {
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
const [selectedBuildIds, setSelectedBuildIds] = useState<string[]>([]);
const [selectedReleaseLogId, setSelectedReleaseLogId] = useState('');
const [hideTargetAppId, setHideTargetAppId] = useState('');
const [loading, setLoading] = useState(true);
const [hidingCandidates, setHidingCandidates] = useState(false);
const [page, setPage] = useState(1);
const [range, setRange] = useState<ReleaseRange>('week');
const [totalReleases, setTotalReleases] = useState(0);
@@ -210,7 +212,8 @@ export function ReleaseManagementPage() {
page: 1,
limit: 100,
since: releaseRangeSince(range),
status: 'SUCCEEDED'
status: 'SUCCEEDED',
hideReleaseCandidates: true
})
]);
setReleases(nextReleases.releases);
@@ -285,6 +288,28 @@ export function ReleaseManagementPage() {
}
};
const hideTargetBuilds = useMemo(
() => candidateBuilds.filter((build) => build.component.app.id === hideTargetAppId),
[candidateBuilds, hideTargetAppId]
);
const hideTargetAppName = hideTargetBuilds[0]?.component.app.name ?? '';
const hideCandidates = async () => {
if (!hideTargetAppId || hidingCandidates) return;
setHidingCandidates(true);
try {
await Promise.all(hideTargetBuilds.map((build) => hideReleaseCandidate(build.id)));
toast({ type: 'success', message: '릴리즈 후보에서 숨겼습니다.' });
setHideTargetAppId('');
await refresh();
} catch (error) {
toast({ type: 'error', message: error instanceof Error ? error.message : '릴리즈 후보 숨김에 실패했습니다.' });
} finally {
setHidingCandidates(false);
}
};
const selectedReleaseLog = releases.find((release) => release.id === selectedReleaseLogId) ?? null;
return (
@@ -406,15 +431,24 @@ export function ReleaseManagementPage() {
</div>
) : null}
{candidateGroups.map((group) => (
<button
<div
key={group.appId}
type="button"
className="flex w-full items-center justify-between rounded-md border border-border bg-background px-3 py-3 text-left transition-colors hover:bg-muted/20"
onClick={() => openReleaseModal(group.appId)}
className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-3 transition-colors hover:bg-muted/20"
>
<span className="text-sm font-medium">{group.appName}</span>
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => openReleaseModal(group.appId)}>
<span className="block truncate text-sm font-medium">{group.appName}</span>
</button>
<Badge tone={group.count > 0 ? 'green' : 'neutral'}>{group.count}개</Badge>
</button>
<Button
variant="ghost"
className="size-8 shrink-0 px-0 text-rose-300 hover:text-rose-200"
title="후보 숨김"
aria-label={`${group.appName} 릴리즈 후보 숨김`}
onClick={() => setHideTargetAppId(group.appId)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
</Card>
@@ -478,6 +512,31 @@ export function ReleaseManagementPage() {
</div>
)}
</Modal>
<Modal
open={Boolean(hideTargetAppId)}
title="릴리즈 후보 숨김"
onClose={() => {
if (!hidingCandidates) setHideTargetAppId('');
}}
footer={(
<>
<Button variant="outline" disabled={hidingCandidates} onClick={() => setHideTargetAppId('')}>취소</Button>
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={hidingCandidates || hideTargetBuilds.length === 0} onClick={hideCandidates}>
{hidingCandidates ? '숨기는 중' : '숨김'}
</Button>
</>
)}
>
<div className="space-y-3 text-sm">
<p>
<span className="font-semibold text-foreground">{hideTargetAppName || '-'}</span>
{' '}릴리즈 후보 {hideTargetBuilds.length}개를 목록에서 숨깁니다.
</p>
<p className="text-muted-foreground">
빌드 이력과 로그는 삭제되지 않습니다.
</p>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);