This commit is contained in:
+157
-27
@@ -5,7 +5,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
ExternalLink,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Search,
|
Search,
|
||||||
SlidersHorizontal
|
SlidersHorizontal
|
||||||
@@ -42,6 +41,12 @@ import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage';
|
|||||||
import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage';
|
import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage';
|
||||||
import { CommonAppLogsPage } from './pages/common-apps/CommonAppLogsPage';
|
import { CommonAppLogsPage } from './pages/common-apps/CommonAppLogsPage';
|
||||||
import { CommonAppSourcePage } from './pages/common-apps/CommonAppSourcePage';
|
import { CommonAppSourcePage } from './pages/common-apps/CommonAppSourcePage';
|
||||||
|
import {
|
||||||
|
InfrastructureClusterPage,
|
||||||
|
InfrastructureNetworkPage,
|
||||||
|
InfrastructureStoragePage,
|
||||||
|
InfrastructureWorkloadsPage
|
||||||
|
} from './pages/infrastructure/InfrastructurePages';
|
||||||
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
AgentCommandsPage as AgentCommandsRestoredPage,
|
AgentCommandsPage as AgentCommandsRestoredPage,
|
||||||
@@ -83,6 +88,10 @@ type ViewKey =
|
|||||||
| 'tailscaleStatus'
|
| 'tailscaleStatus'
|
||||||
| 'tailscaleRoutes'
|
| 'tailscaleRoutes'
|
||||||
| 'tailscaleAcl'
|
| 'tailscaleAcl'
|
||||||
|
| 'infraCluster'
|
||||||
|
| 'infraWorkloads'
|
||||||
|
| 'infraNetwork'
|
||||||
|
| 'infraStorage'
|
||||||
| 'agentRegistration'
|
| 'agentRegistration'
|
||||||
| 'agentInstallStatus'
|
| 'agentInstallStatus'
|
||||||
| 'agentSshKeys'
|
| 'agentSshKeys'
|
||||||
@@ -116,6 +125,10 @@ const viewByLabel: Record<string, ViewKey> = {
|
|||||||
'노드 현황': 'tailscaleStatus',
|
'노드 현황': 'tailscaleStatus',
|
||||||
라우트: 'tailscaleRoutes',
|
라우트: 'tailscaleRoutes',
|
||||||
ACL: 'tailscaleAcl',
|
ACL: 'tailscaleAcl',
|
||||||
|
클러스터: 'infraCluster',
|
||||||
|
워크로드: 'infraWorkloads',
|
||||||
|
네트워크: 'infraNetwork',
|
||||||
|
스토리지: 'infraStorage',
|
||||||
'장비 등록': 'agentRegistration',
|
'장비 등록': 'agentRegistration',
|
||||||
'설치 상태': 'agentInstallStatus',
|
'설치 상태': 'agentInstallStatus',
|
||||||
'SSH 키 배포': 'agentSshKeys',
|
'SSH 키 배포': 'agentSshKeys',
|
||||||
@@ -155,6 +168,10 @@ const pathByView: Record<ViewKey, string> = {
|
|||||||
tailscaleStatus: '/tailscale/status',
|
tailscaleStatus: '/tailscale/status',
|
||||||
tailscaleRoutes: '/tailscale/routes',
|
tailscaleRoutes: '/tailscale/routes',
|
||||||
tailscaleAcl: '/tailscale/acl',
|
tailscaleAcl: '/tailscale/acl',
|
||||||
|
infraCluster: '/infrastructure/cluster',
|
||||||
|
infraWorkloads: '/infrastructure/workloads',
|
||||||
|
infraNetwork: '/infrastructure/network',
|
||||||
|
infraStorage: '/infrastructure/storage',
|
||||||
agentRegistration: '/agents/registration',
|
agentRegistration: '/agents/registration',
|
||||||
agentInstallStatus: '/agents/install-status',
|
agentInstallStatus: '/agents/install-status',
|
||||||
agentSshKeys: '/agents/ssh-keys',
|
agentSshKeys: '/agents/ssh-keys',
|
||||||
@@ -300,6 +317,29 @@ type RuntimePodMetric = {
|
|||||||
memory: string;
|
memory: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClusterMetricValue = {
|
||||||
|
usage: string | null;
|
||||||
|
capacity: string | null;
|
||||||
|
percent: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClusterNodeMetric = {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
cpu: ClusterMetricValue;
|
||||||
|
memory: ClusterMetricValue;
|
||||||
|
storage: ClusterMetricValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClusterMetrics = {
|
||||||
|
nodes: ClusterNodeMetric[];
|
||||||
|
summary: {
|
||||||
|
cpuPercent: number | null;
|
||||||
|
memoryPercent: number | null;
|
||||||
|
storagePercent: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type DashboardJob = {
|
type DashboardJob = {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -393,6 +433,10 @@ function loadRuntimePodMetrics(namespace: string) {
|
|||||||
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods?namespace=${encodeURIComponent(namespace)}`);
|
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods?namespace=${encodeURIComponent(namespace)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadClusterMetrics() {
|
||||||
|
return apiRequest<ClusterMetrics>('/runtime/metrics/cluster');
|
||||||
|
}
|
||||||
|
|
||||||
const statusTone = {
|
const statusTone = {
|
||||||
success: 'green',
|
success: 'green',
|
||||||
succeeded: 'green',
|
succeeded: 'green',
|
||||||
@@ -426,13 +470,35 @@ const statusTone = {
|
|||||||
잠김: 'red',
|
잠김: 'red',
|
||||||
구성완료: 'green',
|
구성완료: 'green',
|
||||||
검토필요: 'amber',
|
검토필요: 'amber',
|
||||||
완료: 'green'
|
완료: 'green',
|
||||||
|
성공: 'green',
|
||||||
|
실패: 'red',
|
||||||
|
취소: 'neutral'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function getTone(status: string) {
|
function getTone(status: string) {
|
||||||
return statusTone[status as keyof typeof statusTone] ?? 'neutral';
|
return statusTone[status as keyof typeof statusTone] ?? 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPipelineStatus(status?: string | null) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
success: '성공',
|
||||||
|
succeeded: '성공',
|
||||||
|
done: '성공',
|
||||||
|
running: '실행 중',
|
||||||
|
started: '실행 중',
|
||||||
|
pending: '대기',
|
||||||
|
blocked: '대기',
|
||||||
|
created: '생성됨',
|
||||||
|
failure: '실패',
|
||||||
|
failed: '실패',
|
||||||
|
error: '오류',
|
||||||
|
killed: '중단'
|
||||||
|
};
|
||||||
|
const normalized = (status ?? '').toLowerCase();
|
||||||
|
return labels[normalized] ?? status ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: number) {
|
function formatRelativeTime(timestamp: number) {
|
||||||
if (!timestamp) return '-';
|
if (!timestamp) return '-';
|
||||||
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
||||||
@@ -983,7 +1049,7 @@ function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVer
|
|||||||
const activeVersion = versions.find((version) => version.isActive) ?? versions[0];
|
const activeVersion = versions.find((version) => version.isActive) ?? versions[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="flex min-h-[520px] min-w-0 flex-col self-stretch overflow-hidden">
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="런처 버전 관리"
|
title="런처 버전 관리"
|
||||||
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태를 관리합니다."
|
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태를 관리합니다."
|
||||||
@@ -1054,7 +1120,7 @@ function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVer
|
|||||||
|
|
||||||
function CommonAppsPanel({ pipelines, loading, onOpen }: { pipelines: CommonAppPipeline[]; loading: boolean; onOpen: () => void }) {
|
function CommonAppsPanel({ pipelines, loading, onOpen }: { pipelines: CommonAppPipeline[]; loading: boolean; onOpen: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="flex min-h-[520px] min-w-0 flex-col overflow-hidden">
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="공통앱"
|
title="공통앱"
|
||||||
description="공통앱의 최근 빌드 상태를 확인합니다."
|
description="공통앱의 최근 빌드 상태를 확인합니다."
|
||||||
@@ -1085,7 +1151,7 @@ function CommonAppsPanel({ pipelines, loading, onOpen }: { pipelines: CommonAppP
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Badge tone={getTone(pipeline.status)}>{pipeline.status}</Badge>
|
<Badge tone={getTone(pipeline.status)}>{formatPipelineStatus(pipeline.status)}</Badge>
|
||||||
<span>{pipeline.branch}</span>
|
<span>{pipeline.branch}</span>
|
||||||
<span>{formatRelativeTime(pipeline.createdAt)}</span>
|
<span>{formatRelativeTime(pipeline.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1195,13 +1261,11 @@ function AppRegistryTable({
|
|||||||
action={<DashboardLinkButton onClick={onOpen} label="앱 관리로 이동" />}
|
action={<DashboardLinkButton onClick={onOpen} label="앱 관리로 이동" />}
|
||||||
/>
|
/>
|
||||||
<div className="max-h-[444px] overflow-auto">
|
<div className="max-h-[444px] overflow-auto">
|
||||||
<table className="w-full min-w-[980px] text-left text-sm">
|
<table className="w-full min-w-[620px] text-left text-sm">
|
||||||
<thead className="border-b border-border text-xs text-muted-foreground">
|
<thead className="border-b border-border text-xs text-muted-foreground">
|
||||||
<tr>
|
<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">상태</th>
|
|
||||||
<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">저장소/경로</th>
|
|
||||||
<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">구성요소</th>
|
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1214,15 +1278,9 @@ function AppRegistryTable({
|
|||||||
<SkeletonBlock className="h-4 w-32" />
|
<SkeletonBlock className="h-4 w-32" />
|
||||||
<SkeletonBlock className="mt-2 h-3 w-44" />
|
<SkeletonBlock className="mt-2 h-3 w-44" />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
|
||||||
<SkeletonBlock className="h-5 w-14 rounded-md" />
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<SkeletonBlock className="h-4 w-24" />
|
<SkeletonBlock className="h-4 w-24" />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
|
||||||
<SkeletonBlock className="h-4 w-52" />
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<SkeletonBlock className="h-4 w-8" />
|
<SkeletonBlock className="h-4 w-8" />
|
||||||
</td>
|
</td>
|
||||||
@@ -1233,12 +1291,11 @@ function AppRegistryTable({
|
|||||||
))
|
))
|
||||||
) : dashboardApps.length === 0 ? (
|
) : dashboardApps.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-5 py-12 text-center text-muted-foreground">등록된 앱이 없습니다.</td>
|
<td colSpan={4} className="px-5 py-12 text-center text-muted-foreground">등록된 앱이 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && dashboardApps.map((app) => {
|
{!loading && dashboardApps.map((app) => {
|
||||||
const latestRelease = latestReleaseByApp[app.id];
|
const latestRelease = latestReleaseByApp[app.id];
|
||||||
const firstRepository = app.components?.find((component) => component.repository)?.repository;
|
|
||||||
const workloadCount = app.components?.filter((component) => ['CONTAINER', 'DATABASE', 'MIGRATION'].includes(component.type)).length ?? 0;
|
const workloadCount = app.components?.filter((component) => ['CONTAINER', 'DATABASE', 'MIGRATION'].includes(component.type)).length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1248,14 +1305,14 @@ function AppRegistryTable({
|
|||||||
<p className="mt-1 text-xs text-muted-foreground">{app.key} · {app.namespace ?? '네임스페이스 없음'}</p>
|
<p className="mt-1 text-xs text-muted-foreground">{app.key} · {app.namespace ?? '네임스페이스 없음'}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<Badge tone={getTone(app.status)}>{app.status}</Badge>
|
{latestRelease ? (
|
||||||
</td>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<td className="px-5 py-3 text-muted-foreground">{latestRelease ? `${latestRelease.version} · ${formatJobStatus(latestRelease.status)}` : '-'}</td>
|
<Badge tone={getTone(formatJobStatus(latestRelease.status))}>{formatJobStatus(latestRelease.status)}</Badge>
|
||||||
<td className="px-5 py-3">
|
<span className="font-medium text-foreground">{latestRelease.version}</span>
|
||||||
<span className="inline-flex max-w-64 items-center gap-1 truncate text-muted-foreground">
|
</div>
|
||||||
{firstRepository ?? '-'}
|
) : (
|
||||||
{firstRepository ? <ExternalLink className="size-3 shrink-0" /> : null}
|
<span className="text-muted-foreground">-</span>
|
||||||
</span>
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">{workloadCount}</td>
|
<td className="px-5 py-3">{workloadCount}</td>
|
||||||
<td className="px-5 py-3">{app.components?.length ?? 0}</td>
|
<td className="px-5 py-3">{app.components?.length ?? 0}</td>
|
||||||
@@ -1269,6 +1326,66 @@ function AppRegistryTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ServerResourcePanel({ metrics, loading }: { metrics: ClusterMetrics | null; loading: boolean }) {
|
||||||
|
const resources = [
|
||||||
|
{ label: 'CPU', value: metrics?.summary.cpuPercent ?? null },
|
||||||
|
{ label: 'Memory', value: metrics?.summary.memoryPercent ?? null },
|
||||||
|
{ label: 'Storage', value: metrics?.summary.storagePercent ?? null }
|
||||||
|
];
|
||||||
|
const nodeCount = metrics?.nodes.length ?? 0;
|
||||||
|
const nodeNames = metrics?.nodes.map((node) => node.name).join(', ') || '-';
|
||||||
|
const primaryNode = metrics?.nodes[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
|
title="서버 리소스"
|
||||||
|
description={nodeCount ? `k3s 노드 ${nodeCount}개 기준 · ${nodeNames}` : 'k3s 서버 자원 사용률'}
|
||||||
|
/>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col justify-between gap-8 p-5">
|
||||||
|
<div className="space-y-5">
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<div key={resource.label}>
|
||||||
|
<div className="mb-2 flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium text-foreground">{resource.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{loading ? '-' : resource.value === null ? '-' : `${resource.value}%`}</span>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<SkeletonBlock className="h-2 w-full rounded-full" />
|
||||||
|
) : (
|
||||||
|
<Progress value={resource.value ?? 0} tone={resource.value !== null && resource.value >= 85 ? 'red' : resource.value !== null && resource.value >= 70 ? 'amber' : 'green'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 px-3 py-3">
|
||||||
|
<div className="grid gap-2 text-xs">
|
||||||
|
<ServerResourceDetail label="노드 상태" value={loading ? '-' : primaryNode?.status ?? '-'} />
|
||||||
|
<ServerResourceDetail label="CPU" value={formatResourceDetail(primaryNode?.cpu)} />
|
||||||
|
<ServerResourceDetail label="Memory" value={formatResourceDetail(primaryNode?.memory)} />
|
||||||
|
<ServerResourceDetail label="Storage" value={formatResourceDetail(primaryNode?.storage)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerResourceDetail({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="min-w-0 truncate text-right font-medium text-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResourceDetail(metric?: ClusterMetricValue | null) {
|
||||||
|
if (!metric) return '-';
|
||||||
|
if (!metric.usage && !metric.capacity) return '-';
|
||||||
|
return `${metric.usage ?? '-'} / ${metric.capacity ?? '-'}`;
|
||||||
|
}
|
||||||
|
|
||||||
function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
|
function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
|
||||||
const visibleResources = resources.filter((resource) => !isIgnoredRuntimeResource(resource));
|
const visibleResources = resources.filter((resource) => !isIgnoredRuntimeResource(resource));
|
||||||
|
|
||||||
@@ -1416,6 +1533,7 @@ function DashboardPage({
|
|||||||
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
|
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
|
||||||
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
|
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
|
||||||
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
|
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
|
||||||
|
const [clusterMetrics, setClusterMetrics] = useState<ClusterMetrics | null>(null);
|
||||||
const [dashboardLoading, setDashboardLoading] = useState(true);
|
const [dashboardLoading, setDashboardLoading] = useState(true);
|
||||||
const loadingVisible = useMinimumLoading(dashboardLoading);
|
const loadingVisible = useMinimumLoading(dashboardLoading);
|
||||||
|
|
||||||
@@ -1424,13 +1542,14 @@ function DashboardPage({
|
|||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
setDashboardLoading(true);
|
setDashboardLoading(true);
|
||||||
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult] = await Promise.allSettled([
|
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult, clusterMetricsResult] = await Promise.allSettled([
|
||||||
loadLauncherVersions(),
|
loadLauncherVersions(),
|
||||||
loadApps(),
|
loadApps(),
|
||||||
loadAppReleases(),
|
loadAppReleases(),
|
||||||
loadUserSessions(),
|
loadUserSessions(),
|
||||||
loadNotices(),
|
loadNotices(),
|
||||||
loadCommonAppPipelines()
|
loadCommonAppPipelines(),
|
||||||
|
loadClusterMetrics()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const nextVersions = versionsResult.status === 'fulfilled' ? versionsResult.value : [];
|
const nextVersions = versionsResult.status === 'fulfilled' ? versionsResult.value : [];
|
||||||
@@ -1439,6 +1558,7 @@ function DashboardPage({
|
|||||||
const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
|
const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
|
||||||
const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : [];
|
const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : [];
|
||||||
const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : [];
|
const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : [];
|
||||||
|
const nextClusterMetrics = clusterMetricsResult.status === 'fulfilled' ? clusterMetricsResult.value : null;
|
||||||
const nextActiveWork = await loadActiveWorkSummary(nextReleases);
|
const nextActiveWork = await loadActiveWorkSummary(nextReleases);
|
||||||
const namespaceApps = nextApps.filter((app) => app.namespace);
|
const namespaceApps = nextApps.filter((app) => app.namespace);
|
||||||
const [runtimeResults, metricResults] = await Promise.all([
|
const [runtimeResults, metricResults] = await Promise.all([
|
||||||
@@ -1480,6 +1600,7 @@ function DashboardPage({
|
|||||||
setCommonAppPipelines(nextCommonApps);
|
setCommonAppPipelines(nextCommonApps);
|
||||||
setDashboardNotices(nextNotices);
|
setDashboardNotices(nextNotices);
|
||||||
setRuntimeResources(nextRuntimeResources);
|
setRuntimeResources(nextRuntimeResources);
|
||||||
|
setClusterMetrics(nextClusterMetrics);
|
||||||
setDashboardLoading(false);
|
setDashboardLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1508,8 +1629,9 @@ function DashboardPage({
|
|||||||
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} onOpen={onCommonBuildsOpen} />
|
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} onOpen={onCommonBuildsOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4 grid items-stretch gap-4 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.55fr)]">
|
||||||
<AppRegistryTable apps={dashboardApps} releases={dashboardReleases} loading={dashboardLoading} onOpen={onAppManagementOpen} />
|
<AppRegistryTable apps={dashboardApps} releases={dashboardReleases} loading={dashboardLoading} onOpen={onAppManagementOpen} />
|
||||||
|
<ServerResourcePanel metrics={clusterMetrics} loading={dashboardLoading} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||||
@@ -1635,6 +1757,14 @@ function App() {
|
|||||||
return <TailscaleRoutesRestoredPage />;
|
return <TailscaleRoutesRestoredPage />;
|
||||||
case 'tailscaleAcl':
|
case 'tailscaleAcl':
|
||||||
return <TailscaleAclRestoredPage />;
|
return <TailscaleAclRestoredPage />;
|
||||||
|
case 'infraCluster':
|
||||||
|
return <InfrastructureClusterPage />;
|
||||||
|
case 'infraWorkloads':
|
||||||
|
return <InfrastructureWorkloadsPage />;
|
||||||
|
case 'infraNetwork':
|
||||||
|
return <InfrastructureNetworkPage />;
|
||||||
|
case 'infraStorage':
|
||||||
|
return <InfrastructureStoragePage />;
|
||||||
case 'agentRegistration':
|
case 'agentRegistration':
|
||||||
return <AgentRegistrationRestoredPage />;
|
return <AgentRegistrationRestoredPage />;
|
||||||
case 'agentInstallStatus':
|
case 'agentInstallStatus':
|
||||||
|
|||||||
+13
-3
@@ -3,6 +3,7 @@ import {
|
|||||||
Boxes,
|
Boxes,
|
||||||
ChartNoAxesColumnIncreasing,
|
ChartNoAxesColumnIncreasing,
|
||||||
FileKey,
|
FileKey,
|
||||||
|
HardDrive,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
@@ -65,7 +66,7 @@ export const navGroups = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Agent',
|
label: '에이전트',
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
children: [
|
children: [
|
||||||
{ label: '장비 등록' },
|
{ label: '장비 등록' },
|
||||||
@@ -84,6 +85,16 @@ export const navGroups = [
|
|||||||
{ label: 'Agent 관리' },
|
{ label: 'Agent 관리' },
|
||||||
{ label: '장비 동기화' }
|
{ label: '장비 동기화' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '인프라',
|
||||||
|
icon: HardDrive,
|
||||||
|
children: [
|
||||||
|
{ label: '클러스터', view: 'infraCluster' },
|
||||||
|
{ label: '워크로드', view: 'infraWorkloads' },
|
||||||
|
{ label: '네트워크', view: 'infraNetwork' },
|
||||||
|
{ label: '스토리지', view: 'infraStorage' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -101,8 +112,7 @@ export const navGroups = [
|
|||||||
label: '시스템',
|
label: '시스템',
|
||||||
items: [
|
items: [
|
||||||
{ label: '모니터링', icon: ChartNoAxesColumnIncreasing },
|
{ label: '모니터링', icon: ChartNoAxesColumnIncreasing },
|
||||||
{ label: '정책', icon: ShieldCheck },
|
{ label: '정책', icon: ShieldCheck }
|
||||||
{ label: '인프라', icon: ServerCog }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Boxes,
|
Boxes,
|
||||||
ChartNoAxesColumnIncreasing,
|
ChartNoAxesColumnIncreasing,
|
||||||
FileKey,
|
FileKey,
|
||||||
|
HardDrive,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
Network,
|
Network,
|
||||||
@@ -72,7 +73,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Agent',
|
label: '에이전트',
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
children: [
|
children: [
|
||||||
{ label: '장비 등록', path: '/agents/registration' },
|
{ label: '장비 등록', path: '/agents/registration' },
|
||||||
@@ -91,6 +92,16 @@ export const navGroups: NavGroup[] = [
|
|||||||
{ label: 'Agent 관리', path: '/gateway/agents' },
|
{ label: 'Agent 관리', path: '/gateway/agents' },
|
||||||
{ label: '장비 동기화', path: '/gateway/devices' }
|
{ label: '장비 동기화', path: '/gateway/devices' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '인프라',
|
||||||
|
icon: HardDrive,
|
||||||
|
children: [
|
||||||
|
{ label: '클러스터', path: '/infrastructure/cluster' },
|
||||||
|
{ label: '워크로드', path: '/infrastructure/workloads' },
|
||||||
|
{ label: '네트워크', path: '/infrastructure/network' },
|
||||||
|
{ label: '스토리지', path: '/infrastructure/storage' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { RotateCcw } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||||
|
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { runtimeStatusDotClass } from '../../lib/status-dot';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import type { RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
||||||
|
import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources } from '../runtime/runtime-api';
|
||||||
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
|
|
||||||
|
const defaultNamespaces = ['studio-system', 'stdo-lms', 'stdo-ads-ide'];
|
||||||
|
|
||||||
|
type NamespaceRuntime = RuntimeResourceResponse & {
|
||||||
|
failed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InfrastructureData = {
|
||||||
|
namespaces: string[];
|
||||||
|
runtimes: NamespaceRuntime[];
|
||||||
|
metrics: RuntimePodMetric[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourceRow = RuntimeResource & {
|
||||||
|
id: string;
|
||||||
|
namespace: string;
|
||||||
|
metric?: RuntimePodMetric;
|
||||||
|
};
|
||||||
|
|
||||||
|
function uniqueNamespaces(values: Array<string | null | undefined>) {
|
||||||
|
return [...new Set(values.filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkload(resource: RuntimeResource) {
|
||||||
|
return ['Deployment', 'StatefulSet', 'DaemonSet', 'Pod', 'Job', 'CronJob', 'ReplicaSet'].includes(resource.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNetwork(resource: RuntimeResource) {
|
||||||
|
return ['Service', 'Ingress', 'Endpoint', 'Endpoints'].includes(resource.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStorage(resource: RuntimeResource) {
|
||||||
|
return ['PersistentVolumeClaim', 'PersistentVolume', 'StorageClass'].includes(resource.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceStatusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||||
|
const normalized = status.toLowerCase();
|
||||||
|
if (['running', 'ready', 'active', 'bound'].some((word) => normalized.includes(word))) return 'green';
|
||||||
|
if (['pending', 'progressing', 'containercreating'].some((word) => normalized.includes(word))) return 'amber';
|
||||||
|
if (['failed', 'error', 'crash', 'unhealthy', 'notready'].some((word) => normalized.includes(word))) return 'red';
|
||||||
|
if (['completed', 'succeeded'].some((word) => normalized.includes(word))) return 'neutral';
|
||||||
|
return 'blue';
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricFor(resource: ResourceRow, metrics: RuntimePodMetric[]) {
|
||||||
|
if (resource.kind !== 'Pod') return undefined;
|
||||||
|
return metrics.find((metric) => metric.namespace === resource.namespace && metric.pod === resource.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInfrastructureData() {
|
||||||
|
const [data, setData] = useState<InfrastructureData>({ namespaces: defaultNamespaces, runtimes: [], metrics: [] });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
loadApps().catch(() => []),
|
||||||
|
loadRuntimePodMetrics().catch(() => [] as RuntimePodMetric[])
|
||||||
|
])
|
||||||
|
.then(async ([apps, metrics]) => {
|
||||||
|
const namespaces = uniqueNamespaces([...defaultNamespaces, ...apps.map((app) => app.namespace)]);
|
||||||
|
const runtimes = await Promise.all(
|
||||||
|
namespaces.map((namespace) => (
|
||||||
|
loadRuntimeResources(namespace)
|
||||||
|
.then((runtime) => runtime)
|
||||||
|
.catch(() => ({ namespace, exists: false, failed: true, summary: { pods: 0, deployments: 0, statefulSets: 0, services: 0, persistentVolumeClaims: 0 }, resources: [] }))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
setData({ namespaces, runtimes, metrics });
|
||||||
|
setFailed(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setFailed(true);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { data, loading, loadingVisible, failed, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsFromData(data: InfrastructureData, predicate: (resource: RuntimeResource) => boolean) {
|
||||||
|
return data.runtimes.flatMap((runtime) => (
|
||||||
|
runtime.resources
|
||||||
|
.filter(predicate)
|
||||||
|
.map((resource) => {
|
||||||
|
const row = {
|
||||||
|
...resource,
|
||||||
|
namespace: runtime.namespace,
|
||||||
|
id: `${runtime.namespace}:${resource.kind}:${resource.name}`
|
||||||
|
};
|
||||||
|
return { ...row, metric: metricFor(row, data.metrics) };
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceStatus({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<span className={cn('size-2.5 shrink-0 rounded-full', runtimeStatusDotClass(status))} />
|
||||||
|
<Badge tone={resourceStatusTone(status)}>{status || '-'}</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NamespaceFilter(rows: ResourceRow[]): DataTableFilter<ResourceRow> {
|
||||||
|
return {
|
||||||
|
key: 'namespace',
|
||||||
|
label: '네임스페이스',
|
||||||
|
options: uniqueNamespaces(rows.map((row) => row.namespace)).map((namespace) => ({ label: namespace, value: namespace })),
|
||||||
|
getValue: (row) => row.namespace
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function KindFilter(rows: ResourceRow[]): DataTableFilter<ResourceRow> {
|
||||||
|
return {
|
||||||
|
key: 'kind',
|
||||||
|
label: '종류',
|
||||||
|
options: uniqueNamespaces(rows.map((row) => row.kind)).map((kind) => ({ label: kind, value: kind })),
|
||||||
|
getValue: (row) => row.kind
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfraPageShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
loading,
|
||||||
|
loadingVisible,
|
||||||
|
failed,
|
||||||
|
onRefresh
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: ReactNode;
|
||||||
|
loading: boolean;
|
||||||
|
loadingVisible: boolean;
|
||||||
|
failed: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="인프라"
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={onRefresh} disabled={loading}>
|
||||||
|
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{failed ? (
|
||||||
|
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
|
인프라 데이터를 불러오지 못했습니다.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceTable({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
rows,
|
||||||
|
loading,
|
||||||
|
emptyMessage
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
rows: ResourceRow[];
|
||||||
|
loading: boolean;
|
||||||
|
emptyMessage: string;
|
||||||
|
}) {
|
||||||
|
const columns = useMemo<Array<DataTableColumn<ResourceRow>>>(
|
||||||
|
() => [
|
||||||
|
{ key: 'namespace', header: '네임스페이스', className: 'w-44 px-5 py-3', cell: (row) => <span className="font-medium">{row.namespace}</span> },
|
||||||
|
{ key: 'kind', header: '종류', className: 'w-40 px-5 py-3', cell: (row) => <Badge tone="neutral">{row.kind}</Badge> },
|
||||||
|
{ key: 'name', header: '이름', className: 'min-w-[240px] px-5 py-3', cell: (row) => <span className="font-medium">{row.name}</span> },
|
||||||
|
{ key: 'status', header: '상태', className: 'w-44 px-5 py-3', cell: (row) => <ResourceStatus status={row.status} /> },
|
||||||
|
{ key: 'ready', header: 'Ready', className: 'w-28 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.ready || '-'}</span> },
|
||||||
|
{ key: 'restarts', header: '재시작', className: 'w-24 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.restarts ?? '-'}</span> },
|
||||||
|
{ key: 'metric', header: '사용량', className: 'w-40 px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.metric ? `${row.metric.cpu} / ${row.metric.memory}` : '-'}</span> },
|
||||||
|
{ key: 'age', header: 'Age', className: 'w-28 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.age || '-'}</span> },
|
||||||
|
{ key: 'detail', header: '상세', className: 'min-w-[260px] px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.detail || '-'}</span> }
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filters = useMemo(() => [NamespaceFilter(rows), KindFilter(rows)], [rows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTableCard
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
filters={filters}
|
||||||
|
searchPlaceholder="네임스페이스, 이름, 상태 검색"
|
||||||
|
getSearchText={(row) => `${row.namespace} ${row.kind} ${row.name} ${row.status} ${row.detail}`}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
pageSize={10}
|
||||||
|
minWidth={1280}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfrastructureClusterPage() {
|
||||||
|
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||||
|
|
||||||
|
const totals = useMemo(() => data.runtimes.reduce(
|
||||||
|
(acc, runtime) => ({
|
||||||
|
namespaces: acc.namespaces + 1,
|
||||||
|
resources: acc.resources + runtime.resources.length,
|
||||||
|
pods: acc.pods + runtime.summary.pods,
|
||||||
|
deployments: acc.deployments + runtime.summary.deployments,
|
||||||
|
services: acc.services + runtime.summary.services,
|
||||||
|
pvc: acc.pvc + runtime.summary.persistentVolumeClaims,
|
||||||
|
failed: acc.failed + (runtime.failed || !runtime.exists ? 1 : 0)
|
||||||
|
}),
|
||||||
|
{ namespaces: 0, resources: 0, pods: 0, deployments: 0, services: 0, pvc: 0, failed: 0 }
|
||||||
|
), [data.runtimes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfraPageShell title="클러스터" description="네임스페이스별 런타임 리소스 요약을 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||||
|
{[
|
||||||
|
['네임스페이스', totals.namespaces],
|
||||||
|
['전체 리소스', totals.resources],
|
||||||
|
['Pod', totals.pods],
|
||||||
|
['Deployment', totals.deployments],
|
||||||
|
['Service', totals.services],
|
||||||
|
['PVC', totals.pvc]
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<Card key={label} className="px-5 py-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-foreground">{value}</p>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-4 overflow-hidden">
|
||||||
|
<CardHeader title="네임스페이스" description="조회 가능한 네임스페이스의 리소스 구성입니다." />
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{data.runtimes.map((runtime) => (
|
||||||
|
<div key={runtime.namespace} className="grid gap-3 px-5 py-4 text-sm md:grid-cols-[minmax(180px,1fr)_repeat(5,minmax(80px,auto))] md:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-2.5 rounded-full', runtime.failed || !runtime.exists ? 'bg-rose-400 shadow-[0_0_0_3px_rgba(251,113,133,0.15)]' : 'bg-emerald-400 shadow-[0_0_0_3px_rgba(52,211,153,0.15)]')} />
|
||||||
|
<p className="truncate font-semibold">{runtime.namespace}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{runtime.failed ? '조회 실패' : runtime.exists ? '조회 가능' : '네임스페이스 없음'}</p>
|
||||||
|
</div>
|
||||||
|
<SummaryValue label="Pod" value={runtime.summary.pods} />
|
||||||
|
<SummaryValue label="Deployment" value={runtime.summary.deployments} />
|
||||||
|
<SummaryValue label="StatefulSet" value={runtime.summary.statefulSets} />
|
||||||
|
<SummaryValue label="Service" value={runtime.summary.services} />
|
||||||
|
<SummaryValue label="PVC" value={runtime.summary.persistentVolumeClaims} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</InfraPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryValue({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 font-semibold text-foreground">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfrastructureWorkloadsPage() {
|
||||||
|
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||||
|
const rows = useMemo(() => rowsFromData(data, isWorkload), [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfraPageShell title="워크로드" description="Deployment, StatefulSet, Pod 등 실행 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||||
|
<ResourceTable title="워크로드 목록" description="실행 상태, Ready, 재시작 횟수와 Pod 메트릭을 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 워크로드가 없습니다." />
|
||||||
|
</InfraPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfrastructureNetworkPage() {
|
||||||
|
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||||
|
const rows = useMemo(() => rowsFromData(data, isNetwork), [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfraPageShell title="네트워크" description="Service, Ingress 등 네트워크 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||||
|
<ResourceTable title="네트워크 리소스" description="서비스와 인그레스 연결 정보를 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 네트워크 리소스가 없습니다." />
|
||||||
|
</InfraPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfrastructureStoragePage() {
|
||||||
|
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||||
|
const rows = useMemo(() => rowsFromData(data, isStorage), [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfraPageShell title="스토리지" description="PVC, PV, StorageClass 등 저장소 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||||
|
<ResourceTable title="스토리지 리소스" description="영구 볼륨과 스토리지 연결 상태를 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 스토리지 리소스가 없습니다." />
|
||||||
|
</InfraPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user