merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-09 14:26:47 +09:00
15 changed files with 301 additions and 60 deletions
+58 -14
View File
@@ -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,7 +456,9 @@ 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">
<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); selectApp(event.target.value);
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value); const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds)); setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
@@ -464,11 +466,17 @@ export function ReleaseManagementPage() {
}}> }}>
{candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)} {candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)}
</select> </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>
<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">
등록 가능한 새 빌드 결과가 없습니다. 등록 가능한 새 빌드 결과가 없습니다.
@@ -494,6 +502,7 @@ export function ReleaseManagementPage() {
})} })}
</div> </div>
</div> </div>
</div>
</Modal> </Modal>
<Modal <Modal
open={Boolean(selectedReleaseLog)} open={Boolean(selectedReleaseLog)}
@@ -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}/
+1 -1
View File
@@ -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`, {
+1 -1
View File
@@ -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">
+3 -3
View File
@@ -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">
+7 -7
View File
@@ -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>
+5 -5
View File
@@ -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"
+4 -4
View File
@@ -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">
+4 -5
View File
@@ -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>
+176 -3
View File
@@ -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} />
</> </>
); );
+2 -2
View File
@@ -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';
} }
+16 -1
View File
@@ -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 ?? [];
+3 -3
View File
@@ -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"
+1
View File
@@ -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<{