guardia-manager/.claude/skills/manager-ui/references/ncloud-patterns.md
DESKTOP-TKLFCPRython 10cc76d6e6 refactor: 101.79.17.164 → zioinfo.co.kr 전체 도메인 변환 + Manager UI 배포
- 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>
2026-05-31 10:09:17 +09:00

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' },
]
},
];
```