init studio admin frontend

This commit is contained in:
2026-07-01 17:34:15 +09:00
commit c50bd2b941
64 changed files with 22452 additions and 0 deletions
+14
View File
@@ -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>
</>
);
}
+828
View File
@@ -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} />
</>
);
}
+157
View File
@@ -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)
});
}
+32
View File
@@ -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>
</>
);
}
+352
View File
@@ -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} />
</>
);
}
+275
View File
@@ -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} />
</>
);
}
+188
View File
@@ -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} />
</>
);
}
+234
View File
@@ -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} />
</>
);
}
+252
View File
@@ -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>
</>
);
}
+505
View File
@@ -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} />
</>
);
}
+423
View File
@@ -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} />
</>
);
}
+150
View File
@@ -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} />
</>
);
}
+52
View File
@@ -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;
}
+271
View File
@@ -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'
});
}
+659
View File
@@ -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>
</>
);
}
+455
View File
@@ -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} />
</>
);
}
+97
View File
@@ -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'
});
}