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:
@@ -7,7 +7,7 @@ import type { BuildJob, SourceComponent } from '../../types/application';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
|
||||
import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobsPage, listSources } from './application-api';
|
||||
import { getStoredSelectedComponentId, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||
import { getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||
|
||||
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
||||
if (event.nativeEvent.isTrusted) return false;
|
||||
@@ -93,8 +93,10 @@ export function BuildManagementPage() {
|
||||
const [branch, setBranch] = useState('develop');
|
||||
const [commitSha, setCommitSha] = useState('');
|
||||
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<BuildJob | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deletingJob, setDeletingJob] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [range, setRange] = useState<BuildRange>('week');
|
||||
const [totalJobs, setTotalJobs] = useState(0);
|
||||
@@ -199,6 +201,27 @@ export function BuildManagementPage() {
|
||||
}
|
||||
}, [componentOptions, selectedAppId, selectedComponentId]);
|
||||
|
||||
useEffect(() => {
|
||||
const storedComponentId = getStoredSelectedComponentId(selectedAppId);
|
||||
if (!storedComponentId || storedComponentId === selectedComponentId) return;
|
||||
if (!componentOptions.some((source) => source.id === storedComponentId)) return;
|
||||
setSelectedComponentId(storedComponentId);
|
||||
setBranch('develop');
|
||||
}, [componentOptions, selectedAppId, selectedComponentId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectedComponentChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ appId?: string; componentId?: string }>).detail;
|
||||
if (detail?.appId !== selectedAppId || !detail.componentId) return;
|
||||
if (!componentOptions.some((source) => source.id === detail.componentId)) return;
|
||||
setSelectedComponentId(detail.componentId);
|
||||
setBranch('develop');
|
||||
};
|
||||
|
||||
window.addEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
|
||||
return () => window.removeEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
|
||||
}, [componentOptions, selectedAppId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [selectedAppId, range]);
|
||||
@@ -237,13 +260,18 @@ export function BuildManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const removeJob = async (id: string) => {
|
||||
const removeJob = async () => {
|
||||
if (!deleteTarget || deletingJob) return;
|
||||
setDeletingJob(true);
|
||||
try {
|
||||
await deleteBuildJob(id);
|
||||
await deleteBuildJob(deleteTarget.id);
|
||||
toast({ type: 'success', message: '빌드 작업이 삭제되었습니다.' });
|
||||
setDeleteTarget(null);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '삭제에 실패했습니다.' });
|
||||
} finally {
|
||||
setDeletingJob(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -376,13 +404,13 @@ export function BuildManagementPage() {
|
||||
)}
|
||||
{isBuildDone(job.status) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8 px-0"
|
||||
variant="ghost"
|
||||
className="size-8 px-0 text-rose-300 hover:text-rose-200"
|
||||
title="삭제"
|
||||
aria-label="삭제"
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
removeJob(job.id);
|
||||
setDeleteTarget(job);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
@@ -451,6 +479,31 @@ export function BuildManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(deleteTarget)}
|
||||
title="빌드 작업 삭제"
|
||||
onClose={() => {
|
||||
if (!deletingJob) setDeleteTarget(null);
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" disabled={deletingJob} onClick={() => setDeleteTarget(null)}>취소</Button>
|
||||
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={deletingJob} onClick={removeJob}>
|
||||
{deletingJob ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
<span className="font-semibold text-foreground">{deleteTarget?.component.app.name ?? '-'}</span>
|
||||
{' / '}
|
||||
<span className="font-semibold text-foreground">{deleteTarget?.component.name ?? '-'}</span>
|
||||
{' '}빌드 작업을 삭제합니다.
|
||||
</p>
|
||||
<p className="text-muted-foreground">삭제된 빌드 작업 이력과 로그는 목록에서 사라집니다.</p>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
title="빌드 실행 확인"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, 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, DeploymentJob } from '../../types/application';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { AppSelector, formatDate, useApps } from './ApplicationControls';
|
||||
import { createDeployment, listDeploymentsPage, listReleases } from './application-api';
|
||||
import { createDeployment, deleteRelease, listDeploymentsPage, listReleases } from './application-api';
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return {
|
||||
@@ -79,8 +79,10 @@ export function DeploymentManagementPage() {
|
||||
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
||||
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
||||
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AppRelease | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [deletingRelease, setDeletingRelease] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [range, setRange] = useState<DeploymentRange>('week');
|
||||
const [totalDeployments, setTotalDeployments] = useState(0);
|
||||
@@ -175,6 +177,21 @@ export function DeploymentManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const removeRelease = async () => {
|
||||
if (!deleteTarget || deletingRelease) return;
|
||||
setDeletingRelease(true);
|
||||
try {
|
||||
await deleteRelease(deleteTarget.id);
|
||||
toast({ type: 'success', message: '릴리즈를 삭제했습니다.' });
|
||||
setDeleteTarget(null);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '릴리즈 삭제에 실패했습니다.' });
|
||||
} finally {
|
||||
setDeletingRelease(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -298,18 +315,29 @@ export function DeploymentManagementPage() {
|
||||
</div>
|
||||
) : null}
|
||||
{deployableCards.map((release) => (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
key={release.id}
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-3 text-left transition-colors hover:bg-muted/20"
|
||||
onClick={() => openDeployModal(release)}
|
||||
className="rounded-md border border-border bg-background px-3 py-3 transition-colors hover:bg-muted/20"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">{release.app.name}</span>
|
||||
<Badge tone="green">{release.version}</Badge>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => openDeployModal(release)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{release.app.name}</span>
|
||||
<Badge tone="green">{release.version}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 truncate font-mono text-xs text-muted-foreground">{firstArtifact(release)}</p>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 shrink-0 px-0 text-rose-300 hover:text-rose-200"
|
||||
title="릴리즈 삭제"
|
||||
aria-label={`${release.app.name} ${release.version} 릴리즈 삭제`}
|
||||
onClick={() => setDeleteTarget(release)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 truncate font-mono text-xs text-muted-foreground">{firstArtifact(release)}</p>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -358,6 +386,34 @@ export function DeploymentManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(deleteTarget)}
|
||||
title="릴리즈 삭제"
|
||||
onClose={() => {
|
||||
if (!deletingRelease) setDeleteTarget(null);
|
||||
}}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" disabled={deletingRelease} onClick={() => setDeleteTarget(null)}>취소</Button>
|
||||
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={deletingRelease} onClick={removeRelease}>
|
||||
{deletingRelease ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
<span className="font-semibold text-foreground">{deleteTarget?.app.name ?? '-'}</span>
|
||||
{' '}
|
||||
<span className="font-semibold text-foreground">{deleteTarget?.version ?? '-'}</span>
|
||||
{' '}릴리즈를 삭제합니다.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
이미 성공 배포된 릴리즈는 서버에서 삭제가 차단됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
listSources,
|
||||
type GiteaCommit
|
||||
} from './application-api';
|
||||
import { getStoredSelectedComponentId, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||
import { getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||
|
||||
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
||||
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? import.meta.env.GITEA_APP_ORG ?? 'platform';
|
||||
@@ -352,6 +352,25 @@ export function SourceManagementPage() {
|
||||
});
|
||||
}, [filteredSources, selectedAppId]);
|
||||
|
||||
useEffect(() => {
|
||||
const storedSourceId = getStoredSelectedComponentId(selectedAppId);
|
||||
if (!storedSourceId || storedSourceId === selectedSourceId) return;
|
||||
if (!filteredSources.some((source) => source.id === storedSourceId)) return;
|
||||
setSelectedSourceId(storedSourceId);
|
||||
}, [filteredSources, selectedAppId, selectedSourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectedComponentChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ appId?: string; componentId?: string }>).detail;
|
||||
if (detail?.appId !== selectedAppId || !detail.componentId) return;
|
||||
if (!filteredSources.some((source) => source.id === detail.componentId)) return;
|
||||
setSelectedSourceId(detail.componentId);
|
||||
};
|
||||
|
||||
window.addEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
|
||||
return () => window.removeEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
|
||||
}, [filteredSources, selectedAppId]);
|
||||
|
||||
useEffect(() => {
|
||||
setCommitPage(1);
|
||||
}, [selectedSourceId, selectedBranch, commitRange]);
|
||||
|
||||
@@ -150,7 +150,7 @@ export function listBuildJobs(appKey?: string) {
|
||||
return apiRequest<BuildJob[]>(`/apps/builds${query}`);
|
||||
}
|
||||
|
||||
export function listBuildJobsPage(options: { appKey?: string; page: number; limit: number; since?: string; status?: string }) {
|
||||
export function listBuildJobsPage(options: { appKey?: string; page: number; limit: number; since?: string; status?: string; hideReleaseCandidates?: boolean }) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(options.page),
|
||||
limit: String(options.limit)
|
||||
@@ -158,6 +158,7 @@ export function listBuildJobsPage(options: { appKey?: string; page: number; limi
|
||||
if (options.appKey) params.set('appKey', options.appKey);
|
||||
if (options.since) params.set('since', options.since);
|
||||
if (options.status) params.set('status', options.status);
|
||||
if (options.hideReleaseCandidates) params.set('hideReleaseCandidates', 'true');
|
||||
return apiRequest<{ jobs: BuildJob[]; total: number; page: number; limit: number }>(`/apps/builds?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -176,6 +177,12 @@ export function deleteBuildJob(buildJobId: string) {
|
||||
return apiRequest<{ deleted: boolean }>(`/apps/builds/${buildJobId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function hideReleaseCandidate(buildJobId: string) {
|
||||
return apiRequest<{ buildJob: BuildJob }>(`/apps/builds/${buildJobId}/hide-release-candidate`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
}
|
||||
|
||||
export function listReleases(appKey?: string) {
|
||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||
return apiRequest<AppRelease[]>(`/apps/releases${query}`);
|
||||
@@ -198,6 +205,12 @@ export function createRelease(input: { appId: string; version: string; branch?:
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRelease(releaseId: string) {
|
||||
return apiRequest<{ deleted: boolean }>(`/apps/releases/${releaseId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export function listDeployments(appKey?: string) {
|
||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||
return apiRequest<DeploymentJob[]>(`/apps/deployments${query}`);
|
||||
|
||||
Reference in New Issue
Block a user