Files
studio-admin-fe/src/pages/runtime/RuntimeConfigPage.tsx
T
chyoo 3cab64a256
ci/woodpecker/push/woodpecker Pipeline was successful
add runtime log views and standardize status dots
2026-07-09 10:16:25 +09:00

806 lines
39 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Plus, RotateCcw, Square, Trash2 } from 'lucide-react';
import { Badge, Button, Card, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { formatDateTime } from '../../lib/date-format';
import { getStoredSelectedAppId, getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedAppId, setStoredSelectedComponentId } from '../../lib/selected-app';
import { statusDotToneClass } from '../../lib/status-dot';
import type { ApiApp, ApiAppComponent, RuntimeEnvProfile, RuntimeEnvVariable, RuntimeIngressRule, RuntimeResource } from '../../types/runtime';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import {
applyRuntimeProfile,
createRuntimeIngressRule,
createRuntimeProfile,
createRuntimeVariable,
deleteRuntimeIngressRule,
deleteRuntimeVariable,
loadApps,
loadRuntimeProfiles,
loadRuntimeResources,
restartRuntimeWorkload,
scaleRuntimeWorkload
} from './runtime-api';
function environmentLabel(value?: string) {
return {
dev: '개발',
stage: '검수',
prod: '운영'
}[value ?? ''] ?? value ?? '-';
}
function formatDate(value?: string | null) {
return formatDateTime(value);
}
function getWorkloadKind(kind: string) {
if (kind === 'Deployment') return 'deployment';
if (kind === 'StatefulSet') return 'statefulset';
if (kind === 'DaemonSet') return 'daemonset';
return '';
}
function isStopped(resource: RuntimeResource) {
return resource.ready.endsWith('/0');
}
function runtimeStatusLabel(resource: RuntimeResource, stopped: boolean) {
if (stopped) return '중지됨';
if (resource.status === 'Running') return '실행 중';
if (resource.status === 'Pending' || resource.status === 'ContainerCreating') return '준비 중';
if (resource.status === 'Error' || resource.status === 'CrashLoopBackOff' || resource.status === 'ImagePullBackOff' || resource.status === 'Failed') return '오류';
return resource.status || '-';
}
function runtimeStatusTone(resource: RuntimeResource, stopped: boolean): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
if (stopped) return 'neutral';
if (resource.status === 'Running') return 'green';
if (resource.status === 'Pending' || resource.status === 'ContainerCreating') return 'amber';
if (resource.status === 'Error' || resource.status === 'CrashLoopBackOff' || resource.status === 'ImagePullBackOff' || resource.status === 'Failed') return 'red';
return 'blue';
}
function preferredComponentId(appId: string, components: ApiAppComponent[]) {
const storedComponentId = getStoredSelectedComponentId(appId);
return components.some((component) => component.id === storedComponentId)
? storedComponentId
: components[0]?.id ?? '';
}
export function RuntimeConfigPage() {
const { toast } = useToast();
const [apps, setApps] = useState<ApiApp[]>([]);
const [appId, setAppId] = useState('');
const [profiles, setProfiles] = useState<RuntimeEnvProfile[]>([]);
const [profileId, setProfileId] = useState('');
const [resources, setResources] = useState<RuntimeResource[]>([]);
const [resourceCount, setResourceCount] = useState(0);
const [loading, setLoading] = useState(true);
const [actionKey, setActionKey] = useState('');
const [error, setError] = useState('');
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [deleteVariableTarget, setDeleteVariableTarget] = useState<RuntimeEnvVariable | null>(null);
const [deleteIngressTarget, setDeleteIngressTarget] = useState<RuntimeIngressRule | null>(null);
const loadingVisible = useMinimumLoading(loading);
const [profileName, setProfileName] = useState('');
const [profileEnvironment, setProfileEnvironment] = useState('dev');
const [profileNamespace, setProfileNamespace] = useState('');
const [variableComponentId, setVariableComponentId] = useState('');
const [variableKey, setVariableKey] = useState('');
const [variableValue, setVariableValue] = useState('');
const [variableIsSecret, setVariableIsSecret] = useState(false);
const [ingressComponentId, setIngressComponentId] = useState('');
const [ingressHost, setIngressHost] = useState('');
const [ingressPath, setIngressPath] = useState('/');
const [ingressPort, setIngressPort] = useState('');
const [ingressStripPrefix, setIngressStripPrefix] = useState(false);
const selectedApp = apps.find((app) => app.id === appId);
const selectedProfile = profiles.find((profile) => profile.id === profileId);
const components = selectedApp?.components ?? [];
const variables = selectedProfile?.variables ?? [];
const ingressRules = selectedProfile?.ingressRules ?? [];
const namespace = selectedProfile?.namespace || selectedApp?.namespace || profileNamespace || '';
const workloads = resources.filter((resource) => ['Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind));
const envCount = variables.filter((variable) => !variable.isSecret).length;
const secretCount = variables.filter((variable) => variable.isSecret).length;
const selectedProfileLabel = selectedProfile
? `${selectedApp?.name ?? '앱'}${environmentLabel(selectedProfile.environment)} / ${environmentLabel(selectedProfile.environment)}`
: '프로필 없음';
const namespaceOptions = useMemo(() => [...new Set(apps.map((app) => app.namespace).filter(Boolean) as string[])], [apps]);
const duplicateProfile = profiles.find((profile) => (
profile.environment === profileEnvironment &&
profile.namespace.trim() === profileNamespace.trim()
));
const refreshResources = (targetNamespace = namespace) => {
if (!targetNamespace.trim()) {
setResourceCount(0);
return;
}
loadRuntimeResources(targetNamespace)
.then((data) => {
setResources(data.resources);
setResourceCount(data.resources.length);
})
.catch(() => {
setResources([]);
setResourceCount(0);
});
};
const applyProfiles = (nextProfiles: RuntimeEnvProfile[], preferredProfileId?: string) => {
setProfiles(nextProfiles);
setProfileId((current) => {
const nextId = preferredProfileId || (nextProfiles.some((profile) => profile.id === current) ? current : nextProfiles[0]?.id ?? '');
const nextProfile = nextProfiles.find((profile) => profile.id === nextId);
if (nextProfile) {
setProfileNamespace(nextProfile.namespace);
refreshResources(nextProfile.namespace);
}
return nextId;
});
};
const refreshProfiles = (targetAppId = appId, preferredProfileId?: string) => {
if (!targetAppId) return Promise.resolve();
return loadRuntimeProfiles(targetAppId).then((nextProfiles) => applyProfiles(nextProfiles, preferredProfileId));
};
useEffect(() => {
setLoading(true);
loadApps()
.then((nextApps) => {
const storedAppId = getStoredSelectedAppId();
const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
const nextApp = nextApps.find((app) => app.id === nextAppId);
const nextComponentId = preferredComponentId(nextAppId, nextApp?.components ?? []);
setApps(nextApps);
setAppId(nextAppId);
setProfileNamespace(nextApp?.namespace ?? '');
setVariableComponentId(nextComponentId);
setIngressComponentId(nextComponentId);
if (nextComponentId) setStoredSelectedComponentId(nextAppId, nextComponentId);
setIngressPort('');
return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]);
})
.then((nextProfiles) => applyProfiles(nextProfiles))
.catch((loadError: Error) => setError(loadError.message))
.finally(() => setLoading(false));
}, []);
const changeApp = (nextAppId: string) => {
const nextApp = apps.find((app) => app.id === nextAppId);
const nextComponentId = preferredComponentId(nextAppId, nextApp?.components ?? []);
setStoredSelectedAppId(nextAppId);
setAppId(nextAppId);
setProfileNamespace(nextApp?.namespace ?? '');
setVariableComponentId(nextComponentId);
setIngressComponentId(nextComponentId);
if (nextComponentId) setStoredSelectedComponentId(nextAppId, nextComponentId);
setIngressPort('');
refreshProfiles(nextAppId).catch((loadError: Error) => setError(loadError.message));
};
useEffect(() => {
const storedComponentId = preferredComponentId(appId, components);
if (!storedComponentId) return;
if (variableComponentId !== storedComponentId) setVariableComponentId(storedComponentId);
if (ingressComponentId !== storedComponentId) setIngressComponentId(storedComponentId);
}, [appId, components, ingressComponentId, variableComponentId]);
useEffect(() => {
const handleSelectedComponentChange = (event: Event) => {
const detail = (event as CustomEvent<{ appId?: string; componentId?: string }>).detail;
if (detail?.appId !== appId || !detail.componentId) return;
if (!components.some((component) => component.id === detail.componentId)) return;
setVariableComponentId(detail.componentId);
setIngressComponentId(detail.componentId);
setIngressPort('');
};
window.addEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
return () => window.removeEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
}, [appId, components]);
const addProfile = () => {
if (!appId || !profileName.trim() || !profileNamespace.trim()) return;
if (duplicateProfile) {
const message = `이미 ${environmentLabel(profileEnvironment)} / ${profileNamespace.trim()} 실행 설정이 있습니다.`;
setError(message);
toast({
type: 'warning',
title: '프로필 중복',
message: '기존 프로필을 선택해 설정값을 수정하세요.'
});
return;
}
createRuntimeProfile({
appId,
name: profileName.trim(),
environment: profileEnvironment,
namespace: profileNamespace.trim()
})
.then(({ profile }) => {
setProfileName('');
setProfileModalOpen(false);
refreshProfiles(appId, profile.id);
})
.catch((createError: Error) => setError(createError.message));
};
const addVariable = () => {
if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return;
const profile = selectedProfile;
const key = variableKey.trim();
setError('');
setActionKey(`variable-add-${profile.id}`);
createRuntimeVariable(selectedProfile.id, {
componentId: variableComponentId,
key,
value: variableValue.trim(),
isSecret: variableIsSecret
})
.then(() => applyRuntimeProfile(profile.id))
.then(() => {
setVariableKey('');
setVariableValue('');
setVariableIsSecret(false);
toast({
type: 'success',
title: '설정값 등록',
message: `${key} 설정값을 추가하고 클러스터 설정에 반영했습니다.`
});
refreshResources(profile.namespace);
refreshProfiles(appId, profile.id);
})
.catch((createError: Error) => {
setError(createError.message);
toast({ type: 'error', title: '설정값 등록 실패', message: createError.message });
})
.finally(() => setActionKey(''));
};
const removeVariable = (variableId: string) => {
if (!selectedProfile) return;
const profile = selectedProfile;
setActionKey(`variable-delete-${variableId}`);
deleteRuntimeVariable(profile.id, variableId)
.then(() => applyRuntimeProfile(profile.id))
.then(() => {
toast({ type: 'success', title: '설정값 삭제', message: '설정값을 삭제하고 클러스터 설정에 반영했습니다.' });
refreshResources(profile.namespace);
refreshProfiles(appId, profile.id);
})
.catch((deleteError: Error) => {
setError(deleteError.message);
toast({ type: 'error', title: '설정값 삭제 실패', message: deleteError.message });
})
.finally(() => setActionKey(''));
};
const addIngressRule = () => {
if (!selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()) return;
const profile = selectedProfile;
const host = ingressHost.trim();
setActionKey(`ingress-add-${profile.id}`);
createRuntimeIngressRule(profile.id, {
componentId: ingressComponentId,
host,
path: ingressPath.trim() || '/',
servicePort: Number(ingressPort),
stripPrefix: ingressStripPrefix,
tlsEnabled: false
})
.then(() => applyRuntimeProfile(profile.id))
.then(() => {
setIngressHost('');
setIngressPath('/');
setIngressStripPrefix(false);
toast({ type: 'success', title: '접속 설정 등록', message: `${host} 접속 설정을 추가하고 클러스터 설정에 반영했습니다.` });
refreshResources(profile.namespace);
refreshProfiles(appId, profile.id);
})
.catch((createError: Error) => {
setError(createError.message);
toast({ type: 'error', title: '접속 설정 등록 실패', message: createError.message });
})
.finally(() => setActionKey(''));
};
const removeIngressRule = (ruleId: string) => {
if (!selectedProfile) return;
const profile = selectedProfile;
setActionKey(`ingress-delete-${ruleId}`);
deleteRuntimeIngressRule(profile.id, ruleId)
.then(() => applyRuntimeProfile(profile.id))
.then(() => {
toast({ type: 'success', title: '접속 설정 삭제', message: '접속 설정을 삭제하고 클러스터 설정에 반영했습니다.' });
refreshResources(profile.namespace);
refreshProfiles(appId, profile.id);
})
.catch((deleteError: Error) => {
setError(deleteError.message);
toast({ type: 'error', title: '접속 설정 삭제 실패', message: deleteError.message });
})
.finally(() => setActionKey(''));
};
const runWorkloadAction = (resource: RuntimeResource, action: 'restart' | 'start' | 'stop') => {
const apiKind = getWorkloadKind(resource.kind);
if (!apiKind || !namespace) return;
const key = `${action}-${resource.kind}-${resource.name}`;
setActionKey(key);
setError('');
const request = action === 'restart'
? restartRuntimeWorkload(namespace, apiKind, resource.name)
: scaleRuntimeWorkload(namespace, apiKind, resource.name, action === 'start' ? 1 : 0);
request
.then(() => {
toast({
type: 'success',
title: action === 'restart' ? '워크로드 재시작' : action === 'start' ? '워크로드 시작' : '워크로드 중지',
message: `${resource.name} 작업을 요청했습니다.`
});
refreshResources(namespace);
})
.catch((workloadError: Error) => {
setError(workloadError.message);
toast({ type: 'error', title: '워크로드 제어 실패', message: workloadError.message });
})
.finally(() => setActionKey(''));
};
const requestDeleteWorkload = () => {
toast({
type: 'warning',
title: '삭제 API 미구현',
message: '워크로드 삭제는 백엔드 delete 엔드포인트 추가 후 연결할 수 있습니다.'
});
};
return (
<>
<PageTitle
eyebrow="어플리케이션"
title="실행 설정"
description="앱 구성요소별 환경변수와 시크릿을 프로필로 묶어 배포 환경에 적용합니다."
actions={(
<Button className="h-8 px-3 text-[13px]" onClick={() => setProfileModalOpen(true)} disabled={!appId}>
<Plus className="size-4" />
프로필 생성
</Button>
)}
/>
{error ? <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">{error}</div> : null}
<div className="space-y-4">
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-sm font-semibold">앱 선택</p>
<p className="mt-1 text-xs text-muted-foreground">설정값을 관리할 앱 그룹을 선택합니다.</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-72"
value={appId}
disabled={loading || apps.length === 0}
onChange={(event) => changeApp(event.target.value)}
>
{apps.length === 0 ? <option value="">등록된 앱 없음</option> : null}
{apps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
</select>
<Badge>{namespace || '-'}</Badge>
<Badge>구성요소 {components.length}</Badge>
<Badge>프로필 {profiles.length}</Badge>
</div>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="p-5">
<p className="text-sm text-muted-foreground">네임스페이스</p>
<p className="mt-4 text-2xl font-medium text-foreground">{namespace || '-'}</p>
</Card>
<Card className="p-5">
<p className="text-sm text-muted-foreground">구성요소</p>
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}개</p>
</Card>
<Card className="p-5">
<p className="text-sm text-muted-foreground">환경변수</p>
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}개</p>
</Card>
<Card className="p-5">
<p className="text-sm text-muted-foreground">시크릿</p>
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}개</p>
</Card>
</div>
<Card className="min-w-0 overflow-hidden">
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">런타임 프로필</h2>
<p className="mt-1 text-xs leading-5 text-muted-foreground">개발과 운영처럼 배포 환경별로 적용할 설정 묶음입니다.</p>
</div>
<span className="text-xs text-muted-foreground">총 {profiles.length}개</span>
</div>
<div className="max-h-[260px] space-y-3 overflow-y-auto p-4">
{profiles.length === 0 ? (
<div className="flex min-h-28 items-center justify-center text-center text-sm text-muted-foreground">
등록된 런타임 프로필이 없습니다.
</div>
) : profiles.map((profile) => (
<div
key={profile.id}
role="button"
tabIndex={0}
className={[
'w-full cursor-pointer rounded-md border px-4 py-3 text-left transition-colors',
profile.id === profileId
? 'border-border bg-primary/15 shadow-[inset_4px_0_0_var(--color-primary)]'
: 'border-border bg-background/70 hover:bg-muted/20'
].join(' ')}
onClick={() => {
setProfileId(profile.id);
setProfileNamespace(profile.namespace);
refreshResources(profile.namespace);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
setProfileId(profile.id);
setProfileNamespace(profile.namespace);
refreshResources(profile.namespace);
}}
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{profile.name}</span>
<Badge tone="blue">{environmentLabel(profile.environment)}</Badge>
<span className="text-xs text-muted-foreground">{profile.namespace}</span>
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>환경변수 <b className="text-foreground">{profile.variables?.filter((variable) => !variable.isSecret).length ?? 0}</b></span>
<span>시크릿 <b className="text-foreground">{profile.variables?.filter((variable) => variable.isSecret).length ?? 0}</b></span>
<span>수정 <b className="text-foreground">{formatDate(profile.updatedAt ?? profile.createdAt)}</b></span>
</div>
</div>
</div>
</div>
))}
</div>
</Card>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card className="min-w-0 overflow-hidden">
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">설정값</h2>
<p className="mt-1 text-xs leading-5 text-muted-foreground">구성요소별로 ConfigMap 또는 Secret에 반영될 항목입니다.</p>
</div>
<span className="text-xs text-muted-foreground">총 {variables.length}개</span>
</div>
<div className="max-h-[480px] overflow-y-auto">
<table className="w-full table-fixed text-left text-sm">
<colgroup>
<col className="w-[22%]" />
<col className="w-[30%]" />
<col />
<col className="w-20" />
</colgroup>
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">구성요소</th>
<th className="px-5 py-3 font-medium">Key</th>
<th className="px-5 py-3 font-medium">Value</th>
<th className="w-16 px-5 py-3 text-right font-medium">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{variables.length === 0 ? (
<tr><td colSpan={4} className="px-5 py-14 text-center text-muted-foreground">등록된 설정값이 없습니다.</td></tr>
) : null}
{variables.map((variable) => (
<tr key={variable.id} className="hover:bg-muted/20">
<td className="px-4 py-3">
<span className="inline-flex items-center gap-2">
<span className={`block size-2.5 shrink-0 rounded-full ${variable.isSecret ? statusDotToneClass('amber') : statusDotToneClass('green')}`} />
<span className="truncate">{variable.component?.name ?? '-'}</span>
</span>
</td>
<td className="break-all px-4 py-3 font-mono text-xs font-medium text-foreground">{variable.key}</td>
<td className="break-all px-4 py-3 font-mono text-xs text-muted-foreground">{variable.isSecret ? '********' : variable.value}</td>
<td className="px-3 py-3 text-right">
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${variable.key} 삭제`} onClick={() => setDeleteVariableTarget(variable)} disabled={Boolean(actionKey)}>
<Trash2 className="size-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card className="p-5">
<p className="text-sm font-semibold">설정값 추가</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">선택한 프로필에 구성요소별 환경변수 또는 시크릿을 추가합니다.</p>
<div className="mt-5 space-y-4">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">프로필</span>
<input className="mt-2 h-9 w-full rounded-md border border-input bg-muted px-3 text-sm text-foreground outline-none" value={selectedProfileLabel} readOnly />
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={variableComponentId}
onChange={(event) => {
const nextId = event.target.value;
setVariableComponentId(nextId);
setStoredSelectedComponentId(appId, nextId);
}}
>
{components.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
{components.length === 0 ? <option value="">구성요소 없음</option> : null}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Key</span>
<div className="mt-2 flex items-center gap-2">
<input className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={variableKey} onChange={(event) => setVariableKey(event.target.value)} placeholder="키 입력" />
<label className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" className="size-4 accent-primary" checked={variableIsSecret} onChange={(event) => setVariableIsSecret(event.target.checked)} />
Secret
</label>
</div>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Value</span>
<input
type={variableIsSecret ? 'password' : 'text'}
className={`mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring ${variableIsSecret ? 'login-password-input' : 'font-mono'}`}
value={variableValue}
onChange={(event) => setVariableValue(event.target.value)}
placeholder="값 입력"
/>
</label>
<Button className="h-9 w-full text-[13px]" onClick={addVariable} disabled={Boolean(actionKey) || !selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()}>
<Plus className="size-4" />
{actionKey.startsWith('variable-add-') ? '저장 중' : '추가'}
</Button>
</div>
</Card>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card className="min-w-0 overflow-hidden">
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">접속 설정</h2>
<p className="mt-1 text-xs leading-5 text-muted-foreground">배포 시 생성할 서비스와 도메인 연결 정보를 관리합니다.</p>
</div>
<span className="text-xs text-muted-foreground">총 {ingressRules.length}개</span>
</div>
<div className="max-h-[320px] overflow-y-auto">
<table className="w-full table-fixed text-left text-sm">
<colgroup>
<col className="w-[24%]" />
<col className="w-[32%]" />
<col className="w-[16%]" />
<col className="w-[10%]" />
<col className="w-[10%]" />
<col className="w-14" />
</colgroup>
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">구성요소</th>
<th className="px-5 py-3 font-medium">도메인</th>
<th className="px-5 py-3 font-medium">Path</th>
<th className="px-5 py-3 font-medium">포트</th>
<th className="px-5 py-3 font-medium">Prefix</th>
<th className="w-16 px-5 py-3 text-right font-medium">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{ingressRules.length === 0 ? (
<tr><td colSpan={6} className="px-5 py-14 text-center text-muted-foreground">등록된 접속 설정이 없습니다.</td></tr>
) : null}
{ingressRules.map((rule) => (
<tr key={rule.id} className="hover:bg-muted/20">
<td className="px-4 py-3">
<span className="inline-flex items-center gap-2">
<span className={`block size-2.5 shrink-0 rounded-full ${statusDotToneClass('blue')}`} />
<span className="truncate">{rule.component?.name ?? '-'}</span>
</span>
</td>
<td className="break-all px-4 py-3 text-foreground">{rule.host}</td>
<td className="break-all px-4 py-3 text-muted-foreground">{rule.path}</td>
<td className="px-4 py-3 text-muted-foreground">{rule.servicePort}</td>
<td className="px-4 py-3 text-muted-foreground">{rule.stripPrefix ? '제거' : '유지'}</td>
<td className="px-3 py-3 text-right">
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${rule.host} 삭제`} onClick={() => setDeleteIngressTarget(rule)} disabled={Boolean(actionKey)}>
<Trash2 className="size-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card className="p-5">
<p className="text-sm font-semibold">접속 설정 추가</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">선택한 프로필에 도메인 연결 정보를 추가합니다.</p>
<div className="mt-5 space-y-4">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={ingressComponentId}
onChange={(event) => {
const nextId = event.target.value;
setIngressComponentId(nextId);
setStoredSelectedComponentId(appId, nextId);
setIngressPort('');
}}
>
{components.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
{components.length === 0 ? <option value="">구성요소 없음</option> : null}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">도메인</span>
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={ingressHost} onChange={(event) => setIngressHost(event.target.value)} placeholder="lms-api.local" />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Path</span>
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={ingressPath} onChange={(event) => setIngressPath(event.target.value)} placeholder="/" />
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">서비스 포트</span>
<input type="number" min="1" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={ingressPort} onChange={(event) => setIngressPort(event.target.value)} placeholder="80" />
</label>
</div>
<label className="flex items-center gap-2 rounded-md border border-border bg-muted/10 px-3 py-2 text-xs font-medium text-muted-foreground">
<input
type="checkbox"
className="size-4 accent-primary"
checked={ingressStripPrefix}
onChange={(event) => setIngressStripPrefix(event.target.checked)}
/>
Path prefix 제거
</label>
<Button className="h-9 w-full text-[13px]" onClick={addIngressRule} disabled={Boolean(actionKey) || !selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()}>
<Plus className="size-4" />
{actionKey.startsWith('ingress-add-') ? '저장 중' : '추가'}
</Button>
</div>
</Card>
</div>
</div>
<Modal
open={profileModalOpen}
title="프로필 생성"
onClose={() => setProfileModalOpen(false)}
footer={(
<>
<Button variant="outline" onClick={() => setProfileModalOpen(false)}>취소</Button>
<Button disabled={!profileName.trim() || !profileNamespace.trim() || Boolean(duplicateProfile)} onClick={addProfile}>생성</Button>
</>
)}
>
<div className="space-y-4">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">프로필명</span>
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="예: LMS개발" />
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">환경</span>
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={profileEnvironment} onChange={(event) => setProfileEnvironment(event.target.value)}>
<option value="dev">개발</option>
<option value="prod">운영</option>
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
<input
list="runtime-namespace-options"
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
value={profileNamespace}
onChange={(event) => setProfileNamespace(event.target.value)}
placeholder="stdo-lms"
/>
<datalist id="runtime-namespace-options">
{namespaceOptions.map((item) => <option key={item} value={item} />)}
</datalist>
</label>
{duplicateProfile ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
이미 같은 환경과 네임스페이스의 프로필이 있습니다. 기존 프로필을 선택해 설정값을 수정하세요.
</p>
) : null}
</div>
</Modal>
<Modal
open={Boolean(deleteVariableTarget)}
title="설정값 삭제"
onClose={() => {
if (!actionKey) setDeleteVariableTarget(null);
}}
footer={(
<>
<Button variant="outline" disabled={Boolean(actionKey)} onClick={() => setDeleteVariableTarget(null)}>취소</Button>
<Button
className="bg-rose-600 text-white hover:bg-rose-500"
disabled={Boolean(actionKey)}
onClick={() => {
if (!deleteVariableTarget) return;
removeVariable(deleteVariableTarget.id);
setDeleteVariableTarget(null);
}}
>
삭제
</Button>
</>
)}
>
<div className="space-y-3 text-sm">
<p>
<span className="font-semibold text-foreground">{deleteVariableTarget?.key ?? '-'}</span>
{' '}설정값을 삭제합니다.
</p>
<p className="text-muted-foreground">삭제 후 클러스터 설정에 바로 반영됩니다.</p>
</div>
</Modal>
<Modal
open={Boolean(deleteIngressTarget)}
title="접속 설정 삭제"
onClose={() => {
if (!actionKey) setDeleteIngressTarget(null);
}}
footer={(
<>
<Button variant="outline" disabled={Boolean(actionKey)} onClick={() => setDeleteIngressTarget(null)}>취소</Button>
<Button
className="bg-rose-600 text-white hover:bg-rose-500"
disabled={Boolean(actionKey)}
onClick={() => {
if (!deleteIngressTarget) return;
removeIngressRule(deleteIngressTarget.id);
setDeleteIngressTarget(null);
}}
>
삭제
</Button>
</>
)}
>
<div className="space-y-3 text-sm">
<p>
<span className="font-semibold text-foreground">{deleteIngressTarget?.host ?? '-'}</span>
{' '}
<span className="font-semibold text-foreground">{deleteIngressTarget?.path ?? ''}</span>
{' '}접속 설정을 삭제합니다.
</p>
<p className="text-muted-foreground">삭제 후 클러스터 설정에 바로 반영됩니다.</p>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
}