improve app registration and component service ports
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -15,6 +15,7 @@ type ComponentPortForm = {
|
||||
name: string;
|
||||
port: string;
|
||||
targetPort: string;
|
||||
nodePort: string;
|
||||
protocol: string;
|
||||
};
|
||||
|
||||
@@ -29,6 +30,7 @@ const emptyComponentPort = (): ComponentPortForm => ({
|
||||
name: '',
|
||||
port: '',
|
||||
targetPort: '',
|
||||
nodePort: '',
|
||||
protocol: 'TCP'
|
||||
});
|
||||
|
||||
@@ -252,6 +254,7 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
||||
name: port.name ?? '',
|
||||
port: port.port ? String(port.port) : '',
|
||||
targetPort: port.targetPort ? String(port.targetPort) : '',
|
||||
nodePort: port.nodePort ? String(port.nodePort) : '',
|
||||
protocol: port.protocol ?? 'TCP'
|
||||
}));
|
||||
}
|
||||
@@ -261,6 +264,7 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
||||
name: 'http',
|
||||
port: String(component.port),
|
||||
targetPort: String(component.targetPort ?? component.port),
|
||||
nodePort: '',
|
||||
protocol: 'TCP'
|
||||
}];
|
||||
}
|
||||
@@ -271,7 +275,20 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
||||
function formatComponentPorts(component: ApiAppComponent) {
|
||||
const ports = componentPortForms(component).filter((port) => port.port);
|
||||
if (ports.length === 0) return '-';
|
||||
return ports.map((port) => `${port.name || 'port'} ${port.port}:${port.targetPort || port.port}`).join(', ');
|
||||
return ports.map((port) => {
|
||||
const nodePort = port.nodePort ? ` / node ${port.nodePort}` : '';
|
||||
return `${port.name || 'port'} ${port.port}:${port.targetPort || port.port}${nodePort}`;
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
function appKeyFromName(name: string) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export function AppManagementPage() {
|
||||
@@ -508,6 +525,13 @@ export function AppManagementPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const changeNewAppName = (name: string) => {
|
||||
const appKey = appKeyFromName(name);
|
||||
setNewAppName(name);
|
||||
setNewAppKey(appKey);
|
||||
setNewAppNamespace(appKey ? `stdo-${appKey}` : '');
|
||||
};
|
||||
|
||||
const createApp = () => {
|
||||
if (!newAppKey.trim() || !newAppName.trim()) return;
|
||||
|
||||
@@ -603,6 +627,7 @@ export function AppManagementPage() {
|
||||
name: item.name.trim() || undefined,
|
||||
port: item.port ? Number(item.port) : undefined,
|
||||
targetPort: item.targetPort ? Number(item.targetPort) : item.port ? Number(item.port) : undefined,
|
||||
nodePort: componentServiceType === 'NodePort' && item.nodePort ? Number(item.nodePort) : undefined,
|
||||
protocol: item.protocol || 'TCP'
|
||||
}))
|
||||
.filter((item) => item.port);
|
||||
@@ -981,7 +1006,7 @@ export function AppManagementPage() {
|
||||
title="앱 구성요소"
|
||||
description="앱을 구성하는 실행 요소와 관련 리소스를 관리합니다."
|
||||
action={
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={() => setIsComponentOpen(true)}>
|
||||
<Button className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={() => setIsComponentOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
추가
|
||||
</Button>
|
||||
@@ -1170,20 +1195,20 @@ export function AppManagementPage() {
|
||||
<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>
|
||||
<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">앱 Key</span>
|
||||
<input value={newAppKey} onChange={(event) => setNewAppKey(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" />
|
||||
<span className="text-xs font-medium text-muted-foreground">앱 이름 <b className="text-foreground">*</b></span>
|
||||
<input value={newAppName} onChange={(event) => changeNewAppName(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>
|
||||
<input value={newAppName} onChange={(event) => setNewAppName(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" />
|
||||
<span className="text-xs font-medium text-muted-foreground">앱 Key</span>
|
||||
<input value={newAppKey} readOnly className="mt-2 h-9 w-full rounded-md border border-border bg-muted/40 px-3 font-mono text-sm text-muted-foreground outline-none" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
||||
<input value={newAppNamespace} onChange={(event) => setNewAppNamespace(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" />
|
||||
<input value={newAppNamespace} readOnly className="mt-2 h-9 w-full rounded-md border border-border bg-muted/40 px-3 font-mono text-sm text-muted-foreground outline-none" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||
@@ -1215,11 +1240,11 @@ export function AppManagementPage() {
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">구성요소명</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">구성요소명 <b className="text-foreground">*</b></span>
|
||||
<input value={componentName} onChange={(event) => setComponentName(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>
|
||||
<span className="text-xs font-medium text-muted-foreground">유형 <b className="text-foreground">*</b></span>
|
||||
<select
|
||||
value={componentType}
|
||||
onChange={(event) => {
|
||||
@@ -1246,7 +1271,7 @@ export function AppManagementPage() {
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">이미지 소스</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">이미지 소스 <b className="text-foreground">*</b></span>
|
||||
<select
|
||||
value={componentImageSource}
|
||||
onChange={(event) => {
|
||||
@@ -1270,7 +1295,7 @@ export function AppManagementPage() {
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">워크로드</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">워크로드 <b className="text-foreground">*</b></span>
|
||||
<select
|
||||
value={componentWorkloadType}
|
||||
onChange={(event) => {
|
||||
@@ -1311,7 +1336,7 @@ export function AppManagementPage() {
|
||||
</>
|
||||
) : (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">이미지명</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">이미지명 <b className="text-foreground">*</b></span>
|
||||
<input
|
||||
value={componentExternalImage}
|
||||
onChange={(event) => setComponentExternalImage(event.target.value)}
|
||||
@@ -1343,7 +1368,15 @@ export function AppManagementPage() {
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{componentPorts.map((item, index) => (
|
||||
<div key={index} className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)_minmax(0,0.85fr)_minmax(0,0.7fr)_2.5rem]">
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'grid min-w-0 gap-2',
|
||||
componentServiceType === 'NodePort'
|
||||
? 'sm:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.65fr)_2.5rem]'
|
||||
: 'sm:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)_minmax(0,0.85fr)_minmax(0,0.7fr)_2.5rem]'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
value={item.name}
|
||||
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, name: event.target.value } : port))}
|
||||
@@ -1366,6 +1399,17 @@ export function AppManagementPage() {
|
||||
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||
placeholder="컨테이너 포트"
|
||||
/>
|
||||
{componentServiceType === 'NodePort' ? (
|
||||
<input
|
||||
type="number"
|
||||
min="30000"
|
||||
max="32767"
|
||||
value={item.nodePort}
|
||||
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, nodePort: event.target.value } : port))}
|
||||
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||
placeholder="노드 포트"
|
||||
/>
|
||||
) : null}
|
||||
<select
|
||||
value={item.protocol}
|
||||
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, protocol: event.target.value } : port))}
|
||||
|
||||
@@ -456,19 +456,27 @@ export function ReleaseManagementPage() {
|
||||
|
||||
<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);
|
||||
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
|
||||
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
|
||||
setVersion(newestBuildVersion(appBuilds));
|
||||
}}>
|
||||
{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" />
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">앱 <b className="text-foreground">*</b></span>
|
||||
<select className="mt-2 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);
|
||||
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
|
||||
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
|
||||
setVersion(newestBuildVersion(appBuilds));
|
||||
}}>
|
||||
{candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">버전 <b className="text-foreground">*</b></span>
|
||||
<input className="mt-2 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" />
|
||||
</label>
|
||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다.
|
||||
</div>
|
||||
<div className="max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">빌드 선택 <b className="text-foreground">*</b></p>
|
||||
<div className="mt-2 max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
|
||||
{modalBuilds.length === 0 ? (
|
||||
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
|
||||
등록 가능한 새 빌드 결과가 없습니다.
|
||||
@@ -492,6 +500,7 @@ export function ReleaseManagementPage() {
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -381,7 +381,7 @@ export function RepositoryManagementPage() {
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">저장소명</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">저장소명 <b className="text-foreground">*</b></span>
|
||||
<div className="mt-2 flex h-9 overflow-hidden rounded-md border border-border bg-background focus-within:border-primary">
|
||||
<span className="flex h-full shrink-0 items-center border-r border-border bg-muted px-3 text-xs font-semibold text-muted-foreground">
|
||||
{giteaAppOrg}/
|
||||
|
||||
@@ -76,7 +76,7 @@ export function createComponent(
|
||||
buildTarget?: string;
|
||||
port?: number;
|
||||
targetPort?: number;
|
||||
ports?: Array<{ name?: string; port?: number; targetPort?: number; protocol?: string }>;
|
||||
ports?: Array<{ name?: string; port?: number; targetPort?: number; nodePort?: number; protocol?: string }>;
|
||||
}
|
||||
) {
|
||||
return apiRequest<{ component: ApiAppComponent }>(`/apps/${appId}/components`, {
|
||||
|
||||
@@ -303,7 +303,7 @@ export function LauncherAppsPage() {
|
||||
<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>
|
||||
<span className="text-xs font-medium text-muted-foreground">표시명 <b className="text-foreground">*</b></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">
|
||||
|
||||
@@ -293,7 +293,7 @@ export function LauncherVersionsPage() {
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">성공한 빌드 결과의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">빌드 결과</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">빌드 결과 <b className="text-foreground">*</b></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}
|
||||
@@ -309,7 +309,7 @@ export function LauncherVersionsPage() {
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">버전</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">버전 <b className="text-foreground">*</b></span>
|
||||
<div className="mt-2 flex h-9 items-center rounded-md border border-border bg-muted/20 px-3 text-sm font-medium text-foreground">
|
||||
{displayValue(releaseForm.version)}
|
||||
</div>
|
||||
@@ -324,7 +324,7 @@ export function LauncherVersionsPage() {
|
||||
<p className="mt-2 break-all font-mono text-xs">{displayValue(currentArtifact)}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-background px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
||||
<p className="text-xs text-muted-foreground">다운로드 URL <b className="text-foreground">*</b></p>
|
||||
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{displayValue(releaseForm.downloadUrl)}</p>
|
||||
</div>
|
||||
<label className="block">
|
||||
|
||||
@@ -171,7 +171,7 @@ export function AdminManagementPage() {
|
||||
};
|
||||
|
||||
const submitAdmin = () => {
|
||||
if (!adminForm.email.trim() || !adminForm.name.trim()) return;
|
||||
if (!adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8) return;
|
||||
setSaveState('saving');
|
||||
createAdmin(adminForm)
|
||||
.then(() => {
|
||||
@@ -396,26 +396,26 @@ export function AdminManagementPage() {
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title="관리자 등록"
|
||||
title="관리자 추가"
|
||||
onClose={() => setCreateOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button>
|
||||
<Button onClick={submitAdmin} disabled={saveState === 'saving'}>{saveState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||
<Button onClick={submitAdmin} disabled={saveState === 'saving' || !adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8}>{saveState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">이메일</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">이메일 <b className="text-foreground">*</b></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>
|
||||
<span className="text-xs font-medium text-muted-foreground">이름 <b className="text-foreground">*</b></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>
|
||||
<span className="text-xs font-medium text-muted-foreground">권한 <b className="text-foreground">*</b></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}>
|
||||
@@ -425,7 +425,7 @@ export function AdminManagementPage() {
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호 <b className="text-foreground">*</b></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>
|
||||
|
||||
@@ -342,7 +342,7 @@ export function LicensesPage() {
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
||||
<Plus className="size-4" />
|
||||
라이센스 부여
|
||||
등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -433,7 +433,7 @@ export function LicensesPage() {
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
{editingRow ? '라이센스 수정' : '라이센스 부여'}
|
||||
{editingRow ? '라이센스 수정' : '라이센스 추가'}
|
||||
</span>
|
||||
}
|
||||
onClose={closeAssignModal}
|
||||
@@ -449,7 +449,7 @@ export function LicensesPage() {
|
||||
<div className="space-y-4">
|
||||
{editingRow ? (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">사용자</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">사용자 <b className="text-foreground">*</b></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>
|
||||
@@ -505,7 +505,7 @@ export function LicensesPage() {
|
||||
</div>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">라이센스</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">라이센스 <b className="text-foreground">*</b></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}
|
||||
@@ -523,7 +523,7 @@ export function LicensesPage() {
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">시작일</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">시작일 <b className="text-foreground">*</b></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"
|
||||
|
||||
@@ -340,7 +340,7 @@ export function NoticesPage() {
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
공지사항
|
||||
{editing ? '공지사항 수정' : '공지사항 추가'}
|
||||
</span>
|
||||
}
|
||||
onClose={closeModal}
|
||||
@@ -357,7 +357,7 @@ export function NoticesPage() {
|
||||
<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>
|
||||
<span className="text-xs font-medium text-muted-foreground">대상 앱 <b className="text-foreground">*</b></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) => (
|
||||
@@ -374,11 +374,11 @@ export function NoticesPage() {
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">제목</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">제목 <b className="text-foreground">*</b></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>
|
||||
<span className="text-xs font-medium text-muted-foreground">본문 <b className="text-foreground">*</b></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">
|
||||
|
||||
@@ -220,7 +220,7 @@ export function OrganizationsPage() {
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
||||
<Plus className="size-4" />
|
||||
조직 등록
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -273,7 +273,7 @@ export function OrganizationsPage() {
|
||||
})() : null}
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editing ? '조직 수정' : '조직 등록'}
|
||||
title={editing ? '조직 수정' : '조직 추가'}
|
||||
onClose={closeModal}
|
||||
footer={
|
||||
<>
|
||||
@@ -284,7 +284,7 @@ export function OrganizationsPage() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">조직명</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">조직명 <b className="text-foreground">*</b></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={form.name}
|
||||
@@ -293,7 +293,7 @@ export function OrganizationsPage() {
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">유형</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">유형 <b className="text-foreground">*</b></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.type}
|
||||
@@ -310,7 +310,6 @@ export function OrganizationsPage() {
|
||||
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={form.domain}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
|
||||
placeholder="yu.ac.kr"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { MoreHorizontal, Plus } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { Button, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
|
||||
import {
|
||||
createManagedUser,
|
||||
loadLicensePlans,
|
||||
loadOrganizations,
|
||||
loadUserSessions,
|
||||
lockUser,
|
||||
terminateUserSession,
|
||||
unlockUser,
|
||||
type LicensePlan,
|
||||
type Organization,
|
||||
type UserSession
|
||||
} from './operations-api';
|
||||
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
|
||||
const emptyCreateForm = {
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
organizationId: '',
|
||||
department: '',
|
||||
position: '',
|
||||
licenseCode: 'EDU' as 'FREE' | 'EDU' | 'PRO'
|
||||
};
|
||||
|
||||
export function UserSessionsPage() {
|
||||
const { toast } = useToast();
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
@@ -31,6 +59,18 @@ export function UserSessionsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
Promise.all([
|
||||
loadOrganizations().catch(() => []),
|
||||
loadLicensePlans().catch(() => [])
|
||||
]).then(([nextOrganizations, nextLicensePlans]) => {
|
||||
setOrganizations(nextOrganizations);
|
||||
setLicensePlans(nextLicensePlans);
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
organizationId: current.organizationId || '',
|
||||
licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
||||
}));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,9 +145,52 @@ export function UserSessionsPage() {
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setCreateForm({
|
||||
...emptyCreateForm,
|
||||
organizationId: '',
|
||||
licenseCode: (licensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const submitCreateUser = () => {
|
||||
if (!createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8) return;
|
||||
|
||||
setCreateSaving(true);
|
||||
createManagedUser({
|
||||
email: createForm.email.trim(),
|
||||
name: createForm.name.trim(),
|
||||
password: createForm.password,
|
||||
organizationId: createForm.organizationId || null,
|
||||
department: createForm.department.trim() || null,
|
||||
position: createForm.position.trim() || null,
|
||||
licenseCode: createForm.licenseCode
|
||||
})
|
||||
.then(() => {
|
||||
toast({ type: 'success', title: '사용자 등록', message: '사용자가 등록되었습니다.' });
|
||||
setCreateOpen(false);
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '사용자 등록 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setCreateSaving(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="운영" title="사용자/세션" description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다." />
|
||||
<PageTitle
|
||||
eyebrow="운영"
|
||||
title="사용자/세션"
|
||||
description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
||||
<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">
|
||||
사용자/세션 데이터를 불러오지 못했습니다.
|
||||
@@ -202,6 +285,96 @@ export function UserSessionsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title="사용자 추가"
|
||||
onClose={() => {
|
||||
if (!createSaving) setCreateOpen(false);
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={createSaving}>취소</Button>
|
||||
<Button
|
||||
onClick={submitCreateUser}
|
||||
disabled={createSaving || !createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8}
|
||||
>
|
||||
{createSaving ? '등록 중' : '등록'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">이메일 <b className="text-foreground">*</b></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={createForm.email}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, email: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">이름 <b className="text-foreground">*</b></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={createForm.name}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호 <b className="text-foreground">*</b></span>
|
||||
<input
|
||||
type="password"
|
||||
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={createForm.password}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, password: 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={createForm.organizationId}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, organizationId: event.target.value }))}
|
||||
>
|
||||
<option value="">없음</option>
|
||||
{organizations.map((organization) => (
|
||||
<option key={organization.id} value={organization.id}>{organization.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
|
||||
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={createForm.department}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, department: 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={createForm.position}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, position: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">라이센스 <b className="text-foreground">*</b></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={createForm.licenseCode}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, licenseCode: event.target.value as 'FREE' | 'EDU' | 'PRO' }))}
|
||||
>
|
||||
{licensePlans.map((plan) => (
|
||||
<option key={plan.code} value={plan.code}>{plan.name}</option>
|
||||
))}
|
||||
{licensePlans.length === 0 ? <option value="EDU">EDU</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,10 +7,10 @@ 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 (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', '유효토큰', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
||||
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
||||
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
||||
if (['없음'].includes(status)) return 'neutral';
|
||||
if (['없음', '세션 없음'].includes(status)) return 'neutral';
|
||||
if (['런처'].includes(status)) return 'violet';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function loadUserSessions() {
|
||||
return (response.sessions ?? []).map((session) => ({
|
||||
...session,
|
||||
session: session.sessionId ? session.session : '-',
|
||||
state: session.state === '세션 없음' ? '없음' : session.state
|
||||
state: session.state
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -188,6 +188,21 @@ export function terminateUserSession(sessionId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function createManagedUser(payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
organizationId?: string | null;
|
||||
department?: string | null;
|
||||
position?: string | null;
|
||||
licenseCode?: 'FREE' | 'EDU' | 'PRO';
|
||||
}) {
|
||||
return apiRequest<{ user: { id: string; email: string; name?: string | null } }>('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadOrganizations() {
|
||||
const response = await apiRequest<{ organizations?: Organization[] }>('/organizations');
|
||||
return response.organizations ?? [];
|
||||
|
||||
@@ -705,18 +705,18 @@ export function RuntimeConfigPage() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">프로필명</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">프로필명 <b className="text-foreground">*</b></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>
|
||||
<span className="text-xs font-medium text-muted-foreground">환경 <b className="text-foreground">*</b></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="prod">운영</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스 <b className="text-foreground">*</b></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"
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ApiAppComponent = {
|
||||
name?: string;
|
||||
port?: number;
|
||||
targetPort?: number;
|
||||
nodePort?: number;
|
||||
protocol?: string;
|
||||
}> | null;
|
||||
buildJobs?: Array<{
|
||||
|
||||
Reference in New Issue
Block a user