From 04432a3420344fcc62b0640e2e7ef5f35a8005c0 Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 9 Jul 2026 11:55:52 +0900 Subject: [PATCH] refine dashboard infrastructure and resource status --- src/App.tsx | 184 ++++++++-- src/data/mock.ts | 16 +- src/layout/navigation.ts | 13 +- .../infrastructure/InfrastructurePages.tsx | 328 ++++++++++++++++++ 4 files changed, 510 insertions(+), 31 deletions(-) create mode 100644 src/pages/infrastructure/InfrastructurePages.tsx diff --git a/src/App.tsx b/src/App.tsx index d46517f..6c5b4b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,6 @@ import { ChevronDown, ChevronRight, CircleHelp, - ExternalLink, LogOut, Search, SlidersHorizontal @@ -42,6 +41,12 @@ import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage'; import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage'; import { CommonAppLogsPage } from './pages/common-apps/CommonAppLogsPage'; 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 { AgentCommandsPage as AgentCommandsRestoredPage, @@ -83,6 +88,10 @@ type ViewKey = | 'tailscaleStatus' | 'tailscaleRoutes' | 'tailscaleAcl' + | 'infraCluster' + | 'infraWorkloads' + | 'infraNetwork' + | 'infraStorage' | 'agentRegistration' | 'agentInstallStatus' | 'agentSshKeys' @@ -116,6 +125,10 @@ const viewByLabel: Record = { '노드 현황': 'tailscaleStatus', 라우트: 'tailscaleRoutes', ACL: 'tailscaleAcl', + 클러스터: 'infraCluster', + 워크로드: 'infraWorkloads', + 네트워크: 'infraNetwork', + 스토리지: 'infraStorage', '장비 등록': 'agentRegistration', '설치 상태': 'agentInstallStatus', 'SSH 키 배포': 'agentSshKeys', @@ -155,6 +168,10 @@ const pathByView: Record = { tailscaleStatus: '/tailscale/status', tailscaleRoutes: '/tailscale/routes', tailscaleAcl: '/tailscale/acl', + infraCluster: '/infrastructure/cluster', + infraWorkloads: '/infrastructure/workloads', + infraNetwork: '/infrastructure/network', + infraStorage: '/infrastructure/storage', agentRegistration: '/agents/registration', agentInstallStatus: '/agents/install-status', agentSshKeys: '/agents/ssh-keys', @@ -300,6 +317,29 @@ type RuntimePodMetric = { 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 = { id: string; status: string; @@ -393,6 +433,10 @@ function loadRuntimePodMetrics(namespace: string) { return apiRequest(`/runtime/metrics/pods?namespace=${encodeURIComponent(namespace)}`); } +function loadClusterMetrics() { + return apiRequest('/runtime/metrics/cluster'); +} + const statusTone = { success: 'green', succeeded: 'green', @@ -426,13 +470,35 @@ const statusTone = { 잠김: 'red', 구성완료: 'green', 검토필요: 'amber', - 완료: 'green' + 완료: 'green', + 성공: 'green', + 실패: 'red', + 취소: 'neutral' } as const; function getTone(status: string) { return statusTone[status as keyof typeof statusTone] ?? 'neutral'; } +function formatPipelineStatus(status?: string | null) { + const labels: Record = { + success: '성공', + succeeded: '성공', + done: '성공', + running: '실행 중', + started: '실행 중', + pending: '대기', + blocked: '대기', + created: '생성됨', + failure: '실패', + failed: '실패', + error: '오류', + killed: '중단' + }; + const normalized = (status ?? '').toLowerCase(); + return labels[normalized] ?? status ?? '-'; +} + function formatRelativeTime(timestamp: number) { if (!timestamp) return '-'; 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]; return ( - + void }) { return ( - +
- {pipeline.status} + {formatPipelineStatus(pipeline.status)} {pipeline.branch} {formatRelativeTime(pipeline.createdAt)}
@@ -1195,13 +1261,11 @@ function AppRegistryTable({ action={} />
- +
- - @@ -1214,15 +1278,9 @@ function AppRegistryTable({ - - @@ -1233,12 +1291,11 @@ function AppRegistryTable({ )) ) : dashboardApps.length === 0 ? ( - + ) : null} {!loading && dashboardApps.map((app) => { 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; return ( @@ -1248,14 +1305,14 @@ function AppRegistryTable({

{app.key} · {app.namespace ?? '네임스페이스 없음'}

- - @@ -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 ( + + +
+
+ {resources.map((resource) => ( +
+
+ {resource.label} + {loading ? '-' : resource.value === null ? '-' : `${resource.value}%`} +
+ {loading ? ( + + ) : ( + = 85 ? 'red' : resource.value !== null && resource.value >= 70 ? 'amber' : 'green'} /> + )} +
+ ))} +
+
+
+ + + + +
+
+
+
+ ); +} + +function ServerResourceDetail({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +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 }) { const visibleResources = resources.filter((resource) => !isIgnoredRuntimeResource(resource)); @@ -1416,6 +1533,7 @@ function DashboardPage({ const [commonAppPipelines, setCommonAppPipelines] = useState([]); const [dashboardNotices, setDashboardNotices] = useState([]); const [runtimeResources, setRuntimeResources] = useState([]); + const [clusterMetrics, setClusterMetrics] = useState(null); const [dashboardLoading, setDashboardLoading] = useState(true); const loadingVisible = useMinimumLoading(dashboardLoading); @@ -1424,13 +1542,14 @@ function DashboardPage({ async function loadDashboard() { setDashboardLoading(true); - const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult] = await Promise.allSettled([ + const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult, clusterMetricsResult] = await Promise.allSettled([ loadLauncherVersions(), loadApps(), loadAppReleases(), loadUserSessions(), loadNotices(), - loadCommonAppPipelines() + loadCommonAppPipelines(), + loadClusterMetrics() ]); const nextVersions = versionsResult.status === 'fulfilled' ? versionsResult.value : []; @@ -1439,6 +1558,7 @@ function DashboardPage({ const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : []; const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : []; const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : []; + const nextClusterMetrics = clusterMetricsResult.status === 'fulfilled' ? clusterMetricsResult.value : null; const nextActiveWork = await loadActiveWorkSummary(nextReleases); const namespaceApps = nextApps.filter((app) => app.namespace); const [runtimeResults, metricResults] = await Promise.all([ @@ -1480,6 +1600,7 @@ function DashboardPage({ setCommonAppPipelines(nextCommonApps); setDashboardNotices(nextNotices); setRuntimeResources(nextRuntimeResources); + setClusterMetrics(nextClusterMetrics); setDashboardLoading(false); } @@ -1508,8 +1629,9 @@ function DashboardPage({ -
+
+
@@ -1635,6 +1757,14 @@ function App() { return ; case 'tailscaleAcl': return ; + case 'infraCluster': + return ; + case 'infraWorkloads': + return ; + case 'infraNetwork': + return ; + case 'infraStorage': + return ; case 'agentRegistration': return ; case 'agentInstallStatus': diff --git a/src/data/mock.ts b/src/data/mock.ts index e15b5b7..ae5a705 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -3,6 +3,7 @@ import { Boxes, ChartNoAxesColumnIncreasing, FileKey, + HardDrive, KeyRound, LayoutDashboard, Megaphone, @@ -65,7 +66,7 @@ export const navGroups = [ ] }, { - label: 'Agent', + label: '에이전트', icon: ShieldCheck, children: [ { label: '장비 등록' }, @@ -84,6 +85,16 @@ export const navGroups = [ { label: 'Agent 관리' }, { 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: '시스템', items: [ { label: '모니터링', icon: ChartNoAxesColumnIncreasing }, - { label: '정책', icon: ShieldCheck }, - { label: '인프라', icon: ServerCog } + { label: '정책', icon: ShieldCheck } ] } ]; diff --git a/src/layout/navigation.ts b/src/layout/navigation.ts index 246cd2d..6ad08f8 100644 --- a/src/layout/navigation.ts +++ b/src/layout/navigation.ts @@ -2,6 +2,7 @@ import { Boxes, ChartNoAxesColumnIncreasing, FileKey, + HardDrive, LayoutDashboard, Megaphone, Network, @@ -72,7 +73,7 @@ export const navGroups: NavGroup[] = [ ] }, { - label: 'Agent', + label: '에이전트', icon: ShieldCheck, children: [ { label: '장비 등록', path: '/agents/registration' }, @@ -91,6 +92,16 @@ export const navGroups: NavGroup[] = [ { label: 'Agent 관리', path: '/gateway/agents' }, { label: '장비 동기화', path: '/gateway/devices' } ] + }, + { + label: '인프라', + icon: HardDrive, + children: [ + { label: '클러스터', path: '/infrastructure/cluster' }, + { label: '워크로드', path: '/infrastructure/workloads' }, + { label: '네트워크', path: '/infrastructure/network' }, + { label: '스토리지', path: '/infrastructure/storage' } + ] } ] }, diff --git a/src/pages/infrastructure/InfrastructurePages.tsx b/src/pages/infrastructure/InfrastructurePages.tsx new file mode 100644 index 0000000..c98367b --- /dev/null +++ b/src/pages/infrastructure/InfrastructurePages.tsx @@ -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) { + 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({ 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 ( +
+ + {status || '-'} +
+ ); +} + +function NamespaceFilter(rows: ResourceRow[]): DataTableFilter { + 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 { + 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 ( + <> + + + 새로고침 + + } + /> + {failed ? ( +
+ 인프라 데이터를 불러오지 못했습니다. +
+ ) : null} + {children} + + + ); +} + +function ResourceTable({ + title, + description, + rows, + loading, + emptyMessage +}: { + title: string; + description: string; + rows: ResourceRow[]; + loading: boolean; + emptyMessage: string; +}) { + const columns = useMemo>>( + () => [ + { key: 'namespace', header: '네임스페이스', className: 'w-44 px-5 py-3', cell: (row) => {row.namespace} }, + { key: 'kind', header: '종류', className: 'w-40 px-5 py-3', cell: (row) => {row.kind} }, + { key: 'name', header: '이름', className: 'min-w-[240px] px-5 py-3', cell: (row) => {row.name} }, + { key: 'status', header: '상태', className: 'w-44 px-5 py-3', cell: (row) => }, + { key: 'ready', header: 'Ready', className: 'w-28 px-5 py-3', cell: (row) => {row.ready || '-'} }, + { key: 'restarts', header: '재시작', className: 'w-24 px-5 py-3', cell: (row) => {row.restarts ?? '-'} }, + { key: 'metric', header: '사용량', className: 'w-40 px-5 py-3', cell: (row) => {row.metric ? `${row.metric.cpu} / ${row.metric.memory}` : '-'} }, + { key: 'age', header: 'Age', className: 'w-28 px-5 py-3', cell: (row) => {row.age || '-'} }, + { key: 'detail', header: '상세', className: 'min-w-[260px] px-5 py-3', cell: (row) => {row.detail || '-'} } + ], + [] + ); + + const filters = useMemo(() => [NamespaceFilter(rows), KindFilter(rows)], [rows]); + + return ( + `${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 ( + +
+ {[ + ['네임스페이스', totals.namespaces], + ['전체 리소스', totals.resources], + ['Pod', totals.pods], + ['Deployment', totals.deployments], + ['Service', totals.services], + ['PVC', totals.pvc] + ].map(([label, value]) => ( + +

{label}

+

{value}

+
+ ))} +
+ + + +
+ {data.runtimes.map((runtime) => ( +
+
+
+ +

{runtime.namespace}

+
+

{runtime.failed ? '조회 실패' : runtime.exists ? '조회 가능' : '네임스페이스 없음'}

+
+ + + + + +
+ ))} +
+
+
+ ); +} + +function SummaryValue({ label, value }: { label: string; value: number }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +export function InfrastructureWorkloadsPage() { + const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData(); + const rows = useMemo(() => rowsFromData(data, isWorkload), [data]); + + return ( + + + + ); +} + +export function InfrastructureNetworkPage() { + const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData(); + const rows = useMemo(() => rowsFromData(data, isNetwork), [data]); + + return ( + + + + ); +} + +export function InfrastructureStoragePage() { + const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData(); + const rows = useMemo(() => rowsFromData(data, isStorage), [data]); + + return ( + + + + ); +}
앱상태 릴리즈저장소/경로 워크로드 구성요소
- - - -
등록된 앱이 없습니다.등록된 앱이 없습니다.
- {app.status} - {latestRelease ? `${latestRelease.version} · ${formatJobStatus(latestRelease.status)}` : '-'} - - {firstRepository ?? '-'} - {firstRepository ? : null} - + {latestRelease ? ( +
+ {formatJobStatus(latestRelease.status)} + {latestRelease.version} +
+ ) : ( + - + )}
{workloadCount} {app.components?.length ?? 0}