- 37개 파일 IP → zioinfo.co.kr 치환 (소스/매뉴얼/설정/하네스) - Manager DrConsole/NetworkConsole/CsapConsole 빌드 + /var/www/manager/ 배포 - 테스트: Manager HTTP 200, ITSM 신규 API 7개 전체 200 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
338 lines
11 KiB
Markdown
338 lines
11 KiB
Markdown
# 네이버 클라우드 콘솔 UI 패턴 참조
|
|
|
|
> GUARDiA Manager UI는 네이버 클라우드 콘솔(console.ncloud.com)의 디자인 패턴을 참조한다.
|
|
> 아래 코드 스니펫은 해당 패턴을 GUARDiA Manager에 맞게 재구현한 것이다.
|
|
|
|
---
|
|
|
|
## 1. DataTable 컴포넌트 (NCloud 리소스 목록)
|
|
|
|
```tsx
|
|
// components/common/DataTable.tsx
|
|
import { useState } from 'react';
|
|
import styles from './DataTable.module.css';
|
|
|
|
interface Column<T> {
|
|
key: keyof T | string;
|
|
header: string;
|
|
width?: string;
|
|
render?: (row: T) => React.ReactNode;
|
|
sortable?: boolean;
|
|
}
|
|
|
|
interface DataTableProps<T extends { id: string | number }> {
|
|
columns: Column<T>[];
|
|
data: T[];
|
|
onRowClick?: (row: T) => void;
|
|
actions?: React.ReactNode; // 상단 액션 버튼
|
|
loading?: boolean;
|
|
emptyMessage?: string;
|
|
selectable?: boolean;
|
|
onSelectionChange?: (selected: T[]) => void;
|
|
}
|
|
|
|
export function DataTable<T extends { id: string | number }>({
|
|
columns, data, onRowClick, actions, loading, emptyMessage,
|
|
selectable = false, onSelectionChange,
|
|
}: DataTableProps<T>) {
|
|
const [selected, setSelected] = useState<Set<string | number>>(new Set());
|
|
const [sortKey, setSortKey] = useState<string | null>(null);
|
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
|
|
|
const toggleAll = () => {
|
|
const next = selected.size === data.length
|
|
? new Set<string | number>()
|
|
: new Set(data.map(r => r.id));
|
|
setSelected(next);
|
|
onSelectionChange?.(data.filter(r => next.has(r.id)));
|
|
};
|
|
|
|
const toggleRow = (id: string | number) => {
|
|
const next = new Set(selected);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
setSelected(next);
|
|
onSelectionChange?.(data.filter(r => next.has(r.id)));
|
|
};
|
|
|
|
return (
|
|
<div className={styles.wrapper}>
|
|
{/* 상단 액션 영역 */}
|
|
{actions && (
|
|
<div className={styles.toolbar}>
|
|
{selected.size > 0 && (
|
|
<span className={styles.selectionCount}>{selected.size}개 선택됨</span>
|
|
)}
|
|
<div className={styles.actions}>{actions}</div>
|
|
</div>
|
|
)}
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
{selectable && (
|
|
<th className={styles.checkboxCol}>
|
|
<input type="checkbox"
|
|
checked={selected.size === data.length && data.length > 0}
|
|
onChange={toggleAll} />
|
|
</th>
|
|
)}
|
|
{columns.map(col => (
|
|
<th key={String(col.key)}
|
|
style={{ width: col.width }}
|
|
className={col.sortable ? styles.sortable : ''}
|
|
onClick={() => col.sortable && (setSortKey(String(col.key)),
|
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc'))}>
|
|
{col.header}
|
|
{sortKey === String(col.key) && (
|
|
<span>{sortDir === 'asc' ? ' ↑' : ' ↓'}</span>
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={columns.length + 1}
|
|
className={styles.loading}>로딩 중...</td></tr>
|
|
) : data.length === 0 ? (
|
|
<tr><td colSpan={columns.length + 1}
|
|
className={styles.empty}>{emptyMessage ?? '데이터가 없습니다.'}</td></tr>
|
|
) : data.map(row => (
|
|
<tr key={row.id}
|
|
className={`${styles.row} ${onRowClick ? styles.clickable : ''}`}
|
|
onClick={() => onRowClick?.(row)}>
|
|
{selectable && (
|
|
<td className={styles.checkboxCol} onClick={e => e.stopPropagation()}>
|
|
<input type="checkbox"
|
|
checked={selected.has(row.id)}
|
|
onChange={() => toggleRow(row.id)} />
|
|
</td>
|
|
)}
|
|
{columns.map(col => (
|
|
<td key={String(col.key)}>
|
|
{col.render ? col.render(row) : String((row as any)[col.key] ?? '')}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 2. ResourceCard 컴포넌트 (서버/서비스 카드)
|
|
|
|
```tsx
|
|
// components/common/ResourceCard.tsx
|
|
import { StatusBadge } from './StatusBadge';
|
|
|
|
interface ResourceCardProps {
|
|
name: string;
|
|
type: string; // '서버' | 'DB' | 'WAS' | 'API'
|
|
status: 'running' | 'stopped' | 'error' | 'pending';
|
|
spec?: string; // '2vCPU / 4GB' 등
|
|
ip?: string;
|
|
onClick?: () => void;
|
|
}
|
|
|
|
export function ResourceCard({ name, type, status, spec, ip, onClick }: ResourceCardProps) {
|
|
return (
|
|
<div className="resource-card" onClick={onClick} style={{
|
|
background: '#fff',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 8,
|
|
padding: '16px',
|
|
cursor: onClick ? 'pointer' : 'default',
|
|
transition: 'box-shadow 0.15s',
|
|
}}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
<span style={{ fontSize: 12, color: 'var(--text-muted)',
|
|
background: 'var(--brand-light)', padding: '2px 8px', borderRadius: 4 }}>
|
|
{type}
|
|
</span>
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>{name}</div>
|
|
{spec && <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{spec}</div>}
|
|
{ip && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{ip}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. SlidePanel 컴포넌트 (NCloud 상세 정보 패널)
|
|
|
|
NCloud 콘솔에서 리소스 클릭 시 우측에서 슬라이드하는 상세 패널.
|
|
|
|
```tsx
|
|
// components/common/SlidePanel.tsx
|
|
import { useEffect } from 'react';
|
|
|
|
interface SlidePanelProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
title: string;
|
|
width?: number; // 기본 480px
|
|
children: React.ReactNode;
|
|
actions?: React.ReactNode;
|
|
}
|
|
|
|
export function SlidePanel({ open, onClose, title, width = 480, children, actions }: SlidePanelProps) {
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<>
|
|
{/* 오버레이 */}
|
|
{open && (
|
|
<div onClick={onClose} style={{
|
|
position: 'fixed', inset: 0,
|
|
background: 'rgba(0,0,0,0.3)', zIndex: 200
|
|
}} />
|
|
)}
|
|
{/* 패널 */}
|
|
<div style={{
|
|
position: 'fixed', top: 0, right: 0, bottom: 0,
|
|
width, background: '#fff', zIndex: 201,
|
|
transform: open ? 'translateX(0)' : `translateX(${width}px)`,
|
|
transition: 'transform 0.25s ease',
|
|
display: 'flex', flexDirection: 'column',
|
|
boxShadow: '-4px 0 24px rgba(0,0,0,0.15)',
|
|
}}>
|
|
{/* 헤더 */}
|
|
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{title}</h3>
|
|
<button onClick={onClose} style={{ background: 'none', border: 'none',
|
|
cursor: 'pointer', fontSize: 18, color: 'var(--text-muted)' }}>✕</button>
|
|
</div>
|
|
{/* 콘텐츠 */}
|
|
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>{children}</div>
|
|
{/* 푸터 액션 */}
|
|
{actions && (
|
|
<div style={{ padding: '16px 24px', borderTop: '1px solid var(--border)',
|
|
display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
{actions}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. StatCard 컴포넌트 (대시보드 통계 카드)
|
|
|
|
```tsx
|
|
// components/common/StatCard.tsx
|
|
interface StatCardProps {
|
|
title: string;
|
|
value: string | number;
|
|
sub?: string;
|
|
trend?: { value: number; positive: boolean };
|
|
icon?: string;
|
|
color?: string; // 아이콘 배경색
|
|
onClick?: () => void;
|
|
}
|
|
|
|
export function StatCard({ title, value, sub, trend, icon, color = 'var(--brand-light)', onClick }: StatCardProps) {
|
|
return (
|
|
<div onClick={onClick} style={{
|
|
background: '#fff', border: '1px solid var(--border)',
|
|
borderRadius: 10, padding: '20px',
|
|
cursor: onClick ? 'pointer' : 'default',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
{icon && (
|
|
<div style={{ width: 44, height: 44, borderRadius: 10,
|
|
background: color, display: 'flex', alignItems: 'center',
|
|
justifyContent: 'center', fontSize: 20, flexShrink: 0 }}>
|
|
{icon}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary)' }}>
|
|
{value}
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{title}</div>
|
|
{sub && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{sub}</div>}
|
|
</div>
|
|
</div>
|
|
{trend && (
|
|
<div style={{ marginTop: 10, fontSize: 11,
|
|
color: trend.positive ? 'var(--status-running)' : 'var(--status-error)' }}>
|
|
{trend.positive ? '▲' : '▼'} {Math.abs(trend.value)}% 전주 대비
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 사이드바 네비게이션 (NCloud 서비스 트리 스타일)
|
|
|
|
```tsx
|
|
const MENU = [
|
|
{ label: '대시보드', icon: '📊', path: '/' },
|
|
{
|
|
label: '인프라 관리', icon: '🖥️',
|
|
children: [
|
|
{ label: '서버 목록', path: '/servers' },
|
|
{ label: 'CMDB 현황', path: '/cmdb' },
|
|
{ label: 'SSH 자격증명', path: '/credentials' },
|
|
]
|
|
},
|
|
{
|
|
label: '배포/CI-CD', icon: '🚀',
|
|
children: [
|
|
{ label: '배포 이력', path: '/deployments' },
|
|
{ label: '저장소 목록', path: '/repos' },
|
|
{ label: '서비스 상태', path: '/services' },
|
|
]
|
|
},
|
|
{
|
|
label: '사용자/테넌트', icon: '👥',
|
|
children: [
|
|
{ label: '사용자 관리', path: '/users' },
|
|
{ label: '기관 관리', path: '/institutions' },
|
|
{ label: '역할 설정', path: '/roles' },
|
|
]
|
|
},
|
|
{
|
|
label: '보안', icon: '🔒',
|
|
children: [
|
|
{ label: 'API Key 관리', path: '/api-keys' },
|
|
{ label: '감사 로그', path: '/audit' },
|
|
{ label: '취약점 현황', path: '/vulns' },
|
|
]
|
|
},
|
|
{
|
|
label: 'AI/LLM', icon: '🤖',
|
|
children: [
|
|
{ label: 'Ollama 모델', path: '/llm' },
|
|
{ label: 'AI 에이전트', path: '/agents' },
|
|
]
|
|
},
|
|
{
|
|
label: '시스템 설정', icon: '⚙️',
|
|
children: [
|
|
{ label: '환경변수', path: '/config/env' },
|
|
{ label: 'Nginx 설정', path: '/config/nginx' },
|
|
{ label: '알림 설정', path: '/config/notify' },
|
|
]
|
|
},
|
|
];
|
|
```
|