- 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>
11 KiB
11 KiB
네이버 클라우드 콘솔 UI 패턴 참조
GUARDiA Manager UI는 네이버 클라우드 콘솔(console.ncloud.com)의 디자인 패턴을 참조한다. 아래 코드 스니펫은 해당 패턴을 GUARDiA Manager에 맞게 재구현한 것이다.
1. DataTable 컴포넌트 (NCloud 리소스 목록)
// 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 컴포넌트 (서버/서비스 카드)
// 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 콘솔에서 리소스 클릭 시 우측에서 슬라이드하는 상세 패널.
// 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 컴포넌트 (대시보드 통계 카드)
// 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 서비스 트리 스타일)
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' },
]
},
];