This commit is contained in:
@@ -26,6 +26,10 @@ function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'bl
|
|||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function environmentLabel(environment: string) {
|
||||||
|
return environment === 'prod' ? '운영' : environment;
|
||||||
|
}
|
||||||
|
|
||||||
type ImageTags = {
|
type ImageTags = {
|
||||||
images?: Record<string, string>;
|
images?: Record<string, string>;
|
||||||
artifacts?: Record<string, string>;
|
artifacts?: Record<string, string>;
|
||||||
@@ -57,7 +61,6 @@ export function DeploymentManagementPage() {
|
|||||||
const [deployments, setDeployments] = useState<DeploymentJob[]>([]);
|
const [deployments, setDeployments] = useState<DeploymentJob[]>([]);
|
||||||
const [releases, setReleases] = useState<AppRelease[]>([]);
|
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||||
const [releaseId, setReleaseId] = useState('');
|
const [releaseId, setReleaseId] = useState('');
|
||||||
const [environment, setEnvironment] = useState('dev');
|
|
||||||
const [namespace, setNamespace] = useState('');
|
const [namespace, setNamespace] = useState('');
|
||||||
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
||||||
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
||||||
@@ -125,7 +128,7 @@ export function DeploymentManagementPage() {
|
|||||||
|
|
||||||
const deploy = async () => {
|
const deploy = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await createDeployment({ releaseId, environment, namespace, updateLauncherAppVersion });
|
const result = await createDeployment({ releaseId, environment: 'prod', namespace, updateLauncherAppVersion });
|
||||||
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
|
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
|
||||||
setSelectedDeployment(result.deploymentJob);
|
setSelectedDeployment(result.deploymentJob);
|
||||||
setDeployModalOpen(false);
|
setDeployModalOpen(false);
|
||||||
@@ -140,7 +143,7 @@ export function DeploymentManagementPage() {
|
|||||||
<PageTitle
|
<PageTitle
|
||||||
eyebrow="어플리케이션"
|
eyebrow="어플리케이션"
|
||||||
title="배포 관리"
|
title="배포 관리"
|
||||||
description="생성된 릴리즈를 개발 또는 운영 환경에 배포하고 재배포와 롤백을 관리합니다."
|
description="생성된 릴리즈를 운영 환경에 배포하고 재배포와 롤백을 관리합니다."
|
||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" disabled={filteredReleases.length === 0} onClick={() => openDeployModal()}>
|
<Button className="h-8 px-3 text-[13px]" disabled={filteredReleases.length === 0} onClick={() => openDeployModal()}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
@@ -173,7 +176,7 @@ export function DeploymentManagementPage() {
|
|||||||
<span className="text-base font-medium">{deployment.release?.version || '-'}</span>
|
<span className="text-base font-medium">{deployment.release?.version || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span className="text-muted-foreground">환경 <b className="text-foreground">{deployment.environment}</b></span>
|
<span className="text-muted-foreground">환경 <b className="text-foreground">{environmentLabel(deployment.environment)}</b></span>
|
||||||
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
|
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
|
||||||
<span className="text-muted-foreground">요청자 <b className="text-foreground">{deployment.requestedBy || 'admin'}</b></span>
|
<span className="text-muted-foreground">요청자 <b className="text-foreground">{deployment.requestedBy || 'admin'}</b></span>
|
||||||
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
|
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
|
||||||
@@ -268,10 +271,6 @@ export function DeploymentManagementPage() {
|
|||||||
<option value="">릴리즈 선택</option>
|
<option value="">릴리즈 선택</option>
|
||||||
{filteredReleases.map((release) => <option key={release.id} value={release.id}>{release.app.name} {release.version}</option>)}
|
{filteredReleases.map((release) => <option key={release.id} value={release.id}>{release.app.name} {release.version}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={environment} onChange={(event) => setEnvironment(event.target.value)}>
|
|
||||||
<option value="dev">개발</option>
|
|
||||||
<option value="prod">운영</option>
|
|
||||||
</select>
|
|
||||||
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={namespace} onChange={(event) => setNamespace(event.target.value)} placeholder="stdo-lms" />
|
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={namespace} onChange={(event) => setNamespace(event.target.value)} placeholder="stdo-lms" />
|
||||||
<label className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-foreground">
|
<label className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-foreground">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export function ReleaseManagementPage() {
|
|||||||
const [version, setVersion] = useState('');
|
const [version, setVersion] = useState('');
|
||||||
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
|
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
|
||||||
const [selectedBuildIds, setSelectedBuildIds] = useState<string[]>([]);
|
const [selectedBuildIds, setSelectedBuildIds] = useState<string[]>([]);
|
||||||
|
const [openLogReleaseId, setOpenLogReleaseId] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
@@ -260,13 +261,24 @@ export function ReleaseManagementPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{pagedReleases.map((release) => {
|
{pagedReleases.map((release) => {
|
||||||
const artifacts = releaseArtifacts(release);
|
const artifacts = releaseArtifacts(release);
|
||||||
|
const isLogOpen = openLogReleaseId === release.id;
|
||||||
return (
|
return (
|
||||||
<div key={release.id} className="rounded-md border border-border bg-background/70 p-4">
|
<div key={release.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge tone={statusTone(release.status)}>{statusLabel(release.status)}</Badge>
|
<Badge tone={statusTone(release.status)}>{statusLabel(release.status)}</Badge>
|
||||||
<span className="text-base font-medium">{release.app.name}</span>
|
<span className="text-base font-medium">{release.app.name}</span>
|
||||||
<span className="text-base font-medium">{release.version}</span>
|
<span className="text-base font-medium">{release.version}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
disabled={!release.logs}
|
||||||
|
onClick={() => setOpenLogReleaseId((prev) => prev === release.id ? '' : release.id)}
|
||||||
|
>
|
||||||
|
{isLogOpen ? '로그 닫기' : '로그'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span>
|
<span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span>
|
||||||
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
|
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
|
||||||
@@ -283,6 +295,11 @@ export function ReleaseManagementPage() {
|
|||||||
<p className="mt-1 text-xs text-muted-foreground">-</p>
|
<p className="mt-1 text-xs text-muted-foreground">-</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isLogOpen ? (
|
||||||
|
<pre className="mt-3 max-h-72 overflow-auto rounded-md border border-border bg-black/70 p-3 font-mono text-xs leading-5 text-slate-100">
|
||||||
|
{release.logs}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Copy, ExternalLink, Link2, Plus } from 'lucide-react';
|
import { Copy, ExternalLink, Link2, Plus } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
@@ -74,6 +74,10 @@ function normalizeRepositoryName(repository: string) {
|
|||||||
return trimmed.includes('/') ? trimmed : `${giteaAppOrg}/${trimmed}`;
|
return trimmed.includes('/') ? trimmed : `${giteaAppOrg}/${trimmed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function repositoryKey(repository: string) {
|
||||||
|
return normalizeRepositoryName(repository).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function getHttpCloneUrl(repository: string) {
|
function getHttpCloneUrl(repository: string) {
|
||||||
return `${giteaBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`;
|
return `${giteaBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`;
|
||||||
}
|
}
|
||||||
@@ -82,6 +86,16 @@ function getSshCloneUrl(repository: string) {
|
|||||||
return `${giteaSshBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`;
|
return `${giteaSshBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSourceLinkComponent(source: SourceComponent) {
|
||||||
|
return (source.imageSource ?? 'SOURCE') === 'SOURCE' && source.type !== 'DATABASE' && source.type !== 'MIGRATION';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLauncherRepo(repo: PlatformRepo) {
|
||||||
|
const name = repo.name.toLowerCase();
|
||||||
|
const fullName = repo.fullName.toLowerCase();
|
||||||
|
return name === 'studio-launcher' || name === 'studio-launcher-fe' || name === 'studio-launcher-be' || fullName.includes('/studio-launcher');
|
||||||
|
}
|
||||||
|
|
||||||
export function SourceManagementPage() {
|
export function SourceManagementPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
||||||
@@ -106,7 +120,7 @@ export function SourceManagementPage() {
|
|||||||
|
|
||||||
const refreshRepos = () => {
|
const refreshRepos = () => {
|
||||||
setGiteaStatus('loading');
|
setGiteaStatus('loading');
|
||||||
listGiteaRepos(giteaAppOrg, 8)
|
listGiteaRepos(giteaAppOrg, 100)
|
||||||
.then((repos) => {
|
.then((repos) => {
|
||||||
setGiteaRepos(repos.map(toPlatformRepo));
|
setGiteaRepos(repos.map(toPlatformRepo));
|
||||||
setGiteaStatus('ready');
|
setGiteaStatus('ready');
|
||||||
@@ -130,7 +144,7 @@ export function SourceManagementPage() {
|
|||||||
setSourcesLoading(false);
|
setSourcesLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
listGiteaRepos(giteaAppOrg, 8)
|
listGiteaRepos(giteaAppOrg, 100)
|
||||||
.then((repos) => {
|
.then((repos) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setGiteaRepos(repos.map(toPlatformRepo));
|
setGiteaRepos(repos.map(toPlatformRepo));
|
||||||
@@ -228,7 +242,22 @@ export function SourceManagementPage() {
|
|||||||
toast({ type: 'success', message: '복사되었습니다.' });
|
toast({ type: 'success', message: '복사되었습니다.' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredSources = apiSources.filter((source) => !selectedAppId || source.appId === selectedAppId);
|
const filteredSources = apiSources.filter((source) => isSourceLinkComponent(source) && (!selectedAppId || source.appId === selectedAppId));
|
||||||
|
const selectedAppRepoKeys = useMemo(
|
||||||
|
() => new Set(filteredSources.map((source) => source.repository).filter((repository): repository is string => Boolean(repository)).map(repositoryKey)),
|
||||||
|
[filteredSources]
|
||||||
|
);
|
||||||
|
const visibleRepos = useMemo(
|
||||||
|
() => giteaRepos
|
||||||
|
.filter((repo) => !isLauncherRepo(repo))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aSelected = selectedAppRepoKeys.has(repositoryKey(a.fullName));
|
||||||
|
const bSelected = selectedAppRepoKeys.has(repositoryKey(b.fullName));
|
||||||
|
if (aSelected !== bSelected) return aSelected ? -1 : 1;
|
||||||
|
return a.fullName.localeCompare(b.fullName);
|
||||||
|
}),
|
||||||
|
[giteaRepos, selectedAppRepoKeys]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -268,16 +297,19 @@ export function SourceManagementPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>백엔드 연결 후 저장소가 표시됩니다.</td>
|
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>백엔드 연결 후 저장소가 표시됩니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : giteaStatus !== 'loading' && giteaRepos.length === 0 ? (
|
) : giteaStatus !== 'loading' && visibleRepos.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>등록된 저장소가 없습니다.</td>
|
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>등록된 저장소가 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
giteaRepos.map((repo) => (
|
visibleRepos.map((repo) => {
|
||||||
|
const isSelectedAppRepo = selectedAppRepoKeys.has(repositoryKey(repo.fullName));
|
||||||
|
return (
|
||||||
<tr key={repo.id} className="hover:bg-muted/20">
|
<tr key={repo.id} className="hover:bg-muted/20">
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold">{repo.fullName}</span>
|
<span className="font-semibold">{repo.fullName}</span>
|
||||||
|
{isSelectedAppRepo ? <Badge tone="green">선택 앱</Badge> : null}
|
||||||
<a href={repo.htmlUrl} target="_blank" rel="noreferrer" className="text-muted-foreground hover:text-foreground">
|
<a href={repo.htmlUrl} target="_blank" rel="noreferrer" className="text-muted-foreground hover:text-foreground">
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
</a>
|
</a>
|
||||||
@@ -292,7 +324,8 @@ export function SourceManagementPage() {
|
|||||||
<Badge tone={repo.private ? 'amber' : 'blue'}>{repo.private ? 'Private' : 'Public'}</Badge>
|
<Badge tone={repo.private ? 'amber' : 'blue'}>{repo.private ? 'Private' : 'Public'}</Badge>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -461,7 +494,7 @@ export function SourceManagementPage() {
|
|||||||
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||||
>
|
>
|
||||||
<option value="">저장소 선택</option>
|
<option value="">저장소 선택</option>
|
||||||
{giteaRepos.map((repo) => (
|
{visibleRepos.map((repo) => (
|
||||||
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
|
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export type AppRelease = {
|
|||||||
artifact?: string | null;
|
artifact?: string | null;
|
||||||
imageTags?: unknown;
|
imageTags?: unknown;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
|
logs?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
app: ApiApp;
|
app: ApiApp;
|
||||||
|
|||||||
Reference in New Issue
Block a user