init studio admin frontend
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { Card } from '../components/ui';
|
||||
import { PageTitle } from '../layout/AppLayout';
|
||||
|
||||
export function PlaceholderPage({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="이관 중" title={title} description="이 화면은 라우팅 구조로 분리하는 중입니다." />
|
||||
<Card className="flex min-h-60 items-center justify-center p-8 text-center text-sm text-muted-foreground">
|
||||
기존 임시 번들에서 TSX 페이지로 옮기는 중입니다.
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, SlidersHorizontal } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
||||
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
||||
import {
|
||||
agentDevices,
|
||||
alerts,
|
||||
authApiEndpoints,
|
||||
gatewayConnections,
|
||||
k3sWorkloads,
|
||||
launcherCommonSettings,
|
||||
launcherVersions,
|
||||
runtimeCommands,
|
||||
tailscaleNodes,
|
||||
tailscaleSettings
|
||||
} from '../data/mock';
|
||||
import { PageTitle } from '../layout/AppLayout';
|
||||
|
||||
type Tone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
||||
|
||||
function tone(status: string): Tone {
|
||||
if (['활성', '완료', '게시됨', '실행 중', '배포됨'].includes(status)) return 'green';
|
||||
if (['주의', '예약됨', '대기', '준비중', '카나리'].includes(status)) return 'amber';
|
||||
if (['잠김', '실패', '오프라인', '비활성'].includes(status)) return 'red';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function StatusBadge({ status, badgeTone }: { status: string; badgeTone?: Tone }) {
|
||||
return <Badge tone={badgeTone ?? tone(status)}>{status}</Badge>;
|
||||
}
|
||||
|
||||
export function LauncherVersionsPage() {
|
||||
const [sourceOpen, setSourceOpen] = useState(false);
|
||||
const [releaseOpen, setReleaseOpen] = useState(false);
|
||||
const launcher = launcherVersions[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="런처 운영"
|
||||
title="버전 관리"
|
||||
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태만 관리합니다."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setSourceOpen(true)} disabled={!launcher}>
|
||||
<Settings2 className="size-4" />
|
||||
소스 연결
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setReleaseOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
릴리즈 등록
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{!launcher ? <Card className="px-5 py-8 text-center text-sm text-muted-foreground">등록된 런처 버전이 없습니다.</Card> : null}
|
||||
{launcher ? (
|
||||
<div className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader title="메인 런처 배포 상태" description="앱 실행의 진입점인 런처 자체 버전과 배포 파일 상태입니다." />
|
||||
<div className="p-5">
|
||||
<div className="flex flex-col gap-4 rounded-md border border-border bg-muted/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-base font-semibold">{launcher.name}</p>
|
||||
<StatusBadge status={launcher.status} />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
최신 {launcher.latestVersion} · 최소 지원 {launcher.minimumSupportedVersion || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3">
|
||||
<div className="rounded-md border border-border bg-background p-4">
|
||||
<p className="text-xs text-muted-foreground">배포 파일</p>
|
||||
<p className="mt-2 truncate text-sm font-semibold">{launcher.artifact}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-background p-4">
|
||||
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
||||
<p className="mt-2 truncate text-sm font-semibold text-muted-foreground">{launcher.downloadUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-background p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">소스 연결</p>
|
||||
<Button variant="ghost" className="h-7 px-2 text-xs" onClick={() => setSourceOpen(true)}>
|
||||
변경
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Repository</p>
|
||||
<p className="mt-1 truncate font-semibold">platform/launcher</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Branch</p>
|
||||
<p className="mt-1 truncate font-semibold">release</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Commit SHA</p>
|
||||
<p className="mt-1 truncate font-mono text-xs font-semibold">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="릴리즈 노트" description="런처 업데이트 안내와 운영 메모입니다." />
|
||||
<div className="p-5 text-sm leading-6 text-muted-foreground">{launcher.releaseNotes}</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
open={sourceOpen}
|
||||
title="런처 소스 연결"
|
||||
onClose={() => setSourceOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSourceOpen(false)}>취소</Button>
|
||||
<Button onClick={() => setSourceOpen(false)}>저장</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Repository</span>
|
||||
<input 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" defaultValue="platform/launcher" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Branch</span>
|
||||
<input 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" defaultValue="release" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={releaseOpen}
|
||||
title="릴리즈 등록"
|
||||
onClose={() => setReleaseOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setReleaseOpen(false)}>취소</Button>
|
||||
<Button onClick={() => setReleaseOpen(false)}>등록</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
{['버전', '최소 지원 버전', '배포 파일', '다운로드 URL', 'Repository', 'Branch', 'Commit SHA'].map((label) => (
|
||||
<label className="block" key={label}>
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<input 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" />
|
||||
</label>
|
||||
))}
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">릴리즈 노트</span>
|
||||
<textarea className="mt-2 min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherAppsPage() {
|
||||
const linked = launcherApps().filter((app) => app.launcherLinked).sort((a, b) => (a.launcherOrder ?? 999) - (b.launcherOrder ?? 999));
|
||||
const unlinked = launcherApps().filter((app) => !app.launcherLinked);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="런처 운영"
|
||||
title="앱 연결"
|
||||
description="앱 구성에 등록된 제품 앱 중 런처 홈에 노출하고 실행할 앱을 연결합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<Plus className="size-4" />
|
||||
앱 연결
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="연결된 앱" description="런처 홈에 표시할 앱과 노출 순서를 관리합니다." />
|
||||
<div className="max-h-[520px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[760px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-28 px-5 py-3 font-medium">노출순서</th>
|
||||
<th className="px-5 py-3 font-medium">앱</th>
|
||||
<th className="px-5 py-3 font-medium">릴리즈</th>
|
||||
<th className="px-5 py-3 font-medium">실행 경로</th>
|
||||
<th className="px-5 py-3 text-right font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{linked.map((app) => (
|
||||
<tr className="hover:bg-muted/20" key={app.key}>
|
||||
<td className="px-5 py-3 font-semibold">{app.launcherOrder ?? '-'}</td>
|
||||
<td className="px-5 py-3 font-semibold">{app.name}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{app.release}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{app.launchUrl}</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="size-8 px-0" aria-label={`${app.name} 위로 이동`}>
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" className="size-8 px-0" aria-label={`${app.name} 아래로 이동`}>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">해제</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="미연결 앱" description="아직 런처에 연결하지 않은 앱입니다." />
|
||||
<div className="max-h-[520px] divide-y divide-border overflow-y-auto">
|
||||
{unlinked.map((app) => (
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-3" key={app.key}>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{app.name}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{app.key} · {app.release}</p>
|
||||
</div>
|
||||
<Button className="h-8 px-3 text-[13px]">연결</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherCommonSettingsPage() {
|
||||
const [apiOpen, setApiOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="런처 운영"
|
||||
title="공통 설정"
|
||||
description="런처에서 공통으로 사용하는 연결값과 기본 설정을 관리합니다."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setApiOpen(true)}>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
공통 인증 API 보기
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<Plus className="size-4" />
|
||||
설정 추가
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{[
|
||||
['설정 항목', `${launcherCommonSettings.length}개`],
|
||||
['Endpoint', `${launcherCommonSettings.filter((item) => item.type === 'endpoint').length}개`],
|
||||
['Database', `${launcherCommonSettings.filter((item) => item.type === 'database').length}개`],
|
||||
['활성 상태', `${launcherCommonSettings.filter((item) => item.status === '활성').length}개`]
|
||||
].map(([label, value]) => (
|
||||
<Card className="p-5" key={label}>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="mt-3 text-2xl font-semibold">{value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="mt-4 min-w-0 overflow-hidden">
|
||||
<CardHeader title="런처 연결 설정" description="여러 기능에서 함께 쓰는 주소, 인증, 저장소 관련 값을 정리합니다." />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1120px] text-left text-sm">
|
||||
<thead className="border-b border-border text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-[280px] px-5 py-3 font-medium">설정</th>
|
||||
<th className="px-5 py-3 font-medium">Key</th>
|
||||
<th className="px-5 py-3 font-medium">Type</th>
|
||||
<th className="px-5 py-3 font-medium">Value</th>
|
||||
<th className="px-5 py-3 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{launcherCommonSettings.map((item) => (
|
||||
<tr className="hover:bg-muted/20" key={item.key}>
|
||||
<td className="px-5 py-3">
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<span className={item.status === '활성' ? 'size-2 rounded-full bg-emerald-400' : 'size-2 rounded-full bg-muted-foreground'} />
|
||||
{item.name}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-5 py-3 font-medium">{item.key}</td>
|
||||
<td className="px-5 py-3">{item.type}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{item.value}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<Button variant="ghost" className="size-8 px-0" aria-label="상세">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal open={apiOpen} title="공통 인증 API" onClose={() => setApiOpen(false)} className="!max-w-[1280px]" footer={<Button variant="ghost" onClick={() => setApiOpen(false)}>닫기</Button>}>
|
||||
<div className="max-h-[70vh] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[980px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">그룹</th>
|
||||
<th className="px-5 py-3 font-medium">Method</th>
|
||||
<th className="px-5 py-3 font-medium">Path</th>
|
||||
<th className="px-5 py-3 font-medium">용도</th>
|
||||
<th className="px-5 py-3 font-medium">권한</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{authApiEndpoints.map((endpoint) => (
|
||||
<tr className="hover:bg-muted/20" key={`${endpoint.method}-${endpoint.path}`}>
|
||||
<td className="px-5 py-3">{endpoint.group}</td>
|
||||
<td className="px-5 py-3 font-semibold">{endpoint.method}</td>
|
||||
<td className="px-5 py-3 font-medium">{endpoint.path}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{endpoint.purpose}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={endpoint.auth === '없음' ? 'neutral' : endpoint.auth === 'ADMIN' ? 'amber' : 'blue'}>{endpoint.auth}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TailscaleStatusPage() {
|
||||
const online = tailscaleNodes.filter((node) => node.status === '활성').length;
|
||||
const subnetRouters = tailscaleNodes.filter((node) => node.routes !== '-').length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="테일스케일"
|
||||
title="노드 현황"
|
||||
description="플랫폼 게이트웨이가 수집한 tailnet 노드, 클러스터 라우트, 원격 실행 노드 연결 상태입니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<RefreshCcw className="size-4" />
|
||||
상태 갱신
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
['등록 노드', `${tailscaleNodes.length}대`, '게이트웨이 수집 기준'],
|
||||
['온라인', `${online}대`, '최근 접속 정상'],
|
||||
['Subnet Router', `${subnetRouters}대`, '클러스터 CIDR 광고']
|
||||
].map(([label, value, description]) => (
|
||||
<Card className="p-5" key={label}>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="mt-3 text-2xl font-semibold">{value}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TailscaleNodeTable />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TailscaleNodeTable() {
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="노드 현황" description="세부 설정은 Tailscale/플랫폼 게이트웨이에서 관리하고, 여기서는 운영 상태를 확인합니다." />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead className="border-b border-border text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">노드</th>
|
||||
<th className="px-5 py-3 font-medium">IP</th>
|
||||
<th className="px-5 py-3 font-medium">역할</th>
|
||||
<th className="px-5 py-3 font-medium">사용자</th>
|
||||
<th className="px-5 py-3 font-medium">Routes</th>
|
||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{tailscaleNodes.map((node) => (
|
||||
<tr className="hover:bg-muted/20" key={node.name}>
|
||||
<td className="px-5 py-3 font-medium">{node.name}</td>
|
||||
<td className="px-5 py-3">{node.ip}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{node.role}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{node.user}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{node.routes}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{node.lastSeen}</td>
|
||||
<td className="px-5 py-3"><StatusBadge status={node.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TailscaleRoutesPage() {
|
||||
const routes = tailscaleNodes.filter((node) => node.routes !== '-');
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="테일스케일" title="라우트" description="플랫폼 게이트웨이와 런처가 접근해야 하는 네트워크 경로 광고 상태입니다." />
|
||||
<Card>
|
||||
<CardHeader title="라우트 광고" description="승인된 subnet route와 노드 상태입니다." />
|
||||
<div className="divide-y divide-border">
|
||||
{routes.map((route) => (
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-3" key={route.name}>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{route.routes}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{route.name} · {route.ip}</p>
|
||||
</div>
|
||||
<StatusBadge status={route.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TailscaleAclPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="테일스케일" title="ACL" description="사용자 그룹과 장비 태그 기준의 tailnet 접근 정책을 관리합니다." />
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{[
|
||||
['강사 장비 접근', 'group:instructors -> tag:teaching-device', '활성'],
|
||||
['학생 워크스페이스', 'group:students -> tag:assigned-workspace', '활성'],
|
||||
['운영자 전체 접근', 'group:ops -> tag:platform-admin', '활성']
|
||||
].map(([title, description, status]) => (
|
||||
<Card className="p-5" key={title}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{description}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TailscaleDiagnosticsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="테일스케일"
|
||||
title="연결 진단"
|
||||
description="Tailnet, subnet route, 플랫폼 게이트웨이 연동 상태를 진단합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<RefreshCcw className="size-4" />
|
||||
진단 실행
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="진단 항목" description="런처에는 연결 정보만 제공하고, 실제 세부 설정은 여기에서 관리합니다." />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[880px] text-left text-sm">
|
||||
<thead className="border-b border-border text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">항목</th>
|
||||
<th className="px-5 py-3 font-medium">Key</th>
|
||||
<th className="px-5 py-3 font-medium">Value</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{tailscaleSettings.map((setting) => (
|
||||
<tr className="hover:bg-muted/20" key={setting.key}>
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-medium">{setting.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{setting.description}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3 font-medium">{setting.key}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{setting.value}</td>
|
||||
<td className="px-5 py-3"><StatusBadge status={setting.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GatewayStatusPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="플랫폼 게이트웨이" title="연결 상태" description="Headscale, Agent, 저장소, 런타임 명령 버스의 연결 상태를 확인합니다." />
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{gatewayConnections.map((connection) => (
|
||||
<Card className="p-5" key={connection.name}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="truncate text-sm font-semibold">{connection.name}</p>
|
||||
<StatusBadge status={connection.status} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{connection.role}</p>
|
||||
<p className="mt-4 truncate text-sm font-medium">{connection.endpoint}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">마지막 동기화 {connection.lastSync}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GatewayAgentsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="플랫폼 게이트웨이" title="Agent 관리" description="플랫폼 게이트웨이가 수집한 Agent 등록 및 상태 정보를 관리합니다." />
|
||||
<AgentTable title="Agent 목록" description="등록된 운영 장비 Agent입니다." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GatewayDevicesPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="플랫폼 게이트웨이" title="장비 동기화" description="계정, 장비, 워크스페이스 정보를 인증 서버와 Agent 상태 기준으로 동기화합니다." />
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{[
|
||||
['계정/장비 매핑', 'Auth Backend -> Gateway', '활성'],
|
||||
['워크스페이스 경로', 'Agent -> Gateway', '활성'],
|
||||
['원격 실행 상태', 'Runtime -> Gateway', '주의']
|
||||
].map(([title, description, status]) => (
|
||||
<Card className="p-5" key={title}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{description}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuntimeDevicesPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="플랫폼 런타임" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
|
||||
<AgentTable
|
||||
title="장비 목록"
|
||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
||||
withCommands
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentTable({ title, description, withCommands = false }: { title: string; description: string; withCommands?: boolean }) {
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title={title}
|
||||
description={description}
|
||||
action={
|
||||
withCommands ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
상태 필터
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<RefreshCcw className="size-4" />
|
||||
상태 갱신
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[1040px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">장비</th>
|
||||
<th className="px-5 py-3 font-medium">계정</th>
|
||||
<th className="px-5 py-3 font-medium">Workspace</th>
|
||||
<th className="px-5 py-3 font-medium">SSH 키</th>
|
||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
{withCommands ? <th className="px-5 py-3 text-right font-medium">명령</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{agentDevices.map((device) => (
|
||||
<tr className="hover:bg-muted/20" key={device.name}>
|
||||
<td className="px-5 py-3 font-medium">{device.name}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
||||
<td className="px-5 py-3">{device.sshKey}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
||||
<td className="px-5 py-3"><StatusBadge status={device.status} /></td>
|
||||
{withCommands ? (
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
||||
<Button variant="ghost" className="size-8 px-0" aria-label="상세">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentRegistrationPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="장비 등록" description="새 장비에 설치할 Agent 등록 토큰과 연결 정보를 발급합니다." />
|
||||
<AgentPlaceholder title="장비 등록 요청" description="Agent 등록 요청과 토큰 발급 상태를 확인합니다." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentInstallStatusPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="설치 상태" description="장비별 Agent 설치와 heartbeat 상태를 확인합니다." />
|
||||
<AgentPlaceholder title="설치 상태" description="설치 완료, 대기, 실패 상태를 확인합니다." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSshKeysPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="SSH 키 배포" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
||||
<AgentPlaceholder title="SSH 키 배포" description="장비별 SSH 키 배포 상태입니다." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentWorkspacePage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="Workspace 설정" description="장비별 작업 디렉터리와 동기화 정책을 관리합니다." />
|
||||
<AgentPlaceholder title="Workspace 설정" description="계정별 workspace 경로와 동기화 상태입니다." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentCommandsPage() {
|
||||
const columns = useMemo<Array<DataTableColumn<(typeof runtimeCommands)[number]>>>(
|
||||
() => [
|
||||
{ key: 'target', header: '장비', cell: (row) => <span className="font-semibold">{row.target}</span> },
|
||||
{ key: 'command', header: '명령', cell: (row) => row.command },
|
||||
{ key: 'requestedBy', header: '요청자', cell: (row) => row.requestedBy },
|
||||
{ key: 'time', header: '시간', cell: (row) => row.time },
|
||||
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status} /> }
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
|
||||
<DataTableCard
|
||||
title="최근 제어 작업"
|
||||
rows={runtimeCommands}
|
||||
columns={columns}
|
||||
getRowKey={(row) => `${row.target}-${row.command}-${row.time}`}
|
||||
getSearchText={(row) => `${row.target} ${row.command} ${row.requestedBy} ${row.status}`}
|
||||
pageSize={4}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPlaceholder({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title={title} description={description} />
|
||||
<div className="p-5">
|
||||
<AgentTable title="Agent 장비" description="등록된 장비 목록입니다." />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitoringPage() {
|
||||
const columns = useMemo<Array<DataTableColumn<(typeof k3sWorkloads)[number]>>>(
|
||||
() => [
|
||||
{ key: 'workload', header: '워크로드', cell: (row) => <div><p className="font-semibold">{row.workload}</p><p className="mt-1 text-xs text-muted-foreground">{row.namespace}</p></div> },
|
||||
{ key: 'pod', header: 'Pod', cell: (row) => row.pod },
|
||||
{ key: 'replicas', header: 'Replicas', cell: (row) => row.replicas },
|
||||
{ key: 'cpu', header: 'CPU', cell: (row) => <Progress value={row.cpu} /> },
|
||||
{ key: 'memory', header: 'Memory', cell: (row) => <Progress value={row.memory} tone="amber" /> },
|
||||
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status} /> }
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="시스템" title="모니터링" />
|
||||
<DataTableCard
|
||||
title="워크로드"
|
||||
rows={k3sWorkloads}
|
||||
columns={columns}
|
||||
getRowKey={(row) => `${row.namespace}-${row.workload}-${row.pod}`}
|
||||
getSearchText={(row) => `${row.namespace} ${row.workload} ${row.pod} ${row.status}`}
|
||||
minWidth={1100}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PoliciesPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="시스템" title="정책" />
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{alerts.map((alert) => {
|
||||
const Icon = alert.icon;
|
||||
return (
|
||||
<Card key={alert.label} className="p-5">
|
||||
<Icon className="size-5 text-primary" />
|
||||
<p className="mt-4 text-sm font-semibold">{alert.label}</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function launcherApps() {
|
||||
return [
|
||||
{
|
||||
name: 'ADS IDE',
|
||||
key: 'ADS IDE',
|
||||
release: 'ADS-IDE-1.1.0.AppImage',
|
||||
launcherLinked: true,
|
||||
launcherOrder: 1,
|
||||
launchUrl: 'Ingress 예정 / localhost:5175'
|
||||
},
|
||||
{
|
||||
name: 'LMS',
|
||||
key: 'LMS',
|
||||
release: 'LMS-1.0.0.AppImage',
|
||||
launcherLinked: true,
|
||||
launcherOrder: 2,
|
||||
launchUrl: 'Ingress 예정 / localhost:5176'
|
||||
},
|
||||
{
|
||||
name: 'Data Management',
|
||||
key: 'Data Management',
|
||||
release: 'data-management',
|
||||
launcherLinked: false,
|
||||
launcherOrder: null,
|
||||
launchUrl: '미설정'
|
||||
},
|
||||
{
|
||||
name: 'FMS',
|
||||
key: 'FMS',
|
||||
release: 'fms',
|
||||
launcherLinked: true,
|
||||
launcherOrder: 3,
|
||||
launchUrl: '미설정'
|
||||
},
|
||||
{
|
||||
name: 'ADS Education',
|
||||
key: 'ADS Education',
|
||||
release: 'ads-education',
|
||||
launcherLinked: true,
|
||||
launcherOrder: 4,
|
||||
launchUrl: '미설정'
|
||||
},
|
||||
{
|
||||
name: 'Calibration',
|
||||
key: 'Calibration',
|
||||
release: 'calibration',
|
||||
launcherLinked: false,
|
||||
launcherOrder: null,
|
||||
launchUrl: '미설정'
|
||||
},
|
||||
{
|
||||
name: 'HDMAP Builder',
|
||||
key: 'HDMAP Builder',
|
||||
release: 'hdmap-builder',
|
||||
launcherLinked: false,
|
||||
launcherOrder: null,
|
||||
launchUrl: '미설정'
|
||||
}
|
||||
];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge } from '../../components/ui';
|
||||
import type { AppWithComponents, JobStatus } from '../../types/application';
|
||||
import { listApps } from './application-api';
|
||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
|
||||
export function useApps() {
|
||||
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
||||
const [selectedAppId, setSelectedAppId] = useState(() => getStoredSelectedAppId());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refreshApps = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextApps = await listApps();
|
||||
setApps(nextApps);
|
||||
const storedAppId = getStoredSelectedAppId();
|
||||
const validAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
|
||||
if (validAppId) {
|
||||
setSelectedAppId(validAppId);
|
||||
setStoredSelectedAppId(validAppId);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshApps();
|
||||
}, []);
|
||||
|
||||
const selectedApp = useMemo(
|
||||
() => apps.find((app) => app.id === selectedAppId) ?? apps[0] ?? null,
|
||||
[apps, selectedAppId]
|
||||
);
|
||||
|
||||
const selectApp = (appId: string) => {
|
||||
setSelectedAppId(appId);
|
||||
setStoredSelectedAppId(appId);
|
||||
};
|
||||
|
||||
return { apps, selectedApp, selectedAppId: selectedApp?.id ?? '', loading, refreshApps, selectApp };
|
||||
}
|
||||
|
||||
export function AppSelector({
|
||||
apps,
|
||||
value,
|
||||
onChange,
|
||||
loading
|
||||
}: {
|
||||
apps: AppWithComponents[];
|
||||
value: string;
|
||||
onChange: (appId: string) => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 flex flex-col gap-2 rounded-lg border border-border bg-card px-5 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">앱 선택</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">선택한 앱 기준으로 소스, 빌드, 릴리즈, 배포 데이터를 봅니다.</p>
|
||||
</div>
|
||||
<select
|
||||
className="h-9 min-w-64 rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={value}
|
||||
disabled={loading || apps.length === 0}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{apps.length === 0 ? <option value="">등록된 앱을 선택해주세요</option> : null}
|
||||
{apps.map((app) => (
|
||||
<option key={app.id} value={app.id}>
|
||||
{app.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const normalized = status.toUpperCase();
|
||||
const label = {
|
||||
SUCCEEDED: '성공',
|
||||
ACTIVE: '활성',
|
||||
READY: '준비',
|
||||
RUNNING: '실행 중',
|
||||
PENDING: '대기',
|
||||
FAILED: '실패',
|
||||
DISABLED: '비활성',
|
||||
CANCELED: '취소'
|
||||
}[normalized] ?? status;
|
||||
|
||||
if (normalized === 'SUCCEEDED' || normalized === 'ACTIVE' || normalized === 'READY' || normalized === 'RUNNING' || status === '성공' || status === '활성' || status === '준비' || status === '실행 중') {
|
||||
return <Badge tone={normalized === 'RUNNING' || status === '실행 중' ? 'blue' : 'green'}>{label}</Badge>;
|
||||
}
|
||||
if (normalized === 'PENDING' || status === '대기') return <Badge tone="amber">{label}</Badge>;
|
||||
if (normalized === 'FAILED' || normalized === 'DISABLED' || normalized === 'CANCELED' || status === '실패' || status === '비활성' || status === '취소') return <Badge tone="red">{label}</Badge>;
|
||||
return <Badge>{label}</Badge>;
|
||||
}
|
||||
|
||||
export function isBuildDone(status: JobStatus) {
|
||||
return !['PENDING', 'RUNNING'].includes(status);
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
export function EmptyState({ children }: { children: string }) {
|
||||
return <div className="flex min-h-40 items-center justify-center text-sm text-muted-foreground">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Search, Square, Trash2 } from 'lucide-react';
|
||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
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, listBuildJobs, listSources } from './application-api';
|
||||
|
||||
function buildTargetForType(type?: string | null) {
|
||||
return {
|
||||
ELECTRON: 'AppImage',
|
||||
CONTAINER: 'container-image',
|
||||
STATIC_WEB: 'web-image',
|
||||
DATABASE: 'database',
|
||||
MIGRATION: 'prisma/migrations'
|
||||
}[type ?? ''] ?? 'container-image';
|
||||
}
|
||||
|
||||
function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) {
|
||||
if (!component) return '-';
|
||||
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch;
|
||||
if (component.type === 'ELECTRON') return `${component.name}-${suffix}.AppImage`;
|
||||
if (component.type === 'DATABASE' || component.type === 'MIGRATION') return `${component.name}:${branch}:migration-check`;
|
||||
return `localhost:5000/${component.name}:${suffix}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return {
|
||||
PENDING: '대기',
|
||||
RUNNING: '실행 중',
|
||||
SUCCEEDED: '성공',
|
||||
FAILED: '실패',
|
||||
CANCELED: '취소'
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
function durationLabel(job: BuildJob) {
|
||||
if (job.status === 'PENDING') return '대기 중';
|
||||
if (job.status === 'RUNNING') return '진행 중';
|
||||
if (!job.startedAt || !job.finishedAt) return '-';
|
||||
|
||||
const startedAt = new Date(job.startedAt).getTime();
|
||||
const finishedAt = new Date(job.finishedAt).getTime();
|
||||
if (Number.isNaN(startedAt) || Number.isNaN(finishedAt) || finishedAt < startedAt) return '-';
|
||||
|
||||
const seconds = Math.max(1, Math.round((finishedAt - startedAt) / 1000));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const restSeconds = seconds % 60;
|
||||
return minutes ? `${minutes}분 ${restSeconds}초` : `${restSeconds}초`;
|
||||
}
|
||||
|
||||
function resultArtifact(job: BuildJob) {
|
||||
return job.image || job.artifact || '-';
|
||||
}
|
||||
|
||||
function jobTime(job: BuildJob) {
|
||||
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
const buildJobPageSize = 3;
|
||||
|
||||
export function BuildManagementPage() {
|
||||
const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps();
|
||||
const { toast } = useToast();
|
||||
const [sources, setSources] = useState<SourceComponent[]>([]);
|
||||
const [jobs, setJobs] = useState<BuildJob[]>([]);
|
||||
const [selectedComponentId, setSelectedComponentId] = useState('');
|
||||
const [branch, setBranch] = useState('develop');
|
||||
const [commitSha, setCommitSha] = useState('');
|
||||
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const loadingVisible = useMinimumLoading(appsLoading || loading);
|
||||
|
||||
const componentOptions = useMemo(
|
||||
() => {
|
||||
const appSources = selectedAppId ? sources.filter((source) => source.appId === selectedAppId) : sources;
|
||||
return appSources.filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE');
|
||||
},
|
||||
[selectedAppId, sources]
|
||||
);
|
||||
const selectedComponent = componentOptions.find((source) => source.id === selectedComponentId) ?? componentOptions[0];
|
||||
const selectedTarget = selectedComponent?.buildTarget || buildTargetForType(selectedComponent?.type);
|
||||
const predicted = predictedArtifact(selectedComponent, branch, commitSha);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextSources, nextJobs] = await Promise.all([listSources(), listBuildJobs(selectedApp?.key)]);
|
||||
setSources(nextSources);
|
||||
setJobs(nextJobs);
|
||||
const filteredSources = (selectedAppId ? nextSources.filter((source) => source.appId === selectedAppId) : nextSources)
|
||||
.filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE');
|
||||
setSelectedComponentId((prev) => {
|
||||
const valid = filteredSources.some((source) => source.id === prev);
|
||||
const next = valid ? prev : filteredSources[0]?.id || '';
|
||||
const nextSource = filteredSources.find((source) => source.id === next);
|
||||
setBranch(nextSource?.branch || 'develop');
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [selectedAppId, selectedApp?.key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!componentOptions.some((source) => source.id === selectedComponentId)) {
|
||||
const nextComponent = componentOptions[0];
|
||||
setSelectedComponentId(nextComponent?.id || '');
|
||||
setBranch(nextComponent?.branch || 'develop');
|
||||
}
|
||||
}, [componentOptions, selectedComponentId]);
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return jobs
|
||||
.filter((job) => {
|
||||
const text = `${job.component.app.name} ${job.component.name} ${job.component.repository ?? ''} ${job.branch} ${job.commitSha ?? ''} ${resultArtifact(job)} ${job.status}`.toLowerCase();
|
||||
const matchesSearch = normalizedSearch ? text.includes(normalizedSearch) : true;
|
||||
const matchesStatus = statusFilter ? job.status === statusFilter : true;
|
||||
return matchesSearch && matchesStatus;
|
||||
})
|
||||
.sort((a, b) => jobTime(b) - jobTime(a));
|
||||
}, [jobs, search, statusFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredJobs.length / buildJobPageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageStart = (currentPage - 1) * buildJobPageSize;
|
||||
const pagedJobs = filteredJobs.slice(pageStart, pageStart + buildJobPageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, statusFilter, selectedAppId]);
|
||||
|
||||
const startBuild = async () => {
|
||||
if (!selectedComponent) return;
|
||||
try {
|
||||
const result = await createBuildJob({ componentId: selectedComponent.id, branch, commitSha });
|
||||
toast({ type: 'success', message: '빌드 작업이 생성되었습니다.' });
|
||||
setSelectedJob(result.buildJob);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '빌드 생성에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const retryBuild = async (job: BuildJob) => {
|
||||
try {
|
||||
const result = await createBuildJob({ componentId: job.componentId, branch: job.branch, commitSha: job.commitSha ?? '' });
|
||||
toast({ type: 'success', message: '빌드 작업이 다시 생성되었습니다.' });
|
||||
setSelectedJob(result.buildJob);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '빌드 재실행에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const stopJob = async (id: string) => {
|
||||
try {
|
||||
await cancelBuildJob(id);
|
||||
toast({ type: 'success', message: '빌드 중지를 요청했습니다.' });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '빌드 중지에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const removeJob = async (id: string) => {
|
||||
try {
|
||||
await deleteBuildJob(id);
|
||||
toast({ type: 'success', message: '빌드 작업이 삭제되었습니다.' });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '삭제에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="빌드 관리"
|
||||
description="저장소 소스에서 컨테이너 이미지와 런처 다운로드용 클라이언트 산출물을 만드는 작업을 관리합니다."
|
||||
/>
|
||||
|
||||
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader title="빌드 요청" description="구성요소, 브랜치, 커밋 기준으로 빌드 작업을 생성합니다." />
|
||||
<div className="grid gap-4 p-5 xl:grid-cols-[1.2fr_1fr]">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<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={selectedComponent?.id ?? ''}
|
||||
onChange={(event) => {
|
||||
const next = componentOptions.find((source) => source.id === event.target.value);
|
||||
setSelectedComponentId(event.target.value);
|
||||
setBranch(next?.branch || 'develop');
|
||||
}}
|
||||
>
|
||||
{componentOptions.length === 0 ? <option value="">구성요소 없음</option> : null}
|
||||
{componentOptions.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Branch</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={branch}
|
||||
onChange={(event) => setBranch(event.target.value)}
|
||||
>
|
||||
<option value="develop">develop</option>
|
||||
<option value="release">release</option>
|
||||
<option value="main">main</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block md:col-span-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
|
||||
<input
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={commitSha}
|
||||
onChange={(event) => setCommitSha(event.target.value)}
|
||||
placeholder="비워두면 브랜치 최신 커밋 기준"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-md border border-border bg-muted/20 p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Repository</span>
|
||||
<span className="truncate font-medium">{selectedComponent?.repository || '미연결'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">빌드 대상</span>
|
||||
<span className="truncate font-medium">{selectedTarget}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">예상 결과물</span>
|
||||
<span className="truncate font-medium">{predicted}</span>
|
||||
</div>
|
||||
<Button className="mt-1 h-8 px-3 text-[13px]" onClick={startBuild} disabled={!selectedComponent || appsLoading || loading}>
|
||||
<Play className="size-4" />
|
||||
빌드 실행
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-border px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<h2 className="text-sm font-semibold text-foreground">빌드 작업 목록</h2>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="앱, 구성요소, 저장소 검색"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
{[...new Set(jobs.map((job) => job.status))].map((status) => (
|
||||
<option key={status} value={status}>{statusLabel(status)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
{!loading && filteredJobs.length === 0 ? (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
빌드 작업이 없습니다.
|
||||
</div>
|
||||
) : null}
|
||||
{pagedJobs.map((job, index) => (
|
||||
<div key={job.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">No. {filteredJobs.length - pageStart - index}</span>
|
||||
<StatusBadge status={statusLabel(job.status)} />
|
||||
<span className="font-medium">{job.component.app.name}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="font-medium">{job.component.name}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span>
|
||||
<span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span>
|
||||
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
||||
<span className="text-muted-foreground">대상 <b className="text-foreground">{job.component.buildTarget || buildTargetForType(job.component.type)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-2">
|
||||
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
{!isBuildDone(job.status) ? (
|
||||
<Button variant="outline" className="size-8 px-0" title="중지" aria-label="중지" onClick={() => stopJob(job.id)}>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" className="size-8 px-0" title="재실행" aria-label="재실행" onClick={() => retryBuild(job)}>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isBuildDone(job.status) ? (
|
||||
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => removeJob(job.id)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,1fr)_220px_120px]">
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground">결과물</p>
|
||||
<p className="mt-1 truncate font-mono font-medium">{resultArtifact(job)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">시작 / 종료</p>
|
||||
<p className="mt-1 font-medium">{formatDate(job.startedAt || job.createdAt)} / {formatDate(job.finishedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">소요시간</p>
|
||||
<p className="mt-1 font-medium">{durationLabel(job)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loading ? '불러오는 중' : `총 ${filteredJobs.length}건 · ${currentPage} / ${totalPages}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
다음
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={Boolean(selectedJob)}
|
||||
title="빌드 로그"
|
||||
onClose={() => setSelectedJob(null)}
|
||||
className="log-modal-shell"
|
||||
footer={<Button variant="outline" onClick={() => setSelectedJob(null)}>닫기</Button>}
|
||||
>
|
||||
{selectedJob?.logs ? (
|
||||
<pre className="whitespace-pre-wrap bg-background p-4 font-mono text-xs leading-6 text-muted-foreground">
|
||||
{selectedJob.logs}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
작업을 선택하면 로그가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw } 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, listDeployments, listReleases } from './application-api';
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return {
|
||||
PENDING: '대기',
|
||||
RUNNING: '실행 중',
|
||||
SUCCEEDED: '성공',
|
||||
FAILED: '실패',
|
||||
CANCELED: '취소'
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||
if (status === 'SUCCEEDED' || status === '성공') return 'green';
|
||||
if (status === 'RUNNING' || status === '실행 중') return 'blue';
|
||||
if (status === 'PENDING' || status === '대기') return 'amber';
|
||||
if (status === 'FAILED' || status === 'CANCELED' || status === '실패' || status === '취소') return 'red';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
type ImageTags = {
|
||||
images?: Record<string, string>;
|
||||
artifacts?: Record<string, string>;
|
||||
};
|
||||
|
||||
function releaseArtifacts(release?: AppRelease | null) {
|
||||
const imageTags = release?.imageTags as ImageTags | null | undefined;
|
||||
const entries = Object.entries(imageTags?.images ?? {});
|
||||
if (entries.length) return entries;
|
||||
if (release?.artifact) return [[release.app.name, release.artifact]];
|
||||
return [];
|
||||
}
|
||||
|
||||
function firstArtifact(release: AppRelease) {
|
||||
const artifacts = releaseArtifacts(release);
|
||||
return artifacts[0]?.[1] ?? release.artifact ?? '-';
|
||||
}
|
||||
|
||||
function deploymentTime(deployment: DeploymentJob) {
|
||||
const value = new Date(deployment.createdAt || deployment.startedAt || deployment.finishedAt || 0).getTime();
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
const deploymentPageSize = 5;
|
||||
|
||||
export function DeploymentManagementPage() {
|
||||
const { toast } = useToast();
|
||||
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
||||
const [deployments, setDeployments] = useState<DeploymentJob[]>([]);
|
||||
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||
const [releaseId, setReleaseId] = useState('');
|
||||
const [environment, setEnvironment] = useState('dev');
|
||||
const [namespace, setNamespace] = useState('');
|
||||
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
||||
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
||||
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const selectedRelease = releases.find((release) => release.id === releaseId);
|
||||
const filteredReleases = useMemo(
|
||||
() => releases.filter((release) => !selectedAppId || release.appId === selectedAppId),
|
||||
[releases, selectedAppId]
|
||||
);
|
||||
const filteredDeployments = useMemo(
|
||||
() => deployments
|
||||
.filter((deployment) => !selectedAppId || deployment.appId === selectedAppId)
|
||||
.sort((a, b) => deploymentTime(b) - deploymentTime(a)),
|
||||
[deployments, selectedAppId]
|
||||
);
|
||||
const totalPages = Math.max(1, Math.ceil(filteredDeployments.length / deploymentPageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageStart = (currentPage - 1) * deploymentPageSize;
|
||||
const pagedDeployments = filteredDeployments.slice(pageStart, pageStart + deploymentPageSize);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextDeployments, nextReleases] = await Promise.all([listDeployments(), listReleases()]);
|
||||
const deployableReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED');
|
||||
setDeployments(nextDeployments);
|
||||
setReleases(deployableReleases);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [selectedAppId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRelease?.app?.namespace) setNamespace((prev) => prev || selectedRelease.app.namespace || '');
|
||||
}, [selectedRelease?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setReleaseId((prev) => filteredReleases.some((release) => release.id === prev) ? prev : filteredReleases[0]?.id || '');
|
||||
setNamespace(filteredReleases[0]?.app?.namespace || '');
|
||||
}, [filteredReleases]);
|
||||
|
||||
const deployableCards = useMemo(() => filteredReleases.slice(0, 8), [filteredReleases]);
|
||||
|
||||
const openDeployModal = (release?: AppRelease) => {
|
||||
if (release) {
|
||||
if (release.appId !== selectedAppId) selectApp(release.appId);
|
||||
setReleaseId(release.id);
|
||||
setNamespace(release.app.namespace || '');
|
||||
}
|
||||
setUpdateLauncherAppVersion(true);
|
||||
setDeployModalOpen(true);
|
||||
};
|
||||
|
||||
const deploy = async () => {
|
||||
try {
|
||||
const result = await createDeployment({ releaseId, environment, namespace, updateLauncherAppVersion });
|
||||
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
|
||||
setSelectedDeployment(result.deploymentJob);
|
||||
setDeployModalOpen(false);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '배포 실행에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="배포 관리"
|
||||
description="생성된 릴리즈를 개발, 검수, 운영 환경에 배포하고 재배포와 롤백을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" disabled={filteredReleases.length === 0} onClick={() => openDeployModal()}>
|
||||
<Plus className="size-4" />
|
||||
배포 실행
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="배포 작업" description="릴리즈별 배포 실행 결과와 rollout 로그를 확인합니다." />
|
||||
<div className="space-y-3 p-4">
|
||||
{filteredDeployments.length === 0 ? (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
배포 작업이 없습니다.
|
||||
</div>
|
||||
) : null}
|
||||
{pagedDeployments.map((deployment, index) => {
|
||||
const artifacts = releaseArtifacts(deployment.release);
|
||||
return (
|
||||
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">No. {filteredDeployments.length - pageStart - index}</span>
|
||||
<Badge tone={statusTone(deployment.status)}>{statusLabel(deployment.status)}</Badge>
|
||||
<span className="text-base font-medium">{deployment.app.name}</span>
|
||||
<span className="text-base font-medium">{deployment.release?.version || '-'}</span>
|
||||
</div>
|
||||
<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">{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">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedDeployment(deployment)}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md border border-border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">이미지</p>
|
||||
{artifacts.length ? (
|
||||
artifacts.map(([name, artifact]) => (
|
||||
<div key={`${deployment.id}-${name}`} className="mt-2 flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{name}</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{artifact}</p>
|
||||
</div>
|
||||
<Button variant="outline" className="size-8 px-0" title="재배포" aria-label="재배포" onClick={() => deployment.release && openDeployModal(deployment.release)}>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-muted-foreground">-</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loading ? '불러오는 중' : `총 ${filteredDeployments.length}건 · ${currentPage} / ${totalPages}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
다음
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="배포 가능 릴리즈" description="성공 상태의 릴리즈만 배포 대상으로 사용할 수 있습니다." />
|
||||
<div className="space-y-3 p-4">
|
||||
{deployableCards.length === 0 ? (
|
||||
<div className="rounded-md border border-border px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
배포 가능한 릴리즈가 없습니다.
|
||||
</div>
|
||||
) : null}
|
||||
{deployableCards.map((release) => (
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
<p className="mt-2 truncate font-mono text-xs text-muted-foreground">{firstArtifact(release)}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal open={deployModalOpen} title="배포 실행" onClose={() => setDeployModalOpen(false)} footer={<><Button variant="outline" onClick={() => setDeployModalOpen(false)}>취소</Button><Button disabled={!releaseId || !namespace} onClick={deploy}>실행</Button></>}>
|
||||
<div className="space-y-3">
|
||||
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={releaseId} onChange={(event) => {
|
||||
const release = releases.find((item) => item.id === event.target.value);
|
||||
setReleaseId(event.target.value);
|
||||
setNamespace(release?.app.namespace || '');
|
||||
}}>
|
||||
<option value="">릴리즈 선택</option>
|
||||
{filteredReleases.map((release) => <option key={release.id} value={release.id}>{release.app.name} {release.version}</option>)}
|
||||
</select>
|
||||
<input 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)} placeholder="dev / release / prod" />
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-primary"
|
||||
checked={updateLauncherAppVersion}
|
||||
onChange={(event) => setUpdateLauncherAppVersion(event.target.checked)}
|
||||
/>
|
||||
런처 앱 버전 업데이트
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(selectedDeployment)}
|
||||
title="배포 로그"
|
||||
onClose={() => setSelectedDeployment(null)}
|
||||
className="log-modal-shell"
|
||||
footer={<Button variant="outline" onClick={() => setSelectedDeployment(null)}>닫기</Button>}
|
||||
>
|
||||
{selectedDeployment?.logs ? (
|
||||
<pre className="whitespace-pre-wrap bg-background p-4 font-mono text-xs leading-6 text-muted-foreground">
|
||||
{selectedDeployment.logs}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
배포 작업을 선택하면 로그가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
|
||||
import { Button, Card, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { AppRelease, RuntimeControlJob } from '../../types/application';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { StatusBadge, formatDate, useApps } from './ApplicationControls';
|
||||
import { createMigration, listMigrations, listReleases } from './application-api';
|
||||
|
||||
type ImageTags = {
|
||||
images?: Record<string, string>;
|
||||
artifacts?: Record<string, string>;
|
||||
};
|
||||
|
||||
function imageMap(imageTags: unknown) {
|
||||
if (!imageTags || typeof imageTags !== 'object') return {};
|
||||
const tags = imageTags as ImageTags;
|
||||
return tags.images ?? tags.artifacts ?? {};
|
||||
}
|
||||
|
||||
function imageEntries(imageTags: unknown) {
|
||||
return Object.entries(imageMap(imageTags));
|
||||
}
|
||||
|
||||
function selectedImageTag(release: AppRelease | undefined, imageName: string) {
|
||||
if (!release || !imageName) return '-';
|
||||
return imageMap(release.imageTags)[imageName] ?? '-';
|
||||
}
|
||||
|
||||
function requestText(job: RuntimeControlJob, key: string, fallback = '-') {
|
||||
const value = job.request?.[key];
|
||||
return typeof value === 'string' && value.trim() ? value : fallback;
|
||||
}
|
||||
|
||||
function jobNumber(index: number, total: number) {
|
||||
return total - index;
|
||||
}
|
||||
|
||||
function migrationTime(job: RuntimeControlJob) {
|
||||
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
const migrationPageSize = 5;
|
||||
|
||||
export function MigrationManagementPage() {
|
||||
const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps();
|
||||
const { toast } = useToast();
|
||||
const [jobs, setJobs] = useState<RuntimeControlJob[]>([]);
|
||||
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||
const [releaseId, setReleaseId] = useState('');
|
||||
const [migrationComponentId, setMigrationComponentId] = useState('');
|
||||
const [imageComponentName, setImageComponentName] = useState('');
|
||||
const [namespace, setNamespace] = useState('');
|
||||
const [command, setCommand] = useState('npx prisma migrate deploy --schema=prisma/schema.prisma');
|
||||
const [envSecretName, setEnvSecretName] = useState('');
|
||||
const [selectedJob, setSelectedJob] = useState<RuntimeControlJob | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const loadingVisible = useMinimumLoading(appsLoading || loading);
|
||||
|
||||
const selectedRelease = releases.find((release) => release.id === releaseId);
|
||||
const migrationComponents = selectedApp?.components?.filter((component) => component.type === 'MIGRATION') ?? [];
|
||||
const images = imageEntries(selectedRelease?.imageTags);
|
||||
const selectedImage = selectedImageTag(selectedRelease, imageComponentName);
|
||||
const sortedJobs = [...jobs].sort((a, b) => migrationTime(b) - migrationTime(a));
|
||||
const totalPages = Math.max(1, Math.ceil(sortedJobs.length / migrationPageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageStart = (currentPage - 1) * migrationPageSize;
|
||||
const pagedJobs = sortedJobs.slice(pageStart, pageStart + migrationPageSize);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextJobs, nextReleases] = await Promise.all([listMigrations(), listReleases(selectedApp?.key)]);
|
||||
const succeededReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED');
|
||||
setJobs(nextJobs);
|
||||
setReleases(succeededReleases);
|
||||
setReleaseId((prev) => (succeededReleases.some((release) => release.id === prev) ? prev : succeededReleases[0]?.id || ''));
|
||||
setNamespace((prev) => prev || selectedApp?.namespace || '');
|
||||
setMigrationComponentId((prev) => (
|
||||
migrationComponents.some((component) => component.id === prev) ? prev : migrationComponents[0]?.id || ''
|
||||
));
|
||||
setEnvSecretName((prev) => prev || `${selectedApp?.key ?? 'app'}-be-secret`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [selectedApp?.key, selectedApp?.namespace]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextImages = imageEntries(selectedRelease?.imageTags);
|
||||
setImageComponentName((prev) => (nextImages.some(([name]) => name === prev) ? prev : nextImages[0]?.[0] || ''));
|
||||
}, [selectedRelease?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setNamespace(selectedApp?.namespace || '');
|
||||
setMigrationComponentId(migrationComponents[0]?.id || '');
|
||||
setEnvSecretName(`${selectedApp?.key ?? 'app'}-be-secret`);
|
||||
setPage(1);
|
||||
}, [selectedAppId]);
|
||||
|
||||
const runMigration = async () => {
|
||||
try {
|
||||
setConfirmOpen(false);
|
||||
const result = await createMigration({ releaseId, migrationComponentId, imageComponentName, namespace, command, envSecretName });
|
||||
toast({ type: 'success', message: '마이그레이션 작업이 실행되었습니다.' });
|
||||
setSelectedJob(result.migrationJob);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '마이그레이션 실행에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const canRun = Boolean(releaseId && migrationComponentId && imageComponentName && namespace && command.trim());
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="마이그레이션"
|
||||
description="마이그레이션 구성요소를 선택하고 릴리즈 기준으로 데이터 변경 작업을 실행합니다."
|
||||
actions={(
|
||||
<Button disabled={!canRun || loading} onClick={() => setConfirmOpen(true)}>
|
||||
<Play className="size-4" />
|
||||
실행
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Card className="mb-4">
|
||||
<div className="grid gap-4 p-5 lg:grid-cols-3">
|
||||
<label className="block">
|
||||
<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={selectedAppId}
|
||||
disabled={appsLoading || apps.length === 0}
|
||||
onChange={(event) => selectApp(event.target.value)}
|
||||
>
|
||||
{apps.length === 0 ? <option value="">등록된 앱 없음</option> : null}
|
||||
{apps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<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={releaseId}
|
||||
onChange={(event) => setReleaseId(event.target.value)}
|
||||
>
|
||||
{releases.length === 0 ? <option value="">릴리즈 없음</option> : null}
|
||||
{releases.map((release) => <option key={release.id} value={release.id}>{release.version}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<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={migrationComponentId}
|
||||
onChange={(event) => setMigrationComponentId(event.target.value)}
|
||||
>
|
||||
{migrationComponents.length === 0 ? <option value="">구성요소 없음</option> : null}
|
||||
{migrationComponents.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<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={imageComponentName}
|
||||
onChange={(event) => setImageComponentName(event.target.value)}
|
||||
>
|
||||
{images.length === 0 ? <option value="">이미지 없음</option> : null}
|
||||
{images.map(([name]) => <option key={name} value={name}>{name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
||||
<input
|
||||
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={namespace}
|
||||
onChange={(event) => setNamespace(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">envFrom Secret</span>
|
||||
<input
|
||||
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={envSecretName}
|
||||
onChange={(event) => setEnvSecretName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block lg:col-span-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">명령</span>
|
||||
<input
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-xs text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="rounded-md border border-border bg-muted/20 p-4 lg:col-span-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">선택 이미지</p>
|
||||
<p className="mt-2 break-all font-mono text-sm font-medium text-foreground">{selectedImage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">마이그레이션 작업</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">마이그레이션 실행 이력과 로그입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-5">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
등록된 마이그레이션 작업이 없습니다.
|
||||
</div>
|
||||
) : pagedJobs.map((job, index) => {
|
||||
const releaseVersion = requestText(job, 'releaseVersion');
|
||||
const imageName = requestText(job, 'imageComponentName', requestText(job, 'image'));
|
||||
const commandText = requestText(job, 'command', command);
|
||||
|
||||
return (
|
||||
<div key={job.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">No. {jobNumber(pageStart + index, sortedJobs.length)}</span>
|
||||
<StatusBadge status={job.status} />
|
||||
<span className="truncate text-base font-medium text-foreground">
|
||||
{selectedApp?.name ?? '앱'} {job.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<span className="text-muted-foreground">릴리즈 <b className="text-foreground">{releaseVersion}</b></span>
|
||||
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{job.namespace}</b></span>
|
||||
<span className="text-muted-foreground">이미지 <b className="text-foreground">{imageName}</b></span>
|
||||
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(job.startedAt || job.createdAt)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="size-8 shrink-0 px-0" onClick={() => setSelectedJob(job)} title="로그" aria-label="로그">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md border border-border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">명령</p>
|
||||
<p className="mt-2 break-all font-mono text-xs text-foreground">{commandText}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loading ? '불러오는 중' : `총 ${sortedJobs.length}건 · ${currentPage} / ${totalPages}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
다음
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal open={Boolean(selectedJob)} title="마이그레이션 로그" onClose={() => setSelectedJob(null)} className="log-modal-shell">
|
||||
{selectedJob?.logs ? (
|
||||
<pre className="whitespace-pre-wrap rounded-md border border-border bg-background p-4 font-mono text-xs leading-5 text-muted-foreground">
|
||||
{selectedJob.logs}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
작업을 선택하면 로그가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
title="마이그레이션 실행"
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>취소</Button>
|
||||
<Button disabled={!canRun || loading} onClick={runMigration}>실행</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
선택한 릴리즈 기준으로 데이터베이스 변경 작업을 실행합니다. 적용 전 대상 정보를 확인하세요.
|
||||
</p>
|
||||
<div className="rounded-md border border-border bg-background/70 p-4">
|
||||
<div className="grid gap-3 text-xs md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground">앱</p>
|
||||
<p className="mt-1 font-medium text-foreground">{selectedApp?.name ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">릴리즈</p>
|
||||
<p className="mt-1 font-medium text-foreground">{selectedRelease?.version ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">네임스페이스</p>
|
||||
<p className="mt-1 font-medium text-foreground">{namespace || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">이미지</p>
|
||||
<p className="mt-1 break-all font-mono font-medium text-foreground">{selectedImage}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-muted-foreground">명령</p>
|
||||
<p className="mt-1 break-all font-mono text-xs font-medium text-foreground">{command}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Plus } 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, listBuildJobs, listReleases } from './application-api';
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return {
|
||||
PENDING: '대기',
|
||||
RUNNING: '실행 중',
|
||||
SUCCEEDED: '성공',
|
||||
FAILED: '실패',
|
||||
CANCELED: '취소'
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||
if (status === 'SUCCEEDED' || status === '성공') return 'green';
|
||||
if (status === 'RUNNING' || status === '실행 중') return 'blue';
|
||||
if (status === 'PENDING' || status === '대기') return 'amber';
|
||||
if (status === 'FAILED' || status === 'CANCELED' || status === '실패' || status === '취소') return 'red';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
type ImageTags = {
|
||||
images?: Record<string, string>;
|
||||
artifacts?: Record<string, string>;
|
||||
};
|
||||
|
||||
function releaseArtifacts(release: AppRelease) {
|
||||
const imageTags = release.imageTags as ImageTags | null | undefined;
|
||||
const values = [
|
||||
...Object.values(imageTags?.images ?? {}),
|
||||
...Object.values(imageTags?.artifacts ?? {})
|
||||
].filter(Boolean);
|
||||
|
||||
if (values.length) return values;
|
||||
return release.artifact ? [release.artifact] : [];
|
||||
}
|
||||
|
||||
function buildCount(release: AppRelease) {
|
||||
const imageTags = release.imageTags as ImageTags | null | undefined;
|
||||
const count = Object.keys(imageTags?.images ?? {}).length + Object.keys(imageTags?.artifacts ?? {}).length;
|
||||
return count || (release.artifact ? 1 : 0);
|
||||
}
|
||||
|
||||
function groupCandidateBuilds(builds: BuildJob[]) {
|
||||
const map = new Map<string, { appId: string; appName: string; count: number }>();
|
||||
for (const build of builds) {
|
||||
const appId = build.component.app.id;
|
||||
const current = map.get(appId);
|
||||
map.set(appId, {
|
||||
appId,
|
||||
appName: build.component.app.name,
|
||||
count: (current?.count ?? 0) + 1
|
||||
});
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
function releaseTime(release: AppRelease) {
|
||||
const value = new Date(release.createdAt).getTime();
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
const releasePageSize = 5;
|
||||
|
||||
export function ReleaseManagementPage() {
|
||||
const { toast } = useToast();
|
||||
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
||||
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||
const [builds, setBuilds] = useState<BuildJob[]>([]);
|
||||
const [version, setVersion] = useState('');
|
||||
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
|
||||
const [branch, setBranch] = useState('release');
|
||||
const [selectedBuildIds, setSelectedBuildIds] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const candidateGroups = useMemo(
|
||||
() => groupCandidateBuilds(builds).filter((group) => !selectedAppId || group.appId === selectedAppId),
|
||||
[builds, selectedAppId]
|
||||
);
|
||||
const filteredReleases = useMemo(
|
||||
() => releases
|
||||
.filter((release) => !selectedAppId || release.appId === selectedAppId)
|
||||
.sort((a, b) => releaseTime(b) - releaseTime(a)),
|
||||
[releases, selectedAppId]
|
||||
);
|
||||
const modalBuilds = useMemo(
|
||||
() => builds.filter((build) => !selectedAppId || build.component.app.id === selectedAppId),
|
||||
[builds, selectedAppId]
|
||||
);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextReleases, nextBuilds] = await Promise.all([listReleases(), listBuildJobs()]);
|
||||
const succeededBuilds = nextBuilds.filter((job) => job.status === 'SUCCEEDED');
|
||||
setReleases(nextReleases);
|
||||
setBuilds(succeededBuilds);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [selectedAppId]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredReleases.length / releasePageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageStart = (currentPage - 1) * releasePageSize;
|
||||
const pagedReleases = filteredReleases.slice(pageStart, pageStart + releasePageSize);
|
||||
|
||||
const openReleaseModal = (appId?: string) => {
|
||||
const nextAppId = appId || selectedAppId || candidateGroups[0]?.appId || '';
|
||||
if (nextAppId && nextAppId !== selectedAppId) selectApp(nextAppId);
|
||||
setSelectedBuildIds(builds.filter((build) => build.component.app.id === nextAppId).map((build) => build.id));
|
||||
setReleaseModalOpen(true);
|
||||
};
|
||||
|
||||
const toggleBuild = (id: string) => {
|
||||
setSelectedBuildIds((prev) => prev.includes(id) ? prev.filter((buildId) => buildId !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!selectedAppId) return;
|
||||
try {
|
||||
await createRelease({ appId: selectedAppId, version, branch, buildJobIds: selectedBuildIds });
|
||||
toast({ type: 'success', message: '릴리즈가 생성되었습니다.' });
|
||||
setVersion('');
|
||||
setSelectedBuildIds([]);
|
||||
setReleaseModalOpen(false);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '릴리즈 생성에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="릴리즈 관리"
|
||||
description="브랜치, 커밋, 빌드 산출물을 묶어 환경별로 배포 가능한 릴리즈를 생성합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" disabled={builds.length === 0} onClick={() => openReleaseModal()}>
|
||||
<Plus className="size-4" />
|
||||
릴리즈 생성
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="릴리즈 목록" description="성공한 빌드 결과를 버전 단위로 묶은 배포 후보입니다." />
|
||||
<div className="space-y-3 p-4">
|
||||
{filteredReleases.length === 0 ? (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
생성된 릴리즈가 없습니다.
|
||||
</div>
|
||||
) : null}
|
||||
{pagedReleases.map((release) => {
|
||||
const artifacts = releaseArtifacts(release);
|
||||
return (
|
||||
<div key={release.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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.version}</span>
|
||||
</div>
|
||||
<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">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
|
||||
<span className="text-muted-foreground">Build <b className="text-foreground">{buildCount(release)}개</b></span>
|
||||
<span className="text-muted-foreground">생성 <b className="text-foreground">{formatDate(release.createdAt)}</b></span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md border border-border bg-card px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">산출물</p>
|
||||
{artifacts.length ? (
|
||||
artifacts.map((artifact) => (
|
||||
<p key={artifact} className="mt-1 truncate font-mono text-xs font-medium">{artifact}</p>
|
||||
))
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">-</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loading ? '불러오는 중' : `총 ${filteredReleases.length}건 · ${currentPage} / ${totalPages}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
다음
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="릴리즈 후보" description="성공한 빌드 작업 중 릴리즈에 포함 가능한 항목입니다." />
|
||||
<div className="space-y-3 p-4">
|
||||
{candidateGroups.length === 0 ? (
|
||||
<div className="rounded-md border border-border px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
릴리즈 후보가 없습니다.
|
||||
</div>
|
||||
) : null}
|
||||
{candidateGroups.map((group) => (
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
<span className="text-sm font-medium">{group.appName}</span>
|
||||
<Badge tone={group.count > 0 ? 'green' : 'neutral'}>{group.count}개</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal open={releaseModalOpen} title="릴리즈 생성" onClose={() => setReleaseModalOpen(false)} footer={<><Button variant="outline" onClick={() => setReleaseModalOpen(false)}>취소</Button><Button disabled={!version || selectedBuildIds.length === 0} onClick={submit}>생성</Button></>}>
|
||||
<div className="space-y-3">
|
||||
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={selectedAppId} onChange={(event) => {
|
||||
selectApp(event.target.value);
|
||||
setSelectedBuildIds(builds.filter((build) => build.component.app.id === event.target.value).map((build) => build.id));
|
||||
}}>
|
||||
{candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)}
|
||||
</select>
|
||||
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={version} onChange={(event) => setVersion(event.target.value)} placeholder="버전 예: 0.1.1" />
|
||||
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={branch} onChange={(event) => setBranch(event.target.value)} placeholder="브랜치" />
|
||||
<div className="max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
|
||||
{modalBuilds.map((job) => (
|
||||
<label key={job.id} className="flex items-start gap-2 rounded-md px-2 py-2 text-sm hover:bg-muted/20">
|
||||
<input type="checkbox" checked={selectedBuildIds.includes(job.id)} onChange={() => toggleBuild(job.id)} />
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium">{job.component.name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{job.image || job.artifact || job.commitSha}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy, ExternalLink, Link2, Plus } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { GiteaRepo } from '../../types/application';
|
||||
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
||||
import { LoadingModal } from '../operations/operation-utils';
|
||||
import { createGiteaOrgRepo, linkComponentSource, listGiteaRepos, listSources } from './application-api';
|
||||
import { AppSelector, useApps } from './ApplicationControls';
|
||||
|
||||
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
||||
const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@localhost:2222';
|
||||
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? import.meta.env.GITEA_APP_ORG ?? 'platform';
|
||||
|
||||
type PlatformRepo = {
|
||||
id: number;
|
||||
owner: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
description?: string;
|
||||
private: boolean;
|
||||
htmlUrl: string;
|
||||
cloneUrl: string;
|
||||
sshUrl: string;
|
||||
defaultBranch: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
type SourceComponent = ApiAppComponent & {
|
||||
app?: ApiApp;
|
||||
};
|
||||
|
||||
function useMinimumLoading(loading: boolean, minimumMs = 500) {
|
||||
const [visible, setVisible] = useState(loading);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setVisible(false), minimumMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loading, minimumMs]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
function toPlatformRepo(repo: GiteaRepo): PlatformRepo {
|
||||
const fullName = (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? repo.name;
|
||||
const owner =
|
||||
typeof repo.owner === 'string'
|
||||
? repo.owner
|
||||
: repo.owner?.login ?? fullName.split('/')[0] ?? 'platform';
|
||||
|
||||
return {
|
||||
id: repo.id ?? Number(`${owner.length}${repo.name.length}`),
|
||||
owner,
|
||||
name: repo.name,
|
||||
fullName,
|
||||
description: (repo as GiteaRepo & { description?: string }).description,
|
||||
private: Boolean(repo.private),
|
||||
htmlUrl: (repo as GiteaRepo & { htmlUrl?: string }).htmlUrl ?? repo.html_url ?? `${giteaBaseUrl}/${fullName}`,
|
||||
cloneUrl: (repo as GiteaRepo & { cloneUrl?: string }).cloneUrl ?? repo.clone_url ?? `${giteaBaseUrl}/${fullName}.git`,
|
||||
sshUrl: (repo as GiteaRepo & { sshUrl?: string }).sshUrl ?? repo.ssh_url ?? `${giteaSshBaseUrl.replace(/\/$/, '')}/${fullName}.git`,
|
||||
defaultBranch: (repo as GiteaRepo & { defaultBranch?: string }).defaultBranch ?? 'main',
|
||||
language: (repo as GiteaRepo & { language?: string }).language
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRepositoryName(repository: string) {
|
||||
const trimmed = repository.trim().replace(/^\/+/, '').replace(/\.git$/, '');
|
||||
return trimmed.includes('/') ? trimmed : `${giteaAppOrg}/${trimmed}`;
|
||||
}
|
||||
|
||||
function getHttpCloneUrl(repository: string) {
|
||||
return `${giteaBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`;
|
||||
}
|
||||
|
||||
function getSshCloneUrl(repository: string) {
|
||||
return `${giteaSshBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`;
|
||||
}
|
||||
|
||||
export function SourceManagementPage() {
|
||||
const { toast } = useToast();
|
||||
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
||||
const [giteaRepos, setGiteaRepos] = useState<PlatformRepo[]>([]);
|
||||
const [apiSources, setApiSources] = useState<SourceComponent[]>([]);
|
||||
const [sourcesLoading, setSourcesLoading] = useState(true);
|
||||
const [giteaStatus, setGiteaStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const sourcesLoadingVisible = useMinimumLoading(giteaStatus === 'loading' || sourcesLoading);
|
||||
const [isCreateRepoOpen, setIsCreateRepoOpen] = useState(false);
|
||||
const [repoName, setRepoName] = useState('');
|
||||
const [repoDescription, setRepoDescription] = useState('');
|
||||
const [repoPrivate, setRepoPrivate] = useState(true);
|
||||
const [createDevelopBranch, setCreateDevelopBranch] = useState(true);
|
||||
const [createReleaseBranch, setCreateReleaseBranch] = useState(true);
|
||||
const [repoCreateStatus, setRepoCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
const [sourceModalOpen, setSourceModalOpen] = useState(false);
|
||||
const [sourceTarget, setSourceTarget] = useState<SourceComponent | null>(null);
|
||||
const [sourceRepo, setSourceRepo] = useState('');
|
||||
const [sourceBranch, setSourceBranch] = useState('develop');
|
||||
const [sourceBuildTarget, setSourceBuildTarget] = useState('');
|
||||
const [sourceSaveStatus, setSourceSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
|
||||
const refreshRepos = () => {
|
||||
setGiteaStatus('loading');
|
||||
listGiteaRepos(giteaAppOrg, 8)
|
||||
.then((repos) => {
|
||||
setGiteaRepos(repos.map(toPlatformRepo));
|
||||
setGiteaStatus('ready');
|
||||
})
|
||||
.catch(() => setGiteaStatus('error'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
setSourcesLoading(true);
|
||||
listSources()
|
||||
.then((sources) => {
|
||||
if (!mounted) return;
|
||||
setApiSources(sources);
|
||||
setSourcesLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setApiSources([]);
|
||||
setSourcesLoading(false);
|
||||
});
|
||||
|
||||
listGiteaRepos(giteaAppOrg, 8)
|
||||
.then((repos) => {
|
||||
if (!mounted) return;
|
||||
setGiteaRepos(repos.map(toPlatformRepo));
|
||||
setGiteaStatus('ready');
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setGiteaStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const createRepo = () => {
|
||||
if (!repoName.trim()) return;
|
||||
|
||||
setRepoCreateStatus('saving');
|
||||
createGiteaOrgRepo(giteaAppOrg, {
|
||||
name: repoName.trim(),
|
||||
description: repoDescription.trim() || undefined,
|
||||
private: repoPrivate,
|
||||
autoInit: true,
|
||||
createDevelopBranch,
|
||||
createReleaseBranch
|
||||
})
|
||||
.then(() => {
|
||||
setRepoName('');
|
||||
setRepoDescription('');
|
||||
setRepoPrivate(true);
|
||||
setCreateDevelopBranch(true);
|
||||
setCreateReleaseBranch(true);
|
||||
setRepoCreateStatus('idle');
|
||||
setIsCreateRepoOpen(false);
|
||||
refreshRepos();
|
||||
toast({ type: 'success', message: '저장소가 생성되었습니다.' });
|
||||
})
|
||||
.catch(() => setRepoCreateStatus('error'));
|
||||
};
|
||||
|
||||
const refreshSources = () => {
|
||||
setSourcesLoading(true);
|
||||
listSources()
|
||||
.then((sources) => {
|
||||
setApiSources(sources);
|
||||
setSourcesLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setApiSources([]);
|
||||
setSourcesLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const openSourceModal = (source: SourceComponent) => {
|
||||
setSourceTarget(source);
|
||||
setSourceRepo(source.repository ?? '');
|
||||
setSourceBranch(source.branch ?? 'develop');
|
||||
setSourceBuildTarget(source.buildTarget ?? '');
|
||||
setSourceSaveStatus('idle');
|
||||
setSourceModalOpen(true);
|
||||
};
|
||||
|
||||
const closeSourceModal = () => {
|
||||
setSourceModalOpen(false);
|
||||
setSourceTarget(null);
|
||||
setSourceRepo('');
|
||||
setSourceBranch('develop');
|
||||
setSourceBuildTarget('');
|
||||
setSourceSaveStatus('idle');
|
||||
};
|
||||
|
||||
const saveSourceLink = () => {
|
||||
if (!sourceTarget || !sourceRepo.trim()) return;
|
||||
|
||||
setSourceSaveStatus('saving');
|
||||
linkComponentSource(sourceTarget.id, {
|
||||
repository: sourceRepo,
|
||||
branch: sourceBranch || undefined,
|
||||
buildTarget: sourceBuildTarget || undefined
|
||||
})
|
||||
.then(() => {
|
||||
closeSourceModal();
|
||||
refreshSources();
|
||||
toast({ type: 'success', message: '소스 연결이 저장되었습니다.' });
|
||||
})
|
||||
.catch(() => {
|
||||
setSourceSaveStatus('error');
|
||||
toast({ type: 'error', message: '소스 연결 저장에 실패했습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (value: string) => {
|
||||
void navigator.clipboard?.writeText(value);
|
||||
toast({ type: 'success', message: '복사되었습니다.' });
|
||||
};
|
||||
|
||||
const filteredSources = apiSources.filter((source) => !selectedAppId || source.appId === selectedAppId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="소스 관리"
|
||||
description="앱별 소스, 브랜치, 커밋, 빌드 대상을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateRepoOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
저장소 생성
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
||||
|
||||
<Card className="mb-4 min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title="저장소"
|
||||
description="앱과 런처 소스를 구성요소에 연결할 수 있도록 관리합니다."
|
||||
/>
|
||||
<div className="max-h-[300px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">저장소</th>
|
||||
<th className="px-5 py-3 font-medium">Owner</th>
|
||||
<th className="px-5 py-3 font-medium">기본 브랜치</th>
|
||||
<th className="px-5 py-3 font-medium">언어</th>
|
||||
<th className="px-5 py-3 font-medium">Clone URL</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{giteaStatus === 'error' ? (
|
||||
<tr>
|
||||
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>백엔드 연결 후 저장소가 표시됩니다.</td>
|
||||
</tr>
|
||||
) : giteaStatus !== 'loading' && giteaRepos.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>등록된 저장소가 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
giteaRepos.map((repo) => (
|
||||
<tr key={repo.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{repo.fullName}</span>
|
||||
<a href={repo.htmlUrl} target="_blank" rel="noreferrer" className="text-muted-foreground hover:text-foreground">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
{repo.description ? <p className="mt-1 text-xs text-muted-foreground">{repo.description}</p> : null}
|
||||
</td>
|
||||
<td className="px-5 py-3">{repo.owner}</td>
|
||||
<td className="px-5 py-3">{repo.defaultBranch}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{repo.language || '-'}</td>
|
||||
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{repo.cloneUrl}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={repo.private ? 'amber' : 'blue'}>{repo.private ? 'Private' : 'Public'}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="소스 연결" description="앱 구성요소별 저장소와 빌드 대상을 확인합니다." />
|
||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[1180px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">앱</th>
|
||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||
<th className="w-72 px-5 py-3 font-medium">저장소</th>
|
||||
<th className="px-5 py-3 font-medium">기본 브랜치</th>
|
||||
<th className="px-5 py-3 font-medium">Commit</th>
|
||||
<th className="px-5 py-3 font-medium">유형</th>
|
||||
<th className="px-5 py-3 font-medium">빌드 대상</th>
|
||||
<th className="w-[260px] px-5 py-3 font-medium">Clone URL</th>
|
||||
<th className="w-24 px-5 py-3 font-medium">상태</th>
|
||||
<th className="w-24 px-5 py-3 text-right font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{!sourcesLoading && filteredSources.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-5 py-6 text-center text-muted-foreground">등록된 앱 구성요소가 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
!sourcesLoading && filteredSources.map((source) => (
|
||||
<tr key={source.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3 font-semibold">{source.app?.name ?? '-'}</td>
|
||||
<td className="px-5 py-3">{source.name}</td>
|
||||
<td className="max-w-72 px-5 py-3 text-muted-foreground">
|
||||
<span className="block truncate whitespace-nowrap">{source.repository || '미연결'}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">{source.branch || '-'}</td>
|
||||
<td className="px-5 py-3 font-medium">-</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{source.type}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{source.buildTarget || '-'}</td>
|
||||
<td className="w-[260px] px-5 py-3 text-muted-foreground">
|
||||
{source.repository ? (
|
||||
<div className="w-[220px] space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="min-w-0 flex-1 truncate whitespace-nowrap font-mono text-xs">{getHttpCloneUrl(source.repository)}</p>
|
||||
<button
|
||||
type="button"
|
||||
title="HTTP clone URL 복사"
|
||||
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => copyToClipboard(getHttpCloneUrl(source.repository!))}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="min-w-0 flex-1 truncate whitespace-nowrap font-mono text-xs">{getSshCloneUrl(source.repository)}</p>
|
||||
<button
|
||||
type="button"
|
||||
title="SSH clone URL 복사"
|
||||
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => copyToClipboard(getSshCloneUrl(source.repository!))}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3"><Badge tone={source.repository ? 'green' : 'neutral'}>{source.repository ? '연결됨' : '미연결'}</Badge></td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" className="size-8 px-0" title="소스 연결" aria-label="소스 연결" onClick={() => openSourceModal(source)}>
|
||||
<Link2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isCreateRepoOpen ? (
|
||||
<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-lg rounded-lg border border-border bg-card shadow-xl">
|
||||
<div className="border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-semibold">저장소 생성</h2>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">저장소명</span>
|
||||
<input
|
||||
value={repoName}
|
||||
onChange={(event) => setRepoName(event.target.value)}
|
||||
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||
<textarea
|
||||
value={repoDescription}
|
||||
onChange={(event) => setRepoDescription(event.target.value)}
|
||||
className="mt-2 min-h-20 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={repoPrivate}
|
||||
onChange={(event) => setRepoPrivate(event.target.checked)}
|
||||
className="size-4 accent-primary"
|
||||
/>
|
||||
Private 저장소로 생성
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createDevelopBranch}
|
||||
onChange={(event) => setCreateDevelopBranch(event.target.checked)}
|
||||
className="size-4 accent-primary"
|
||||
/>
|
||||
develop 브랜치 자동 생성
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createReleaseBranch}
|
||||
onChange={(event) => setCreateReleaseBranch(event.target.checked)}
|
||||
className="size-4 accent-primary"
|
||||
/>
|
||||
release 브랜치 자동 생성
|
||||
</label>
|
||||
{repoCreateStatus === 'error' ? (
|
||||
<p className="text-xs text-rose-300">저장소 생성에 실패했습니다. 권한과 중복 저장소명을 확인하세요.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<Button variant="ghost" onClick={() => setIsCreateRepoOpen(false)}>취소</Button>
|
||||
<Button onClick={createRepo} disabled={!repoName.trim() || repoCreateStatus === 'saving'}>
|
||||
{repoCreateStatus === 'saving' ? '생성 중' : '생성'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sourceModalOpen && sourceTarget ? (
|
||||
<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-lg rounded-lg border border-border bg-card shadow-xl">
|
||||
<div className="border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-semibold">소스 연결</h2>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">구성요소</p>
|
||||
<p className="mt-1 text-sm font-semibold">{sourceTarget.app?.name ?? '-'} / {sourceTarget.name}</p>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">저장소</span>
|
||||
<select
|
||||
value={sourceRepo}
|
||||
onChange={(event) => setSourceRepo(event.target.value)}
|
||||
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>
|
||||
{giteaRepos.map((repo) => (
|
||||
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">기본 브랜치</span>
|
||||
<select
|
||||
value={sourceBranch}
|
||||
onChange={(event) => setSourceBranch(event.target.value)}
|
||||
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="develop">develop</option>
|
||||
<option value="release">release</option>
|
||||
<option value="main">main</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">빌드 대상</span>
|
||||
<input
|
||||
value={sourceBuildTarget}
|
||||
onChange={(event) => setSourceBuildTarget(event.target.value)}
|
||||
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
</label>
|
||||
{sourceSaveStatus === 'error' ? <p className="text-xs text-rose-300">소스 연결 저장에 실패했습니다.</p> : null}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<Button variant="ghost" onClick={closeSourceModal}>취소</Button>
|
||||
<Button onClick={saveSourceLink} disabled={!sourceRepo.trim() || sourceSaveStatus === 'saving'}>
|
||||
{sourceSaveStatus === 'saving' ? '저장 중' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LoadingModal open={sourcesLoadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { filterAppsByCurrentAdmin } from '../../lib/admin-app-permissions';
|
||||
import type {
|
||||
AppRelease,
|
||||
AppWithComponents,
|
||||
BuildJob,
|
||||
ComponentType,
|
||||
DeploymentJob,
|
||||
GiteaRepo,
|
||||
RuntimeControlJob,
|
||||
SourceComponent
|
||||
} from '../../types/application';
|
||||
import type { ApiAppComponent } from '../../types/runtime';
|
||||
|
||||
export function listApps() {
|
||||
return apiRequest<AppWithComponents[]>('/apps').then(filterAppsByCurrentAdmin);
|
||||
}
|
||||
|
||||
export function createApp(input: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
return apiRequest<{ app: AppWithComponents }>('/apps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function createComponent(
|
||||
appId: string,
|
||||
input: {
|
||||
name: string;
|
||||
type: ComponentType;
|
||||
imageSource?: string;
|
||||
externalImage?: string;
|
||||
workloadType?: string;
|
||||
replicas?: number;
|
||||
serviceType?: string;
|
||||
storageEnabled?: boolean;
|
||||
storageSize?: string;
|
||||
storageMountPath?: string;
|
||||
storageRetain?: boolean;
|
||||
repository?: string;
|
||||
branch?: string;
|
||||
buildTarget?: string;
|
||||
port?: number;
|
||||
targetPort?: number;
|
||||
ports?: Array<{ name?: string; port?: number; targetPort?: number; protocol?: string }>;
|
||||
}
|
||||
) {
|
||||
return apiRequest<{ component: ApiAppComponent }>(`/apps/${appId}/components`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listSources() {
|
||||
return apiRequest<SourceComponent[]>('/apps/sources');
|
||||
}
|
||||
|
||||
export function linkComponentSource(
|
||||
componentId: string,
|
||||
input: { repository: string; branch?: string; buildTarget?: string }
|
||||
) {
|
||||
return apiRequest<{ component: SourceComponent }>(`/apps/components/${componentId}/source`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listGiteaRepos(owner = 'platform', limit = 50) {
|
||||
return apiRequest<{ owner: string | null; repos: GiteaRepo[] }>(
|
||||
`/gitea/repos?owner=${encodeURIComponent(owner)}&limit=${limit}`
|
||||
).then((result) => result.repos);
|
||||
}
|
||||
|
||||
export function createGiteaOrgRepo(
|
||||
org: string,
|
||||
input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
private?: boolean;
|
||||
autoInit?: boolean;
|
||||
createDevelopBranch?: boolean;
|
||||
createReleaseBranch?: boolean;
|
||||
}
|
||||
) {
|
||||
return apiRequest<{ repo: GiteaRepo; branches: unknown }>(`/gitea/orgs/${encodeURIComponent(org)}/repos`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}).then((result) => result.repo);
|
||||
}
|
||||
|
||||
export function listBuildJobs(appKey?: string) {
|
||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||
return apiRequest<BuildJob[]>(`/apps/builds${query}`);
|
||||
}
|
||||
|
||||
export function createBuildJob(input: { componentId: string; branch?: string; commitSha?: string }) {
|
||||
return apiRequest<{ buildJob: BuildJob }>('/apps/builds', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelBuildJob(buildJobId: string) {
|
||||
return apiRequest<{ buildJob: BuildJob }>(`/apps/builds/${buildJobId}/cancel`, { method: 'PATCH' });
|
||||
}
|
||||
|
||||
export function deleteBuildJob(buildJobId: string) {
|
||||
return apiRequest<{ deleted: boolean }>(`/apps/builds/${buildJobId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function listReleases(appKey?: string) {
|
||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||
return apiRequest<AppRelease[]>(`/apps/releases${query}`);
|
||||
}
|
||||
|
||||
export function createRelease(input: { appId: string; version: string; branch?: string; buildJobIds: string[] }) {
|
||||
return apiRequest<{ release: AppRelease }>('/apps/releases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listDeployments(appKey?: string) {
|
||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||
return apiRequest<DeploymentJob[]>(`/apps/deployments${query}`);
|
||||
}
|
||||
|
||||
export function createDeployment(input: { releaseId: string; environment: string; namespace: string; updateLauncherAppVersion?: boolean }) {
|
||||
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listMigrations() {
|
||||
return apiRequest<RuntimeControlJob[]>('/apps/migrations');
|
||||
}
|
||||
|
||||
export function createMigration(input: {
|
||||
releaseId: string;
|
||||
migrationComponentId: string;
|
||||
imageComponentName: string;
|
||||
namespace: string;
|
||||
command: string;
|
||||
envSecretName: string;
|
||||
}) {
|
||||
return apiRequest<{ migrationJob: RuntimeControlJob }>('/apps/migrations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Activity, Boxes, FileKey, Users } from 'lucide-react';
|
||||
import { Card } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="플랫폼 운영"
|
||||
title="대시보드"
|
||||
description="런처, 앱, 사용자, 라이센스, 런타임 상태를 관리하는 스튜디오 어드민 콘솔입니다."
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{[
|
||||
['활성 앱', '10', Boxes],
|
||||
['접속 사용자', '1,284', Users],
|
||||
['라이센스 사용률', '72%', FileKey],
|
||||
['런타임 정상률', '96.4%', Activity]
|
||||
].map(([label, value, Icon]) => (
|
||||
<Card key={label as string} className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{label as string}</p>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-semibold tracking-tight">{value as string}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { ChevronDown, Link2, MoreHorizontal, RotateCcw, Unlink } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import {
|
||||
createLauncherAppLink,
|
||||
deleteLauncherAppLink,
|
||||
listLauncherAppLinks,
|
||||
moveLauncherAppLink,
|
||||
syncLauncherAppsToAdminApps,
|
||||
updateLauncherAppLink,
|
||||
type LauncherAppLink,
|
||||
type LauncherUnlinkedApp
|
||||
} from './launcher-api';
|
||||
|
||||
type DisplayForm = {
|
||||
displayName: string;
|
||||
description: string;
|
||||
subtitle: string;
|
||||
detailUrl: string;
|
||||
videoPath: string;
|
||||
iconKey: string;
|
||||
launchUrl: string;
|
||||
devAppPath: string;
|
||||
execPath: string;
|
||||
displayOrder: string;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
function releaseLabel(link: LauncherAppLink) {
|
||||
return link.release?.version ?? '-';
|
||||
}
|
||||
|
||||
function launchUrl(link: LauncherAppLink) {
|
||||
return link.launchUrl || link.execPath || link.devAppPath || link.artifact || '-';
|
||||
}
|
||||
|
||||
function toDisplayForm(link: LauncherAppLink): DisplayForm {
|
||||
return {
|
||||
displayName: link.displayName || link.app.name || '',
|
||||
description: link.description || link.app.description || '',
|
||||
subtitle: link.subtitle || '',
|
||||
detailUrl: link.detailUrl || '',
|
||||
videoPath: link.videoPath || '',
|
||||
iconKey: link.iconKey || link.app.key.toLowerCase(),
|
||||
launchUrl: link.launchUrl || '',
|
||||
devAppPath: link.devAppPath || '',
|
||||
execPath: link.execPath || '',
|
||||
displayOrder: String(link.displayOrder || ''),
|
||||
isEnabled: link.isEnabled
|
||||
};
|
||||
}
|
||||
|
||||
export function LauncherAppsPage() {
|
||||
const { toast } = useToast();
|
||||
const [linkedApps, setLinkedApps] = useState<LauncherAppLink[]>([]);
|
||||
const [unlinkedApps, setUnlinkedApps] = useState<LauncherUnlinkedApp[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [workingId, setWorkingId] = useState('');
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [editingLink, setEditingLink] = useState<LauncherAppLink | null>(null);
|
||||
const [displayForm, setDisplayForm] = useState<DisplayForm | null>(null);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
listLauncherAppLinks()
|
||||
.then(({ linked, unlinked }) => {
|
||||
setLinkedApps(linked);
|
||||
setUnlinkedApps(unlinked);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '앱 연결 조회 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const connect = (app: LauncherUnlinkedApp) => {
|
||||
setWorkingId(app.id);
|
||||
createLauncherAppLink({ appId: app.id, releaseId: app.releases?.[0]?.id })
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: '런처에 앱을 연결했습니다.' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '앱 연결 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setWorkingId(''));
|
||||
};
|
||||
|
||||
const move = (link: LauncherAppLink, direction: 'up' | 'down') => {
|
||||
setWorkingId(`${link.id}-${direction}`);
|
||||
moveLauncherAppLink(link.id, direction)
|
||||
.then(({ linked, unlinked }) => {
|
||||
setLinkedApps(linked);
|
||||
setUnlinkedApps(unlinked);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '순서 변경 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setWorkingId(''));
|
||||
};
|
||||
|
||||
const unlink = (link: LauncherAppLink) => {
|
||||
setWorkingId(link.id);
|
||||
deleteLauncherAppLink(link.id)
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: '런처 앱 연결을 해제했습니다.' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '연결 해제 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setWorkingId(''));
|
||||
};
|
||||
|
||||
const syncAdminApps = () => {
|
||||
setSyncing(true);
|
||||
syncLauncherAppsToAdminApps()
|
||||
.then(({ syncedApps, syncedReleases }) => {
|
||||
toast({ type: 'success', message: `앱 ${syncedApps}개, 릴리즈 ${syncedReleases}개를 동기화했습니다.` });
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '앱 관리 동기화 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSyncing(false));
|
||||
};
|
||||
|
||||
const openSettings = (link: LauncherAppLink) => {
|
||||
setEditingLink(link);
|
||||
setDisplayForm(toDisplayForm(link));
|
||||
};
|
||||
|
||||
const closeSettings = () => {
|
||||
setEditingLink(null);
|
||||
setDisplayForm(null);
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
if (!editingLink || !displayForm) return;
|
||||
setWorkingId(`${editingLink.id}-settings`);
|
||||
updateLauncherAppLink(editingLink.id, {
|
||||
displayName: displayForm.displayName,
|
||||
description: displayForm.description,
|
||||
subtitle: displayForm.subtitle,
|
||||
detailUrl: displayForm.detailUrl,
|
||||
videoPath: displayForm.videoPath,
|
||||
iconKey: displayForm.iconKey,
|
||||
launchUrl: displayForm.launchUrl,
|
||||
devAppPath: displayForm.devAppPath,
|
||||
execPath: displayForm.execPath,
|
||||
displayOrder: Number(displayForm.displayOrder) || undefined,
|
||||
isEnabled: displayForm.isEnabled
|
||||
})
|
||||
.then(({ link }) => {
|
||||
setLinkedApps((prev) => prev.map((item) => (item.id === link.id ? link : item)).sort((a, b) => a.displayOrder - b.displayOrder));
|
||||
toast({ type: 'success', message: '런처 표시 설정을 저장했습니다.' });
|
||||
closeSettings();
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '설정 저장 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setWorkingId(''));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="런처 운영"
|
||||
title="앱 연결"
|
||||
description="등록된 앱 중 런처에 노출할 앱과 표시 순서를 관리합니다."
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={syncAdminApps} disabled={syncing || loading}>
|
||||
<Link2 className={syncing ? 'size-4 animate-spin' : 'size-4'} />
|
||||
앱 관리 동기화
|
||||
</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="연결된 앱" description="런처 홈에 표시할 앱과 노출 순서를 관리합니다." />
|
||||
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[760px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-28 px-5 py-3 font-medium">노출순서</th>
|
||||
<th className="px-5 py-3 font-medium">앱</th>
|
||||
<th className="px-5 py-3 font-medium">릴리즈</th>
|
||||
<th className="px-5 py-3 font-medium">실행 경로</th>
|
||||
<th className="px-5 py-3 text-right font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{!loading && linkedApps.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-12 text-center text-sm text-muted-foreground">
|
||||
연결된 앱이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{linkedApps.map((link, index) => (
|
||||
<tr key={link.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3">{link.displayOrder || index + 1}</td>
|
||||
<td className="px-5 py-3">
|
||||
<p className="inline-flex items-center gap-2">
|
||||
<span className={link.isEnabled ? 'size-2 rounded-full bg-emerald-400' : 'size-2 rounded-full bg-muted-foreground'} />
|
||||
{link.displayName || link.app.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{[link.app.key, link.subtitle].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={link.release ? 'green' : 'neutral'}>{releaseLabel(link)}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{launchUrl(link)}</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="size-8 px-0" aria-label={`${link.app.name} 위로 이동`} onClick={() => move(link, 'up')} disabled={index === 0 || workingId === `${link.id}-up`}>
|
||||
<ChevronDown className="size-4 rotate-180" />
|
||||
</Button>
|
||||
<Button variant="outline" className="size-8 px-0" aria-label={`${link.app.name} 아래로 이동`} onClick={() => move(link, 'down')} disabled={index === linkedApps.length - 1 || workingId === `${link.id}-down`}>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
<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" />
|
||||
</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}>
|
||||
<Unlink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="미연결 앱" description="아직 런처에 연결하지 않은 앱입니다." />
|
||||
<div className="max-h-[420px] divide-y divide-border overflow-y-auto">
|
||||
{!loading && unlinkedApps.length === 0 ? (
|
||||
<div className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">미연결 앱이 없습니다.</div>
|
||||
) : null}
|
||||
{unlinkedApps.map((app) => (
|
||||
<div key={app.id} className="flex items-center justify-between gap-4 px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{app.name}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{app.key} · {app.releases?.[0]?.version ?? '릴리즈 없음'}
|
||||
</p>
|
||||
</div>
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => connect(app)} disabled={workingId === app.id}>
|
||||
<Link2 className="size-4" />
|
||||
연결
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={Boolean(editingLink && displayForm)}
|
||||
title="런처 표시 설정"
|
||||
onClose={closeSettings}
|
||||
className="!max-w-3xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeSettings}>취소</Button>
|
||||
<Button onClick={saveSettings} disabled={!displayForm?.displayName.trim() || workingId.endsWith('-settings')}>
|
||||
저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{displayForm ? (
|
||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); saveSettings(); }}>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_120px_120px]">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">표시명</span>
|
||||
<input 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={displayForm.displayName} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayName: event.target.value } : prev)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출순서</span>
|
||||
<input type="number" 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={displayForm.displayOrder} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayOrder: event.target.value } : prev)} />
|
||||
</label>
|
||||
<label className="mt-6 inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
||||
<input type="checkbox" checked={displayForm.isEnabled} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, isEnabled: event.target.checked } : prev)} />
|
||||
활성
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">보조 문구</span>
|
||||
<input 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={displayForm.subtitle} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, subtitle: event.target.value } : prev)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">아이콘 키</span>
|
||||
<input 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={displayForm.iconKey} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, iconKey: event.target.value } : prev)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||
<textarea className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.description} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, description: event.target.value } : prev)} />
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">실행 URL</span>
|
||||
<input 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={displayForm.launchUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, launchUrl: event.target.value } : prev)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">상세 URL</span>
|
||||
<input 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={displayForm.detailUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, detailUrl: event.target.value } : prev)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">개발 앱 경로</span>
|
||||
<input 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={displayForm.devAppPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, devAppPath: event.target.value } : prev)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">실행 파일 경로</span>
|
||||
<input 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={displayForm.execPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, execPath: event.target.value } : prev)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">소개 영상 경로</span>
|
||||
<input 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={displayForm.videoPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, videoPath: event.target.value } : prev)} />
|
||||
</label>
|
||||
</form>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import {
|
||||
createLauncherBuildJob,
|
||||
listLauncherBuildJobs,
|
||||
listLauncherVersions,
|
||||
type LauncherBuildJob,
|
||||
type LauncherVersion
|
||||
} from './launcher-api';
|
||||
|
||||
const r2PublicBaseUrl = import.meta.env.VITE_R2_PUBLIC_BASE_URL ?? '';
|
||||
const r2ArtifactPrefix = import.meta.env.VITE_R2_ARTIFACT_PREFIX ?? 'studio-artifacts';
|
||||
|
||||
function formatDate(value: string) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function statusText(status: LauncherBuildJob['status']) {
|
||||
if (status === 'SUCCEEDED') return '성공';
|
||||
if (status === 'RUNNING') return '실행 중';
|
||||
if (status === 'PENDING') return '대기';
|
||||
if (status === 'FAILED') return '실패';
|
||||
if (status === 'CANCELED') return '취소';
|
||||
return status;
|
||||
}
|
||||
|
||||
function statusTone(status: LauncherBuildJob['status']): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||
if (status === 'SUCCEEDED') return 'green';
|
||||
if (status === 'RUNNING') return 'blue';
|
||||
if (status === 'PENDING') return 'amber';
|
||||
if (status === 'FAILED') return 'red';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function jobTime(job: LauncherBuildJob) {
|
||||
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
const launcherBuildJobPageSize = 3;
|
||||
|
||||
function sanitizeObjectKeyPart(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.replace(/[^a-zA-Z0-9._@+=-]+/g, '-')
|
||||
|| 'unknown';
|
||||
}
|
||||
|
||||
function encodeObjectKey(key: string) {
|
||||
return key.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
function launcherArtifactKey(version: string, branch: string, commitSha: string, artifactName: string) {
|
||||
const revision = commitSha.trim().slice(0, 7) || 'latest';
|
||||
return [
|
||||
r2ArtifactPrefix,
|
||||
'launcher',
|
||||
version || '0.0.0',
|
||||
branch || 'release',
|
||||
revision,
|
||||
artifactName,
|
||||
].map(sanitizeObjectKeyPart).join('/');
|
||||
}
|
||||
|
||||
export function LauncherBuildPage() {
|
||||
const { toast } = useToast();
|
||||
const [versions, setVersions] = useState<LauncherVersion[]>([]);
|
||||
const [version, setVersion] = useState('');
|
||||
const [branch, setBranch] = useState('release');
|
||||
const [commitSha, setCommitSha] = useState('');
|
||||
const [jobs, setJobs] = useState<LauncherBuildJob[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState<LauncherBuildJob | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const activeVersion = useMemo(() => versions.find((item) => item.isActive) ?? versions[0], [versions]);
|
||||
const artifactName = `autonomy-studio-launcher-${version || activeVersion?.version || '0.0.0'}.deb`;
|
||||
const uploadPath = launcherArtifactKey(version || activeVersion?.version || '0.0.0', branch, commitSha, artifactName);
|
||||
const downloadUrl = r2PublicBaseUrl ? `${r2PublicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(uploadPath)}` : '백엔드 R2 설정 기준으로 생성됩니다.';
|
||||
const sortedJobs = useMemo(() => [...jobs].sort((a, b) => jobTime(b) - jobTime(a)), [jobs]);
|
||||
const totalPages = Math.max(1, Math.ceil(sortedJobs.length / launcherBuildJobPageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageStart = (currentPage - 1) * launcherBuildJobPageSize;
|
||||
const pagedJobs = sortedJobs.slice(pageStart, pageStart + launcherBuildJobPageSize);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.all([listLauncherVersions(), listLauncherBuildJobs()])
|
||||
.then(([nextVersions, nextJobs]) => {
|
||||
setVersions(nextVersions);
|
||||
setJobs(nextJobs);
|
||||
const nextVersion = nextVersions.find((item) => item.isActive) ?? nextVersions[0];
|
||||
setVersion(nextVersion?.version ?? '');
|
||||
setBranch(nextVersion?.branch ?? 'release');
|
||||
setCommitSha(nextVersion?.commitSha ?? '');
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '런처 버전 조회 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const createJob = () => {
|
||||
setLoading(true);
|
||||
createLauncherBuildJob({
|
||||
target: 'launcher-client',
|
||||
repository: activeVersion?.repository ?? undefined,
|
||||
branch,
|
||||
commitSha: commitSha.trim() || undefined,
|
||||
version: version.trim(),
|
||||
uploadPath,
|
||||
artifact: artifactName,
|
||||
downloadUrl: r2PublicBaseUrl ? downloadUrl : undefined
|
||||
})
|
||||
.then(({ job }) => {
|
||||
setJobs((prev) => [job, ...prev]);
|
||||
setPage(1);
|
||||
setSelectedJob(job);
|
||||
toast({ type: 'success', title: '빌드 작업', message: '런처 빌드 작업이 생성되었습니다.' });
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '빌드 작업 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="런처 운영"
|
||||
title="빌드 관리"
|
||||
description="런처 설치 파일을 만들고 내부 파일 서버 업로드 위치를 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={createJob} disabled={!version.trim()}>
|
||||
<Play className="size-4" />
|
||||
실행
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="빌드 요청" description="브랜치와 커밋 기준으로 런처 설치 파일을 생성합니다." />
|
||||
<div className="space-y-4 p-5">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">버전</span>
|
||||
<input 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={version} onChange={(event) => setVersion(event.target.value)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<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={branch} onChange={(event) => setBranch(event.target.value)}>
|
||||
<option value="develop">develop</option>
|
||||
<option value="release">release</option>
|
||||
<option value="main">main</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={commitSha} onChange={(event) => setCommitSha(event.target.value)} placeholder="비워두면 브랜치 최신 커밋" />
|
||||
</label>
|
||||
<div className="rounded-md border border-border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">예상 산출물</p>
|
||||
<p className="mt-2 break-all font-mono text-xs text-foreground">{artifactName}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">R2 Object Key</p>
|
||||
<p className="mt-1 break-all font-mono text-xs text-foreground">{uploadPath}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">다운로드 URL</p>
|
||||
<p className="mt-1 break-all font-mono text-xs text-foreground">{downloadUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="빌드 작업 목록" description="생성된 런처 빌드 작업과 업로드 계획입니다." />
|
||||
<div className="flex justify-end px-4 pt-4">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
빌드 작업이 없습니다.
|
||||
</div>
|
||||
) : null}
|
||||
{pagedJobs.map((job, index) => (
|
||||
<div key={job.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">No. {sortedJobs.length - (pageStart + index)}</span>
|
||||
<Badge tone={statusTone(job.status)}>{statusText(job.status)}</Badge>
|
||||
<span className="font-medium">{job.target}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>브랜치 <b className="text-foreground">{job.branch}</b></span>
|
||||
<span>커밋 <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
||||
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground">산출물</p>
|
||||
<p className="mt-1 truncate font-mono font-medium">{job.artifact}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground">다운로드 URL</p>
|
||||
<p className="mt-1 truncate font-mono font-medium">{job.downloadUrl || '-'}</p>
|
||||
</div>
|
||||
<div className="min-w-0 md:col-span-2">
|
||||
<p className="text-muted-foreground">R2 Object Key</p>
|
||||
<p className="mt-1 truncate font-mono font-medium">{job.uploadPath}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loading ? '불러오는 중' : `총 ${sortedJobs.length}건 · ${currentPage} / ${totalPages}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
다음
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={Boolean(selectedJob)}
|
||||
title="런처 빌드 로그"
|
||||
onClose={() => setSelectedJob(null)}
|
||||
className="log-modal-shell"
|
||||
footer={<Button variant="outline" onClick={() => setSelectedJob(null)}>닫기</Button>}
|
||||
>
|
||||
<pre className="whitespace-pre-wrap bg-background p-4 font-mono text-xs leading-6 text-muted-foreground">
|
||||
{selectedJob?.logs || '작업을 선택하면 로그가 표시됩니다.'}
|
||||
</pre>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { MoreHorizontal, Plus, SlidersHorizontal, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { authApiEndpoints } from '../../data/mock';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import {
|
||||
createLauncherCommonSetting,
|
||||
deleteLauncherCommonSetting,
|
||||
listLauncherCommonSettings,
|
||||
updateLauncherCommonSetting,
|
||||
type LauncherCommonSetting
|
||||
} from './launcher-api';
|
||||
|
||||
type FormState = {
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
isSecret: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
key: '',
|
||||
name: '',
|
||||
type: 'endpoint',
|
||||
value: '',
|
||||
description: '',
|
||||
isActive: true,
|
||||
isSecret: false
|
||||
};
|
||||
|
||||
function maskValue(setting: LauncherCommonSetting) {
|
||||
if (!setting.isSecret) return setting.value;
|
||||
if (!setting.value) return '-';
|
||||
return '******';
|
||||
}
|
||||
|
||||
function toForm(setting?: LauncherCommonSetting): FormState {
|
||||
if (!setting) return emptyForm;
|
||||
return {
|
||||
key: setting.key,
|
||||
name: setting.name,
|
||||
type: setting.type,
|
||||
value: setting.value,
|
||||
description: setting.description ?? '',
|
||||
isActive: setting.isActive,
|
||||
isSecret: setting.isSecret
|
||||
};
|
||||
}
|
||||
|
||||
export function LauncherCommonSettingsPage() {
|
||||
const { toast } = useToast();
|
||||
const [apiOpen, setApiOpen] = useState(false);
|
||||
const [settings, setSettings] = useState<LauncherCommonSetting[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LauncherCommonSetting | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const summary = useMemo(() => ({
|
||||
total: settings.length,
|
||||
endpoint: settings.filter((item) => item.type === 'endpoint').length,
|
||||
database: settings.filter((item) => item.type === 'database').length,
|
||||
active: settings.filter((item) => item.isActive).length
|
||||
}), [settings]);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
listLauncherCommonSettings()
|
||||
.then(setSettings)
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '공통 설정 조회 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (setting: LauncherCommonSetting) => {
|
||||
setEditing(setting);
|
||||
setForm(toForm(setting));
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
key: form.key.trim(),
|
||||
name: form.name.trim(),
|
||||
type: form.type.trim(),
|
||||
value: form.value.trim(),
|
||||
description: form.description.trim(),
|
||||
isActive: form.isActive,
|
||||
isSecret: form.isSecret
|
||||
};
|
||||
const request = editing
|
||||
? updateLauncherCommonSetting(editing.id, payload)
|
||||
: createLauncherCommonSetting(payload);
|
||||
|
||||
request
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: editing ? '설정을 수정했습니다.' : '설정을 추가했습니다.' });
|
||||
setModalOpen(false);
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: editing ? '설정 수정 실패' : '설정 추가 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
if (!editing) return;
|
||||
setSaving(true);
|
||||
deleteLauncherCommonSetting(editing.id)
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: '설정을 삭제했습니다.' });
|
||||
setModalOpen(false);
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '설정 삭제 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="런처 운영"
|
||||
title="공통 설정"
|
||||
description="런처에서 공통으로 사용하는 연결값과 기본 설정을 관리합니다."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setApiOpen(true)}>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
공통 인증 API 보기
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
설정 추가
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{[
|
||||
['설정 항목', `${summary.total}개`],
|
||||
['Endpoint', `${summary.endpoint}개`],
|
||||
['Database', `${summary.database}개`],
|
||||
['활성 상태', `${summary.active}개`]
|
||||
].map(([label, value]) => (
|
||||
<Card className="p-5" key={label}>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="mt-3 text-2xl font-semibold">{value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="mt-4 min-w-0 overflow-hidden">
|
||||
<CardHeader title="런처 연결 설정" description="여러 기능에서 함께 쓰는 주소, 인증, 저장소 관련 값을 정리합니다." />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1120px] text-left text-sm">
|
||||
<thead className="border-b border-border text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-[280px] px-5 py-3 font-medium">설정</th>
|
||||
<th className="px-5 py-3 font-medium">Key</th>
|
||||
<th className="px-5 py-3 font-medium">Type</th>
|
||||
<th className="px-5 py-3 font-medium">Value</th>
|
||||
<th className="px-5 py-3 text-right font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{!loading && settings.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-12 text-center text-sm text-muted-foreground">등록된 설정이 없습니다.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{settings.map((setting) => (
|
||||
<tr className="hover:bg-muted/20" key={setting.id}>
|
||||
<td className="px-5 py-3">
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<span className={setting.isActive ? 'size-2 rounded-full bg-emerald-400' : 'size-2 rounded-full bg-muted-foreground'} />
|
||||
{setting.name}
|
||||
</p>
|
||||
{setting.description ? <p className="mt-1 truncate text-xs text-muted-foreground">{setting.description}</p> : null}
|
||||
</td>
|
||||
<td className="px-5 py-3 font-medium">{setting.key}</td>
|
||||
<td className="px-5 py-3">{setting.type}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{maskValue(setting)}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<Button variant="ghost" className="size-8 px-0" aria-label={`${setting.name} 관리`} onClick={() => openEdit(setting)}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editing ? '설정 수정' : '설정 추가'}
|
||||
onClose={() => setModalOpen(false)}
|
||||
className="!max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
{editing ? (
|
||||
<Button variant="outline" className="border-rose-500/40 text-rose-300 hover:bg-rose-500/10" onClick={remove} disabled={saving}>
|
||||
<Trash2 className="size-4" />
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={submit as unknown as () => void} disabled={saving || !form.key.trim() || !form.name.trim() || !form.type.trim()}>
|
||||
{saving ? '저장 중' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form className="grid gap-4" onSubmit={submit}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">설정명</span>
|
||||
<input 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={form.name} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Type</span>
|
||||
<input 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={form.type} onChange={(event) => setForm((prev) => ({ ...prev, type: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Key</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.key} onChange={(event) => setForm((prev) => ({ ...prev, key: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Value</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.value} onChange={(event) => setForm((prev) => ({ ...prev, value: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||
<textarea className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.description} onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))} />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="inline-flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((prev) => ({ ...prev, isActive: event.target.checked }))} />
|
||||
활성
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.isSecret} onChange={(event) => setForm((prev) => ({ ...prev, isSecret: event.target.checked }))} />
|
||||
민감값
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={apiOpen} title="공통 인증 API" onClose={() => setApiOpen(false)} className="!max-w-[1280px]" footer={<Button variant="ghost" onClick={() => setApiOpen(false)}>닫기</Button>}>
|
||||
<div className="max-h-[70vh] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[980px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">그룹</th>
|
||||
<th className="px-5 py-3 font-medium">Method</th>
|
||||
<th className="px-5 py-3 font-medium">Path</th>
|
||||
<th className="px-5 py-3 font-medium">용도</th>
|
||||
<th className="px-5 py-3 font-medium">권한</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{authApiEndpoints.map((endpoint) => (
|
||||
<tr className="hover:bg-muted/20" key={`${endpoint.method}-${endpoint.path}`}>
|
||||
<td className="px-5 py-3">{endpoint.group}</td>
|
||||
<td className="px-5 py-3 font-semibold">{endpoint.method}</td>
|
||||
<td className="px-5 py-3 font-medium">{endpoint.path}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{endpoint.purpose}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={endpoint.auth === '없음' ? 'neutral' : endpoint.auth === 'ADMIN' ? 'amber' : 'blue'}>{endpoint.auth}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Copy, ExternalLink, Link2, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { GiteaRepo } from '../../types/application';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { getLauncherSource, listLauncherVersions, listPlatformRepos, updateLauncherSource, type LauncherVersion } from './launcher-api';
|
||||
|
||||
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
||||
const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@localhost:2222';
|
||||
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? 'platform';
|
||||
|
||||
function repoFullName(repo: GiteaRepo) {
|
||||
return (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? normalizeRepository(repo.name);
|
||||
}
|
||||
|
||||
function normalizeRepository(repository?: string | null) {
|
||||
if (!repository) return '';
|
||||
const value = repository.replace(/^\/+|\/+$/g, '');
|
||||
return value.includes('/') ? value : `${giteaAppOrg}/${value}`;
|
||||
}
|
||||
|
||||
function httpCloneUrl(repository?: string | null) {
|
||||
const normalizedRepository = normalizeRepository(repository);
|
||||
return normalizedRepository ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalizedRepository}.git` : '-';
|
||||
}
|
||||
|
||||
function sshCloneUrl(repository?: string | null) {
|
||||
const normalizedRepository = normalizeRepository(repository);
|
||||
return normalizedRepository ? `${giteaSshBaseUrl.replace(/\/$/, '')}/${normalizedRepository}.git` : '-';
|
||||
}
|
||||
|
||||
export function LauncherSourcePage() {
|
||||
const { toast } = useToast();
|
||||
const [versions, setVersions] = useState<LauncherVersion[]>([]);
|
||||
const [repos, setRepos] = useState<GiteaRepo[]>([]);
|
||||
const [repository, setRepository] = useState('');
|
||||
const [branch, setBranch] = useState('release');
|
||||
const [commitSha, setCommitSha] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.all([listLauncherVersions(), listPlatformRepos(), getLauncherSource()])
|
||||
.then(([nextVersions, nextRepos, sourceResult]) => {
|
||||
setVersions(nextVersions);
|
||||
setRepos(nextRepos);
|
||||
const nextVersion = nextVersions.find((version) => version.isActive) ?? nextVersions[0];
|
||||
setRepository(sourceResult.source.repository ?? nextVersion?.repository ?? (nextRepos[0] ? repoFullName(nextRepos[0]) : ''));
|
||||
setBranch(sourceResult.source.branch ?? nextVersion?.branch ?? 'release');
|
||||
setCommitSha(sourceResult.source.commitSha ?? nextVersion?.commitSha ?? '');
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '소스 정보 조회 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
if (!repository.trim()) return;
|
||||
setSaving(true);
|
||||
updateLauncherSource({
|
||||
repository: repository.trim(),
|
||||
branch: branch.trim() || undefined,
|
||||
commitSha: commitSha.trim() || undefined
|
||||
})
|
||||
.then(() => {
|
||||
toast({ type: 'success', title: '소스 연결', message: '런처 소스 연결을 저장했습니다.' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '소스 연결 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const copy = (value: string) => {
|
||||
navigator.clipboard?.writeText(value);
|
||||
toast({ type: 'success', message: '복사되었습니다.' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="런처 운영"
|
||||
title="소스 관리"
|
||||
description="런처 클라이언트와 관련 소스 저장소를 연결합니다."
|
||||
actions={
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
||||
새로고침
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,620px)_minmax(0,1fr)]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="소스 연결" description="런처 빌드에 사용할 저장소와 기준 브랜치를 선택합니다." />
|
||||
<div className="grid gap-4 p-5 md:grid-cols-3">
|
||||
<label className="block md:col-span-2">
|
||||
<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={repository}
|
||||
onChange={(event) => setRepository(event.target.value)}
|
||||
>
|
||||
{repos.length === 0 ? <option value="">등록된 저장소 없음</option> : null}
|
||||
{repos.map((repo) => <option key={repoFullName(repo)} value={repoFullName(repo)}>{repoFullName(repo)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<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={branch}
|
||||
onChange={(event) => setBranch(event.target.value)}
|
||||
>
|
||||
<option value="develop">develop</option>
|
||||
<option value="release">release</option>
|
||||
<option value="main">main</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block md:col-span-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">Commit SHA</span>
|
||||
<input
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={commitSha}
|
||||
onChange={(event) => setCommitSha(event.target.value)}
|
||||
placeholder="비워두면 브랜치 최신 커밋 기준"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end md:col-span-3">
|
||||
<Button onClick={save} disabled={!repository.trim() || saving}>
|
||||
<Link2 className="size-4" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="현재 연결" description="런처 빌드 기준입니다." />
|
||||
<div className="space-y-4 p-5 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">버전</span>
|
||||
<Badge tone={activeVersion?.isActive ? 'green' : 'neutral'}>{activeVersion?.version ?? '-'}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">HTTP Clone URL</p>
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
||||
<p className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">{httpCloneUrl(repository)}</p>
|
||||
<Button variant="ghost" className="size-8 px-0" onClick={() => copy(httpCloneUrl(repository))} aria-label="HTTP clone URL 복사">
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">SSH Clone URL</p>
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
||||
<p className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">{sshCloneUrl(repository)}</p>
|
||||
<Button variant="ghost" className="size-8 px-0" onClick={() => copy(sshCloneUrl(repository))} aria-label="SSH clone URL 복사">
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{repository ? (
|
||||
<a className="inline-flex items-center gap-1.5 text-xs text-primary" href={`${giteaBaseUrl.replace(/\/$/, '')}/${normalizeRepository(repository)}`} target="_blank" rel="noreferrer">
|
||||
저장소 열기
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Plus, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import {
|
||||
createLauncherVersion,
|
||||
listLauncherBuildJobs,
|
||||
listLauncherVersions,
|
||||
type LauncherBuildJob,
|
||||
type LauncherVersion
|
||||
} from './launcher-api';
|
||||
|
||||
function artifactName(version: string) {
|
||||
return `autonomy-studio-launcher-${version || '0.0.0'}.deb`;
|
||||
}
|
||||
|
||||
export function LauncherVersionsPage() {
|
||||
const { toast } = useToast();
|
||||
const [versions, setVersions] = useState<LauncherVersion[]>([]);
|
||||
const [buildJobs, setBuildJobs] = useState<LauncherBuildJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [releaseOpen, setReleaseOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [releaseForm, setReleaseForm] = useState({
|
||||
buildJobId: '',
|
||||
version: '',
|
||||
minimumVersion: '',
|
||||
downloadUrl: '',
|
||||
releaseNotes: ''
|
||||
});
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
const launcher = useMemo(() => versions.find((item) => item.isActive) ?? versions[0], [versions]);
|
||||
const successfulBuildJobs = useMemo(() => buildJobs.filter((job) => job.status === 'SUCCEEDED'), [buildJobs]);
|
||||
const selectedBuildJob = useMemo(
|
||||
() => successfulBuildJobs.find((job) => job.id === releaseForm.buildJobId),
|
||||
[releaseForm.buildJobId, successfulBuildJobs]
|
||||
);
|
||||
const currentArtifact = selectedBuildJob?.artifact || artifactName(releaseForm.version);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.all([listLauncherVersions(), listLauncherBuildJobs()])
|
||||
.then(([nextVersions, nextBuildJobs]) => {
|
||||
setVersions(nextVersions);
|
||||
setBuildJobs(nextBuildJobs);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '런처 버전 조회 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const openRelease = () => {
|
||||
const nextVersion = '';
|
||||
const latestBuild = successfulBuildJobs[0];
|
||||
setReleaseForm({
|
||||
buildJobId: latestBuild?.id ?? '',
|
||||
version: nextVersion,
|
||||
minimumVersion: launcher?.minimumVersion ?? '',
|
||||
downloadUrl: latestBuild?.downloadUrl ?? '',
|
||||
releaseNotes: ''
|
||||
});
|
||||
setReleaseOpen(true);
|
||||
};
|
||||
|
||||
const selectBuildJob = (buildJobId: string) => {
|
||||
const buildJob = successfulBuildJobs.find((job) => job.id === buildJobId);
|
||||
setReleaseForm((prev) => ({
|
||||
...prev,
|
||||
buildJobId,
|
||||
version: buildJob?.version ?? prev.version,
|
||||
downloadUrl: buildJob?.downloadUrl ?? prev.downloadUrl
|
||||
}));
|
||||
};
|
||||
|
||||
const saveRelease = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!releaseForm.version.trim() || !releaseForm.downloadUrl.trim()) return;
|
||||
setSaving(true);
|
||||
createLauncherVersion({
|
||||
version: releaseForm.version.trim(),
|
||||
minimumVersion: releaseForm.minimumVersion.trim() || undefined,
|
||||
artifact: currentArtifact,
|
||||
downloadUrl: releaseForm.downloadUrl.trim(),
|
||||
releaseNotes: releaseForm.releaseNotes.trim() || undefined,
|
||||
repository: selectedBuildJob?.repository ?? launcher?.repository ?? undefined,
|
||||
branch: selectedBuildJob?.branch ?? launcher?.branch ?? 'release',
|
||||
commitSha: selectedBuildJob?.commitSha ?? launcher?.commitSha ?? undefined
|
||||
})
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: '릴리즈를 등록했습니다.' });
|
||||
setReleaseOpen(false);
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '릴리즈 등록 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="런처 운영"
|
||||
title="버전 관리"
|
||||
description="스튜디오 메인 런처의 버전과 배포 파일 정보를 관리합니다."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={openRelease}>
|
||||
<Plus className="size-4" />
|
||||
릴리즈 등록
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{!loading && !launcher ? <Card className="px-5 py-12 text-center text-sm text-muted-foreground">등록된 런처 버전이 없습니다.</Card> : null}
|
||||
|
||||
{launcher ? (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(360px,0.65fr)]">
|
||||
<Card>
|
||||
<CardHeader title="메인 런처 배포 상태" description="현재 배포 기준으로 사용하는 런처 정보입니다." />
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="flex flex-col gap-4 rounded-md border border-border bg-muted/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-base font-semibold">Autonomy Studio Launcher</p>
|
||||
<Badge tone={launcher.isActive ? 'green' : 'neutral'}>{launcher.isActive ? '활성' : '비활성'}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">최신 {launcher.version} · 최소 지원 {launcher.minimumVersion || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-md border border-border bg-background p-4">
|
||||
<p className="text-xs text-muted-foreground">배포 파일</p>
|
||||
<p className="mt-2 break-all font-mono text-sm font-medium">{launcher.artifact}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-background p-4">
|
||||
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
||||
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{launcher.downloadUrl || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-background p-4">
|
||||
<p className="text-xs text-muted-foreground">소스 기준</p>
|
||||
<div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Repository</p>
|
||||
<p className="mt-1 truncate font-medium">{launcher.repository || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Branch</p>
|
||||
<p className="mt-1 truncate font-medium">{launcher.branch || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Commit SHA</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{launcher.commitSha || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="릴리즈 노트" description="최근 런처 배포 메모입니다." />
|
||||
<div className="p-5 text-sm leading-6 text-muted-foreground">{launcher.releaseNotes || '-'}</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
open={releaseOpen}
|
||||
title="릴리즈 등록"
|
||||
onClose={() => setReleaseOpen(false)}
|
||||
className="!max-w-3xl"
|
||||
footer={<><Button variant="outline" onClick={() => setReleaseOpen(false)}>취소</Button><Button onClick={saveRelease as unknown as () => void} disabled={saving || !releaseForm.version.trim() || !releaseForm.downloadUrl.trim()}>{saving ? '등록 중' : '등록'}</Button></>}
|
||||
>
|
||||
<form className="space-y-5" onSubmit={saveRelease}>
|
||||
<div className="rounded-md border border-border bg-background px-4 py-3">
|
||||
<p className="text-sm font-medium">배포 파일 기준 등록</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">빌드가 끝난 `.deb` 파일의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
|
||||
</div>
|
||||
<label className="block">
|
||||
<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={releaseForm.buildJobId}
|
||||
onChange={(event) => selectBuildJob(event.target.value)}
|
||||
>
|
||||
<option value="">직접 입력</option>
|
||||
{successfulBuildJobs.map((job) => (
|
||||
<option key={job.id} value={job.id}>
|
||||
{job.version} · {job.artifact}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">버전</span>
|
||||
<input 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={releaseForm.version} onChange={(event) => setReleaseForm((prev) => ({ ...prev, version: event.target.value }))} placeholder="예: 1.0.2" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">최소 지원 버전</span>
|
||||
<input 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={releaseForm.minimumVersion} onChange={(event) => setReleaseForm((prev) => ({ ...prev, minimumVersion: event.target.value }))} placeholder="예: 1.0.0" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-background px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">배포 파일</p>
|
||||
<p className="mt-2 break-all font-mono text-xs">{currentArtifact}</p>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">다운로드 URL</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={releaseForm.downloadUrl} onChange={(event) => setReleaseForm((prev) => ({ ...prev, downloadUrl: event.target.value }))} placeholder="https://.../autonomy-studio-launcher-1.0.2.deb" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">릴리즈 노트</span>
|
||||
<textarea className="mt-2 min-h-28 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={releaseForm.releaseNotes} onChange={(event) => setReleaseForm((prev) => ({ ...prev, releaseNotes: event.target.value }))} />
|
||||
</label>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import type { AppRelease, AppWithComponents } from '../../types/application';
|
||||
import type { GiteaRepo } from '../../types/application';
|
||||
|
||||
export type LauncherVersion = {
|
||||
id: string;
|
||||
version: string;
|
||||
minimumVersion?: string | null;
|
||||
artifact: string;
|
||||
downloadUrl: string;
|
||||
releaseNotes?: string | null;
|
||||
repository?: string | null;
|
||||
branch?: string | null;
|
||||
commitSha?: string | null;
|
||||
isActive: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type LauncherBuildJob = {
|
||||
id: string;
|
||||
target: string;
|
||||
repository?: string | null;
|
||||
branch: string;
|
||||
commitSha?: string | null;
|
||||
version: string;
|
||||
uploadPath: string;
|
||||
downloadUrl: string;
|
||||
artifact: string;
|
||||
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELED';
|
||||
logs?: string | null;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type LauncherSource = {
|
||||
repository?: string | null;
|
||||
branch?: string | null;
|
||||
commitSha?: string | null;
|
||||
};
|
||||
|
||||
export type LauncherAppLink = {
|
||||
id: string;
|
||||
appId: string;
|
||||
releaseId?: string | null;
|
||||
displayOrder: number;
|
||||
isEnabled: boolean;
|
||||
displayName?: string | null;
|
||||
description?: string | null;
|
||||
subtitle?: string | null;
|
||||
detailUrl?: string | null;
|
||||
videoPath?: string | null;
|
||||
iconKey?: string | null;
|
||||
launchUrl?: string | null;
|
||||
devAppPath?: string | null;
|
||||
execPath?: string | null;
|
||||
artifact?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
app: AppWithComponents;
|
||||
release?: AppRelease | null;
|
||||
};
|
||||
|
||||
export type LauncherCommonSetting = {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
description?: string | null;
|
||||
isActive: boolean;
|
||||
isSecret: boolean;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type LauncherUnlinkedApp = AppWithComponents & {
|
||||
releases?: AppRelease[];
|
||||
};
|
||||
|
||||
export function listLauncherVersions() {
|
||||
return apiRequest<LauncherVersion[]>('/launcher/versions');
|
||||
}
|
||||
|
||||
export function createLauncherVersion(input: {
|
||||
version: string;
|
||||
minimumVersion?: string;
|
||||
artifact: string;
|
||||
downloadUrl: string;
|
||||
releaseNotes?: string;
|
||||
repository?: string;
|
||||
branch?: string;
|
||||
commitSha?: string;
|
||||
}) {
|
||||
return apiRequest<{ version: LauncherVersion }>('/launcher/versions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function linkLauncherVersionSource(
|
||||
versionId: string,
|
||||
input: { repository: string; branch?: string; commitSha?: string }
|
||||
) {
|
||||
return apiRequest<{ version: LauncherVersion }>(`/launcher/versions/${versionId}/source`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function getLauncherSource() {
|
||||
return apiRequest<{ source: LauncherSource }>('/launcher/source');
|
||||
}
|
||||
|
||||
export function updateLauncherSource(input: { repository: string; branch?: string; commitSha?: string }) {
|
||||
return apiRequest<{ source: LauncherSource }>('/launcher/source', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listPlatformRepos(limit = 50) {
|
||||
return apiRequest<{ owner: string | null; repos: GiteaRepo[] }>(`/gitea/repos?owner=platform&limit=${limit}`)
|
||||
.then((result) => result.repos);
|
||||
}
|
||||
|
||||
export function listLauncherBuildJobs() {
|
||||
return apiRequest<LauncherBuildJob[]>('/launcher/builds');
|
||||
}
|
||||
|
||||
export function createLauncherBuildJob(input: {
|
||||
target?: string;
|
||||
repository?: string;
|
||||
branch?: string;
|
||||
commitSha?: string;
|
||||
version: string;
|
||||
artifact: string;
|
||||
uploadPath?: string;
|
||||
downloadUrl?: string;
|
||||
}) {
|
||||
return apiRequest<{ job: LauncherBuildJob }>('/launcher/builds', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listLauncherAppLinks() {
|
||||
return apiRequest<{ linked: LauncherAppLink[]; unlinked: LauncherUnlinkedApp[] }>('/launcher/app-links');
|
||||
}
|
||||
|
||||
export function syncLauncherAppsToAdminApps() {
|
||||
return apiRequest<{ syncedApps: number; syncedReleases: number }>('/launcher/app-links/sync-admin-apps', {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function createLauncherAppLink(input: {
|
||||
appId: string;
|
||||
releaseId?: string;
|
||||
displayOrder?: number;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
subtitle?: string;
|
||||
detailUrl?: string;
|
||||
videoPath?: string;
|
||||
iconKey?: string;
|
||||
launchUrl?: string;
|
||||
devAppPath?: string;
|
||||
execPath?: string;
|
||||
artifact?: string;
|
||||
}) {
|
||||
return apiRequest<{ link: LauncherAppLink }>('/launcher/app-links', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLauncherAppLink(
|
||||
id: string,
|
||||
input: Partial<{
|
||||
displayOrder: number;
|
||||
isEnabled: boolean;
|
||||
displayName: string;
|
||||
description: string;
|
||||
subtitle: string;
|
||||
detailUrl: string;
|
||||
videoPath: string;
|
||||
iconKey: string;
|
||||
launchUrl: string;
|
||||
devAppPath: string;
|
||||
execPath: string;
|
||||
}>
|
||||
) {
|
||||
return apiRequest<{ link: LauncherAppLink }>(`/launcher/app-links/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function moveLauncherAppLink(id: string, direction: 'up' | 'down') {
|
||||
return apiRequest<{ linked: LauncherAppLink[]; unlinked: LauncherUnlinkedApp[] }>(`/launcher/app-links/${id}/move`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ direction })
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteLauncherAppLink(id: string) {
|
||||
return apiRequest<{ deleted: boolean }>(`/launcher/app-links/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function listLauncherCommonSettings() {
|
||||
return apiRequest<LauncherCommonSetting[]>('/launcher/common-settings');
|
||||
}
|
||||
|
||||
export function createLauncherCommonSetting(input: {
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
isSecret?: boolean;
|
||||
}) {
|
||||
return apiRequest<{ setting: LauncherCommonSetting }>('/launcher/common-settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLauncherCommonSetting(
|
||||
id: string,
|
||||
input: Partial<{
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
isSecret: boolean;
|
||||
}>
|
||||
) {
|
||||
return apiRequest<{ setting: LauncherCommonSetting }>(`/launcher/common-settings/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteLauncherCommonSetting(id: string) {
|
||||
return apiRequest<{ deleted: boolean }>(`/launcher/common-settings/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
import { MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { Button, Modal } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { getAdminAppPermissions, isOwnerAdmin, setAdminAppPermissions } from '../../lib/admin-app-permissions';
|
||||
import { listApps } from '../applications/application-api';
|
||||
import type { AppWithComponents } from '../../types/application';
|
||||
import {
|
||||
createAdmin,
|
||||
createAdminSshKey,
|
||||
deleteAdminSshKey,
|
||||
loadAdminRoles,
|
||||
loadAdmins,
|
||||
resetAdminPassword,
|
||||
updateAdminRole,
|
||||
updateAdminStatus,
|
||||
type AdminAccount,
|
||||
type AdminRoleOption
|
||||
} from './operations-api';
|
||||
import { formatDateTime, LoadingModal, StatusBadge, statusTone, useMinimumLoading } from './operation-utils';
|
||||
|
||||
type AdminRow = AdminAccount & {
|
||||
no: number;
|
||||
state: string;
|
||||
};
|
||||
|
||||
const emptyAdminForm = {
|
||||
email: '',
|
||||
name: '',
|
||||
role: 'ADMIN',
|
||||
temporaryPassword: ''
|
||||
};
|
||||
|
||||
const fallbackAdminRoles: AdminRoleOption[] = [
|
||||
{ id: 'OWNER', code: 'OWNER', name: 'OWNER', sortOrder: 1, isActive: true },
|
||||
{ id: 'ADMIN', code: 'ADMIN', name: 'ADMIN', sortOrder: 2, isActive: true },
|
||||
{ id: 'VIEWER', code: 'VIEWER', name: 'VIEWER', sortOrder: 3, isActive: true }
|
||||
];
|
||||
|
||||
export function AdminManagementPage() {
|
||||
const { toast } = useToast();
|
||||
const [admins, setAdmins] = useState<AdminAccount[]>([]);
|
||||
const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [passwordTarget, setPasswordTarget] = useState<{ admin: AdminAccount | null; temporaryPassword: string }>({
|
||||
admin: null,
|
||||
temporaryPassword: ''
|
||||
});
|
||||
const [sshTarget, setSshTarget] = useState({
|
||||
admin: null as AdminAccount | null,
|
||||
label: '',
|
||||
giteaUsername: '',
|
||||
publicKey: ''
|
||||
});
|
||||
const [adminForm, setAdminForm] = useState(emptyAdminForm);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
const [sshState, setSshState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
const [sshError, setSshError] = useState('');
|
||||
const [menuAdmin, setMenuAdmin] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [permissionTarget, setPermissionTarget] = useState<{ admin: AdminAccount | null; appIds: string[] }>({
|
||||
admin: null,
|
||||
appIds: []
|
||||
});
|
||||
const [permissionApps, setPermissionApps] = useState<AppWithComponents[]>([]);
|
||||
const [permissionLoading, setPermissionLoading] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.all([loadAdmins(), loadAdminRoles().catch(() => fallbackAdminRoles)])
|
||||
.then(([nextAdmins, nextRoles]) => {
|
||||
setAdmins(nextAdmins);
|
||||
setAdminRoles(nextRoles.filter((role) => role.isActive));
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setAdmins([]);
|
||||
setAdminRoles(fallbackAdminRoles);
|
||||
setFailed(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuAdmin) return;
|
||||
|
||||
const close = () => setMenuAdmin(null);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuAdmin(null);
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuAdmin]);
|
||||
|
||||
const rows = useMemo<AdminRow[]>(
|
||||
() => admins.map((admin, index) => ({ ...admin, no: admins.length - index, state: admin.isActive ? '활성' : '비활성' })),
|
||||
[admins]
|
||||
);
|
||||
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
|
||||
|
||||
const openSshModal = (admin: AdminAccount) => {
|
||||
setMenuAdmin(null);
|
||||
const account = admin.email.split('@')[0] || 'admin';
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
setSshTarget({ admin, label: `${account}-${random}-key`, giteaUsername: '', publicKey: '' });
|
||||
setSshState('idle');
|
||||
setSshError('');
|
||||
};
|
||||
|
||||
const openPermissionModal = (admin: AdminAccount) => {
|
||||
setMenuAdmin(null);
|
||||
setPermissionTarget({ admin, appIds: isOwnerAdmin(admin) ? [] : getAdminAppPermissions(admin) });
|
||||
setPermissionLoading(true);
|
||||
listApps()
|
||||
.then((apps) => {
|
||||
setPermissionApps(apps);
|
||||
if (isOwnerAdmin(admin)) {
|
||||
setPermissionTarget({ admin, appIds: apps.map((app) => app.id) });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setPermissionApps([]);
|
||||
toast({ type: 'error', title: '앱 목록 조회 실패', message: '앱 권한 설정에 사용할 앱 목록을 불러오지 못했습니다.' });
|
||||
})
|
||||
.finally(() => setPermissionLoading(false));
|
||||
};
|
||||
|
||||
const togglePermissionApp = (appId: string) => {
|
||||
setPermissionTarget((prev) => ({
|
||||
...prev,
|
||||
appIds: prev.appIds.includes(appId) ? prev.appIds.filter((id) => id !== appId) : [...prev.appIds, appId]
|
||||
}));
|
||||
};
|
||||
|
||||
const saveAppPermissions = () => {
|
||||
if (!permissionTarget.admin || isOwnerAdmin(permissionTarget.admin)) {
|
||||
setPermissionTarget({ admin: null, appIds: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
setAdminAppPermissions(permissionTarget.admin, permissionTarget.appIds);
|
||||
setPermissionTarget({ admin: null, appIds: [] });
|
||||
toast({ type: 'success', title: '앱 권한 저장', message: '관리자 앱 권한이 저장되었습니다.' });
|
||||
};
|
||||
|
||||
const submitAdmin = () => {
|
||||
if (!adminForm.email.trim() || !adminForm.name.trim()) return;
|
||||
setSaveState('saving');
|
||||
createAdmin(adminForm)
|
||||
.then(() => {
|
||||
setSaveState('idle');
|
||||
setCreateOpen(false);
|
||||
setAdminForm(emptyAdminForm);
|
||||
refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
setSaveState('error');
|
||||
toast({ type: 'error', title: '관리자 등록 실패', message: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
const submitPassword = () => {
|
||||
if (!passwordTarget.admin) return;
|
||||
setPasswordState('saving');
|
||||
resetAdminPassword(passwordTarget.admin.id, passwordTarget.temporaryPassword)
|
||||
.then(() => {
|
||||
setPasswordState('idle');
|
||||
setPasswordTarget({ admin: null, temporaryPassword: '' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
setPasswordState('error');
|
||||
toast({ type: 'error', title: '비밀번호 초기화 실패', message: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
const submitSshKey = () => {
|
||||
if (!sshTarget.admin || !sshTarget.publicKey.trim()) return;
|
||||
setSshState('saving');
|
||||
setSshError('');
|
||||
createAdminSshKey(sshTarget.admin.id, {
|
||||
label: sshTarget.label.trim() || undefined,
|
||||
giteaUsername: sshTarget.giteaUsername.trim() || undefined,
|
||||
publicKey: sshTarget.publicKey.trim()
|
||||
})
|
||||
.then(() => {
|
||||
setSshState('idle');
|
||||
setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' });
|
||||
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
setSshError(error.message);
|
||||
setSshState('error');
|
||||
toast({ type: 'error', title: 'SSH 키 등록 실패', message: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
const removeSshKey = (admin: AdminAccount, keyId: string) => {
|
||||
deleteAdminSshKey(admin.id, keyId)
|
||||
.then(() => {
|
||||
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error) => toast({ type: 'error', title: 'SSH 키 삭제 실패', message: error.message }));
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<AdminRow>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||
{
|
||||
key: 'name',
|
||||
header: '관리자',
|
||||
className: 'w-[260px] px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<p className="font-semibold">{row.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.email}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: '권한',
|
||||
cell: (row) => (
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs font-semibold text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={row.role}
|
||||
onChange={(event) => {
|
||||
updateAdminRole(row.id, event.target.value).then(refresh).catch(() => setFailed(true));
|
||||
}}
|
||||
>
|
||||
{roleOptions.some((role) => role.code === row.role) ? null : <option value={row.role}>{row.role}</option>}
|
||||
{roleOptions.map((role) => (
|
||||
<option key={role.code} value={role.code}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'session',
|
||||
header: '최근 세션',
|
||||
cell: (row) => {
|
||||
const session = row.sessions?.[0];
|
||||
const state = session ? (session.revokedAt ? '종료' : '활성') : '세션 없음';
|
||||
return <StatusBadge status={state} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'sshKeys',
|
||||
header: 'SSH 키',
|
||||
cell: (row) => {
|
||||
const keyCount = row.sshKeys?.length ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
|
||||
onClick={() => openSshModal(row)}
|
||||
>
|
||||
<StatusBadge status={`${keyCount}개`} tone={keyCount > 0 ? 'green' : 'neutral'} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ key: 'lastLoginAt', header: '마지막 로그인', cell: (row) => <span className="text-muted-foreground">{formatDateTime(row.lastLoginAt)}</span> },
|
||||
{ key: 'state', header: '상태', cell: (row) => <StatusBadge status={row.state} tone={statusTone(row.state)} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => (
|
||||
<div className="relative flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuAdmin((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[menuAdmin?.id, roleOptions]
|
||||
);
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<AdminRow>>>(
|
||||
() => [
|
||||
{ key: 'role', label: '권한', options: [...new Set(rows.map((row) => row.role))].map((role) => ({ label: role, value: role })), getValue: (row) => row.role },
|
||||
{ key: 'state', label: '상태', options: [...new Set(rows.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
||||
],
|
||||
[rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="운영"
|
||||
title="관리자 관리"
|
||||
description="스튜디오 어드민 콘솔에 접근할 운영자 계정과 권한을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{failed ? (
|
||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
관리자 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
) : null}
|
||||
<DataTableCard
|
||||
title="목록"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="관리자명, 이메일, 권한 검색"
|
||||
getSearchText={(row) => `${row.name} ${row.email} ${row.role} ${row.state}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1180}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 관리자가 없습니다."
|
||||
/>
|
||||
{menuAdmin ? (() => {
|
||||
const row = rows.find((item) => item.id === menuAdmin.id);
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-36 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuAdmin.x, top: menuAdmin.y, transform: 'translateX(-100%)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => openSshModal(row)}
|
||||
>
|
||||
SSH 키 등록
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => openPermissionModal(row)}
|
||||
>
|
||||
앱 권한 설정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMenuAdmin(null);
|
||||
updateAdminStatus(row.id, !row.isActive).then(refresh).catch(() => setFailed(true));
|
||||
}}
|
||||
>
|
||||
{row.isActive ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMenuAdmin(null);
|
||||
setPasswordTarget({ admin: row, temporaryPassword: '' });
|
||||
}}
|
||||
>
|
||||
비밀번호 초기화
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
<LoadingModal open={loadingVisible} />
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title="관리자 등록"
|
||||
onClose={() => setCreateOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button>
|
||||
<Button onClick={submitAdmin} disabled={saveState === 'saving'}>{saveState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">이메일</span>
|
||||
<input 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={adminForm.email} onChange={(event) => setAdminForm((prev) => ({ ...prev, email: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">이름</span>
|
||||
<input 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={adminForm.name} onChange={(event) => setAdminForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<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={adminForm.role} onChange={(event) => setAdminForm((prev) => ({ ...prev, role: event.target.value }))}>
|
||||
{roleOptions.map((role) => (
|
||||
<option key={role.code} value={role.code}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호</span>
|
||||
<input 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={adminForm.temporaryPassword} onChange={(event) => setAdminForm((prev) => ({ ...prev, temporaryPassword: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(passwordTarget.admin)}
|
||||
title="비밀번호 초기화"
|
||||
onClose={() => setPasswordTarget({ admin: null, temporaryPassword: '' })}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPasswordTarget({ admin: null, temporaryPassword: '' })}>취소</Button>
|
||||
<Button onClick={submitPassword} disabled={passwordState === 'saving'}>{passwordState === 'saving' ? '저장 중' : '초기화'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">새 임시 비밀번호</span>
|
||||
<input 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={passwordTarget.temporaryPassword} onChange={(event) => setPasswordTarget((prev) => ({ ...prev, temporaryPassword: event.target.value }))} />
|
||||
</label>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(sshTarget.admin)}
|
||||
title="SSH 키 등록"
|
||||
onClose={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}
|
||||
className="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}>취소</Button>
|
||||
<Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">키 이름</span>
|
||||
<input 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={sshTarget.label} onChange={(event) => setSshTarget((prev) => ({ ...prev, label: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">저장소 사용자명</span>
|
||||
<input 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={sshTarget.giteaUsername} onChange={(event) => setSshTarget((prev) => ({ ...prev, giteaUsername: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">공개키</span>
|
||||
<textarea className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.publicKey} onChange={(event) => setSshTarget((prev) => ({ ...prev, publicKey: event.target.value }))} />
|
||||
</label>
|
||||
{sshError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{sshError}</p> : null}
|
||||
{sshTarget.admin?.sshKeys?.length ? (
|
||||
<div className="rounded-md border border-border">
|
||||
{sshTarget.admin.sshKeys.map((key) => (
|
||||
<div key={key.id} className="flex items-center justify-between gap-3 border-b border-border px-3 py-2 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
|
||||
</div>
|
||||
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && removeSshKey(sshTarget.admin, key.id)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(permissionTarget.admin)}
|
||||
title="앱 권한"
|
||||
onClose={() => setPermissionTarget({ admin: null, appIds: [] })}
|
||||
className="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPermissionTarget({ admin: null, appIds: [] })}>취소</Button>
|
||||
<Button onClick={saveAppPermissions} disabled={permissionLoading || isOwnerAdmin(permissionTarget.admin)}>
|
||||
저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||
<p className="text-sm font-medium text-foreground">{permissionTarget.admin?.name ?? '-'}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{permissionTarget.admin?.email ?? '-'}</p>
|
||||
</div>
|
||||
{isOwnerAdmin(permissionTarget.admin) ? (
|
||||
<div className="rounded-md border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary-foreground">
|
||||
오너 권한은 모든 앱에 접근할 수 있습니다.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-80 overflow-y-auto rounded-md border border-border">
|
||||
{permissionLoading ? (
|
||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">앱 목록을 불러오는 중입니다.</div>
|
||||
) : permissionApps.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">등록된 앱이 없습니다.</div>
|
||||
) : (
|
||||
permissionApps.map((app) => {
|
||||
const checked = isOwnerAdmin(permissionTarget.admin) || permissionTarget.appIds.includes(app.id);
|
||||
return (
|
||||
<label key={app.id} className="flex cursor-pointer items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-foreground">{app.name}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{app.key} · {app.namespace || '-'}</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-primary"
|
||||
checked={checked}
|
||||
disabled={isOwnerAdmin(permissionTarget.admin)}
|
||||
onChange={() => togglePermissionApp(app.id)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { Button, Card, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateOnly } from '../../lib/date-format';
|
||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
import {
|
||||
assignUserLicense,
|
||||
loadLicensePlans,
|
||||
loadLicenseUsers,
|
||||
revokeUserLicense,
|
||||
updateUserLicense,
|
||||
type AuthLicenseUser,
|
||||
type LicensePlan
|
||||
} from './operations-api';
|
||||
|
||||
type LicenseAssignmentRow = {
|
||||
id: string;
|
||||
userId: string;
|
||||
user: string;
|
||||
name: string;
|
||||
role: string;
|
||||
code: string;
|
||||
licenseName: string;
|
||||
startsAt: string;
|
||||
expiresAt?: string | null;
|
||||
assignedBy?: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(dateValue: string, days?: number | null) {
|
||||
if (!dateValue || !days) return '';
|
||||
const date = new Date(`${dateValue}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatOptionalDate(value?: string | null) {
|
||||
if (value == null) return '-';
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed || trimmed === 'null' || trimmed === 'undefined') return '-';
|
||||
return formatDateOnly(trimmed);
|
||||
}
|
||||
|
||||
function toDateInputValue(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function assignmentRows(users: AuthLicenseUser[]) {
|
||||
return users.flatMap((user) =>
|
||||
user.licenses.map((license) => ({
|
||||
id: license.id,
|
||||
userId: user.id,
|
||||
user: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
code: license.license.code,
|
||||
licenseName: license.license.name,
|
||||
startsAt: license.startsAt,
|
||||
expiresAt: license.expiresAt,
|
||||
assignedBy: license.assignedById,
|
||||
status: license.status
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function LicensesPage() {
|
||||
const { toast } = useToast();
|
||||
const [plans, setPlans] = useState<LicensePlan[]>([]);
|
||||
const [users, setUsers] = useState<AuthLicenseUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<LicenseAssignmentRow | null>(null);
|
||||
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [assignForm, setAssignForm] = useState({
|
||||
userId: '',
|
||||
licenseCode: '',
|
||||
startsAt: today(),
|
||||
expiresAt: ''
|
||||
});
|
||||
const [userKeyword, setUserKeyword] = useState('');
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const rows = useMemo(() => assignmentRows(users), [users]);
|
||||
const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
|
||||
const plan = plans.find((item) => item.code === licenseCode);
|
||||
return addDays(startsAt, plan?.defaultDurationDays);
|
||||
};
|
||||
const filteredUsers = useMemo(() => {
|
||||
const keyword = userKeyword.trim().toLowerCase();
|
||||
if (!keyword) return [];
|
||||
return users.filter((user) => `${user.email} ${user.name}`.toLowerCase().includes(keyword));
|
||||
}, [userKeyword, users]);
|
||||
const selectedUser = useMemo(() => users.find((user) => user.id === assignForm.userId), [assignForm.userId, users]);
|
||||
const selectedUserCurrentLicense = selectedUser?.currentLicense ?? null;
|
||||
const cannotAssignBecauseActive = Boolean(!editingRow && selectedUserCurrentLicense);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
|
||||
setPlans(nextPlans);
|
||||
setUsers(nextUsers);
|
||||
setAssignForm((prev) => ({
|
||||
...prev,
|
||||
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
|
||||
expiresAt: prev.expiresAt || addDays(prev.startsAt, nextPlans.find((plan) => plan.code === (prev.licenseCode || nextPlans[0]?.code || ''))?.defaultDurationDays)
|
||||
}));
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 정보를 불러오지 못했습니다.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuRow) return;
|
||||
|
||||
const close = () => setMenuRow(null);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuRow(null);
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuRow]);
|
||||
|
||||
const openAssignModal = () => {
|
||||
const startsAt = today();
|
||||
const licenseCode = assignForm.licenseCode || plans[0]?.code || '';
|
||||
setEditingRow(null);
|
||||
setAssignForm((prev) => ({
|
||||
userId: '',
|
||||
licenseCode,
|
||||
startsAt,
|
||||
expiresAt: calculateExpiresAt(licenseCode, startsAt)
|
||||
}));
|
||||
setUserKeyword('');
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (row: LicenseAssignmentRow) => {
|
||||
const startsAt = toDateInputValue(row.startsAt) || today();
|
||||
setMenuRow(null);
|
||||
setEditingRow(row);
|
||||
setAssignForm({
|
||||
userId: row.userId,
|
||||
licenseCode: row.code,
|
||||
startsAt,
|
||||
expiresAt: toDateInputValue(row.expiresAt)
|
||||
});
|
||||
setUserKeyword('');
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
const closeAssignModal = () => {
|
||||
setAssignOpen(false);
|
||||
setEditingRow(null);
|
||||
setUserKeyword('');
|
||||
};
|
||||
|
||||
const submitAssign = async () => {
|
||||
if (!assignForm.userId || !assignForm.licenseCode) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
licenseCode: assignForm.licenseCode,
|
||||
startsAt: assignForm.startsAt || undefined,
|
||||
expiresAt: assignForm.expiresAt || undefined
|
||||
};
|
||||
if (editingRow) {
|
||||
await updateUserLicense(editingRow.id, payload);
|
||||
} else {
|
||||
await assignUserLicense(assignForm.userId, payload);
|
||||
}
|
||||
toast({ type: 'success', message: editingRow ? '라이센스를 수정했습니다.' : '라이센스를 부여했습니다.' });
|
||||
closeAssignModal();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : editingRow ? '라이센스 수정에 실패했습니다.' : '라이센스 부여에 실패했습니다.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await revokeUserLicense(deleteTarget.id);
|
||||
toast({ type: 'success', message: '라이센스를 삭제했습니다.' });
|
||||
setDeleteTarget(null);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 삭제에 실패했습니다.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'user',
|
||||
header: '사용자',
|
||||
className: 'w-[260px] px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<p className="text-foreground">{row.user}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.name}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'licenseName', header: '라이선스명', cell: (row) => <span className="text-muted-foreground">{row.licenseName}</span> },
|
||||
{ key: 'startsAt', header: '시작일', cell: (row) => <span className="text-muted-foreground">{formatDateOnly(row.startsAt)}</span> },
|
||||
{ key: 'expiresAt', header: '만료일', cell: (row) => <span className="text-muted-foreground">{formatOptionalDate(row.expiresAt)}</span> },
|
||||
{ key: 'assignedBy', header: '부여자', cell: (row) => <span className="text-muted-foreground">{row.assignedBy || '-'}</span> },
|
||||
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => (
|
||||
<div className="relative flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuRow((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
|
||||
() => [
|
||||
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.name })), getValue: (row) => row.licenseName },
|
||||
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status }
|
||||
],
|
||||
[plans, rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="운영"
|
||||
title="라이센스"
|
||||
description="공통 인증 DB 기준으로 라이센스 플랜과 사용자 부여 이력을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
||||
<Plus className="size-4" />
|
||||
라이센스 부여
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
<Card key={plan.code} className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">{plan.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
|
||||
</div>
|
||||
<StatusBadge status={plan.isActive ? '활성' : '비활성'} />
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">부여</p>
|
||||
<p className="mt-1 text-xl font-semibold">{plan.assignedCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">활성</p>
|
||||
<p className="mt-1 text-xl font-semibold">{plan.activeUserCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">만료임박</p>
|
||||
<p className="mt-1 text-xl font-semibold">{plan.expiringSoonCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-muted-foreground">기본 기간 {plan.defaultDurationDays ?? '-'}일</p>
|
||||
</Card>
|
||||
))}
|
||||
{!loading && plans.length === 0 ? (
|
||||
<Card className="flex min-h-40 items-center justify-center p-5 text-sm text-muted-foreground xl:col-span-3">
|
||||
등록된 라이센스 플랜이 없습니다.
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTableCard
|
||||
title="부여 이력"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="사용자, 라이선스, 부여자 검색"
|
||||
getSearchText={(row) => `${row.user} ${row.name} ${row.licenseName} ${row.assignedBy ?? ''} ${row.status}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={980}
|
||||
loading={loading}
|
||||
emptyMessage="부여된 라이센스가 없습니다."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{menuRow ? (() => {
|
||||
const row = rows.find((item) => item.id === menuRow.id);
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuRow.x, top: menuRow.y, transform: 'translateX(-100%)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => openEditModal(row)}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10"
|
||||
onClick={() => {
|
||||
setMenuRow(null);
|
||||
setDeleteTarget(row);
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
<Modal
|
||||
open={assignOpen}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
{editingRow ? '라이센스 수정' : '라이센스 부여'}
|
||||
</span>
|
||||
}
|
||||
onClose={closeAssignModal}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeAssignModal} disabled={saving}>취소</Button>
|
||||
<Button disabled={saving || !assignForm.userId || !assignForm.licenseCode || cannotAssignBecauseActive} onClick={submitAssign}>
|
||||
{saving ? '저장 중' : editingRow ? '저장' : '부여'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{editingRow ? (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">사용자</span>
|
||||
<div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm">
|
||||
<p className="text-foreground">{editingRow.user}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">사용자</span>
|
||||
<input
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={userKeyword}
|
||||
onChange={(event) => setUserKeyword(event.target.value)}
|
||||
placeholder="이메일 또는 이름 검색"
|
||||
/>
|
||||
{selectedUser ? (
|
||||
<div className="mt-2 rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-sm">
|
||||
<p className="text-foreground">{selectedUser.email}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{selectedUser.name || '-'}</p>
|
||||
{selectedUserCurrentLicense ? (
|
||||
<p className="mt-2 rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-200">
|
||||
이미 활성 라이센스가 있습니다. 기존 이력에서 수정하세요.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{userKeyword.trim() ? (
|
||||
<div className="mt-2 max-h-44 overflow-y-auto rounded-md border border-border bg-background/80">
|
||||
{filteredUsers.map((user) => {
|
||||
const selected = assignForm.userId === user.id;
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition hover:bg-white/5 ${selected ? 'bg-primary/15 text-foreground' : 'text-muted-foreground'}`}
|
||||
onClick={() => {
|
||||
setAssignForm((prev) => ({ ...prev, userId: user.id }));
|
||||
setUserKeyword('');
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-foreground">{user.email}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{user.name || '-'}</span>
|
||||
</span>
|
||||
{selected ? <span className="text-xs text-primary">선택됨</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">검색된 사용자가 없습니다.</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<label className="block">
|
||||
<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={assignForm.licenseCode}
|
||||
onChange={(event) => {
|
||||
const licenseCode = event.target.value;
|
||||
setAssignForm((prev) => ({
|
||||
...prev,
|
||||
licenseCode,
|
||||
expiresAt: calculateExpiresAt(licenseCode, prev.startsAt)
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{plans.map((plan) => <option key={plan.code} value={plan.code}>{plan.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">시작일</span>
|
||||
<input
|
||||
type="date"
|
||||
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={assignForm.startsAt}
|
||||
onChange={(event) => {
|
||||
const startsAt = event.target.value;
|
||||
setAssignForm((prev) => ({
|
||||
...prev,
|
||||
startsAt,
|
||||
expiresAt: calculateExpiresAt(prev.licenseCode, startsAt)
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">만료일</span>
|
||||
<input
|
||||
type="date"
|
||||
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={assignForm.expiresAt}
|
||||
onChange={(event) => setAssignForm((prev) => ({ ...prev, expiresAt: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(deleteTarget)}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trash2 className="size-4 text-rose-300" />
|
||||
라이센스 삭제
|
||||
</span>
|
||||
}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
|
||||
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={revoke} disabled={saving}>
|
||||
{saving ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="pb-6 text-sm text-foreground">라이센스를 삭제할까요?</p>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { Button, Modal } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
import { listApps } from '../applications/application-api';
|
||||
import {
|
||||
createLauncherNotice,
|
||||
deleteLauncherNotice,
|
||||
loadLauncherNotices,
|
||||
updateLauncherNotice,
|
||||
type LauncherNotice
|
||||
} from './operations-api';
|
||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
import type { AppWithComponents } from '../../types/application';
|
||||
|
||||
type NoticeForm = {
|
||||
appKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
linkUrl: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
priority: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: NoticeForm = {
|
||||
appKey: 'launcher',
|
||||
title: '',
|
||||
body: '',
|
||||
linkUrl: '',
|
||||
startsAt: '',
|
||||
endsAt: '',
|
||||
priority: '0',
|
||||
isActive: true
|
||||
};
|
||||
|
||||
function formatNoticeDateTime(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function toDateTimeInputValue(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mi = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function toApiDateTime(value: string) {
|
||||
return value ? new Date(value).toISOString() : undefined;
|
||||
}
|
||||
|
||||
function exposureText(row: LauncherNotice) {
|
||||
if (!row.startsAt && !row.endsAt) return '상시';
|
||||
if (row.startsAt && row.endsAt) return `${formatNoticeDateTime(row.startsAt)} ~ ${formatNoticeDateTime(row.endsAt)}`;
|
||||
if (row.startsAt) return `${formatNoticeDateTime(row.startsAt)}부터`;
|
||||
return `${formatNoticeDateTime(row.endsAt)}까지`;
|
||||
}
|
||||
|
||||
function toForm(notice: LauncherNotice): NoticeForm {
|
||||
return {
|
||||
appKey: notice.appKey,
|
||||
title: notice.title,
|
||||
body: notice.body,
|
||||
linkUrl: notice.linkUrl ?? '',
|
||||
startsAt: toDateTimeInputValue(notice.startsAt),
|
||||
endsAt: toDateTimeInputValue(notice.endsAt),
|
||||
priority: String(notice.priority ?? 0),
|
||||
isActive: notice.isActive
|
||||
};
|
||||
}
|
||||
|
||||
function isLauncherNoticeKey(appKey: string) {
|
||||
return appKey.toLowerCase() === 'launcher' || appKey.toLowerCase() === 'login';
|
||||
}
|
||||
|
||||
export function NoticesPage() {
|
||||
const { toast } = useToast();
|
||||
const [notices, setNotices] = useState<LauncherNotice[]>([]);
|
||||
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LauncherNotice | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<LauncherNotice | null>(null);
|
||||
const [menuNotice, setMenuNotice] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [form, setForm] = useState<NoticeForm>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
loadLauncherNotices(),
|
||||
listApps().catch(() => [])
|
||||
])
|
||||
.then(([items, appItems]) => {
|
||||
setNotices(items);
|
||||
setApps(appItems);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setNotices([]);
|
||||
setFailed(true);
|
||||
toast({ type: 'error', title: '공지사항 조회 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuNotice) return;
|
||||
|
||||
const close = () => setMenuNotice(null);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuNotice(null);
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuNotice]);
|
||||
|
||||
const appNameByKey = useMemo(() => {
|
||||
const map = new Map<string, string>([
|
||||
['launcher', 'Launcher'],
|
||||
['login', 'Launcher']
|
||||
]);
|
||||
apps.forEach((app) => map.set(app.key, app.name));
|
||||
return map;
|
||||
}, [apps]);
|
||||
|
||||
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
|
||||
|
||||
const rows = useMemo(
|
||||
() => notices.map((notice, index) => ({ ...notice, no: notices.length - index, appName: appLabel(notice.appKey) })),
|
||||
[appNameByKey, notices]
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (notice: LauncherNotice) => {
|
||||
setMenuNotice(null);
|
||||
setEditing(notice);
|
||||
setForm(toForm(notice));
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const payload = () => ({
|
||||
appKey: form.appKey.trim(),
|
||||
title: form.title.trim(),
|
||||
body: form.body.trim(),
|
||||
linkUrl: form.linkUrl.trim(),
|
||||
startsAt: toApiDateTime(form.startsAt),
|
||||
endsAt: toApiDateTime(form.endsAt),
|
||||
priority: Number(form.priority) || 0,
|
||||
isActive: form.isActive
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
if (!form.appKey.trim() || !form.title.trim() || !form.body.trim()) return;
|
||||
setSaving(true);
|
||||
const request = editing
|
||||
? updateLauncherNotice(editing.id, payload())
|
||||
: createLauncherNotice(payload());
|
||||
|
||||
request
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: editing ? '공지사항을 수정했습니다.' : '공지사항을 등록했습니다.' });
|
||||
closeModal();
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: editing ? '공지사항 수정 실패' : '공지사항 등록 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const removeNotice = (notice: LauncherNotice) => {
|
||||
setMenuNotice(null);
|
||||
setDeleteTarget(notice);
|
||||
};
|
||||
|
||||
const confirmRemoveNotice = () => {
|
||||
if (!deleteTarget) return;
|
||||
setSaving(true);
|
||||
deleteLauncherNotice(deleteTarget.id)
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: '공지사항을 삭제했습니다.' });
|
||||
setDeleteTarget(null);
|
||||
closeModal();
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '공지사항 삭제 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<(typeof rows)[number]>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||
{
|
||||
key: 'title',
|
||||
header: '공지',
|
||||
className: 'w-[300px] px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<p className="truncate">{row.title}</p>
|
||||
<p className="mt-1 max-w-[320px] truncate text-xs text-muted-foreground">{row.body}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'appKey', header: '대상 앱', cell: (row) => <StatusBadge status={row.appName} tone={isLauncherNoticeKey(row.appKey) ? 'violet' : 'blue'} /> },
|
||||
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status ?? (row.isActive ? '게시중' : '비활성')} /> },
|
||||
{ key: 'exposure', header: '노출 일시', cell: (row) => <span className="text-muted-foreground">{exposureText(row)}</span> },
|
||||
{
|
||||
key: 'linkUrl',
|
||||
header: '링크주소',
|
||||
cell: (row) => row.linkUrl ? <span className="block max-w-56 truncate text-muted-foreground">{row.linkUrl}</span> : <span className="text-muted-foreground">-</span>
|
||||
},
|
||||
{ key: 'createdAt', header: '생성일시', cell: (row) => <span className="text-muted-foreground">{formatNoticeDateTime(row.createdAt)}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div className="relative flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuNotice((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[menuNotice?.id]
|
||||
);
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
||||
() => [
|
||||
{ key: 'appKey', label: '대상 앱', options: [...new Set(rows.map((row) => row.appKey))].map((appKey) => ({ label: appLabel(appKey), value: appKey })), getValue: (row) => row.appKey },
|
||||
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status ?? (row.isActive ? '게시중' : '비활성')))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status ?? (row.isActive ? '게시중' : '비활성') }
|
||||
],
|
||||
[rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="운영"
|
||||
title="공지사항"
|
||||
description="런처와 앱 화면에 노출할 공지사항을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{failed ? (
|
||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
공지사항 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
) : null}
|
||||
<DataTableCard
|
||||
title="목록"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="공지 제목, 대상 앱, 링크 검색"
|
||||
getSearchText={(row) => `${row.title} ${row.body} ${row.appKey} ${row.appName} ${row.linkUrl ?? ''} ${row.status ?? ''}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1180}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 공지사항이 없습니다."
|
||||
/>
|
||||
|
||||
{menuNotice ? (() => {
|
||||
const row = rows.find((item) => item.id === menuNotice.id);
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuNotice.x, top: menuNotice.y, transform: 'translateX(-100%)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10"
|
||||
onClick={() => removeNotice(row)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
공지사항
|
||||
</span>
|
||||
}
|
||||
onClose={closeModal}
|
||||
className="!max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button>
|
||||
<Button onClick={submit} disabled={saving || !form.appKey.trim() || !form.title.trim() || !form.body.trim()}>
|
||||
{saving ? '저장 중' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||
<label className="block">
|
||||
<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={form.appKey} onChange={(event) => setForm((prev) => ({ ...prev, appKey: event.target.value }))}>
|
||||
<option value="launcher">Launcher</option>
|
||||
{apps.map((app) => (
|
||||
<option key={app.id} value={app.key}>{app.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">게시 여부</span>
|
||||
<span className="mt-2 inline-flex h-9 w-full items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
||||
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((prev) => ({ ...prev, isActive: event.target.checked }))} />
|
||||
게시
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">제목</span>
|
||||
<input 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={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">본문</span>
|
||||
<textarea className="mt-2 min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">링크주소</span>
|
||||
<input 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={form.linkUrl} onChange={(event) => setForm((prev) => ({ ...prev, linkUrl: event.target.value }))} />
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출 시작 일시</span>
|
||||
<input type="datetime-local" 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={form.startsAt} onChange={(event) => setForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출 종료 일시</span>
|
||||
<input type="datetime-local" 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={form.endsAt} onChange={(event) => setForm((prev) => ({ ...prev, endsAt: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(deleteTarget)}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trash2 className="size-4 text-rose-300" />
|
||||
공지사항 삭제
|
||||
</span>
|
||||
}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
|
||||
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={confirmRemoveNotice} disabled={saving}>
|
||||
{saving ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="pb-6 text-sm text-foreground">공지사항을 삭제할까요?</p>
|
||||
</Modal>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { Button } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
|
||||
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
|
||||
export function UserSessionsPage() {
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
loadUserSessions()
|
||||
.then((nextSessions) => {
|
||||
setSessions(nextSessions);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setSessions([]);
|
||||
setFailed(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuSession) return;
|
||||
|
||||
const close = () => setMenuSession(null);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuSession(null);
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuSession]);
|
||||
|
||||
const rows = useMemo(() => sessions.map((session, index) => ({ ...session, no: sessions.length - index })), [sessions]);
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||
{ key: 'user', header: '사용자', className: 'w-[220px] px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.user}</span> },
|
||||
{ key: 'role', header: '권한', cell: (row) => <span className="text-muted-foreground">{row.role}</span> },
|
||||
{ key: 'session', header: '세션', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> },
|
||||
{ key: 'expiresAt', header: '만료', cell: (row) => <span className="text-muted-foreground">{formatDateTime(row.expiresAt)}</span> },
|
||||
{ key: 'lastSeen', header: '최근 접속', cell: (row) => <span className="text-muted-foreground">{formatDateTime(row.lastSeen)}</span> },
|
||||
{ key: 'state', header: '상태', cell: (row) => <StatusBadge status={row.state} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => (
|
||||
<div className="relative flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuSession((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[menuSession?.id]
|
||||
);
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<UserSession>>>(
|
||||
() => [
|
||||
{ key: 'state', label: '상태', options: [...new Set(sessions.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
||||
],
|
||||
[sessions]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="운영" title="사용자/세션" description="접속 중인 사용자와 세션 상태를 확인하고 필요한 제어 작업을 실행합니다." />
|
||||
{failed ? (
|
||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
사용자/세션 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
) : null}
|
||||
<DataTableCard
|
||||
title="목록"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="사용자, 이메일, 권한, 세션 검색"
|
||||
getSearchText={(row) => `${row.user} ${row.email ?? ''} ${row.role} ${row.session} ${row.state}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1180}
|
||||
loading={loading}
|
||||
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
||||
/>
|
||||
{menuSession ? (() => {
|
||||
const row = rows.find((item) => item.id === menuSession.id);
|
||||
if (!row) return null;
|
||||
const locked = row.state === '잠김';
|
||||
return (
|
||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuSession.x, top: menuSession.y, transform: 'translateX(-100%)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMenuSession(null);
|
||||
const request = locked ? unlockUser(row.userId) : lockUser(row.userId);
|
||||
request.then(refresh).catch(() => setFailed(true));
|
||||
}}
|
||||
>
|
||||
{locked ? '잠금 해제' : '잠금'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:bg-transparent"
|
||||
disabled={!row.sessionId || row.state === '종료' || row.state === '만료'}
|
||||
onClick={() => {
|
||||
if (!row.sessionId) return;
|
||||
setMenuSession(null);
|
||||
terminateUserSession(row.sessionId).then(refresh).catch(() => setFailed(true));
|
||||
}}
|
||||
>
|
||||
세션 종료
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '../../components/ui';
|
||||
import { formatDateTime as formatDateTimeValue } from '../../lib/date-format';
|
||||
|
||||
export type BadgeTone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
||||
|
||||
export function statusTone(status?: string | null): BadgeTone {
|
||||
if (!status) return 'neutral';
|
||||
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
||||
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
||||
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
||||
if (['런처'].includes(status)) return 'violet';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, tone }: { status: string; tone?: BadgeTone }) {
|
||||
return <Badge tone={tone ?? statusTone(status)}>{status}</Badge>;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null) {
|
||||
return formatDateTimeValue(value);
|
||||
}
|
||||
|
||||
export function LoadingModal({ open }: { open: boolean }) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 top-16 z-[15] flex items-center justify-center bg-background/20 px-4 backdrop-blur-[1px] lg:left-[17rem]">
|
||||
<div className="flex min-h-28 min-w-72 items-center justify-center gap-3 rounded-lg border border-border bg-card px-8 py-6 text-sm font-semibold shadow-2xl lg:-translate-x-[8.5rem]">
|
||||
<LoaderCircle className="size-5 animate-spin text-primary" />
|
||||
데이터를 불러오는 중입니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMinimumLoading(loading: boolean, minimumMs = 500) {
|
||||
const [visible, setVisible] = useState(loading);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setVisible(false), minimumMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loading, minimumMs]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { apiRequest } from '../../lib/api';
|
||||
|
||||
export type AdminSession = {
|
||||
id?: string;
|
||||
revokedAt?: string | null;
|
||||
};
|
||||
|
||||
export type AdminSshKey = {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
fingerprint?: string | null;
|
||||
};
|
||||
|
||||
export type AdminAccount = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string;
|
||||
isActive: boolean;
|
||||
lastLoginAt?: string | null;
|
||||
sessions?: AdminSession[];
|
||||
sshKeys?: AdminSshKey[];
|
||||
};
|
||||
|
||||
export type AdminRoleOption = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type UserSession = {
|
||||
id: string;
|
||||
no?: number;
|
||||
userId: string;
|
||||
sessionId?: string | null;
|
||||
user: string;
|
||||
email?: string;
|
||||
role: string;
|
||||
session: string;
|
||||
expiresAt?: string | null;
|
||||
lastSeen?: string | null;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type LauncherNotice = {
|
||||
id: string;
|
||||
no?: number;
|
||||
appKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
linkUrl?: string | null;
|
||||
isActive: boolean;
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
priority: number;
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export async function loadAdmins() {
|
||||
const response = await apiRequest<AdminAccount[] | { admins?: AdminAccount[] }>('/admins');
|
||||
return Array.isArray(response) ? response : response.admins ?? [];
|
||||
}
|
||||
|
||||
export function loadAdminRoles() {
|
||||
return apiRequest<AdminRoleOption[]>('/admins/roles');
|
||||
}
|
||||
|
||||
export function createAdmin(payload: {
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
temporaryPassword?: string;
|
||||
}) {
|
||||
return apiRequest('/admins', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAdminStatus(adminId: string, isActive: boolean) {
|
||||
return apiRequest(`/admins/${adminId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive })
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAdminRole(adminId: string, role: string) {
|
||||
return apiRequest(`/admins/${adminId}/role`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ role })
|
||||
});
|
||||
}
|
||||
|
||||
export function resetAdminPassword(adminId: string, temporaryPassword: string) {
|
||||
return apiRequest(`/admins/${adminId}/password`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ temporaryPassword })
|
||||
});
|
||||
}
|
||||
|
||||
export function createAdminSshKey(
|
||||
adminId: string,
|
||||
payload: { label?: string; giteaUsername?: string; publicKey: string }
|
||||
) {
|
||||
return apiRequest(`/admins/${adminId}/ssh-keys`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAdminSshKey(adminId: string, keyId: string) {
|
||||
return apiRequest(`/admins/${adminId}/ssh-keys/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadUserSessions() {
|
||||
const response = await apiRequest<{ sessions?: UserSession[] }>('/users/sessions');
|
||||
return response.sessions ?? [];
|
||||
}
|
||||
|
||||
export function lockUser(userId: string) {
|
||||
return apiRequest(`/users/${userId}/lock`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
}
|
||||
|
||||
export function unlockUser(userId: string) {
|
||||
return apiRequest(`/users/${userId}/unlock`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
}
|
||||
|
||||
export function terminateUserSession(sessionId: string) {
|
||||
return apiRequest(`/users/sessions/${sessionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadLauncherNotices() {
|
||||
const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices');
|
||||
return response.notices ?? [];
|
||||
}
|
||||
|
||||
export function createLauncherNotice(payload: {
|
||||
appKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
linkUrl?: string;
|
||||
isActive?: boolean;
|
||||
startsAt?: string;
|
||||
endsAt?: string;
|
||||
priority?: number;
|
||||
}) {
|
||||
return apiRequest<{ notice: LauncherNotice }>('/notices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLauncherNotice(
|
||||
noticeId: string,
|
||||
payload: Partial<{
|
||||
appKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
linkUrl: string;
|
||||
isActive: boolean;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
priority: number;
|
||||
}>
|
||||
) {
|
||||
return apiRequest<{ notice: LauncherNotice }>(`/notices/${noticeId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteLauncherNotice(noticeId: string) {
|
||||
return apiRequest<{ ok: boolean }>(`/notices/${noticeId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export type LicensePlan = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
defaultDurationDays?: number | null;
|
||||
isActive: boolean;
|
||||
assignedCount: number;
|
||||
activeUserCount: number;
|
||||
expiringSoonCount: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type AuthUserLicense = {
|
||||
id: string;
|
||||
userId: string;
|
||||
licenseId: string;
|
||||
startsAt: string;
|
||||
expiresAt?: string | null;
|
||||
assignedById?: string | null;
|
||||
license: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type AuthLicenseUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
currentLicense?: AuthUserLicense | null;
|
||||
licenses: AuthUserLicense[];
|
||||
};
|
||||
|
||||
export async function loadLicensePlans() {
|
||||
const response = await apiRequest<{ plans?: LicensePlan[] }>('/licenses/plans');
|
||||
return response.plans ?? [];
|
||||
}
|
||||
|
||||
export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.keyword) searchParams.set('keyword', params.keyword);
|
||||
if (params?.licenseCode) searchParams.set('licenseCode', params.licenseCode);
|
||||
const suffix = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
||||
const response = await apiRequest<{ users?: AuthLicenseUser[] }>(`/licenses/users${suffix}`);
|
||||
return response.users ?? [];
|
||||
}
|
||||
|
||||
export function assignUserLicense(userId: string, payload: {
|
||||
licenseCode: string;
|
||||
startsAt?: string;
|
||||
expiresAt?: string;
|
||||
}) {
|
||||
return apiRequest<{ userLicense: AuthUserLicense }>(`/licenses/users/${userId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUserLicense(userLicenseId: string, payload: {
|
||||
licenseCode: string;
|
||||
startsAt?: string;
|
||||
expiresAt?: string;
|
||||
}) {
|
||||
return apiRequest<{ userLicense: AuthUserLicense }>(`/licenses/user-licenses/${userLicenseId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeUserLicense(userLicenseId: string) {
|
||||
return apiRequest<{ ok: boolean }>(`/licenses/user-licenses/${userLicenseId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Play, Plus, RotateCcw, Square, Trash2 } from 'lucide-react';
|
||||
import { Badge, Button, Card, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||
import type { ApiApp, RuntimeEnvProfile, RuntimeResource } from '../../types/runtime';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import {
|
||||
applyRuntimeProfile,
|
||||
createRuntimeIngressRule,
|
||||
createRuntimeProfile,
|
||||
createRuntimeVariable,
|
||||
deleteRuntimeIngressRule,
|
||||
deleteRuntimeVariable,
|
||||
loadApps,
|
||||
loadRuntimeProfiles,
|
||||
loadRuntimeResources,
|
||||
restartRuntimeWorkload,
|
||||
scaleRuntimeWorkload
|
||||
} from './runtime-api';
|
||||
|
||||
function environmentLabel(value?: string) {
|
||||
return {
|
||||
dev: '개발',
|
||||
stage: '검수',
|
||||
prod: '운영'
|
||||
}[value ?? ''] ?? value ?? '-';
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function getWorkloadKind(kind: string) {
|
||||
if (kind === 'Deployment') return 'deployment';
|
||||
if (kind === 'StatefulSet') return 'statefulset';
|
||||
if (kind === 'DaemonSet') return 'daemonset';
|
||||
return '';
|
||||
}
|
||||
|
||||
function statusDot(status?: string) {
|
||||
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(status ?? '')) return 'bg-emerald-400';
|
||||
if (['Pending', 'ContainerCreating'].includes(status ?? '')) return 'bg-amber-400';
|
||||
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'bg-rose-400';
|
||||
return 'bg-muted-foreground';
|
||||
}
|
||||
|
||||
function isStopped(resource: RuntimeResource) {
|
||||
return resource.ready.endsWith('/0');
|
||||
}
|
||||
|
||||
function runtimeStatusLabel(resource: RuntimeResource, stopped: boolean) {
|
||||
if (stopped) return '중지됨';
|
||||
if (resource.status === 'Running') return '실행 중';
|
||||
if (resource.status === 'Pending' || resource.status === 'ContainerCreating') return '준비 중';
|
||||
if (resource.status === 'Error' || resource.status === 'CrashLoopBackOff' || resource.status === 'ImagePullBackOff' || resource.status === 'Failed') return '오류';
|
||||
return resource.status || '-';
|
||||
}
|
||||
|
||||
function runtimeStatusTone(resource: RuntimeResource, stopped: boolean): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||
if (stopped) return 'neutral';
|
||||
if (resource.status === 'Running') return 'green';
|
||||
if (resource.status === 'Pending' || resource.status === 'ContainerCreating') return 'amber';
|
||||
if (resource.status === 'Error' || resource.status === 'CrashLoopBackOff' || resource.status === 'ImagePullBackOff' || resource.status === 'Failed') return 'red';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
export function RuntimeConfigPage() {
|
||||
const { toast } = useToast();
|
||||
const [apps, setApps] = useState<ApiApp[]>([]);
|
||||
const [appId, setAppId] = useState('');
|
||||
const [profiles, setProfiles] = useState<RuntimeEnvProfile[]>([]);
|
||||
const [profileId, setProfileId] = useState('');
|
||||
const [resources, setResources] = useState<RuntimeResource[]>([]);
|
||||
const [resourceCount, setResourceCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionKey, setActionKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const [profileName, setProfileName] = useState('');
|
||||
const [profileEnvironment, setProfileEnvironment] = useState('dev');
|
||||
const [profileNamespace, setProfileNamespace] = useState('');
|
||||
const [variableComponentId, setVariableComponentId] = useState('');
|
||||
const [variableKey, setVariableKey] = useState('');
|
||||
const [variableValue, setVariableValue] = useState('');
|
||||
const [variableIsSecret, setVariableIsSecret] = useState(false);
|
||||
const [ingressComponentId, setIngressComponentId] = useState('');
|
||||
const [ingressHost, setIngressHost] = useState('');
|
||||
const [ingressPath, setIngressPath] = useState('/');
|
||||
const [ingressPort, setIngressPort] = useState('');
|
||||
|
||||
const selectedApp = apps.find((app) => app.id === appId);
|
||||
const selectedProfile = profiles.find((profile) => profile.id === profileId);
|
||||
const components = selectedApp?.components ?? [];
|
||||
const variables = selectedProfile?.variables ?? [];
|
||||
const ingressRules = selectedProfile?.ingressRules ?? [];
|
||||
const namespace = selectedProfile?.namespace || selectedApp?.namespace || profileNamespace || '';
|
||||
const workloads = resources.filter((resource) => ['Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind));
|
||||
const envCount = variables.filter((variable) => !variable.isSecret).length;
|
||||
const secretCount = variables.filter((variable) => variable.isSecret).length;
|
||||
|
||||
const selectedProfileLabel = selectedProfile
|
||||
? `${selectedApp?.name ?? '앱'}${environmentLabel(selectedProfile.environment)} / ${environmentLabel(selectedProfile.environment)}`
|
||||
: '프로필 없음';
|
||||
|
||||
const namespaceOptions = useMemo(() => [...new Set(apps.map((app) => app.namespace).filter(Boolean) as string[])], [apps]);
|
||||
const duplicateProfile = profiles.find((profile) => (
|
||||
profile.environment === profileEnvironment &&
|
||||
profile.namespace.trim() === profileNamespace.trim()
|
||||
));
|
||||
|
||||
const refreshResources = (targetNamespace = namespace) => {
|
||||
if (!targetNamespace.trim()) {
|
||||
setResourceCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
loadRuntimeResources(targetNamespace)
|
||||
.then((data) => {
|
||||
setResources(data.resources);
|
||||
setResourceCount(data.resources.length);
|
||||
})
|
||||
.catch(() => {
|
||||
setResources([]);
|
||||
setResourceCount(0);
|
||||
});
|
||||
};
|
||||
|
||||
const applyProfiles = (nextProfiles: RuntimeEnvProfile[], preferredProfileId?: string) => {
|
||||
setProfiles(nextProfiles);
|
||||
setProfileId((current) => {
|
||||
const nextId = preferredProfileId || (nextProfiles.some((profile) => profile.id === current) ? current : nextProfiles[0]?.id ?? '');
|
||||
const nextProfile = nextProfiles.find((profile) => profile.id === nextId);
|
||||
if (nextProfile) {
|
||||
setProfileNamespace(nextProfile.namespace);
|
||||
refreshResources(nextProfile.namespace);
|
||||
}
|
||||
return nextId;
|
||||
});
|
||||
};
|
||||
|
||||
const refreshProfiles = (targetAppId = appId, preferredProfileId?: string) => {
|
||||
if (!targetAppId) return Promise.resolve();
|
||||
return loadRuntimeProfiles(targetAppId).then((nextProfiles) => applyProfiles(nextProfiles, preferredProfileId));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadApps()
|
||||
.then((nextApps) => {
|
||||
const storedAppId = getStoredSelectedAppId();
|
||||
const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
|
||||
const nextApp = nextApps.find((app) => app.id === nextAppId);
|
||||
setApps(nextApps);
|
||||
setAppId(nextAppId);
|
||||
setProfileNamespace(nextApp?.namespace ?? '');
|
||||
setVariableComponentId(nextApp?.components?.[0]?.id ?? '');
|
||||
setIngressComponentId(nextApp?.components?.[0]?.id ?? '');
|
||||
setIngressPort(nextApp?.components?.[0]?.port ? String(nextApp.components[0].port) : '');
|
||||
return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]);
|
||||
})
|
||||
.then((nextProfiles) => applyProfiles(nextProfiles))
|
||||
.catch((loadError: Error) => setError(loadError.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const changeApp = (nextAppId: string) => {
|
||||
const nextApp = apps.find((app) => app.id === nextAppId);
|
||||
setStoredSelectedAppId(nextAppId);
|
||||
setAppId(nextAppId);
|
||||
setProfileNamespace(nextApp?.namespace ?? '');
|
||||
setVariableComponentId(nextApp?.components?.[0]?.id ?? '');
|
||||
setIngressComponentId(nextApp?.components?.[0]?.id ?? '');
|
||||
setIngressPort(nextApp?.components?.[0]?.port ? String(nextApp.components[0].port) : '');
|
||||
refreshProfiles(nextAppId).catch((loadError: Error) => setError(loadError.message));
|
||||
};
|
||||
|
||||
const addProfile = () => {
|
||||
if (!appId || !profileName.trim() || !profileNamespace.trim()) return;
|
||||
if (duplicateProfile) {
|
||||
const message = `이미 ${environmentLabel(profileEnvironment)} / ${profileNamespace.trim()} 실행 설정이 있습니다.`;
|
||||
setError(message);
|
||||
toast({
|
||||
type: 'warning',
|
||||
title: '프로필 중복',
|
||||
message: '기존 프로필을 선택해 설정값을 수정하세요.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createRuntimeProfile({
|
||||
appId,
|
||||
name: profileName.trim(),
|
||||
environment: profileEnvironment,
|
||||
namespace: profileNamespace.trim()
|
||||
})
|
||||
.then(({ profile }) => {
|
||||
setProfileName('');
|
||||
setProfileModalOpen(false);
|
||||
refreshProfiles(appId, profile.id);
|
||||
})
|
||||
.catch((createError: Error) => setError(createError.message));
|
||||
};
|
||||
|
||||
const addVariable = () => {
|
||||
if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return;
|
||||
createRuntimeVariable(selectedProfile.id, {
|
||||
componentId: variableComponentId,
|
||||
key: variableKey.trim(),
|
||||
value: variableValue.trim(),
|
||||
isSecret: variableIsSecret
|
||||
})
|
||||
.then(() => {
|
||||
setVariableKey('');
|
||||
setVariableValue('');
|
||||
setVariableIsSecret(false);
|
||||
refreshProfiles(appId, selectedProfile.id);
|
||||
})
|
||||
.catch((createError: Error) => setError(createError.message));
|
||||
};
|
||||
|
||||
const removeVariable = (variableId: string) => {
|
||||
if (!selectedProfile) return;
|
||||
deleteRuntimeVariable(selectedProfile.id, variableId)
|
||||
.then(() => refreshProfiles(appId, selectedProfile.id))
|
||||
.catch((deleteError: Error) => setError(deleteError.message));
|
||||
};
|
||||
|
||||
const addIngressRule = () => {
|
||||
if (!selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()) return;
|
||||
createRuntimeIngressRule(selectedProfile.id, {
|
||||
componentId: ingressComponentId,
|
||||
host: ingressHost.trim(),
|
||||
path: ingressPath.trim() || '/',
|
||||
servicePort: Number(ingressPort),
|
||||
tlsEnabled: false
|
||||
})
|
||||
.then(() => {
|
||||
setIngressHost('');
|
||||
setIngressPath('/');
|
||||
refreshProfiles(appId, selectedProfile.id);
|
||||
})
|
||||
.catch((createError: Error) => setError(createError.message));
|
||||
};
|
||||
|
||||
const removeIngressRule = (ruleId: string) => {
|
||||
if (!selectedProfile) return;
|
||||
deleteRuntimeIngressRule(selectedProfile.id, ruleId)
|
||||
.then(() => refreshProfiles(appId, selectedProfile.id))
|
||||
.catch((deleteError: Error) => setError(deleteError.message));
|
||||
};
|
||||
|
||||
const applyProfile = (profile = selectedProfile) => {
|
||||
if (!profile) return;
|
||||
setActionKey(`profile-${profile.id}`);
|
||||
applyRuntimeProfile(profile.id)
|
||||
.then(() => {
|
||||
refreshResources(profile.namespace);
|
||||
refreshProfiles(appId, profile.id);
|
||||
})
|
||||
.catch((applyError: Error) => setError(applyError.message))
|
||||
.finally(() => setActionKey(''));
|
||||
};
|
||||
|
||||
const runWorkloadAction = (resource: RuntimeResource, action: 'restart' | 'start' | 'stop') => {
|
||||
const apiKind = getWorkloadKind(resource.kind);
|
||||
if (!apiKind || !namespace) return;
|
||||
const key = `${action}-${resource.kind}-${resource.name}`;
|
||||
setActionKey(key);
|
||||
setError('');
|
||||
|
||||
const request = action === 'restart'
|
||||
? restartRuntimeWorkload(namespace, apiKind, resource.name)
|
||||
: scaleRuntimeWorkload(namespace, apiKind, resource.name, action === 'start' ? 1 : 0);
|
||||
|
||||
request
|
||||
.then(() => {
|
||||
toast({
|
||||
type: 'success',
|
||||
title: action === 'restart' ? '워크로드 재시작' : action === 'start' ? '워크로드 시작' : '워크로드 중지',
|
||||
message: `${resource.name} 작업을 요청했습니다.`
|
||||
});
|
||||
refreshResources(namespace);
|
||||
})
|
||||
.catch((workloadError: Error) => {
|
||||
setError(workloadError.message);
|
||||
toast({ type: 'error', title: '워크로드 제어 실패', message: workloadError.message });
|
||||
})
|
||||
.finally(() => setActionKey(''));
|
||||
};
|
||||
|
||||
const requestDeleteWorkload = () => {
|
||||
toast({
|
||||
type: 'warning',
|
||||
title: '삭제 API 미구현',
|
||||
message: '워크로드 삭제는 백엔드 delete 엔드포인트 추가 후 연결할 수 있습니다.'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="실행 설정"
|
||||
description="앱 구성요소별 환경변수와 시크릿을 프로필로 묶어 배포 환경에 적용합니다."
|
||||
actions={(
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setProfileModalOpen(true)} disabled={!appId}>
|
||||
<Plus className="size-4" />
|
||||
프로필 생성
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error ? <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">{error}</div> : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">앱 선택</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">설정값을 관리할 앱 그룹을 선택합니다.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<select
|
||||
className="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 sm:w-72"
|
||||
value={appId}
|
||||
disabled={loading || apps.length === 0}
|
||||
onChange={(event) => changeApp(event.target.value)}
|
||||
>
|
||||
{apps.length === 0 ? <option value="">등록된 앱 없음</option> : null}
|
||||
{apps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
|
||||
</select>
|
||||
<Badge>{namespace || '-'}</Badge>
|
||||
<Badge>구성요소 {components.length}</Badge>
|
||||
<Badge>프로필 {profiles.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">네임스페이스</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{namespace || '-'}</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">구성요소</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}개</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">환경변수</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}개</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">시크릿</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}개</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">런타임 프로필</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">개발, 검수, 운영처럼 배포 환경별로 적용할 설정 묶음입니다.</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">총 {profiles.length}개</span>
|
||||
</div>
|
||||
<div className="max-h-[260px] space-y-3 overflow-y-auto p-4">
|
||||
{profiles.length === 0 ? (
|
||||
<div className="flex min-h-28 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
등록된 런타임 프로필이 없습니다.
|
||||
</div>
|
||||
) : profiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={[
|
||||
'w-full cursor-pointer rounded-md border px-4 py-3 text-left transition-colors',
|
||||
profile.id === profileId
|
||||
? 'border-border bg-primary/15 shadow-[inset_4px_0_0_var(--color-primary)]'
|
||||
: 'border-border bg-background/70 hover:bg-muted/20'
|
||||
].join(' ')}
|
||||
onClick={() => {
|
||||
setProfileId(profile.id);
|
||||
setProfileNamespace(profile.namespace);
|
||||
refreshResources(profile.namespace);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
setProfileId(profile.id);
|
||||
setProfileNamespace(profile.namespace);
|
||||
refreshResources(profile.namespace);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{profile.name}</span>
|
||||
<Badge tone="blue">{environmentLabel(profile.environment)}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{profile.namespace}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>환경변수 <b className="text-foreground">{profile.variables?.filter((variable) => !variable.isSecret).length ?? 0}</b></span>
|
||||
<span>시크릿 <b className="text-foreground">{profile.variables?.filter((variable) => variable.isSecret).length ?? 0}</b></span>
|
||||
<span>수정 <b className="text-foreground">{formatDate(profile.updatedAt ?? profile.createdAt)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={actionKey === `profile-${profile.id}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
applyProfile(profile);
|
||||
}}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">설정값</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">구성요소별로 ConfigMap 또는 Secret에 반영될 항목입니다.</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">총 {variables.length}개</span>
|
||||
</div>
|
||||
<div className="max-h-[480px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[920px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||
<th className="px-5 py-3 font-medium">Key</th>
|
||||
<th className="px-5 py-3 font-medium">Value</th>
|
||||
<th className="px-5 py-3 font-medium">등록일</th>
|
||||
<th className="w-16 px-5 py-3 text-right font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{variables.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-5 py-14 text-center text-muted-foreground">등록된 설정값이 없습니다.</td></tr>
|
||||
) : null}
|
||||
{variables.map((variable) => (
|
||||
<tr key={variable.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className={variable.isSecret ? 'block size-2.5 rounded-full bg-amber-400' : 'block size-2.5 rounded-full bg-emerald-400'} />
|
||||
<span>{variable.component?.name ?? '-'}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 font-mono text-xs font-medium text-foreground">{variable.key}</td>
|
||||
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{variable.isSecret ? '********' : variable.value}</td>
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground">{formatDate(variable.createdAt)}</td>
|
||||
<td className="px-5 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)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<p className="text-sm font-semibold">설정값 추가</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">선택한 프로필에 구성요소별 환경변수 또는 시크릿을 추가합니다.</p>
|
||||
<div className="mt-5 space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">프로필</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-muted px-3 text-sm text-foreground outline-none" value={selectedProfileLabel} readOnly />
|
||||
</label>
|
||||
<label className="block">
|
||||
<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)}>
|
||||
{components.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
||||
{components.length === 0 ? <option value="">구성요소 없음</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Key</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={variableKey} onChange={(event) => setVariableKey(event.target.value)} placeholder="DATABASE_URL" />
|
||||
<label className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<input type="checkbox" className="size-4 accent-primary" checked={variableIsSecret} onChange={(event) => setVariableIsSecret(event.target.checked)} />
|
||||
Secret
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Value</span>
|
||||
<input
|
||||
type={variableIsSecret ? 'password' : 'text'}
|
||||
className={`mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring ${variableIsSecret ? 'login-password-input' : 'font-mono'}`}
|
||||
value={variableValue}
|
||||
onChange={(event) => setVariableValue(event.target.value)}
|
||||
placeholder="값 입력"
|
||||
/>
|
||||
</label>
|
||||
<Button className="h-9 w-full text-[13px]" onClick={addVariable} disabled={!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()}>
|
||||
<Plus className="size-4" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">접속 설정</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">배포 시 생성할 서비스와 도메인 연결 정보를 관리합니다.</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">총 {ingressRules.length}개</span>
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[820px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||
<th className="px-5 py-3 font-medium">도메인</th>
|
||||
<th className="px-5 py-3 font-medium">Path</th>
|
||||
<th className="px-5 py-3 font-medium">포트</th>
|
||||
<th className="w-16 px-5 py-3 text-right font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{ingressRules.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-5 py-14 text-center text-muted-foreground">등록된 접속 설정이 없습니다.</td></tr>
|
||||
) : null}
|
||||
{ingressRules.map((rule) => (
|
||||
<tr key={rule.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="block size-2.5 rounded-full bg-sky-400" />
|
||||
<span>{rule.component?.name ?? '-'}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-foreground">{rule.host}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{rule.path}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{rule.servicePort}</td>
|
||||
<td className="px-5 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)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<p className="text-sm font-semibold">접속 설정 추가</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">선택한 프로필에 도메인 연결 정보를 추가합니다.</p>
|
||||
<div className="mt-5 space-y-4">
|
||||
<label className="block">
|
||||
<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={ingressComponentId}
|
||||
onChange={(event) => {
|
||||
const nextId = event.target.value;
|
||||
const component = components.find((item) => item.id === nextId);
|
||||
setIngressComponentId(nextId);
|
||||
setIngressPort(component?.port ? String(component.port) : '');
|
||||
}}
|
||||
>
|
||||
{components.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
||||
{components.length === 0 ? <option value="">구성요소 없음</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">도메인</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={ingressHost} onChange={(event) => setIngressHost(event.target.value)} placeholder="lms-api.local" />
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Path</span>
|
||||
<input 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={ingressPath} onChange={(event) => setIngressPath(event.target.value)} placeholder="/" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">서비스 포트</span>
|
||||
<input type="number" min="1" 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={ingressPort} onChange={(event) => setIngressPort(event.target.value)} placeholder="80" />
|
||||
</label>
|
||||
</div>
|
||||
<Button className="h-9 w-full text-[13px]" onClick={addIngressRule} disabled={!selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()}>
|
||||
<Plus className="size-4" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={profileModalOpen}
|
||||
title="프로필 생성"
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setProfileModalOpen(false)}>취소</Button>
|
||||
<Button disabled={!profileName.trim() || !profileNamespace.trim() || Boolean(duplicateProfile)} onClick={addProfile}>생성</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">프로필명</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="예: LMS개발" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<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={profileEnvironment} onChange={(event) => setProfileEnvironment(event.target.value)}>
|
||||
<option value="dev">개발</option>
|
||||
<option value="stage">검수</option>
|
||||
<option value="prod">운영</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
||||
<input
|
||||
list="runtime-namespace-options"
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={profileNamespace}
|
||||
onChange={(event) => setProfileNamespace(event.target.value)}
|
||||
placeholder="stdo-lms"
|
||||
/>
|
||||
<datalist id="runtime-namespace-options">
|
||||
{namespaceOptions.map((item) => <option key={item} value={item} />)}
|
||||
</datalist>
|
||||
</label>
|
||||
{duplicateProfile ? (
|
||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
이미 같은 환경과 네임스페이스의 프로필이 있습니다. 기존 프로필을 선택해 설정값을 수정하세요.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Activity, MoreHorizontal, Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { agentDevices } from '../../data/mock';
|
||||
|
||||
function getTone(status: string) {
|
||||
if (['정상', '활성', '성공', '운영', 'Running'].includes(status)) return 'green';
|
||||
if (['주의', '예약됨', '대기', '준비중', '카나리', 'Pending'].includes(status)) return 'amber';
|
||||
if (['오류', '실패', '비활성', '만료', 'Error', 'Failed'].includes(status)) return 'red';
|
||||
if (['검수', '동기화'].includes(status)) return 'blue';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function RuntimeDeviceControlPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title="장비 목록"
|
||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
상태 필터
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<Activity className="size-4" />
|
||||
상태 갱신
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="border-b border-border px-5 pb-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="장비명, 계정, 워크스페이스 검색"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[1040px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">장비</th>
|
||||
<th className="px-5 py-3 font-medium">계정</th>
|
||||
<th className="px-5 py-3 font-medium">Workspace</th>
|
||||
<th className="px-5 py-3 font-medium">SSH 키</th>
|
||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
<th className="px-5 py-3 text-right font-medium">명령</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{agentDevices.map((device) => (
|
||||
<tr key={device.name} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3 font-medium">{device.name}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
||||
<td className="px-5 py-3">{device.sshKey}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={getTone(device.status)}>{device.status}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
||||
<Button variant="ghost" className="size-8 px-0">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FileText, RotateCcw, Search, TerminalSquare } from 'lucide-react';
|
||||
import { Badge, Button, Card } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ApiApp, RuntimeEvent, RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources, loadRuntimeWorkloadLogs } from './runtime-api';
|
||||
|
||||
function statusDot(status?: string) {
|
||||
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer', 'Normal'].includes(status ?? '')) return 'bg-emerald-400';
|
||||
if (['Pending', 'ContainerCreating', 'Warning'].includes(status ?? '')) return 'bg-amber-400';
|
||||
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'bg-rose-400';
|
||||
return 'bg-muted-foreground';
|
||||
}
|
||||
|
||||
function eventTone(type?: string) {
|
||||
if (type === 'Normal') return 'green';
|
||||
if (type === 'Warning') return 'amber';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function apiKind(kind: string) {
|
||||
return kind.toLowerCase();
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function stripAnsi(value: string) {
|
||||
return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, '');
|
||||
}
|
||||
|
||||
function isLoggable(resource: RuntimeResource) {
|
||||
return ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'Job'].includes(resource.kind);
|
||||
}
|
||||
|
||||
function parseReadyCount(ready?: string) {
|
||||
const [current, desired] = (ready ?? '').split('/').map((value) => Number(value));
|
||||
return {
|
||||
current: Number.isFinite(current) ? current : 0,
|
||||
desired: Number.isFinite(desired) ? desired : 0
|
||||
};
|
||||
}
|
||||
|
||||
function hasReadyReplica(resource: RuntimeResource) {
|
||||
const ready = parseReadyCount(resource.ready);
|
||||
return ready.current > 0 && (ready.desired === 0 || ready.current <= ready.desired);
|
||||
}
|
||||
|
||||
function isActiveLogTarget(resource: RuntimeResource) {
|
||||
if (!isLoggable(resource)) return false;
|
||||
if (resource.kind === 'Job') return resource.status === 'Running';
|
||||
if (resource.kind === 'Pod') return ['Running', 'Pending', 'ContainerCreating'].includes(resource.status);
|
||||
return resource.status === 'Running' && hasReadyReplica(resource);
|
||||
}
|
||||
|
||||
function workloadStatusDot(resource: RuntimeResource) {
|
||||
if (resource.kind === 'Pod') return statusDot(resource.status);
|
||||
if (resource.kind === 'Job') return resource.status === 'Running' ? 'bg-emerald-400' : 'bg-muted-foreground';
|
||||
if (hasReadyReplica(resource) && resource.status === 'Running') return 'bg-emerald-400';
|
||||
if (resource.status === 'Pending') return 'bg-amber-400';
|
||||
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(resource.status)) return 'bg-rose-400';
|
||||
return 'bg-muted-foreground';
|
||||
}
|
||||
|
||||
function metricKey(namespace: string, pod: string) {
|
||||
return `${namespace}/${pod}`;
|
||||
}
|
||||
|
||||
function parseCpuMillicores(value?: string) {
|
||||
if (!value) return 0;
|
||||
if (value.endsWith('m')) return Number(value.replace('m', '')) || 0;
|
||||
return (Number(value) || 0) * 1000;
|
||||
}
|
||||
|
||||
function parseMemoryMi(value?: string) {
|
||||
if (!value) return 0;
|
||||
const numeric = Number(value.replace(/[^\d.]/g, '')) || 0;
|
||||
if (value.endsWith('Gi')) return numeric * 1024;
|
||||
if (value.endsWith('Ki')) return numeric / 1024;
|
||||
if (value.endsWith('Mi')) return numeric;
|
||||
return numeric / 1024 / 1024;
|
||||
}
|
||||
|
||||
function usagePercent(value: number, max: number) {
|
||||
return Math.max(4, Math.min(100, Math.round((value / max) * 100)));
|
||||
}
|
||||
|
||||
function UsageBars({ metric, compact = false }: { metric?: RuntimePodMetric; compact?: boolean }) {
|
||||
if (!metric) {
|
||||
return <span className="text-xs text-muted-foreground">사용량 -</span>;
|
||||
}
|
||||
|
||||
const cpuPercent = usagePercent(parseCpuMillicores(metric.cpu), 1000);
|
||||
const memoryPercent = usagePercent(parseMemoryMi(metric.memory), 512);
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-2', compact ? 'w-full grid-cols-2' : 'sm:grid-cols-2')}>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>CPU</span>
|
||||
<span>{metric.cpu}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary" style={{ width: `${cpuPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>메모리</span>
|
||||
<span>{metric.memory}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-emerald-400" style={{ width: `${memoryPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuntimeLogsPage() {
|
||||
const [apps, setApps] = useState<ApiApp[]>([]);
|
||||
const [appId, setAppId] = useState('');
|
||||
const [namespace, setNamespace] = useState('');
|
||||
const [data, setData] = useState<RuntimeResourceResponse | null>(null);
|
||||
const [podMetrics, setPodMetrics] = useState<RuntimePodMetric[]>([]);
|
||||
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
||||
const [selectedResource, setSelectedResource] = useState<RuntimeResource | null>(null);
|
||||
const [logs, setLogs] = useState('');
|
||||
const [tail, setTail] = useState(100);
|
||||
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const logContainerRef = useRef<HTMLPreElement | null>(null);
|
||||
const loadingVisible = useMinimumLoading(loading || logLoading);
|
||||
|
||||
const namespaceOptions = useMemo(
|
||||
() => [...new Set(apps.map((app) => app.namespace).filter(Boolean) as string[])],
|
||||
[apps]
|
||||
);
|
||||
const resources = data?.resources ?? [];
|
||||
const activeNamespace = data?.namespace || namespace;
|
||||
const metricByPod = useMemo(() => {
|
||||
const map = new Map<string, RuntimePodMetric>();
|
||||
podMetrics.forEach((metric) => map.set(metricKey(metric.namespace, metric.pod), metric));
|
||||
return map;
|
||||
}, [podMetrics]);
|
||||
const loggableResources = resources.filter(isActiveLogTarget);
|
||||
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
||||
const filteredEvents = events.filter((event) => {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) return true;
|
||||
return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'logs') return;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const container = logContainerRef.current;
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [activeTab, logs, logLoading, selectedResource?.name]);
|
||||
|
||||
const loadLogs = (resource: RuntimeResource, targetNamespace = namespace, nextTail = tail) => {
|
||||
setSelectedResource(resource);
|
||||
setLogLoading(true);
|
||||
setError('');
|
||||
loadRuntimeWorkloadLogs(targetNamespace, apiKind(resource.kind), resource.name, nextTail)
|
||||
.then((result) => setLogs(result.logs ? stripAnsi(result.logs) : '로그가 없습니다.'))
|
||||
.catch((logError: Error) => {
|
||||
setLogs('');
|
||||
setError(logError.message);
|
||||
})
|
||||
.finally(() => setLogLoading(false));
|
||||
};
|
||||
|
||||
const refresh = (nextNamespace = namespace) => {
|
||||
if (!nextNamespace.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
Promise.all([loadRuntimeResources(nextNamespace.trim()), loadRuntimeEvents(nextNamespace.trim()), loadRuntimePodMetrics(nextNamespace.trim()).catch(() => [])])
|
||||
.then(([nextData, nextEvents, nextMetrics]) => {
|
||||
setData(nextData);
|
||||
setEvents(nextEvents);
|
||||
setPodMetrics(nextMetrics);
|
||||
setSelectedResource(null);
|
||||
setLogs('');
|
||||
})
|
||||
.catch((refreshError: Error) => setError(refreshError.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadApps()
|
||||
.then((nextApps) => {
|
||||
const storedAppId = getStoredSelectedAppId();
|
||||
const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
|
||||
const selectedApp = nextApps.find((app) => app.id === nextAppId);
|
||||
const namespaces = [...new Set(nextApps.map((app) => app.namespace).filter(Boolean) as string[])];
|
||||
const firstNamespace = selectedApp?.namespace || namespaces[0] || 'default';
|
||||
setApps(nextApps);
|
||||
setAppId(nextAppId);
|
||||
setStoredSelectedAppId(nextAppId);
|
||||
setNamespace(firstNamespace);
|
||||
return Promise.all([loadRuntimeResources(firstNamespace), loadRuntimeEvents(firstNamespace), loadRuntimePodMetrics(firstNamespace).catch(() => [])] as const);
|
||||
})
|
||||
.then(([nextData, nextEvents, nextMetrics]) => {
|
||||
setData(nextData);
|
||||
setEvents(nextEvents);
|
||||
setPodMetrics(nextMetrics);
|
||||
setSelectedResource(null);
|
||||
setLogs('');
|
||||
})
|
||||
.catch((loadError: Error) => setError(loadError.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="로그/이벤트"
|
||||
description="네임스페이스 기준으로 워크로드 로그와 이벤트를 확인합니다."
|
||||
actions={(
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => refresh()} disabled={loading || !namespace.trim()}>
|
||||
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||
새로고침
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error ? <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">{error}</div> : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">조회 기준</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">앱 그룹과 네임스페이스를 선택해 로그와 이벤트를 조회합니다.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label className="flex items-center gap-3">
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">앱 그룹</span>
|
||||
<select
|
||||
className="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 sm:w-64"
|
||||
value={appId}
|
||||
onChange={(event) => {
|
||||
const nextAppId = event.target.value;
|
||||
const nextApp = apps.find((app) => app.id === nextAppId);
|
||||
const nextNamespace = nextApp?.namespace || namespace;
|
||||
setAppId(nextAppId);
|
||||
setStoredSelectedAppId(nextAppId);
|
||||
setNamespace(nextNamespace);
|
||||
refresh(nextNamespace);
|
||||
}}
|
||||
>
|
||||
{apps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">네임스페이스</span>
|
||||
<select
|
||||
className="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 sm:w-64"
|
||||
value={namespace}
|
||||
onChange={(event) => {
|
||||
setNamespace(event.target.value);
|
||||
refresh(event.target.value);
|
||||
}}
|
||||
>
|
||||
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
{!namespaceOptions.includes(namespace) && namespace ? <option value={namespace}>{namespace}</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">워크로드</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">실행 중인 로그 대상을 선택합니다.</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">총 {loggableResources.length}개</span>
|
||||
</div>
|
||||
<div className="max-h-[620px] space-y-2 overflow-y-auto p-4">
|
||||
{loggableResources.length === 0 ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
실행 중인 로그 대상이 없습니다.
|
||||
</div>
|
||||
) : loggableResources.map((resource) => {
|
||||
const selected = selectedResource?.kind === resource.kind && selectedResource?.name === resource.name;
|
||||
const metric = resource.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, resource.name)) : undefined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${resource.kind}-${resource.name}`}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-3 text-left transition-colors',
|
||||
selected ? 'border-border bg-primary/15 shadow-[inset_4px_0_0_var(--color-primary)]' : 'border-border bg-background/70 hover:bg-muted/20'
|
||||
)}
|
||||
onClick={() => loadLogs(resource)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('size-2 rounded-full', workloadStatusDot(resource))} />
|
||||
<span className="truncate text-sm font-medium">{resource.name}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{resource.kind}</span>
|
||||
<span>준비 <b className="text-foreground">{resource.ready}</b></span>
|
||||
<span>재시작 <b className="text-foreground">{resource.restarts ?? 0}</b></span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<UsageBars metric={metric} compact />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-border px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium', activeTab === 'logs' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
>
|
||||
<TerminalSquare className="size-4" />
|
||||
로그
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium', activeTab === 'events' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => setActiveTab('events')}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
이벤트
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'logs' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-3 text-xs text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={tail}
|
||||
onChange={(event) => {
|
||||
const nextTail = Number(event.target.value);
|
||||
setTail(nextTail);
|
||||
if (selectedResource) loadLogs(selectedResource, namespace, nextTail);
|
||||
}}
|
||||
>
|
||||
<option value={100}>최근 100줄</option>
|
||||
<option value={300}>최근 300줄</option>
|
||||
<option value={1000}>최근 1000줄</option>
|
||||
</select>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedResource || logLoading} onClick={() => selectedResource && loadLogs(selectedResource)}>
|
||||
<RotateCcw className={cn('size-4', logLoading && 'animate-spin')} />
|
||||
로그 새로고침
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full lg:w-80">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="이벤트 검색"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'logs' ? (
|
||||
<div className="min-h-[620px] bg-background">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
대상 <b className="text-foreground">{selectedResource ? `${selectedResource.kind}/${selectedResource.name}` : '-'}</b>
|
||||
</div>
|
||||
{selectedResource ? (
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-[minmax(0,1fr)_120px_120px] lg:min-w-[620px]">
|
||||
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||
<UsageBars metric={selectedMetric} />
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||
<p className="text-muted-foreground">Ready</p>
|
||||
<p className="mt-1 font-semibold text-foreground">{selectedResource.ready}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||
<p className="text-muted-foreground">재시작</p>
|
||||
<p className="mt-1 font-semibold text-foreground">{selectedResource.restarts ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{selectedResource ? (
|
||||
<pre ref={logContainerRef} className="max-h-[640px] overflow-auto whitespace-pre-wrap p-5 font-mono text-xs leading-6 text-muted-foreground">
|
||||
{logLoading ? '로그를 불러오는 중입니다...' : logs || '로그가 없습니다.'}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex min-h-[520px] items-center justify-center text-center text-sm text-muted-foreground">
|
||||
왼쪽에서 워크로드를 선택하면 로그가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[680px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[920px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">Type</th>
|
||||
<th className="px-5 py-3 font-medium">Reason</th>
|
||||
<th className="px-5 py-3 font-medium">Object</th>
|
||||
<th className="px-5 py-3 font-medium">Message</th>
|
||||
<th className="px-5 py-3 font-medium">Count</th>
|
||||
<th className="px-5 py-3 font-medium">Last</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredEvents.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-5 py-14 text-center text-muted-foreground">표시할 이벤트가 없습니다.</td></tr>
|
||||
) : null}
|
||||
{filteredEvents.map((event, index) => (
|
||||
<tr key={`${event.involvedObject}-${event.reason}-${index}`} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3"><Badge tone={eventTone(event.type)}>{event.type ?? '-'}</Badge></td>
|
||||
<td className="px-5 py-3 font-medium">{event.reason ?? '-'}</td>
|
||||
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{event.involvedObject ?? '-'}</td>
|
||||
<td className="max-w-[460px] px-5 py-3 text-muted-foreground"><span className="block truncate">{event.message ?? '-'}</span></td>
|
||||
<td className="px-5 py-3">{event.count ?? '-'}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{formatDate(event.lastTimestamp || event.firstTimestamp)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { filterAppsByCurrentAdmin } from '../../lib/admin-app-permissions';
|
||||
import type { ApiApp, RuntimeEnvProfile, RuntimeEvent, RuntimePodMetric, RuntimeResourceResponse, RuntimeWorkloadLogResponse } from '../../types/runtime';
|
||||
|
||||
export function loadApps() {
|
||||
return apiRequest<ApiApp[]>('/apps').then(filterAppsByCurrentAdmin);
|
||||
}
|
||||
|
||||
export function loadRuntimeProfiles(appId?: string) {
|
||||
const query = appId ? `?appId=${encodeURIComponent(appId)}` : '';
|
||||
return apiRequest<RuntimeEnvProfile[]>(`/apps/runtime-profiles${query}`);
|
||||
}
|
||||
|
||||
export function createRuntimeProfile(input: { appId: string; name: string; environment: string; namespace: string }) {
|
||||
return apiRequest<{ profile: RuntimeEnvProfile }>('/apps/runtime-profiles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function createRuntimeVariable(
|
||||
profileId: string,
|
||||
input: { componentId: string; key: string; value: string; isSecret: boolean }
|
||||
) {
|
||||
return apiRequest(`/apps/runtime-profiles/${profileId}/variables`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function createRuntimeIngressRule(
|
||||
profileId: string,
|
||||
input: { componentId: string; host: string; path?: string; servicePort: number; tlsEnabled?: boolean; tlsSecretName?: string }
|
||||
) {
|
||||
return apiRequest(`/apps/runtime-profiles/${profileId}/ingress-rules`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRuntimeIngressRule(profileId: string, ruleId: string) {
|
||||
return apiRequest(`/apps/runtime-profiles/${profileId}/ingress-rules/${ruleId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRuntimeVariable(profileId: string, variableId: string) {
|
||||
return apiRequest(`/apps/runtime-profiles/${profileId}/variables/${variableId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export function applyRuntimeProfile(profileId: string) {
|
||||
return apiRequest<{ profile: RuntimeEnvProfile; logs: string }>(`/apps/runtime-profiles/${profileId}/apply`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
}
|
||||
|
||||
export function loadRuntimeResources(namespace: string) {
|
||||
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
|
||||
}
|
||||
|
||||
export function loadRuntimePodMetrics(namespace?: string) {
|
||||
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
||||
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods${query}`);
|
||||
}
|
||||
|
||||
export function loadRuntimeEvents(namespace?: string) {
|
||||
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
||||
return apiRequest<RuntimeEvent[]>(`/runtime/events${query}`);
|
||||
}
|
||||
|
||||
export function loadRuntimeWorkloadLogs(namespace: string, kind: string, name: string, tail = 100) {
|
||||
return apiRequest<RuntimeWorkloadLogResponse>(
|
||||
`/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/logs?tail=${tail}`
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureRuntimeNamespace(namespace: string) {
|
||||
return apiRequest<{ namespace: string; created: boolean; logs: string }>('/apps/runtime-namespaces', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ namespace })
|
||||
});
|
||||
}
|
||||
|
||||
export function scaleRuntimeWorkload(namespace: string, kind: string, name: string, replicas: number) {
|
||||
return apiRequest(`/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/scale`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ replicas })
|
||||
});
|
||||
}
|
||||
|
||||
export function restartRuntimeWorkload(namespace: string, kind: string, name: string) {
|
||||
return apiRequest(`/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/restart`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user