replace launcher common settings with internal launcher settings
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-09 09:46:39 +09:00
9 changed files with 394 additions and 45 deletions
+9 -3
View File
@@ -532,8 +532,11 @@ function Sidebar({
return ( return (
<aside className="fixed inset-y-0 left-0 hidden w-[17rem] flex-col border-r border-border bg-sidebar px-3 py-4 lg:flex"> <aside className="fixed inset-y-0 left-0 hidden w-[17rem] flex-col border-r border-border bg-sidebar px-3 py-4 lg:flex">
<div className="mb-8 flex shrink-0 justify-center px-4 py-3"> <div className="mb-8 shrink-0 px-4 py-3 text-center">
<img className="h-5 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" /> <img className="mx-auto h-5 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" />
<p className="mt-2 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Studio Management System
</p>
</div> </div>
<nav className="scrollbar-stable min-h-0 flex-1 space-y-5 overflow-y-auto pr-2"> <nav className="scrollbar-stable min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
@@ -767,10 +770,13 @@ function LoginPage({ onLogin }: { onLogin: (admin: CurrentAdmin) => void }) {
<div className="min-h-screen bg-background px-4 text-foreground"> <div className="min-h-screen bg-background px-4 text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-sm items-center justify-center py-12"> <div className="mx-auto flex min-h-screen w-full max-w-sm items-center justify-center py-12">
<div className="w-full rounded-lg border border-border bg-card/95 shadow-2xl"> <div className="w-full rounded-lg border border-border bg-card/95 shadow-2xl">
<div className="px-6 pb-5 pt-8"> <div className="px-6 pb-6 pt-8">
<div className="flex justify-center"> <div className="flex justify-center">
<img className="h-6 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" /> <img className="h-6 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" />
</div> </div>
<p className="mt-2 text-center text-[9px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Studio Management System
</p>
<div className="mt-7 text-center"> <div className="mt-7 text-center">
<h1 className="text-xl font-semibold tracking-tight">관리자 로그인</h1> <h1 className="text-xl font-semibold tracking-tight">관리자 로그인</h1>
<p className="mt-2 text-sm text-muted-foreground">계정 정보를 입력해 주세요.</p> <p className="mt-2 text-sm text-muted-foreground">계정 정보를 입력해 주세요.</p>
+4
View File
@@ -1,5 +1,6 @@
export const selectedAppStorageKey = 'studio-admin:selected-app-id'; export const selectedAppStorageKey = 'studio-admin:selected-app-id';
export const selectedComponentByAppStorageKey = 'studio-admin:selected-component-by-app-id'; export const selectedComponentByAppStorageKey = 'studio-admin:selected-component-by-app-id';
export const selectedComponentChangedEventName = 'studio-admin:selected-component-change';
const legacySelectedAppStorageKey = 'studio-selected-app-id'; const legacySelectedAppStorageKey = 'studio-selected-app-id';
export function getStoredSelectedAppId() { export function getStoredSelectedAppId() {
@@ -48,4 +49,7 @@ export function setStoredSelectedComponentId(appId: string, componentId: string)
...readSelectedComponentMap(), ...readSelectedComponentMap(),
[appId]: componentId, [appId]: componentId,
})); }));
window.dispatchEvent(new CustomEvent(selectedComponentChangedEventName, {
detail: { appId, componentId },
}));
} }
+59 -6
View File
@@ -7,7 +7,7 @@ import type { BuildJob, SourceComponent } from '../../types/application';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls'; import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobsPage, listSources } from './application-api'; 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>) { function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
if (event.nativeEvent.isTrusted) return false; if (event.nativeEvent.isTrusted) return false;
@@ -93,8 +93,10 @@ export function BuildManagementPage() {
const [branch, setBranch] = useState('develop'); const [branch, setBranch] = useState('develop');
const [commitSha, setCommitSha] = useState(''); const [commitSha, setCommitSha] = useState('');
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null); const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
const [deleteTarget, setDeleteTarget] = useState<BuildJob | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [deletingJob, setDeletingJob] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [range, setRange] = useState<BuildRange>('week'); const [range, setRange] = useState<BuildRange>('week');
const [totalJobs, setTotalJobs] = useState(0); const [totalJobs, setTotalJobs] = useState(0);
@@ -199,6 +201,27 @@ export function BuildManagementPage() {
} }
}, [componentOptions, selectedAppId, selectedComponentId]); }, [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(() => { useEffect(() => {
setPage(1); setPage(1);
}, [selectedAppId, range]); }, [selectedAppId, range]);
@@ -237,13 +260,18 @@ export function BuildManagementPage() {
} }
}; };
const removeJob = async (id: string) => { const removeJob = async () => {
if (!deleteTarget || deletingJob) return;
setDeletingJob(true);
try { try {
await deleteBuildJob(id); await deleteBuildJob(deleteTarget.id);
toast({ type: 'success', message: '빌드 작업이 삭제되었습니다.' }); toast({ type: 'success', message: '빌드 작업이 삭제되었습니다.' });
setDeleteTarget(null);
await refresh(); await refresh();
} catch (error) { } catch (error) {
toast({ type: 'error', message: error instanceof Error ? error.message : '삭제에 실패했습니다.' }); toast({ type: 'error', message: error instanceof Error ? error.message : '삭제에 실패했습니다.' });
} finally {
setDeletingJob(false);
} }
}; };
@@ -376,13 +404,13 @@ export function BuildManagementPage() {
)} )}
{isBuildDone(job.status) ? ( {isBuildDone(job.status) ? (
<Button <Button
variant="outline" variant="ghost"
className="size-8 px-0" className="size-8 px-0 text-rose-300 hover:text-rose-200"
title="삭제" title="삭제"
aria-label="삭제" aria-label="삭제"
onClick={(event) => { onClick={(event) => {
if (blockPageAgentDestructiveClick(event)) return; if (blockPageAgentDestructiveClick(event)) return;
removeJob(job.id); setDeleteTarget(job);
}} }}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
@@ -451,6 +479,31 @@ export function BuildManagementPage() {
</div> </div>
)} )}
</Modal> </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 <Modal
open={confirmOpen} open={confirmOpen}
title="빌드 실행 확인" title="빌드 실행 확인"
@@ -1,12 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; 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 { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import type { AppRelease, DeploymentJob } from '../../types/application'; import type { AppRelease, DeploymentJob } from '../../types/application';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import { AppSelector, formatDate, useApps } from './ApplicationControls'; 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) { function statusLabel(status: string) {
return { return {
@@ -79,8 +79,10 @@ export function DeploymentManagementPage() {
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true); const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
const [deployModalOpen, setDeployModalOpen] = useState(false); const [deployModalOpen, setDeployModalOpen] = useState(false);
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null); const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AppRelease | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [deploying, setDeploying] = useState(false); const [deploying, setDeploying] = useState(false);
const [deletingRelease, setDeletingRelease] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [range, setRange] = useState<DeploymentRange>('week'); const [range, setRange] = useState<DeploymentRange>('week');
const [totalDeployments, setTotalDeployments] = useState(0); 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 ( return (
<> <>
<PageTitle <PageTitle
@@ -298,18 +315,29 @@ export function DeploymentManagementPage() {
</div> </div>
) : null} ) : null}
{deployableCards.map((release) => ( {deployableCards.map((release) => (
<button <div
type="button"
key={release.id} 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" className="rounded-md border border-border bg-background px-3 py-3 transition-colors hover:bg-muted/20"
onClick={() => openDeployModal(release)}
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<span className="text-sm font-medium">{release.app.name}</span> <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> <Badge tone="green">{release.version}</Badge>
</div> </div>
<p className="mt-2 truncate font-mono text-xs text-muted-foreground">{firstArtifact(release)}</p> <p className="mt-2 truncate font-mono text-xs text-muted-foreground">{firstArtifact(release)}</p>
</button> </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>
</div>
))} ))}
</div> </div>
</Card> </Card>
@@ -358,6 +386,34 @@ export function DeploymentManagementPage() {
</div> </div>
)} )}
</Modal> </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} /> <LoadingModal open={loadingVisible} />
</> </>
); );
@@ -1,12 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; 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 { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import type { AppRelease, BuildJob } from '../../types/application'; import type { AppRelease, BuildJob } from '../../types/application';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import { AppSelector, formatDate, useApps } from './ApplicationControls'; 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) { function statusLabel(status: string) {
return { return {
@@ -165,7 +165,9 @@ export function ReleaseManagementPage() {
const [releaseModalOpen, setReleaseModalOpen] = useState(false); const [releaseModalOpen, setReleaseModalOpen] = useState(false);
const [selectedBuildIds, setSelectedBuildIds] = useState<string[]>([]); const [selectedBuildIds, setSelectedBuildIds] = useState<string[]>([]);
const [selectedReleaseLogId, setSelectedReleaseLogId] = useState(''); const [selectedReleaseLogId, setSelectedReleaseLogId] = useState('');
const [hideTargetAppId, setHideTargetAppId] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hidingCandidates, setHidingCandidates] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [range, setRange] = useState<ReleaseRange>('week'); const [range, setRange] = useState<ReleaseRange>('week');
const [totalReleases, setTotalReleases] = useState(0); const [totalReleases, setTotalReleases] = useState(0);
@@ -210,7 +212,8 @@ export function ReleaseManagementPage() {
page: 1, page: 1,
limit: 100, limit: 100,
since: releaseRangeSince(range), since: releaseRangeSince(range),
status: 'SUCCEEDED' status: 'SUCCEEDED',
hideReleaseCandidates: true
}) })
]); ]);
setReleases(nextReleases.releases); 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; const selectedReleaseLog = releases.find((release) => release.id === selectedReleaseLogId) ?? null;
return ( return (
@@ -406,15 +431,24 @@ export function ReleaseManagementPage() {
</div> </div>
) : null} ) : null}
{candidateGroups.map((group) => ( {candidateGroups.map((group) => (
<button <div
key={group.appId} key={group.appId}
type="button" 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"
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)}
> >
<span className="text-sm font-medium">{group.appName}</span> <button type="button" className="min-w-0 flex-1 text-left" onClick={() => openReleaseModal(group.appId)}>
<Badge tone={group.count > 0 ? 'green' : 'neutral'}>{group.count}개</Badge> <span className="block truncate text-sm font-medium">{group.appName}</span>
</button> </button>
<Badge tone={group.count > 0 ? 'green' : 'neutral'}>{group.count}개</Badge>
<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> </div>
</Card> </Card>
@@ -478,6 +512,31 @@ export function ReleaseManagementPage() {
</div> </div>
)} )}
</Modal> </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} /> <LoadingModal open={loadingVisible} />
</> </>
); );
@@ -12,7 +12,7 @@ import {
listSources, listSources,
type GiteaCommit type GiteaCommit
} from './application-api'; } 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 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'; 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]); }, [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(() => { useEffect(() => {
setCommitPage(1); setCommitPage(1);
}, [selectedSourceId, selectedBranch, commitRange]); }, [selectedSourceId, selectedBranch, commitRange]);
+14 -1
View File
@@ -150,7 +150,7 @@ export function listBuildJobs(appKey?: string) {
return apiRequest<BuildJob[]>(`/apps/builds${query}`); 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({ const params = new URLSearchParams({
page: String(options.page), page: String(options.page),
limit: String(options.limit) 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.appKey) params.set('appKey', options.appKey);
if (options.since) params.set('since', options.since); if (options.since) params.set('since', options.since);
if (options.status) params.set('status', options.status); 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()}`); 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' }); 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) { export function listReleases(appKey?: string) {
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : ''; const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
return apiRequest<AppRelease[]>(`/apps/releases${query}`); 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) { export function listDeployments(appKey?: string) {
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : ''; const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
return apiRequest<DeploymentJob[]>(`/apps/deployments${query}`); return apiRequest<DeploymentJob[]>(`/apps/deployments${query}`);
+37 -5
View File
@@ -60,7 +60,9 @@ export function LauncherAppsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [workingId, setWorkingId] = useState(''); const [workingId, setWorkingId] = useState('');
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [unlinking, setUnlinking] = useState(false);
const [editingLink, setEditingLink] = useState<LauncherAppLink | null>(null); const [editingLink, setEditingLink] = useState<LauncherAppLink | null>(null);
const [unlinkTarget, setUnlinkTarget] = useState<LauncherAppLink | null>(null);
const [displayForm, setDisplayForm] = useState<DisplayForm | null>(null); const [displayForm, setDisplayForm] = useState<DisplayForm | null>(null);
const loadingVisible = useMinimumLoading(loading); const loadingVisible = useMinimumLoading(loading);
@@ -107,17 +109,23 @@ export function LauncherAppsPage() {
.finally(() => setWorkingId('')); .finally(() => setWorkingId(''));
}; };
const unlink = (link: LauncherAppLink) => { const unlink = () => {
setWorkingId(link.id); if (!unlinkTarget || unlinking) return;
deleteLauncherAppLink(link.id) setUnlinking(true);
setWorkingId(unlinkTarget.id);
deleteLauncherAppLink(unlinkTarget.id)
.then(() => { .then(() => {
toast({ type: 'success', message: '런처 앱 연결을 해제했습니다.' }); toast({ type: 'success', message: '런처 앱 연결을 해제했습니다.' });
setUnlinkTarget(null);
refresh(); refresh();
}) })
.catch((error: Error) => { .catch((error: Error) => {
toast({ type: 'error', title: '연결 해제 실패', message: error.message }); toast({ type: 'error', title: '연결 해제 실패', message: error.message });
}) })
.finally(() => setWorkingId('')); .finally(() => {
setWorkingId('');
setUnlinking(false);
});
}; };
const syncAdminApps = () => { const syncAdminApps = () => {
@@ -240,7 +248,7 @@ export function LauncherAppsPage() {
<Button variant="outline" className="size-8 px-0" aria-label={`${link.app.name} 표시 설정`} onClick={() => openSettings(link)} disabled={workingId === `${link.id}-settings`}> <Button variant="outline" className="size-8 px-0" aria-label={`${link.app.name} 표시 설정`} onClick={() => openSettings(link)} disabled={workingId === `${link.id}-settings`}>
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
</Button> </Button>
<Button variant="outline" className="size-8 border-rose-500/40 px-0 text-rose-300 hover:bg-rose-500/10" aria-label={`${link.app.name} 연결 해제`} onClick={() => unlink(link)} disabled={workingId === link.id}> <Button variant="outline" className="size-8 border-rose-500/40 px-0 text-rose-300 hover:bg-rose-500/10" aria-label={`${link.app.name} 연결 해제`} onClick={() => setUnlinkTarget(link)} disabled={workingId === link.id}>
<Unlink className="size-4" /> <Unlink className="size-4" />
</Button> </Button>
</div> </div>
@@ -349,6 +357,30 @@ export function LauncherAppsPage() {
) : null} ) : null}
</Modal> </Modal>
<Modal
open={Boolean(unlinkTarget)}
title="앱 연결 해제"
onClose={() => {
if (!unlinking) setUnlinkTarget(null);
}}
footer={
<>
<Button variant="outline" disabled={unlinking} onClick={() => setUnlinkTarget(null)}>취소</Button>
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={unlinking} onClick={unlink}>
{unlinking ? '해제 중' : '해제'}
</Button>
</>
}
>
<div className="space-y-3 text-sm">
<p>
<span className="font-semibold text-foreground">{unlinkTarget?.displayName || unlinkTarget?.app.name || '-'}</span>
{' '}앱의 런처 연결을 해제합니다.
</p>
<p className="text-muted-foreground">연결을 해제하면 런처 홈에 표시되지 않습니다.</p>
</div>
</Modal>
<LoadingModal open={loadingVisible} /> <LoadingModal open={loadingVisible} />
</> </>
); );
+116 -9
View File
@@ -4,8 +4,8 @@ import { Badge, Button, Card, Modal } from '../../components/ui';
import { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import { formatDateTime } from '../../lib/date-format'; import { formatDateTime } from '../../lib/date-format';
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app'; import { getStoredSelectedAppId, getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedAppId, setStoredSelectedComponentId } from '../../lib/selected-app';
import type { ApiApp, RuntimeEnvProfile, RuntimeResource } from '../../types/runtime'; import type { ApiApp, ApiAppComponent, RuntimeEnvProfile, RuntimeEnvVariable, RuntimeIngressRule, RuntimeResource } from '../../types/runtime';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import { import {
applyRuntimeProfile, applyRuntimeProfile,
@@ -67,6 +67,13 @@ function runtimeStatusTone(resource: RuntimeResource, stopped: boolean): 'neutra
return 'blue'; 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() { export function RuntimeConfigPage() {
const { toast } = useToast(); const { toast } = useToast();
const [apps, setApps] = useState<ApiApp[]>([]); const [apps, setApps] = useState<ApiApp[]>([]);
@@ -79,6 +86,8 @@ export function RuntimeConfigPage() {
const [actionKey, setActionKey] = useState(''); const [actionKey, setActionKey] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [profileModalOpen, setProfileModalOpen] = useState(false); const [profileModalOpen, setProfileModalOpen] = useState(false);
const [deleteVariableTarget, setDeleteVariableTarget] = useState<RuntimeEnvVariable | null>(null);
const [deleteIngressTarget, setDeleteIngressTarget] = useState<RuntimeIngressRule | null>(null);
const loadingVisible = useMinimumLoading(loading); const loadingVisible = useMinimumLoading(loading);
const [profileName, setProfileName] = useState(''); const [profileName, setProfileName] = useState('');
@@ -156,11 +165,13 @@ export function RuntimeConfigPage() {
const storedAppId = getStoredSelectedAppId(); const storedAppId = getStoredSelectedAppId();
const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? ''; const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
const nextApp = nextApps.find((app) => app.id === nextAppId); const nextApp = nextApps.find((app) => app.id === nextAppId);
const nextComponentId = preferredComponentId(nextAppId, nextApp?.components ?? []);
setApps(nextApps); setApps(nextApps);
setAppId(nextAppId); setAppId(nextAppId);
setProfileNamespace(nextApp?.namespace ?? ''); setProfileNamespace(nextApp?.namespace ?? '');
setVariableComponentId(nextApp?.components?.[0]?.id ?? ''); setVariableComponentId(nextComponentId);
setIngressComponentId(nextApp?.components?.[0]?.id ?? ''); setIngressComponentId(nextComponentId);
if (nextComponentId) setStoredSelectedComponentId(nextAppId, nextComponentId);
setIngressPort(''); setIngressPort('');
return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]); return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]);
}) })
@@ -171,15 +182,38 @@ export function RuntimeConfigPage() {
const changeApp = (nextAppId: string) => { const changeApp = (nextAppId: string) => {
const nextApp = apps.find((app) => app.id === nextAppId); const nextApp = apps.find((app) => app.id === nextAppId);
const nextComponentId = preferredComponentId(nextAppId, nextApp?.components ?? []);
setStoredSelectedAppId(nextAppId); setStoredSelectedAppId(nextAppId);
setAppId(nextAppId); setAppId(nextAppId);
setProfileNamespace(nextApp?.namespace ?? ''); setProfileNamespace(nextApp?.namespace ?? '');
setVariableComponentId(nextApp?.components?.[0]?.id ?? ''); setVariableComponentId(nextComponentId);
setIngressComponentId(nextApp?.components?.[0]?.id ?? ''); setIngressComponentId(nextComponentId);
if (nextComponentId) setStoredSelectedComponentId(nextAppId, nextComponentId);
setIngressPort(''); setIngressPort('');
refreshProfiles(nextAppId).catch((loadError: Error) => setError(loadError.message)); 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 = () => { const addProfile = () => {
if (!appId || !profileName.trim() || !profileNamespace.trim()) return; if (!appId || !profileName.trim() || !profileNamespace.trim()) return;
if (duplicateProfile) { 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 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="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"> <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" /> <Trash2 className="size-4" />
</Button> </Button>
</td> </td>
@@ -514,7 +548,15 @@ export function RuntimeConfigPage() {
</label> </label>
<label className="block"> <label className="block">
<span className="text-xs font-medium text-muted-foreground">구성요소</span> <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.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
{components.length === 0 ? <option value="">구성요소 없음</option> : null} {components.length === 0 ? <option value="">구성요소 없음</option> : null}
</select> </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.servicePort}</td>
<td className="px-4 py-3 text-muted-foreground">{rule.stripPrefix ? '제거' : '유지'}</td> <td className="px-4 py-3 text-muted-foreground">{rule.stripPrefix ? '제거' : '유지'}</td>
<td className="px-3 py-3 text-right"> <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" /> <Trash2 className="size-4" />
</Button> </Button>
</td> </td>
@@ -616,6 +658,7 @@ export function RuntimeConfigPage() {
onChange={(event) => { onChange={(event) => {
const nextId = event.target.value; const nextId = event.target.value;
setIngressComponentId(nextId); setIngressComponentId(nextId);
setStoredSelectedComponentId(appId, nextId);
setIngressPort(''); setIngressPort('');
}} }}
> >
@@ -698,6 +741,70 @@ export function RuntimeConfigPage() {
) : null} ) : null}
</div> </div>
</Modal> </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} /> <LoadingModal open={loadingVisible} />
</> </>
); );