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;
|
name: string;
|
||||||
port: string;
|
port: string;
|
||||||
targetPort: string;
|
targetPort: string;
|
||||||
|
nodePort: string;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ const emptyComponentPort = (): ComponentPortForm => ({
|
|||||||
name: '',
|
name: '',
|
||||||
port: '',
|
port: '',
|
||||||
targetPort: '',
|
targetPort: '',
|
||||||
|
nodePort: '',
|
||||||
protocol: 'TCP'
|
protocol: 'TCP'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,6 +254,7 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
|||||||
name: port.name ?? '',
|
name: port.name ?? '',
|
||||||
port: port.port ? String(port.port) : '',
|
port: port.port ? String(port.port) : '',
|
||||||
targetPort: port.targetPort ? String(port.targetPort) : '',
|
targetPort: port.targetPort ? String(port.targetPort) : '',
|
||||||
|
nodePort: port.nodePort ? String(port.nodePort) : '',
|
||||||
protocol: port.protocol ?? 'TCP'
|
protocol: port.protocol ?? 'TCP'
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -261,6 +264,7 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
|||||||
name: 'http',
|
name: 'http',
|
||||||
port: String(component.port),
|
port: String(component.port),
|
||||||
targetPort: String(component.targetPort ?? component.port),
|
targetPort: String(component.targetPort ?? component.port),
|
||||||
|
nodePort: '',
|
||||||
protocol: 'TCP'
|
protocol: 'TCP'
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -271,7 +275,20 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
|||||||
function formatComponentPorts(component: ApiAppComponent) {
|
function formatComponentPorts(component: ApiAppComponent) {
|
||||||
const ports = componentPortForms(component).filter((port) => port.port);
|
const ports = componentPortForms(component).filter((port) => port.port);
|
||||||
if (ports.length === 0) return '-';
|
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() {
|
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 = () => {
|
const createApp = () => {
|
||||||
if (!newAppKey.trim() || !newAppName.trim()) return;
|
if (!newAppKey.trim() || !newAppName.trim()) return;
|
||||||
|
|
||||||
@@ -603,6 +627,7 @@ export function AppManagementPage() {
|
|||||||
name: item.name.trim() || undefined,
|
name: item.name.trim() || undefined,
|
||||||
port: item.port ? Number(item.port) : undefined,
|
port: item.port ? Number(item.port) : undefined,
|
||||||
targetPort: item.targetPort ? Number(item.targetPort) : 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'
|
protocol: item.protocol || 'TCP'
|
||||||
}))
|
}))
|
||||||
.filter((item) => item.port);
|
.filter((item) => item.port);
|
||||||
@@ -981,7 +1006,7 @@ export function AppManagementPage() {
|
|||||||
title="앱 구성요소"
|
title="앱 구성요소"
|
||||||
description="앱을 구성하는 실행 요소와 관련 리소스를 관리합니다."
|
description="앱을 구성하는 실행 요소와 관련 리소스를 관리합니다."
|
||||||
action={
|
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" />
|
<Plus className="size-4" />
|
||||||
추가
|
추가
|
||||||
</Button>
|
</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="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="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
||||||
<div className="border-b border-border px-5 py-3">
|
<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>
|
||||||
<div className="space-y-4 px-5 py-5">
|
<div className="space-y-4 px-5 py-5">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">앱 Key</span>
|
<span className="text-xs font-medium text-muted-foreground">앱 이름 <b className="text-foreground">*</b></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" />
|
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">앱 이름</span>
|
<span className="text-xs font-medium text-muted-foreground">앱 Key</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" />
|
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
||||||
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||||
@@ -1215,11 +1240,11 @@ export function AppManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">구성요소명</span>
|
<span className="text-xs font-medium text-muted-foreground">구성요소명 <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" />
|
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">유형</span>
|
<span className="text-xs font-medium text-muted-foreground">유형 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<select
|
||||||
value={componentType}
|
value={componentType}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -1246,7 +1271,7 @@ export function AppManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">이미지 소스</span>
|
<span className="text-xs font-medium text-muted-foreground">이미지 소스 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<select
|
||||||
value={componentImageSource}
|
value={componentImageSource}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -1270,7 +1295,7 @@ export function AppManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">워크로드</span>
|
<span className="text-xs font-medium text-muted-foreground">워크로드 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<select
|
||||||
value={componentWorkloadType}
|
value={componentWorkloadType}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -1311,7 +1336,7 @@ export function AppManagementPage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">이미지명</span>
|
<span className="text-xs font-medium text-muted-foreground">이미지명 <b className="text-foreground">*</b></span>
|
||||||
<input
|
<input
|
||||||
value={componentExternalImage}
|
value={componentExternalImage}
|
||||||
onChange={(event) => setComponentExternalImage(event.target.value)}
|
onChange={(event) => setComponentExternalImage(event.target.value)}
|
||||||
@@ -1343,7 +1368,15 @@ export function AppManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{componentPorts.map((item, index) => (
|
{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
|
<input
|
||||||
value={item.name}
|
value={item.name}
|
||||||
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, name: event.target.value } : port))}
|
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"
|
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||||
placeholder="컨테이너 포트"
|
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
|
<select
|
||||||
value={item.protocol}
|
value={item.protocol}
|
||||||
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, protocol: event.target.value } : port))}
|
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></>}>
|
<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">
|
<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) => {
|
<label className="block">
|
||||||
selectApp(event.target.value);
|
<span className="text-xs font-medium text-muted-foreground">앱 <b className="text-foreground">*</b></span>
|
||||||
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
|
<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) => {
|
||||||
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
|
selectApp(event.target.value);
|
||||||
setVersion(newestBuildVersion(appBuilds));
|
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
|
||||||
}}>
|
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
|
||||||
{candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)}
|
setVersion(newestBuildVersion(appBuilds));
|
||||||
</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" />
|
{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">
|
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||||
선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다.
|
선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다.
|
||||||
</div>
|
</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 ? (
|
{modalBuilds.length === 0 ? (
|
||||||
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
|
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
|
||||||
등록 가능한 새 빌드 결과가 없습니다.
|
등록 가능한 새 빌드 결과가 없습니다.
|
||||||
@@ -492,6 +500,7 @@ export function ReleaseManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ export function RepositoryManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-5 py-5">
|
<div className="space-y-4 px-5 py-5">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">저장소명</span>
|
<span className="text-xs font-medium text-muted-foreground">저장소명 <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">
|
<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">
|
<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}/
|
{giteaAppOrg}/
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function createComponent(
|
|||||||
buildTarget?: string;
|
buildTarget?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
targetPort?: 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`, {
|
return apiRequest<{ component: ApiAppComponent }>(`/apps/${appId}/components`, {
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export function LauncherAppsPage() {
|
|||||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); saveSettings(); }}>
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); saveSettings(); }}>
|
||||||
<div className="grid gap-4 md:grid-cols-[1fr_120px_120px]">
|
<div className="grid gap-4 md:grid-cols-[1fr_120px_120px]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">표시명</span>
|
<span className="text-xs font-medium text-muted-foreground">표시명 <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)} />
|
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ export function LauncherVersionsPage() {
|
|||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">성공한 빌드 결과의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">성공한 빌드 결과의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">빌드 결과</span>
|
<span className="text-xs font-medium text-muted-foreground">빌드 결과 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<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"
|
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}
|
value={releaseForm.buildJobId}
|
||||||
@@ -309,7 +309,7 @@ export function LauncherVersionsPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<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">
|
<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)}
|
{displayValue(releaseForm.version)}
|
||||||
</div>
|
</div>
|
||||||
@@ -324,7 +324,7 @@ export function LauncherVersionsPage() {
|
|||||||
<p className="mt-2 break-all font-mono text-xs">{displayValue(currentArtifact)}</p>
|
<p className="mt-2 break-all font-mono text-xs">{displayValue(currentArtifact)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border bg-background px-4 py-3">
|
<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>
|
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{displayValue(releaseForm.downloadUrl)}</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function AdminManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submitAdmin = () => {
|
const submitAdmin = () => {
|
||||||
if (!adminForm.email.trim() || !adminForm.name.trim()) return;
|
if (!adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8) return;
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
createAdmin(adminForm)
|
createAdmin(adminForm)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -396,26 +396,26 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
title="관리자 등록"
|
title="관리자 추가"
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button>
|
<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">
|
<div className="grid gap-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">이메일</span>
|
<span className="text-xs font-medium text-muted-foreground">이메일 <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 }))} />
|
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">이름</span>
|
<span className="text-xs font-medium text-muted-foreground">이름 <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 }))} />
|
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">권한</span>
|
<span className="text-xs font-medium text-muted-foreground">권한 <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 }))}>
|
<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) => (
|
{roleOptions.map((role) => (
|
||||||
<option key={role.code} value={role.code}>
|
<option key={role.code} value={role.code}>
|
||||||
@@ -425,7 +425,7 @@ export function AdminManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호</span>
|
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호 <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 }))} />
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export function LicensesPage() {
|
|||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
라이센스 부여
|
등록
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -433,7 +433,7 @@ export function LicensesPage() {
|
|||||||
title={
|
title={
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Pencil className="size-4 text-primary" />
|
<Pencil className="size-4 text-primary" />
|
||||||
{editingRow ? '라이센스 수정' : '라이센스 부여'}
|
{editingRow ? '라이센스 수정' : '라이센스 추가'}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
onClose={closeAssignModal}
|
onClose={closeAssignModal}
|
||||||
@@ -449,7 +449,7 @@ export function LicensesPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{editingRow ? (
|
{editingRow ? (
|
||||||
<div>
|
<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">
|
<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="text-foreground">{editingRow.user}</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
|
||||||
@@ -505,7 +505,7 @@ export function LicensesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">라이센스</span>
|
<span className="text-xs font-medium text-muted-foreground">라이센스 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<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"
|
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}
|
value={assignForm.licenseCode}
|
||||||
@@ -523,7 +523,7 @@ export function LicensesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">시작일</span>
|
<span className="text-xs font-medium text-muted-foreground">시작일 <b className="text-foreground">*</b></span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
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"
|
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={
|
title={
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Pencil className="size-4 text-primary" />
|
<Pencil className="size-4 text-primary" />
|
||||||
공지사항
|
{editing ? '공지사항 수정' : '공지사항 추가'}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
@@ -357,7 +357,7 @@ export function NoticesPage() {
|
|||||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||||
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">대상 앱</span>
|
<span className="text-xs font-medium text-muted-foreground">대상 앱 <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 }))}>
|
<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>
|
<option value="Launcher">Launcher</option>
|
||||||
{apps.map((app) => (
|
{apps.map((app) => (
|
||||||
@@ -374,11 +374,11 @@ export function NoticesPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">제목</span>
|
<span className="text-xs font-medium text-muted-foreground">제목 <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 }))} />
|
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">본문</span>
|
<span className="text-xs font-medium text-muted-foreground">본문 <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 }))} />
|
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export function OrganizationsPage() {
|
|||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
조직 등록
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -273,7 +273,7 @@ export function OrganizationsPage() {
|
|||||||
})() : null}
|
})() : null}
|
||||||
<Modal
|
<Modal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
title={editing ? '조직 수정' : '조직 등록'}
|
title={editing ? '조직 수정' : '조직 추가'}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -284,7 +284,7 @@ export function OrganizationsPage() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">조직명</span>
|
<span className="text-xs font-medium text-muted-foreground">조직명 <b className="text-foreground">*</b></span>
|
||||||
<input
|
<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"
|
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}
|
value={form.name}
|
||||||
@@ -293,7 +293,7 @@ export function OrganizationsPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">유형</span>
|
<span className="text-xs font-medium text-muted-foreground">유형 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<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"
|
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}
|
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"
|
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}
|
value={form.domain}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
|
||||||
placeholder="yu.ac.kr"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,44 @@
|
|||||||
import { MoreHorizontal } from 'lucide-react';
|
import { MoreHorizontal, Plus } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||||
import { Button, Modal } from '../../components/ui';
|
import { Button, Modal } from '../../components/ui';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { cn } from '../../lib/utils';
|
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';
|
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() {
|
export function UserSessionsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
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 [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
@@ -31,6 +59,18 @@ export function UserSessionsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -105,9 +145,52 @@ export function UserSessionsPage() {
|
|||||||
[sessions]
|
[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 (
|
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 ? (
|
{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 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</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} />
|
<LoadingModal open={loadingVisible} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ export type BadgeTone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet
|
|||||||
|
|
||||||
export function statusTone(status?: string | null): BadgeTone {
|
export function statusTone(status?: string | null): BadgeTone {
|
||||||
if (!status) return 'neutral';
|
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 (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
||||||
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
||||||
if (['없음'].includes(status)) return 'neutral';
|
if (['없음', '세션 없음'].includes(status)) return 'neutral';
|
||||||
if (['런처'].includes(status)) return 'violet';
|
if (['런처'].includes(status)) return 'violet';
|
||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export async function loadUserSessions() {
|
|||||||
return (response.sessions ?? []).map((session) => ({
|
return (response.sessions ?? []).map((session) => ({
|
||||||
...session,
|
...session,
|
||||||
session: session.sessionId ? 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() {
|
export async function loadOrganizations() {
|
||||||
const response = await apiRequest<{ organizations?: Organization[] }>('/organizations');
|
const response = await apiRequest<{ organizations?: Organization[] }>('/organizations');
|
||||||
return response.organizations ?? [];
|
return response.organizations ?? [];
|
||||||
|
|||||||
@@ -705,18 +705,18 @@ export function RuntimeConfigPage() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">프로필명</span>
|
<span className="text-xs font-medium text-muted-foreground">프로필명 <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개발" />
|
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">환경</span>
|
<span className="text-xs font-medium text-muted-foreground">환경 <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)}>
|
<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="dev">개발</option>
|
||||||
<option value="prod">운영</option>
|
<option value="prod">운영</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
<span className="text-xs font-medium text-muted-foreground">네임스페이스 <b className="text-foreground">*</b></span>
|
||||||
<input
|
<input
|
||||||
list="runtime-namespace-options"
|
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"
|
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;
|
name?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
targetPort?: number;
|
targetPort?: number;
|
||||||
|
nodePort?: number;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
}> | null;
|
}> | null;
|
||||||
buildJobs?: Array<{
|
buildJobs?: Array<{
|
||||||
|
|||||||
Reference in New Issue
Block a user