init studio admin frontend
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Play, Plus, RotateCcw, Square, Trash2 } from 'lucide-react';
|
||||
import { Badge, Button, Card, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||
import type { ApiApp, RuntimeEnvProfile, RuntimeResource } from '../../types/runtime';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import {
|
||||
applyRuntimeProfile,
|
||||
createRuntimeIngressRule,
|
||||
createRuntimeProfile,
|
||||
createRuntimeVariable,
|
||||
deleteRuntimeIngressRule,
|
||||
deleteRuntimeVariable,
|
||||
loadApps,
|
||||
loadRuntimeProfiles,
|
||||
loadRuntimeResources,
|
||||
restartRuntimeWorkload,
|
||||
scaleRuntimeWorkload
|
||||
} from './runtime-api';
|
||||
|
||||
function environmentLabel(value?: string) {
|
||||
return {
|
||||
dev: '개발',
|
||||
stage: '검수',
|
||||
prod: '운영'
|
||||
}[value ?? ''] ?? value ?? '-';
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function getWorkloadKind(kind: string) {
|
||||
if (kind === 'Deployment') return 'deployment';
|
||||
if (kind === 'StatefulSet') return 'statefulset';
|
||||
if (kind === 'DaemonSet') return 'daemonset';
|
||||
return '';
|
||||
}
|
||||
|
||||
function statusDot(status?: string) {
|
||||
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(status ?? '')) return 'bg-emerald-400';
|
||||
if (['Pending', 'ContainerCreating'].includes(status ?? '')) return 'bg-amber-400';
|
||||
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'bg-rose-400';
|
||||
return 'bg-muted-foreground';
|
||||
}
|
||||
|
||||
function isStopped(resource: RuntimeResource) {
|
||||
return resource.ready.endsWith('/0');
|
||||
}
|
||||
|
||||
function runtimeStatusLabel(resource: RuntimeResource, stopped: boolean) {
|
||||
if (stopped) return '중지됨';
|
||||
if (resource.status === 'Running') return '실행 중';
|
||||
if (resource.status === 'Pending' || resource.status === 'ContainerCreating') return '준비 중';
|
||||
if (resource.status === 'Error' || resource.status === 'CrashLoopBackOff' || resource.status === 'ImagePullBackOff' || resource.status === 'Failed') return '오류';
|
||||
return resource.status || '-';
|
||||
}
|
||||
|
||||
function runtimeStatusTone(resource: RuntimeResource, stopped: boolean): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||
if (stopped) return 'neutral';
|
||||
if (resource.status === 'Running') return 'green';
|
||||
if (resource.status === 'Pending' || resource.status === 'ContainerCreating') return 'amber';
|
||||
if (resource.status === 'Error' || resource.status === 'CrashLoopBackOff' || resource.status === 'ImagePullBackOff' || resource.status === 'Failed') return 'red';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
export function RuntimeConfigPage() {
|
||||
const { toast } = useToast();
|
||||
const [apps, setApps] = useState<ApiApp[]>([]);
|
||||
const [appId, setAppId] = useState('');
|
||||
const [profiles, setProfiles] = useState<RuntimeEnvProfile[]>([]);
|
||||
const [profileId, setProfileId] = useState('');
|
||||
const [resources, setResources] = useState<RuntimeResource[]>([]);
|
||||
const [resourceCount, setResourceCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionKey, setActionKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const [profileName, setProfileName] = useState('');
|
||||
const [profileEnvironment, setProfileEnvironment] = useState('dev');
|
||||
const [profileNamespace, setProfileNamespace] = useState('');
|
||||
const [variableComponentId, setVariableComponentId] = useState('');
|
||||
const [variableKey, setVariableKey] = useState('');
|
||||
const [variableValue, setVariableValue] = useState('');
|
||||
const [variableIsSecret, setVariableIsSecret] = useState(false);
|
||||
const [ingressComponentId, setIngressComponentId] = useState('');
|
||||
const [ingressHost, setIngressHost] = useState('');
|
||||
const [ingressPath, setIngressPath] = useState('/');
|
||||
const [ingressPort, setIngressPort] = useState('');
|
||||
|
||||
const selectedApp = apps.find((app) => app.id === appId);
|
||||
const selectedProfile = profiles.find((profile) => profile.id === profileId);
|
||||
const components = selectedApp?.components ?? [];
|
||||
const variables = selectedProfile?.variables ?? [];
|
||||
const ingressRules = selectedProfile?.ingressRules ?? [];
|
||||
const namespace = selectedProfile?.namespace || selectedApp?.namespace || profileNamespace || '';
|
||||
const workloads = resources.filter((resource) => ['Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind));
|
||||
const envCount = variables.filter((variable) => !variable.isSecret).length;
|
||||
const secretCount = variables.filter((variable) => variable.isSecret).length;
|
||||
|
||||
const selectedProfileLabel = selectedProfile
|
||||
? `${selectedApp?.name ?? '앱'}${environmentLabel(selectedProfile.environment)} / ${environmentLabel(selectedProfile.environment)}`
|
||||
: '프로필 없음';
|
||||
|
||||
const namespaceOptions = useMemo(() => [...new Set(apps.map((app) => app.namespace).filter(Boolean) as string[])], [apps]);
|
||||
const duplicateProfile = profiles.find((profile) => (
|
||||
profile.environment === profileEnvironment &&
|
||||
profile.namespace.trim() === profileNamespace.trim()
|
||||
));
|
||||
|
||||
const refreshResources = (targetNamespace = namespace) => {
|
||||
if (!targetNamespace.trim()) {
|
||||
setResourceCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
loadRuntimeResources(targetNamespace)
|
||||
.then((data) => {
|
||||
setResources(data.resources);
|
||||
setResourceCount(data.resources.length);
|
||||
})
|
||||
.catch(() => {
|
||||
setResources([]);
|
||||
setResourceCount(0);
|
||||
});
|
||||
};
|
||||
|
||||
const applyProfiles = (nextProfiles: RuntimeEnvProfile[], preferredProfileId?: string) => {
|
||||
setProfiles(nextProfiles);
|
||||
setProfileId((current) => {
|
||||
const nextId = preferredProfileId || (nextProfiles.some((profile) => profile.id === current) ? current : nextProfiles[0]?.id ?? '');
|
||||
const nextProfile = nextProfiles.find((profile) => profile.id === nextId);
|
||||
if (nextProfile) {
|
||||
setProfileNamespace(nextProfile.namespace);
|
||||
refreshResources(nextProfile.namespace);
|
||||
}
|
||||
return nextId;
|
||||
});
|
||||
};
|
||||
|
||||
const refreshProfiles = (targetAppId = appId, preferredProfileId?: string) => {
|
||||
if (!targetAppId) return Promise.resolve();
|
||||
return loadRuntimeProfiles(targetAppId).then((nextProfiles) => applyProfiles(nextProfiles, preferredProfileId));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadApps()
|
||||
.then((nextApps) => {
|
||||
const storedAppId = getStoredSelectedAppId();
|
||||
const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
|
||||
const nextApp = nextApps.find((app) => app.id === nextAppId);
|
||||
setApps(nextApps);
|
||||
setAppId(nextAppId);
|
||||
setProfileNamespace(nextApp?.namespace ?? '');
|
||||
setVariableComponentId(nextApp?.components?.[0]?.id ?? '');
|
||||
setIngressComponentId(nextApp?.components?.[0]?.id ?? '');
|
||||
setIngressPort(nextApp?.components?.[0]?.port ? String(nextApp.components[0].port) : '');
|
||||
return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]);
|
||||
})
|
||||
.then((nextProfiles) => applyProfiles(nextProfiles))
|
||||
.catch((loadError: Error) => setError(loadError.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const changeApp = (nextAppId: string) => {
|
||||
const nextApp = apps.find((app) => app.id === nextAppId);
|
||||
setStoredSelectedAppId(nextAppId);
|
||||
setAppId(nextAppId);
|
||||
setProfileNamespace(nextApp?.namespace ?? '');
|
||||
setVariableComponentId(nextApp?.components?.[0]?.id ?? '');
|
||||
setIngressComponentId(nextApp?.components?.[0]?.id ?? '');
|
||||
setIngressPort(nextApp?.components?.[0]?.port ? String(nextApp.components[0].port) : '');
|
||||
refreshProfiles(nextAppId).catch((loadError: Error) => setError(loadError.message));
|
||||
};
|
||||
|
||||
const addProfile = () => {
|
||||
if (!appId || !profileName.trim() || !profileNamespace.trim()) return;
|
||||
if (duplicateProfile) {
|
||||
const message = `이미 ${environmentLabel(profileEnvironment)} / ${profileNamespace.trim()} 실행 설정이 있습니다.`;
|
||||
setError(message);
|
||||
toast({
|
||||
type: 'warning',
|
||||
title: '프로필 중복',
|
||||
message: '기존 프로필을 선택해 설정값을 수정하세요.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createRuntimeProfile({
|
||||
appId,
|
||||
name: profileName.trim(),
|
||||
environment: profileEnvironment,
|
||||
namespace: profileNamespace.trim()
|
||||
})
|
||||
.then(({ profile }) => {
|
||||
setProfileName('');
|
||||
setProfileModalOpen(false);
|
||||
refreshProfiles(appId, profile.id);
|
||||
})
|
||||
.catch((createError: Error) => setError(createError.message));
|
||||
};
|
||||
|
||||
const addVariable = () => {
|
||||
if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return;
|
||||
createRuntimeVariable(selectedProfile.id, {
|
||||
componentId: variableComponentId,
|
||||
key: variableKey.trim(),
|
||||
value: variableValue.trim(),
|
||||
isSecret: variableIsSecret
|
||||
})
|
||||
.then(() => {
|
||||
setVariableKey('');
|
||||
setVariableValue('');
|
||||
setVariableIsSecret(false);
|
||||
refreshProfiles(appId, selectedProfile.id);
|
||||
})
|
||||
.catch((createError: Error) => setError(createError.message));
|
||||
};
|
||||
|
||||
const removeVariable = (variableId: string) => {
|
||||
if (!selectedProfile) return;
|
||||
deleteRuntimeVariable(selectedProfile.id, variableId)
|
||||
.then(() => refreshProfiles(appId, selectedProfile.id))
|
||||
.catch((deleteError: Error) => setError(deleteError.message));
|
||||
};
|
||||
|
||||
const addIngressRule = () => {
|
||||
if (!selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()) return;
|
||||
createRuntimeIngressRule(selectedProfile.id, {
|
||||
componentId: ingressComponentId,
|
||||
host: ingressHost.trim(),
|
||||
path: ingressPath.trim() || '/',
|
||||
servicePort: Number(ingressPort),
|
||||
tlsEnabled: false
|
||||
})
|
||||
.then(() => {
|
||||
setIngressHost('');
|
||||
setIngressPath('/');
|
||||
refreshProfiles(appId, selectedProfile.id);
|
||||
})
|
||||
.catch((createError: Error) => setError(createError.message));
|
||||
};
|
||||
|
||||
const removeIngressRule = (ruleId: string) => {
|
||||
if (!selectedProfile) return;
|
||||
deleteRuntimeIngressRule(selectedProfile.id, ruleId)
|
||||
.then(() => refreshProfiles(appId, selectedProfile.id))
|
||||
.catch((deleteError: Error) => setError(deleteError.message));
|
||||
};
|
||||
|
||||
const applyProfile = (profile = selectedProfile) => {
|
||||
if (!profile) return;
|
||||
setActionKey(`profile-${profile.id}`);
|
||||
applyRuntimeProfile(profile.id)
|
||||
.then(() => {
|
||||
refreshResources(profile.namespace);
|
||||
refreshProfiles(appId, profile.id);
|
||||
})
|
||||
.catch((applyError: Error) => setError(applyError.message))
|
||||
.finally(() => setActionKey(''));
|
||||
};
|
||||
|
||||
const runWorkloadAction = (resource: RuntimeResource, action: 'restart' | 'start' | 'stop') => {
|
||||
const apiKind = getWorkloadKind(resource.kind);
|
||||
if (!apiKind || !namespace) return;
|
||||
const key = `${action}-${resource.kind}-${resource.name}`;
|
||||
setActionKey(key);
|
||||
setError('');
|
||||
|
||||
const request = action === 'restart'
|
||||
? restartRuntimeWorkload(namespace, apiKind, resource.name)
|
||||
: scaleRuntimeWorkload(namespace, apiKind, resource.name, action === 'start' ? 1 : 0);
|
||||
|
||||
request
|
||||
.then(() => {
|
||||
toast({
|
||||
type: 'success',
|
||||
title: action === 'restart' ? '워크로드 재시작' : action === 'start' ? '워크로드 시작' : '워크로드 중지',
|
||||
message: `${resource.name} 작업을 요청했습니다.`
|
||||
});
|
||||
refreshResources(namespace);
|
||||
})
|
||||
.catch((workloadError: Error) => {
|
||||
setError(workloadError.message);
|
||||
toast({ type: 'error', title: '워크로드 제어 실패', message: workloadError.message });
|
||||
})
|
||||
.finally(() => setActionKey(''));
|
||||
};
|
||||
|
||||
const requestDeleteWorkload = () => {
|
||||
toast({
|
||||
type: 'warning',
|
||||
title: '삭제 API 미구현',
|
||||
message: '워크로드 삭제는 백엔드 delete 엔드포인트 추가 후 연결할 수 있습니다.'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="실행 설정"
|
||||
description="앱 구성요소별 환경변수와 시크릿을 프로필로 묶어 배포 환경에 적용합니다."
|
||||
actions={(
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setProfileModalOpen(true)} disabled={!appId}>
|
||||
<Plus className="size-4" />
|
||||
프로필 생성
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error ? <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">{error}</div> : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">앱 선택</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">설정값을 관리할 앱 그룹을 선택합니다.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-72"
|
||||
value={appId}
|
||||
disabled={loading || apps.length === 0}
|
||||
onChange={(event) => changeApp(event.target.value)}
|
||||
>
|
||||
{apps.length === 0 ? <option value="">등록된 앱 없음</option> : null}
|
||||
{apps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
|
||||
</select>
|
||||
<Badge>{namespace || '-'}</Badge>
|
||||
<Badge>구성요소 {components.length}</Badge>
|
||||
<Badge>프로필 {profiles.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">네임스페이스</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{namespace || '-'}</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">구성요소</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}개</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">환경변수</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}개</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">시크릿</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}개</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">런타임 프로필</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">개발, 검수, 운영처럼 배포 환경별로 적용할 설정 묶음입니다.</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">총 {profiles.length}개</span>
|
||||
</div>
|
||||
<div className="max-h-[260px] space-y-3 overflow-y-auto p-4">
|
||||
{profiles.length === 0 ? (
|
||||
<div className="flex min-h-28 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
등록된 런타임 프로필이 없습니다.
|
||||
</div>
|
||||
) : profiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={[
|
||||
'w-full cursor-pointer rounded-md border px-4 py-3 text-left transition-colors',
|
||||
profile.id === profileId
|
||||
? 'border-border bg-primary/15 shadow-[inset_4px_0_0_var(--color-primary)]'
|
||||
: 'border-border bg-background/70 hover:bg-muted/20'
|
||||
].join(' ')}
|
||||
onClick={() => {
|
||||
setProfileId(profile.id);
|
||||
setProfileNamespace(profile.namespace);
|
||||
refreshResources(profile.namespace);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
setProfileId(profile.id);
|
||||
setProfileNamespace(profile.namespace);
|
||||
refreshResources(profile.namespace);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{profile.name}</span>
|
||||
<Badge tone="blue">{environmentLabel(profile.environment)}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{profile.namespace}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>환경변수 <b className="text-foreground">{profile.variables?.filter((variable) => !variable.isSecret).length ?? 0}</b></span>
|
||||
<span>시크릿 <b className="text-foreground">{profile.variables?.filter((variable) => variable.isSecret).length ?? 0}</b></span>
|
||||
<span>수정 <b className="text-foreground">{formatDate(profile.updatedAt ?? profile.createdAt)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={actionKey === `profile-${profile.id}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
applyProfile(profile);
|
||||
}}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">설정값</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">구성요소별로 ConfigMap 또는 Secret에 반영될 항목입니다.</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">총 {variables.length}개</span>
|
||||
</div>
|
||||
<div className="max-h-[480px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[920px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||
<th className="px-5 py-3 font-medium">Key</th>
|
||||
<th className="px-5 py-3 font-medium">Value</th>
|
||||
<th className="px-5 py-3 font-medium">등록일</th>
|
||||
<th className="w-16 px-5 py-3 text-right font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{variables.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-5 py-14 text-center text-muted-foreground">등록된 설정값이 없습니다.</td></tr>
|
||||
) : null}
|
||||
{variables.map((variable) => (
|
||||
<tr key={variable.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className={variable.isSecret ? 'block size-2.5 rounded-full bg-amber-400' : 'block size-2.5 rounded-full bg-emerald-400'} />
|
||||
<span>{variable.component?.name ?? '-'}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 font-mono text-xs font-medium text-foreground">{variable.key}</td>
|
||||
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{variable.isSecret ? '********' : variable.value}</td>
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground">{formatDate(variable.createdAt)}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${variable.key} 삭제`} onClick={() => removeVariable(variable.id)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<p className="text-sm font-semibold">설정값 추가</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">선택한 프로필에 구성요소별 환경변수 또는 시크릿을 추가합니다.</p>
|
||||
<div className="mt-5 space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">프로필</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-muted px-3 text-sm text-foreground outline-none" value={selectedProfileLabel} readOnly />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
|
||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={variableComponentId} onChange={(event) => setVariableComponentId(event.target.value)}>
|
||||
{components.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
||||
{components.length === 0 ? <option value="">구성요소 없음</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Key</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={variableKey} onChange={(event) => setVariableKey(event.target.value)} placeholder="DATABASE_URL" />
|
||||
<label className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<input type="checkbox" className="size-4 accent-primary" checked={variableIsSecret} onChange={(event) => setVariableIsSecret(event.target.checked)} />
|
||||
Secret
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Value</span>
|
||||
<input
|
||||
type={variableIsSecret ? 'password' : 'text'}
|
||||
className={`mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring ${variableIsSecret ? 'login-password-input' : 'font-mono'}`}
|
||||
value={variableValue}
|
||||
onChange={(event) => setVariableValue(event.target.value)}
|
||||
placeholder="값 입력"
|
||||
/>
|
||||
</label>
|
||||
<Button className="h-9 w-full text-[13px]" onClick={addVariable} disabled={!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()}>
|
||||
<Plus className="size-4" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">접속 설정</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">배포 시 생성할 서비스와 도메인 연결 정보를 관리합니다.</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">총 {ingressRules.length}개</span>
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[820px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||
<th className="px-5 py-3 font-medium">도메인</th>
|
||||
<th className="px-5 py-3 font-medium">Path</th>
|
||||
<th className="px-5 py-3 font-medium">포트</th>
|
||||
<th className="w-16 px-5 py-3 text-right font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{ingressRules.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-5 py-14 text-center text-muted-foreground">등록된 접속 설정이 없습니다.</td></tr>
|
||||
) : null}
|
||||
{ingressRules.map((rule) => (
|
||||
<tr key={rule.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="block size-2.5 rounded-full bg-sky-400" />
|
||||
<span>{rule.component?.name ?? '-'}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-foreground">{rule.host}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{rule.path}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{rule.servicePort}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${rule.host} 삭제`} onClick={() => removeIngressRule(rule.id)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<p className="text-sm font-semibold">접속 설정 추가</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">선택한 프로필에 도메인 연결 정보를 추가합니다.</p>
|
||||
<div className="mt-5 space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
|
||||
<select
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={ingressComponentId}
|
||||
onChange={(event) => {
|
||||
const nextId = event.target.value;
|
||||
const component = components.find((item) => item.id === nextId);
|
||||
setIngressComponentId(nextId);
|
||||
setIngressPort(component?.port ? String(component.port) : '');
|
||||
}}
|
||||
>
|
||||
{components.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
||||
{components.length === 0 ? <option value="">구성요소 없음</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">도메인</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={ingressHost} onChange={(event) => setIngressHost(event.target.value)} placeholder="lms-api.local" />
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Path</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={ingressPath} onChange={(event) => setIngressPath(event.target.value)} placeholder="/" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">서비스 포트</span>
|
||||
<input type="number" min="1" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={ingressPort} onChange={(event) => setIngressPort(event.target.value)} placeholder="80" />
|
||||
</label>
|
||||
</div>
|
||||
<Button className="h-9 w-full text-[13px]" onClick={addIngressRule} disabled={!selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()}>
|
||||
<Plus className="size-4" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={profileModalOpen}
|
||||
title="프로필 생성"
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setProfileModalOpen(false)}>취소</Button>
|
||||
<Button disabled={!profileName.trim() || !profileNamespace.trim() || Boolean(duplicateProfile)} onClick={addProfile}>생성</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">프로필명</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="예: LMS개발" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">환경</span>
|
||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={profileEnvironment} onChange={(event) => setProfileEnvironment(event.target.value)}>
|
||||
<option value="dev">개발</option>
|
||||
<option value="stage">검수</option>
|
||||
<option value="prod">운영</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
||||
<input
|
||||
list="runtime-namespace-options"
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={profileNamespace}
|
||||
onChange={(event) => setProfileNamespace(event.target.value)}
|
||||
placeholder="stdo-lms"
|
||||
/>
|
||||
<datalist id="runtime-namespace-options">
|
||||
{namespaceOptions.map((item) => <option key={item} value={item} />)}
|
||||
</datalist>
|
||||
</label>
|
||||
{duplicateProfile ? (
|
||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
이미 같은 환경과 네임스페이스의 프로필이 있습니다. 기존 프로필을 선택해 설정값을 수정하세요.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Activity, MoreHorizontal, Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { agentDevices } from '../../data/mock';
|
||||
|
||||
function getTone(status: string) {
|
||||
if (['정상', '활성', '성공', '운영', 'Running'].includes(status)) return 'green';
|
||||
if (['주의', '예약됨', '대기', '준비중', '카나리', 'Pending'].includes(status)) return 'amber';
|
||||
if (['오류', '실패', '비활성', '만료', 'Error', 'Failed'].includes(status)) return 'red';
|
||||
if (['검수', '동기화'].includes(status)) return 'blue';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function RuntimeDeviceControlPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title="장비 목록"
|
||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
상태 필터
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<Activity className="size-4" />
|
||||
상태 갱신
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="border-b border-border px-5 pb-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="장비명, 계정, 워크스페이스 검색"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[1040px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">장비</th>
|
||||
<th className="px-5 py-3 font-medium">계정</th>
|
||||
<th className="px-5 py-3 font-medium">Workspace</th>
|
||||
<th className="px-5 py-3 font-medium">SSH 키</th>
|
||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
<th className="px-5 py-3 text-right font-medium">명령</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{agentDevices.map((device) => (
|
||||
<tr key={device.name} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3 font-medium">{device.name}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
||||
<td className="px-5 py-3">{device.sshKey}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={getTone(device.status)}>{device.status}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
||||
<Button variant="ghost" className="size-8 px-0">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FileText, RotateCcw, Search, TerminalSquare } from 'lucide-react';
|
||||
import { Badge, Button, Card } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ApiApp, RuntimeEvent, RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources, loadRuntimeWorkloadLogs } from './runtime-api';
|
||||
|
||||
function statusDot(status?: string) {
|
||||
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer', 'Normal'].includes(status ?? '')) return 'bg-emerald-400';
|
||||
if (['Pending', 'ContainerCreating', 'Warning'].includes(status ?? '')) return 'bg-amber-400';
|
||||
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'bg-rose-400';
|
||||
return 'bg-muted-foreground';
|
||||
}
|
||||
|
||||
function eventTone(type?: string) {
|
||||
if (type === 'Normal') return 'green';
|
||||
if (type === 'Warning') return 'amber';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function apiKind(kind: string) {
|
||||
return kind.toLowerCase();
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function stripAnsi(value: string) {
|
||||
return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, '');
|
||||
}
|
||||
|
||||
function isLoggable(resource: RuntimeResource) {
|
||||
return ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'Job'].includes(resource.kind);
|
||||
}
|
||||
|
||||
function parseReadyCount(ready?: string) {
|
||||
const [current, desired] = (ready ?? '').split('/').map((value) => Number(value));
|
||||
return {
|
||||
current: Number.isFinite(current) ? current : 0,
|
||||
desired: Number.isFinite(desired) ? desired : 0
|
||||
};
|
||||
}
|
||||
|
||||
function hasReadyReplica(resource: RuntimeResource) {
|
||||
const ready = parseReadyCount(resource.ready);
|
||||
return ready.current > 0 && (ready.desired === 0 || ready.current <= ready.desired);
|
||||
}
|
||||
|
||||
function isActiveLogTarget(resource: RuntimeResource) {
|
||||
if (!isLoggable(resource)) return false;
|
||||
if (resource.kind === 'Job') return resource.status === 'Running';
|
||||
if (resource.kind === 'Pod') return ['Running', 'Pending', 'ContainerCreating'].includes(resource.status);
|
||||
return resource.status === 'Running' && hasReadyReplica(resource);
|
||||
}
|
||||
|
||||
function workloadStatusDot(resource: RuntimeResource) {
|
||||
if (resource.kind === 'Pod') return statusDot(resource.status);
|
||||
if (resource.kind === 'Job') return resource.status === 'Running' ? 'bg-emerald-400' : 'bg-muted-foreground';
|
||||
if (hasReadyReplica(resource) && resource.status === 'Running') return 'bg-emerald-400';
|
||||
if (resource.status === 'Pending') return 'bg-amber-400';
|
||||
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(resource.status)) return 'bg-rose-400';
|
||||
return 'bg-muted-foreground';
|
||||
}
|
||||
|
||||
function metricKey(namespace: string, pod: string) {
|
||||
return `${namespace}/${pod}`;
|
||||
}
|
||||
|
||||
function parseCpuMillicores(value?: string) {
|
||||
if (!value) return 0;
|
||||
if (value.endsWith('m')) return Number(value.replace('m', '')) || 0;
|
||||
return (Number(value) || 0) * 1000;
|
||||
}
|
||||
|
||||
function parseMemoryMi(value?: string) {
|
||||
if (!value) return 0;
|
||||
const numeric = Number(value.replace(/[^\d.]/g, '')) || 0;
|
||||
if (value.endsWith('Gi')) return numeric * 1024;
|
||||
if (value.endsWith('Ki')) return numeric / 1024;
|
||||
if (value.endsWith('Mi')) return numeric;
|
||||
return numeric / 1024 / 1024;
|
||||
}
|
||||
|
||||
function usagePercent(value: number, max: number) {
|
||||
return Math.max(4, Math.min(100, Math.round((value / max) * 100)));
|
||||
}
|
||||
|
||||
function UsageBars({ metric, compact = false }: { metric?: RuntimePodMetric; compact?: boolean }) {
|
||||
if (!metric) {
|
||||
return <span className="text-xs text-muted-foreground">사용량 -</span>;
|
||||
}
|
||||
|
||||
const cpuPercent = usagePercent(parseCpuMillicores(metric.cpu), 1000);
|
||||
const memoryPercent = usagePercent(parseMemoryMi(metric.memory), 512);
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-2', compact ? 'w-full grid-cols-2' : 'sm:grid-cols-2')}>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>CPU</span>
|
||||
<span>{metric.cpu}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary" style={{ width: `${cpuPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>메모리</span>
|
||||
<span>{metric.memory}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-emerald-400" style={{ width: `${memoryPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuntimeLogsPage() {
|
||||
const [apps, setApps] = useState<ApiApp[]>([]);
|
||||
const [appId, setAppId] = useState('');
|
||||
const [namespace, setNamespace] = useState('');
|
||||
const [data, setData] = useState<RuntimeResourceResponse | null>(null);
|
||||
const [podMetrics, setPodMetrics] = useState<RuntimePodMetric[]>([]);
|
||||
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
||||
const [selectedResource, setSelectedResource] = useState<RuntimeResource | null>(null);
|
||||
const [logs, setLogs] = useState('');
|
||||
const [tail, setTail] = useState(100);
|
||||
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const logContainerRef = useRef<HTMLPreElement | null>(null);
|
||||
const loadingVisible = useMinimumLoading(loading || logLoading);
|
||||
|
||||
const namespaceOptions = useMemo(
|
||||
() => [...new Set(apps.map((app) => app.namespace).filter(Boolean) as string[])],
|
||||
[apps]
|
||||
);
|
||||
const resources = data?.resources ?? [];
|
||||
const activeNamespace = data?.namespace || namespace;
|
||||
const metricByPod = useMemo(() => {
|
||||
const map = new Map<string, RuntimePodMetric>();
|
||||
podMetrics.forEach((metric) => map.set(metricKey(metric.namespace, metric.pod), metric));
|
||||
return map;
|
||||
}, [podMetrics]);
|
||||
const loggableResources = resources.filter(isActiveLogTarget);
|
||||
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
||||
const filteredEvents = events.filter((event) => {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) return true;
|
||||
return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'logs') return;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const container = logContainerRef.current;
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [activeTab, logs, logLoading, selectedResource?.name]);
|
||||
|
||||
const loadLogs = (resource: RuntimeResource, targetNamespace = namespace, nextTail = tail) => {
|
||||
setSelectedResource(resource);
|
||||
setLogLoading(true);
|
||||
setError('');
|
||||
loadRuntimeWorkloadLogs(targetNamespace, apiKind(resource.kind), resource.name, nextTail)
|
||||
.then((result) => setLogs(result.logs ? stripAnsi(result.logs) : '로그가 없습니다.'))
|
||||
.catch((logError: Error) => {
|
||||
setLogs('');
|
||||
setError(logError.message);
|
||||
})
|
||||
.finally(() => setLogLoading(false));
|
||||
};
|
||||
|
||||
const refresh = (nextNamespace = namespace) => {
|
||||
if (!nextNamespace.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
Promise.all([loadRuntimeResources(nextNamespace.trim()), loadRuntimeEvents(nextNamespace.trim()), loadRuntimePodMetrics(nextNamespace.trim()).catch(() => [])])
|
||||
.then(([nextData, nextEvents, nextMetrics]) => {
|
||||
setData(nextData);
|
||||
setEvents(nextEvents);
|
||||
setPodMetrics(nextMetrics);
|
||||
setSelectedResource(null);
|
||||
setLogs('');
|
||||
})
|
||||
.catch((refreshError: Error) => setError(refreshError.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadApps()
|
||||
.then((nextApps) => {
|
||||
const storedAppId = getStoredSelectedAppId();
|
||||
const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
|
||||
const selectedApp = nextApps.find((app) => app.id === nextAppId);
|
||||
const namespaces = [...new Set(nextApps.map((app) => app.namespace).filter(Boolean) as string[])];
|
||||
const firstNamespace = selectedApp?.namespace || namespaces[0] || 'default';
|
||||
setApps(nextApps);
|
||||
setAppId(nextAppId);
|
||||
setStoredSelectedAppId(nextAppId);
|
||||
setNamespace(firstNamespace);
|
||||
return Promise.all([loadRuntimeResources(firstNamespace), loadRuntimeEvents(firstNamespace), loadRuntimePodMetrics(firstNamespace).catch(() => [])] as const);
|
||||
})
|
||||
.then(([nextData, nextEvents, nextMetrics]) => {
|
||||
setData(nextData);
|
||||
setEvents(nextEvents);
|
||||
setPodMetrics(nextMetrics);
|
||||
setSelectedResource(null);
|
||||
setLogs('');
|
||||
})
|
||||
.catch((loadError: Error) => setError(loadError.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="어플리케이션"
|
||||
title="로그/이벤트"
|
||||
description="네임스페이스 기준으로 워크로드 로그와 이벤트를 확인합니다."
|
||||
actions={(
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => refresh()} disabled={loading || !namespace.trim()}>
|
||||
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||
새로고침
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error ? <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">{error}</div> : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">조회 기준</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">앱 그룹과 네임스페이스를 선택해 로그와 이벤트를 조회합니다.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label className="flex items-center gap-3">
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">앱 그룹</span>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-64"
|
||||
value={appId}
|
||||
onChange={(event) => {
|
||||
const nextAppId = event.target.value;
|
||||
const nextApp = apps.find((app) => app.id === nextAppId);
|
||||
const nextNamespace = nextApp?.namespace || namespace;
|
||||
setAppId(nextAppId);
|
||||
setStoredSelectedAppId(nextAppId);
|
||||
setNamespace(nextNamespace);
|
||||
refresh(nextNamespace);
|
||||
}}
|
||||
>
|
||||
{apps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">네임스페이스</span>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-64"
|
||||
value={namespace}
|
||||
onChange={(event) => {
|
||||
setNamespace(event.target.value);
|
||||
refresh(event.target.value);
|
||||
}}
|
||||
>
|
||||
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
{!namespaceOptions.includes(namespace) && namespace ? <option value={namespace}>{namespace}</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">워크로드</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">실행 중인 로그 대상을 선택합니다.</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">총 {loggableResources.length}개</span>
|
||||
</div>
|
||||
<div className="max-h-[620px] space-y-2 overflow-y-auto p-4">
|
||||
{loggableResources.length === 0 ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
실행 중인 로그 대상이 없습니다.
|
||||
</div>
|
||||
) : loggableResources.map((resource) => {
|
||||
const selected = selectedResource?.kind === resource.kind && selectedResource?.name === resource.name;
|
||||
const metric = resource.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, resource.name)) : undefined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${resource.kind}-${resource.name}`}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-3 text-left transition-colors',
|
||||
selected ? 'border-border bg-primary/15 shadow-[inset_4px_0_0_var(--color-primary)]' : 'border-border bg-background/70 hover:bg-muted/20'
|
||||
)}
|
||||
onClick={() => loadLogs(resource)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('size-2 rounded-full', workloadStatusDot(resource))} />
|
||||
<span className="truncate text-sm font-medium">{resource.name}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{resource.kind}</span>
|
||||
<span>준비 <b className="text-foreground">{resource.ready}</b></span>
|
||||
<span>재시작 <b className="text-foreground">{resource.restarts ?? 0}</b></span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<UsageBars metric={metric} compact />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-border px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium', activeTab === 'logs' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
>
|
||||
<TerminalSquare className="size-4" />
|
||||
로그
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium', activeTab === 'events' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => setActiveTab('events')}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
이벤트
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'logs' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-3 text-xs text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={tail}
|
||||
onChange={(event) => {
|
||||
const nextTail = Number(event.target.value);
|
||||
setTail(nextTail);
|
||||
if (selectedResource) loadLogs(selectedResource, namespace, nextTail);
|
||||
}}
|
||||
>
|
||||
<option value={100}>최근 100줄</option>
|
||||
<option value={300}>최근 300줄</option>
|
||||
<option value={1000}>최근 1000줄</option>
|
||||
</select>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedResource || logLoading} onClick={() => selectedResource && loadLogs(selectedResource)}>
|
||||
<RotateCcw className={cn('size-4', logLoading && 'animate-spin')} />
|
||||
로그 새로고침
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full lg:w-80">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="이벤트 검색"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'logs' ? (
|
||||
<div className="min-h-[620px] bg-background">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
대상 <b className="text-foreground">{selectedResource ? `${selectedResource.kind}/${selectedResource.name}` : '-'}</b>
|
||||
</div>
|
||||
{selectedResource ? (
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-[minmax(0,1fr)_120px_120px] lg:min-w-[620px]">
|
||||
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||
<UsageBars metric={selectedMetric} />
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||
<p className="text-muted-foreground">Ready</p>
|
||||
<p className="mt-1 font-semibold text-foreground">{selectedResource.ready}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||
<p className="text-muted-foreground">재시작</p>
|
||||
<p className="mt-1 font-semibold text-foreground">{selectedResource.restarts ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{selectedResource ? (
|
||||
<pre ref={logContainerRef} className="max-h-[640px] overflow-auto whitespace-pre-wrap p-5 font-mono text-xs leading-6 text-muted-foreground">
|
||||
{logLoading ? '로그를 불러오는 중입니다...' : logs || '로그가 없습니다.'}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex min-h-[520px] items-center justify-center text-center text-sm text-muted-foreground">
|
||||
왼쪽에서 워크로드를 선택하면 로그가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[680px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[920px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">Type</th>
|
||||
<th className="px-5 py-3 font-medium">Reason</th>
|
||||
<th className="px-5 py-3 font-medium">Object</th>
|
||||
<th className="px-5 py-3 font-medium">Message</th>
|
||||
<th className="px-5 py-3 font-medium">Count</th>
|
||||
<th className="px-5 py-3 font-medium">Last</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredEvents.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-5 py-14 text-center text-muted-foreground">표시할 이벤트가 없습니다.</td></tr>
|
||||
) : null}
|
||||
{filteredEvents.map((event, index) => (
|
||||
<tr key={`${event.involvedObject}-${event.reason}-${index}`} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3"><Badge tone={eventTone(event.type)}>{event.type ?? '-'}</Badge></td>
|
||||
<td className="px-5 py-3 font-medium">{event.reason ?? '-'}</td>
|
||||
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{event.involvedObject ?? '-'}</td>
|
||||
<td className="max-w-[460px] px-5 py-3 text-muted-foreground"><span className="block truncate">{event.message ?? '-'}</span></td>
|
||||
<td className="px-5 py-3">{event.count ?? '-'}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{formatDate(event.lastTimestamp || event.firstTimestamp)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { filterAppsByCurrentAdmin } from '../../lib/admin-app-permissions';
|
||||
import type { ApiApp, RuntimeEnvProfile, RuntimeEvent, RuntimePodMetric, RuntimeResourceResponse, RuntimeWorkloadLogResponse } from '../../types/runtime';
|
||||
|
||||
export function loadApps() {
|
||||
return apiRequest<ApiApp[]>('/apps').then(filterAppsByCurrentAdmin);
|
||||
}
|
||||
|
||||
export function loadRuntimeProfiles(appId?: string) {
|
||||
const query = appId ? `?appId=${encodeURIComponent(appId)}` : '';
|
||||
return apiRequest<RuntimeEnvProfile[]>(`/apps/runtime-profiles${query}`);
|
||||
}
|
||||
|
||||
export function createRuntimeProfile(input: { appId: string; name: string; environment: string; namespace: string }) {
|
||||
return apiRequest<{ profile: RuntimeEnvProfile }>('/apps/runtime-profiles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function createRuntimeVariable(
|
||||
profileId: string,
|
||||
input: { componentId: string; key: string; value: string; isSecret: boolean }
|
||||
) {
|
||||
return apiRequest(`/apps/runtime-profiles/${profileId}/variables`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function createRuntimeIngressRule(
|
||||
profileId: string,
|
||||
input: { componentId: string; host: string; path?: string; servicePort: number; tlsEnabled?: boolean; tlsSecretName?: string }
|
||||
) {
|
||||
return apiRequest(`/apps/runtime-profiles/${profileId}/ingress-rules`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRuntimeIngressRule(profileId: string, ruleId: string) {
|
||||
return apiRequest(`/apps/runtime-profiles/${profileId}/ingress-rules/${ruleId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRuntimeVariable(profileId: string, variableId: string) {
|
||||
return apiRequest(`/apps/runtime-profiles/${profileId}/variables/${variableId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export function applyRuntimeProfile(profileId: string) {
|
||||
return apiRequest<{ profile: RuntimeEnvProfile; logs: string }>(`/apps/runtime-profiles/${profileId}/apply`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
}
|
||||
|
||||
export function loadRuntimeResources(namespace: string) {
|
||||
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
|
||||
}
|
||||
|
||||
export function loadRuntimePodMetrics(namespace?: string) {
|
||||
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
||||
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods${query}`);
|
||||
}
|
||||
|
||||
export function loadRuntimeEvents(namespace?: string) {
|
||||
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
||||
return apiRequest<RuntimeEvent[]>(`/runtime/events${query}`);
|
||||
}
|
||||
|
||||
export function loadRuntimeWorkloadLogs(namespace: string, kind: string, name: string, tail = 100) {
|
||||
return apiRequest<RuntimeWorkloadLogResponse>(
|
||||
`/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/logs?tail=${tail}`
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureRuntimeNamespace(namespace: string) {
|
||||
return apiRequest<{ namespace: string; created: boolean; logs: string }>('/apps/runtime-namespaces', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ namespace })
|
||||
});
|
||||
}
|
||||
|
||||
export function scaleRuntimeWorkload(namespace: string, kind: string, name: string, replicas: number) {
|
||||
return apiRequest(`/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/scale`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ replicas })
|
||||
});
|
||||
}
|
||||
|
||||
export function restartRuntimeWorkload(namespace: string, kind: string, name: string) {
|
||||
return apiRequest(`/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/restart`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user