sync: update from workspace (latest ITSM/CICD/DR changes)

This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-02 06:25:06 +09:00
parent fa2657a29a
commit 1a89a432c7
9 changed files with 826 additions and 1 deletions

View File

@ -23,6 +23,12 @@ const DrConsole = lazy(() => import('./pages/DrConsole'))
const NetworkConsole = lazy(() => import('./pages/NetworkConsole'))
const CsapConsole = lazy(() => import('./pages/CsapConsole'))
const ScrapingManager = lazy(() => import('./pages/ScrapingManager'))
// ── GUARDiA 확장 v3 ──
const KpiDashboard = lazy(() => import('./pages/KpiDashboard'))
const BiAnalytics = lazy(() => import('./pages/BiAnalytics'))
const BillingManage = lazy(() => import('./pages/BillingManage'))
const IntegrationHub = lazy(() => import('./pages/IntegrationHub'))
const AiPlatform = lazy(() => import('./pages/AiPlatform'))
function Loading() {
return (
@ -61,6 +67,12 @@ export default function App() {
<Route path="network" element={<NetworkConsole />} />
<Route path="csap" element={<CsapConsole />} />
<Route path="scraping" element={<ScrapingManager />} />
{/* GUARDiA 확장 v3 */}
<Route path="kpi" element={<KpiDashboard />} />
<Route path="bi" element={<BiAnalytics />} />
<Route path="billing" element={<BillingManage />} />
<Route path="integrations" element={<IntegrationHub />} />
<Route path="ai-platform" element={<AiPlatform />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -21,6 +21,12 @@ const PAGE_TITLES: Record<string, string> = {
'/dr': 'DR 재해복구 관제',
'/network': '네트워크 장비 관제',
'/csap': 'CSAP 보안 점검',
// GUARDiA 확장 v3
'/kpi': 'KPI 대시보드',
'/bi': 'BI 대시보드',
'/billing': '구독 · 과금 관리',
'/integrations': '외부 연동 허브',
'/ai-platform': 'AI 플랫폼',
}
export function AppLayout() {

View File

@ -35,6 +35,14 @@ const NAV: NavItem[] = [
{ label: '네트워크 장비', icon: '', path: '/network' },
{ label: 'CSAP 점검', icon: '', path: '/csap' },
]},
// ── GUARDiA 확장 v3 ──
{ label: '분석 · KPI', icon: '📈', children: [
{ label: 'KPI 대시보드', icon: '', path: '/kpi' },
{ label: 'BI 대시보드', icon: '', path: '/bi' },
]},
{ label: 'AI 플랫폼', icon: '🧠', path: '/ai-platform' },
{ label: '외부 연동', icon: '🔗', path: '/integrations' },
{ label: '구독 · 과금', icon: '💰', path: '/billing' },
]
/* Variant 스타일 색상 상수 */

View File

@ -0,0 +1,199 @@
import { useEffect, useState } from 'react'
import { guardiaApi } from '../api/clients'
type Tab = 'insights' | 'learning' | 'predict' | 'benchmark'
export default function AiPlatform() {
const [tab, setTab] = useState<Tab>('insights')
const tabs: { id: Tab; label: string; icon: string }[] = [
{ id: 'insights', label: 'AI 인사이트', icon: '🧠' },
{ id: 'learning', label: 'Learning Loop', icon: '🔄' },
{ id: 'predict', label: '예측 분석', icon: '🔮' },
{ id: 'benchmark', label: '벤치마킹', icon: '📊' },
]
return (
<div style={{ padding: '24px 28px' }}>
<h2 style={{ margin: '0 0 20px', fontSize: 20, fontWeight: 700 }}>🧠 AI </h2>
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid #e2e8f0' }}>
{tabs.map(t => (
<button key={t.id} onClick={() => setTab(t.id)} style={{
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: tab === t.id ? 700 : 400,
color: tab === t.id ? '#003366' : '#64748b',
borderBottom: tab === t.id ? '2px solid #003366' : '2px solid transparent',
}}>{t.icon} {t.label}</button>
))}
</div>
{tab === 'insights' && <InsightsTab />}
{tab === 'learning' && <LearningTab />}
{tab === 'predict' && <PredictTab />}
{tab === 'benchmark' && <BenchmarkTab />}
</div>
)
}
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20, marginBottom: 16 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 14px' }}>{title}</h3>
{children}
</div>
)
}
function InsightsTab() {
const [weekly, setWeekly] = useState<any>(null)
const [anomalies, setAnomalies] = useState<any>(null)
useEffect(() => {
guardiaApi.get('/api/insights/weekly').then((r: any) => setWeekly(r.data)).catch(() => {})
guardiaApi.get('/api/insights/anomalies').then((r: any) => setAnomalies(r.data)).catch(() => {})
}, [])
return (
<>
{anomalies?.anomalies?.length > 0 && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 8, padding: 12, marginBottom: 16 }}>
<strong style={{ fontSize: 13 }}> ({anomalies.anomalies.length})</strong>
{anomalies.anomalies.map((a: any, i: number) => (
<div key={i} style={{ fontSize: 12, marginTop: 6, color: '#7f1d1d' }}>{a.message}</div>
))}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12, marginBottom: 16 }}>
{weekly && [
{ label: '신규 SR', val: weekly.stats?.total || 0, color: '#003366' },
{ label: '완료율', val: `${weekly.stats?.completion_rate || 0}%`, color: '#10B981' },
{ label: '미처리', val: weekly.stats?.open || 0, color: '#EF4444' },
].map(s => (
<div key={s.label} style={{ background: '#fff', border: `1px solid #e2e8f0`, borderLeft: `4px solid ${s.color}`, borderRadius: 8, padding: 16 }}>
<div style={{ fontSize: 28, fontWeight: 700, color: s.color }}>{s.val}</div>
<div style={{ fontSize: 11, color: '#64748b' }}>{s.label}</div>
</div>
))}
</div>
<Card title="🤖 AI 주간 인사이트">
<p style={{ fontSize: 13, lineHeight: 1.8, whiteSpace: 'pre-wrap', color: '#374151' }}>
{weekly?.ai_insight || '데이터 수집 중...'}
</p>
</Card>
</>
)
}
function LearningTab() {
const [status, setStatus] = useState<any>(null)
const [quality, setQuality] = useState<any>(null)
const [history, setHistory] = useState<any[]>([])
useEffect(() => {
guardiaApi.get('/api/learn/status').then((r: any) => setStatus(r.data)).catch(() => {})
guardiaApi.get('/api/learn/quality').then((r: any) => setQuality(r.data)).catch(() => {})
guardiaApi.get('/api/learn/history?limit=10').then((r: any) => setHistory(r.data || [])).catch(() => {})
}, [])
async function startTrain() {
await guardiaApi.post('/api/learn/train')
guardiaApi.get('/api/learn/history?limit=10').then((r: any) => setHistory(r.data || [])).catch(() => {})
}
return (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<Card title="📦 학습 데이터">
<div style={{ fontSize: 36, fontWeight: 700, color: '#003366' }}>{status?.available_samples || 0}</div>
<div style={{ fontSize: 12, color: '#64748b' }}> </div>
<div style={{ fontSize: 12, marginTop: 6 }}>
RAG : {status?.high_quality_rag || 0} · SR : {status?.sr_samples || 0}
</div>
<button onClick={startTrain} disabled={!status?.ready_to_train} style={{
marginTop: 12, padding: '7px 16px', background: status?.ready_to_train ? '#003366' : '#94a3b8',
color: '#fff', border: 'none', borderRadius: 6, cursor: status?.ready_to_train ? 'pointer' : 'not-allowed', fontSize: 12,
}}>🚀 </button>
</Card>
<Card title="📈 모델 품질">
<div style={{ fontSize: 48, fontWeight: 700, color: '#003366', textAlign: 'center' }}>{quality?.quality_grade || 'N/A'}</div>
<div style={{ textAlign: 'center', fontSize: 12, color: '#64748b' }}>
{quality?.avg_rating || 0} · {quality?.positive_rate || 0}%
</div>
</Card>
</div>
<Card title="📋 학습 이력">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ borderBottom: '1px solid #f1f5f9' }}>
{['모델', '상태', '샘플', '시작'].map(h => <th key={h} style={{ textAlign: 'left', padding: '6px 8px', color: '#64748b', fontSize: 11 }}>{h}</th>)}
</tr></thead>
<tbody>
{history.slice(0, 8).map((h: any) => (
<tr key={h.id} style={{ borderBottom: '1px solid #f8fafc' }}>
<td style={{ padding: '8px', fontFamily: 'monospace', fontSize: 11 }}>{h.model_name || '-'}</td>
<td style={{ padding: '8px', color: h.status === 'SUCCESS' ? '#10B981' : h.status === 'FAILED' ? '#EF4444' : '#F59E0B' }}>{h.status}</td>
<td style={{ padding: '8px' }}>{h.samples_used || 0}</td>
<td style={{ padding: '8px', color: '#94a3b8', fontSize: 11 }}>{h.started_at ? new Date(h.started_at).toLocaleDateString('ko-KR') : '-'}</td>
</tr>
))}
{!history.length && <tr><td colSpan={4} style={{ textAlign: 'center', padding: 16, color: '#94a3b8' }}> </td></tr>}
</tbody>
</table>
</Card>
</>
)
}
function PredictTab() {
const [sla, setSla] = useState<any>(null)
const [surge, setSurge] = useState<any>(null)
useEffect(() => {
guardiaApi.get('/api/predict/sla-breach').then((r: any) => setSla(r.data)).catch(() => {})
guardiaApi.get('/api/predict/sr-surge').then((r: any) => setSurge(r.data)).catch(() => {})
}, [])
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<Card title="📉 SLA 위반 예측 (7일)">
<div style={{ fontSize: 36, fontWeight: 700, color: sla?.status === 'CRITICAL' ? '#EF4444' : sla?.status === 'WARNING' ? '#F59E0B' : '#10B981' }}>
{Math.round((sla?.breach_probability_7d || 0) * 100)}%
</div>
<div style={{ fontSize: 12, color: '#64748b' }}> SLA: {sla?.current_rate || 0}% · : {sla?.target || 95}%</div>
{sla?.insight && <p style={{ fontSize: 12, lineHeight: 1.7, marginTop: 10, color: '#374151' }}>{sla.insight}</p>}
</Card>
<Card title="📈 SR 급증 감지">
<div style={{ fontSize: 36, fontWeight: 700, color: surge?.status === 'SURGE' ? '#EF4444' : surge?.status === 'HIGH' ? '#F59E0B' : '#10B981' }}>
{surge?.surge_ratio || 1}x
</div>
<div style={{ fontSize: 12, color: '#64748b' }}> {surge?.today_count || 0} · 7 {surge?.avg_7d || 0}</div>
{surge?.insight && <p style={{ fontSize: 12, lineHeight: 1.7, marginTop: 10, color: '#374151' }}>{surge.insight}</p>}
</Card>
</div>
)
}
function BenchmarkTab() {
const [comp, setComp] = useState<any>(null)
const [rank, setRank] = useState<any>(null)
useEffect(() => {
guardiaApi.get('/api/benchmark/comparison').then((r: any) => setComp(r.data)).catch(() => {})
guardiaApi.get('/api/benchmark/my-rank').then((r: any) => setRank(r.data)).catch(() => {})
}, [])
async function contribute() { await guardiaApi.post('/api/benchmark/contribute') }
return (
<>
<Card title="📊 업계 평균 대비 비교">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 16 }}>
{(comp?.comparison || []).map((m: any) => (
<div key={m.metric} style={{ textAlign: 'center', padding: 16, background: '#f8fafc', borderRadius: 8 }}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>{m.metric}</div>
<div style={{ fontSize: 28, fontWeight: 700, color: m.status === 'ABOVE' ? '#10B981' : '#EF4444' }}>
{m.mine}<span style={{ fontSize: 12 }}>{m.unit}</span>
</div>
<div style={{ fontSize: 11, marginTop: 4 }}> : {m.industry}{m.unit}</div>
<div style={{ fontSize: 11, color: m.status === 'ABOVE' ? '#10B981' : '#EF4444', marginTop: 2 }}>
{m.status === 'ABOVE' ? '▲ 평균 이상' : '▼ 평균 이하'}
</div>
</div>
))}
</div>
</Card>
<div style={{ marginTop: 12 }}>
<button onClick={contribute} style={{ padding: '7px 16px', border: '1px solid #e2e8f0', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>
📤
</button>
<span style={{ fontSize: 11, color: '#94a3b8', marginLeft: 8 }}> ( )</span>
</div>
</>
)
}

View File

@ -0,0 +1,150 @@
import { useEffect, useState } from 'react'
import {
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from 'recharts'
import { guardiaApi } from '../api/clients'
const COLORS = ['#003366', '#00A0C8', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6']
export default function BiAnalytics() {
const [overview, setOverview] = useState<any>(null)
const [srTrend, setSrTrend] = useState<any>(null)
const [pie, setPie] = useState<any>(null)
const [mttr, setMttr] = useState<any>(null)
const [tab, setTab] = useState<'overview' | 'trend' | 'engineer' | 'mttr'>('overview')
useEffect(() => { loadData() }, [])
async function loadData() {
const [o, t, p, m] = await Promise.all([
guardiaApi.get('/api/bi/overview').then((r: any) => r.data).catch(() => null),
guardiaApi.get('/api/bi/sr-trend?days=30').then((r: any) => r.data).catch(() => null),
guardiaApi.get('/api/bi/category-pie?days=30').then((r: any) => r.data).catch(() => null),
guardiaApi.get('/api/bi/mttr-trend?months=6').then((r: any) => r.data).catch(() => null),
])
setOverview(o); setSrTrend(t); setPie(p); setMttr(m)
}
const tabs = [
{ id: 'overview', label: '개요' },
{ id: 'trend', label: 'SR 트렌드' },
{ id: 'engineer', label: '엔지니어' },
{ id: 'mttr', label: 'MTTR' },
] as const
return (
<div style={{ padding: '24px 28px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>📊 BI </h2>
<button onClick={loadData} style={{ padding: '6px 14px', border: '1px solid #e2e8f0', borderRadius: 6, background: '#fff', cursor: 'pointer' }}>🔄</button>
</div>
{/* 탭 */}
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid #e2e8f0', paddingBottom: 0 }}>
{tabs.map(t => (
<button key={t.id} onClick={() => setTab(t.id)} style={{
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: tab === t.id ? 700 : 400,
color: tab === t.id ? '#003366' : '#64748b',
borderBottom: tab === t.id ? '2px solid #003366' : '2px solid transparent',
}}>{t.label}</button>
))}
</div>
{/* 개요 탭 */}
{tab === 'overview' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 }}>
{(overview?.cards || []).map((c: any) => (
<div key={c.key} style={{ background: '#fff', border: '1px solid #e2e8f0', borderLeft: '4px solid #003366', borderRadius: 8, padding: 14 }}>
<div style={{ fontSize: 24, fontWeight: 700 }}>{c.value}</div>
<div style={{ fontSize: 11, color: '#64748b' }}>{c.label} ({c.unit})</div>
</div>
))}
</div>
{pie && (
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 16px' }}>SR (30)</h3>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={pie.data || []} dataKey="count" nameKey="category" cx="50%" cy="50%" outerRadius={90} label={({category, pct}) => `${category} ${pct}%`}>
{(pie.data || []).map((_: any, i: number) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
</>
)}
{/* SR 트렌드 탭 */}
{tab === 'trend' && srTrend && (
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 16px' }}>SR / (30)</h3>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={(srTrend.labels || []).map((d: string, i: number) => ({
date: d.slice(5),
신규: srTrend.datasets[0]?.data[i] || 0,
완료: srTrend.datasets[1]?.data[i] || 0,
}))}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="신규" stroke="#003366" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="완료" stroke="#10B981" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* MTTR 탭 */}
{tab === 'mttr' && mttr && (
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 16px' }}>MTTR ()</h3>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={(mttr.raw || []).map((r: any) => ({ month: r.month, mttr: r.mttr_hours }))}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => `${v}시간`} />
<Bar dataKey="mttr" fill="#6366F1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{tab === 'engineer' && <EngineerLoad />}
</div>
)
}
function EngineerLoad() {
const [data, setData] = useState<any>(null)
useEffect(() => {
guardiaApi.get('/api/bi/engineer-load?days=30').then((r: any) => setData(r.data)).catch(() => {})
}, [])
if (!data) return <div style={{ padding: 32, textAlign: 'center' }}></div>
const combined = (data.labels || []).map((name: string, i: number) => ({
name, 완료: data.datasets[0]?.data[i] || 0, 진행중: data.datasets[1]?.data[i] || 0,
}))
return (
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 16px' }}> SR </h3>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={combined} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={90} />
<Tooltip />
<Legend />
<Bar dataKey="완료" fill="#10B981" stackId="a" />
<Bar dataKey="진행중" fill="#F59E0B" stackId="a" />
</BarChart>
</ResponsiveContainer>
</div>
)
}

View File

@ -0,0 +1,118 @@
import { useEffect, useState } from 'react'
import { guardiaApi } from '../api/clients'
export default function BillingManage() {
const [sub, setSub] = useState<any>(null)
const [usage, setUsage] = useState<any>(null)
const [invoices, setInvoices] = useState<any[]>([])
const [plans, setPlans] = useState<any[]>([])
useEffect(() => { load() }, [])
async function load() {
const [s, u, i, p] = await Promise.all([
guardiaApi.get('/api/billing/subscription').then((r: any) => r.data).catch(() => null),
guardiaApi.get('/api/billing/usage').then((r: any) => r.data).catch(() => null),
guardiaApi.get('/api/billing/invoices').then((r: any) => r.data).catch(() => []),
guardiaApi.get('/api/billing/plans').then((r: any) => r.data).catch(() => []),
])
setSub(s); setUsage(u); setInvoices(i || []); setPlans(p || [])
}
async function generateInvoice() {
await guardiaApi.post('/api/billing/invoices/generate')
load()
}
const pctColor = (pct: number) => pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#003366'
return (
<div style={{ padding: '24px 28px' }}>
<h2 style={{ margin: '0 0 20px', fontSize: 20, fontWeight: 700 }}>💰 · </h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
{/* 현재 구독 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>📋 </h3>
<div style={{ fontSize: 28, fontWeight: 700, color: '#003366' }}>{sub?.plan || 'COMMUNITY'}</div>
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
{sub?.price ? `${sub.price.toLocaleString()}` : '무료'} · {sub?.billing_cycle || 'MONTHLY'}
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
{plans.slice(0, 3).map((p: any) => (
<button key={p.code} style={{
padding: '6px 12px', borderRadius: 6, fontSize: 12, cursor: 'pointer',
background: sub?.plan === p.code ? '#003366' : '#f8fafc',
color: sub?.plan === p.code ? '#fff' : '#1e293b',
border: `1px solid ${sub?.plan === p.code ? '#003366' : '#e2e8f0'}`,
}}>
{p.name}
</button>
))}
</div>
</div>
{/* 사용량 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>📊 </h3>
{usage && [
{ label: '서버', used: usage.servers?.used || 0, limit: usage.servers?.limit || 20 },
{ label: '사용자', used: usage.users?.used || 0, limit: usage.users?.limit || 10 },
].map(item => {
const pct = item.limit > 0 ? Math.min(100, Math.round(item.used / item.limit * 100)) : 0
return (
<div key={item.label} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span style={{ fontWeight: 600 }}>{item.label}</span>
<span style={{ color: pctColor(pct) }}>{item.used} / {item.limit < 0 ? '∞' : item.limit}</span>
</div>
<div style={{ background: '#f1f5f9', borderRadius: 4, height: 8 }}>
<div style={{ width: `${pct}%`, height: 8, background: pctColor(pct), borderRadius: 4, transition: 'width .4s' }} />
</div>
</div>
)
})}
<div style={{ fontSize: 13, color: '#64748b' }}>SR ( ): {usage?.sr_this_month || 0}</div>
</div>
</div>
{/* 청구서 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: 0 }}>🧾 ({invoices.length})</h3>
<button onClick={generateInvoice} style={{ padding: '6px 14px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>
+
</button>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #e2e8f0' }}>
{['기간', '플랜', '금액', '상태', '생성일'].map(h => (
<th key={h} style={{ textAlign: 'left', padding: '8px 12px', fontSize: 11, fontWeight: 600, color: '#64748b' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{invoices.length === 0 ? (
<tr><td colSpan={5} style={{ textAlign: 'center', padding: 24, color: '#94a3b8' }}> </td></tr>
) : invoices.slice(0, 12).map((inv: any) => (
<tr key={inv.id} style={{ borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '10px 12px' }}>{inv.period}</td>
<td style={{ padding: '10px 12px' }}>{inv.plan || '-'}</td>
<td style={{ padding: '10px 12px' }}>{inv.amount ? `${inv.amount.toLocaleString()}` : '무료'}</td>
<td style={{ padding: '10px 12px' }}>
<span style={{ background: inv.status === 'PAID' ? '#dcfce7' : '#fef3c7', color: inv.status === 'PAID' ? '#166534' : '#92400e', padding: '2px 8px', borderRadius: 10, fontSize: 11 }}>
{inv.status}
</span>
</td>
<td style={{ padding: '10px 12px', color: '#64748b', fontSize: 11 }}>
{inv.created_at ? new Date(inv.created_at).toLocaleDateString('ko-KR') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -0,0 +1,213 @@
import { useEffect, useState } from 'react'
import { guardiaApi } from '../api/clients'
type Tab = 'jira' | 'slack' | 'servicenow' | 'erp' | 'kakao' | 'sso'
export default function IntegrationHub() {
const [tab, setTab] = useState<Tab>('jira')
const tabs: { id: Tab; label: string; icon: string }[] = [
{ id: 'jira', label: 'Jira', icon: '🔵' },
{ id: 'slack', label: 'Slack', icon: '💬' },
{ id: 'servicenow', label: 'ServiceNow', icon: '🔗' },
{ id: 'erp', label: 'ERP', icon: '🏢' },
{ id: 'kakao', label: '카카오', icon: '💛' },
{ id: 'sso', label: 'SSO', icon: '🔐' },
]
return (
<div style={{ padding: '24px 28px' }}>
<h2 style={{ margin: '0 0 20px', fontSize: 20, fontWeight: 700 }}>🔗 </h2>
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid #e2e8f0' }}>
{tabs.map(t => (
<button key={t.id} onClick={() => setTab(t.id)} style={{
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: tab === t.id ? 700 : 400,
color: tab === t.id ? '#003366' : '#64748b',
borderBottom: tab === t.id ? '2px solid #003366' : '2px solid transparent',
}}>{t.icon} {t.label}</button>
))}
</div>
{tab === 'jira' && <JiraTab />}
{tab === 'slack' && <SlackTab />}
{tab === 'servicenow' && <ServiceNowTab />}
{tab === 'erp' && <ERPTab />}
{tab === 'kakao' && <KakaoTab />}
{tab === 'sso' && <SSOTab />}
</div>
)
}
function Card({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
return <div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20, ...style }}>{children}</div>
}
function JiraTab() {
const [cfg, setCfg] = useState<any>(null)
const [mappings, setMappings] = useState<any[]>([])
useEffect(() => {
guardiaApi.get('/api/jira/config').then((r: any) => setCfg(r.data)).catch(() => {})
guardiaApi.get('/api/jira/mappings').then((r: any) => setMappings(r.data || [])).catch(() => {})
}, [])
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 16 }}>
<Card>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>Jira </h3>
{cfg ? (
<div style={{ fontSize: 13, lineHeight: 2 }}>
<div>URL: {cfg.base_url}</div>
<div>: {cfg.project_key}</div>
<div style={{ color: '#10B981' }}> </div>
</div>
) : <p style={{ color: '#94a3b8', fontSize: 13 }}> </p>}
</Card>
<Card>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>SR-Issue ({mappings.length})</h3>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ borderBottom: '1px solid #f1f5f9' }}>
{['SR ID', 'Jira Key', '동기화 시간'].map(h => <th key={h} style={{ textAlign: 'left', padding: '6px 8px', color: '#64748b', fontSize: 11 }}>{h}</th>)}
</tr></thead>
<tbody>
{mappings.slice(0, 8).map((m: any) => (
<tr key={m.id} style={{ borderBottom: '1px solid #f8fafc' }}>
<td style={{ padding: '8px' }}>SR-{m.sr_id}</td>
<td style={{ padding: '8px', color: '#003366' }}>{m.jira_key}</td>
<td style={{ padding: '8px', color: '#94a3b8', fontSize: 11 }}>{m.synced_at ? new Date(m.synced_at).toLocaleDateString('ko-KR') : '-'}</td>
</tr>
))}
{!mappings.length && <tr><td colSpan={3} style={{ textAlign: 'center', padding: 16, color: '#94a3b8' }}> </td></tr>}
</tbody>
</table>
</Card>
</div>
)
}
function SlackTab() {
const [cfg, setCfg] = useState<any>(null)
const [webhook, setWebhook] = useState('')
const [channel, setChannel] = useState('#guardia-ops')
useEffect(() => { guardiaApi.get('/api/slack/config').then((r: any) => setCfg(r.data)).catch(() => {}) }, [])
async function save() {
await guardiaApi.post('/api/slack/config', { name: 'Slack', webhook_url: webhook, default_channel: channel })
const r: any = await guardiaApi.get('/api/slack/config')
setCfg(r.data)
}
async function testMsg() { await guardiaApi.get('/api/slack/test') }
return (
<Card style={{ maxWidth: 520 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>Slack </h3>
{cfg && <div style={{ fontSize: 13, marginBottom: 12, color: '#10B981' }}> ({cfg.default_channel})</div>}
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>Webhook URL</label>
<input value={webhook} onChange={e => setWebhook(e.target.value)} placeholder="https://hooks.slack.com/..." style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}> </label>
<input value={channel} onChange={e => setChannel(e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={save} style={{ padding: '7px 16px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}></button>
{cfg && <button onClick={testMsg} style={{ padding: '7px 16px', border: '1px solid #e2e8f0', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}></button>}
</div>
</Card>
)
}
function ServiceNowTab() {
const [url, setUrl] = useState(''); const [user, setUser] = useState(''); const [pw, setPw] = useState('')
async function save() { await guardiaApi.post('/api/servicenow/config', { instance_url: url, username: user, password: pw }) }
return (
<Card style={{ maxWidth: 520 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>ServiceNow </h3>
{[['인스턴스 URL', url, setUrl, 'https://company.service-now.com'], ['사용자명', user, setUser, ''], ['비밀번호', pw, setPw, '']].map(([label, val, set, ph]: any) => (
<div key={label as string} style={{ marginBottom: 12 }}>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>{label as string}</label>
<input type={label === '비밀번호' ? 'password' : 'text'} value={val as string} onChange={(e: any) => (set as Function)(e.target.value)} placeholder={ph as string}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
</div>
))}
<button onClick={save} style={{ padding: '7px 16px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}></button>
</Card>
)
}
function ERPTab() {
const [cfgs, setCfgs] = useState<any[]>([])
useEffect(() => { guardiaApi.get('/api/erp/config').then((r: any) => setCfgs(r.data || [])).catch(() => {}) }, [])
return (
<div>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}> ERP ({cfgs.length})</h3>
{cfgs.map((c: any) => (
<Card key={c.id} style={{ marginBottom: 10, display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 24 }}>🏢</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{c.name}</div>
<div style={{ fontSize: 11, color: '#64748b' }}>{c.erp_type} · {c.base_url}</div>
</div>
</Card>
))}
{!cfgs.length && <p style={{ color: '#94a3b8' }}> </p>}
</div>
)
}
function KakaoTab() {
const [cfg, setCfg] = useState<any>(null)
const [history, setHistory] = useState<any[]>([])
useEffect(() => {
guardiaApi.get('/api/kakao/config').then((r: any) => setCfg(r.data)).catch(() => {})
guardiaApi.get('/api/kakao/history').then((r: any) => setHistory(r.data || [])).catch(() => {})
}, [])
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 16 }}>
<Card>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}> </h3>
{cfg ? <div style={{ fontSize: 13, color: '#10B981' }}> <br/><span style={{ color: '#64748b' }}>: {cfg.sender}</span></div>
: <p style={{ color: '#94a3b8', fontSize: 13 }}></p>}
</Card>
<Card>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}> </h3>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ borderBottom: '1px solid #f1f5f9' }}>
{['템플릿', '수신', '결과', '시간'].map(h => <th key={h} style={{ textAlign: 'left', padding: '4px 8px', color: '#64748b', fontSize: 11 }}>{h}</th>)}
</tr></thead>
<tbody>
{history.slice(0, 8).map((h: any) => (
<tr key={h.id} style={{ borderBottom: '1px solid #f8fafc' }}>
<td style={{ padding: '6px 8px' }}>{h.template}</td>
<td style={{ padding: '6px 8px' }}>{h.receivers}</td>
<td style={{ padding: '6px 8px', color: h.success ? '#10B981' : '#EF4444' }}>{h.success ? '성공' : '실패'}</td>
<td style={{ padding: '6px 8px', color: '#94a3b8', fontSize: 11 }}>{h.sent_at ? new Date(h.sent_at).toLocaleDateString('ko-KR') : '-'}</td>
</tr>
))}
{!history.length && <tr><td colSpan={4} style={{ textAlign: 'center', padding: 12, color: '#94a3b8' }}> </td></tr>}
</tbody>
</table>
</Card>
</div>
)
}
function SSOTab() {
const [configs, setConfigs] = useState<any[]>([])
useEffect(() => { guardiaApi.get('/api/sso/config').then((r: any) => setConfigs(r.data || [])).catch(() => {}) }, [])
return (
<div>
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}> SSO IdP ({configs.length})</h3>
{configs.map((c: any) => (
<Card key={c.id} style={{ marginBottom: 10, display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 24 }}>{c.provider_type === 'SAML' ? '🏛️' : '🔑'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{c.name}</div>
<div style={{ fontSize: 11, color: '#64748b' }}>{c.provider_type} · {c.is_active ? '활성' : '비활성'}</div>
</div>
<a href={`/api/sso/login/${c.id}`} style={{ fontSize: 12, color: '#003366', textDecoration: 'none' }}> </a>
</Card>
))}
{!configs.length && <p style={{ color: '#94a3b8' }}> IdP </p>}
<div style={{ marginTop: 16 }}>
<a href="/api/sso/metadata" target="_blank" style={{ fontSize: 12, color: '#64748b' }}>📄 SP Metadata XML</a>
</div>
</div>
)
}

View File

@ -0,0 +1,119 @@
import { useEffect, useState } from 'react'
import { guardiaApi } from '../api/clients'
const STATUS_COLOR: Record<string, string> = {
GREEN: '#22c55e', YELLOW: '#f59e0b', RED: '#ef4444', NO_DATA: '#94a3b8',
}
const STATUS_ICON: Record<string, string> = {
GREEN: '✅', YELLOW: '⚠️', RED: '❌', NO_DATA: '❔',
}
interface KPI {
id: number; name: string; display_name: string
unit: string; direction: string; target: number
current_value: number | null; status: string
achievement_pct: number | null; last_calculated_at: string | null
}
interface Dashboard { kpis: KPI[]; summary: Record<string, number>; overall_status: string }
function GaugeBar({ pct }: { pct: number | null }) {
const p = Math.min(100, Math.max(0, pct ?? 0))
const color = p >= 95 ? '#22c55e' : p >= 80 ? '#f59e0b' : '#ef4444'
return (
<div style={{ background: '#f1f5f9', borderRadius: 4, height: 6, marginTop: 6 }}>
<div style={{ width: `${p}%`, height: 6, background: color, borderRadius: 4, transition: 'width .4s' }} />
</div>
)
}
export default function KpiDashboard() {
const [data, setData] = useState<Dashboard | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try {
const d = await guardiaApi.get('/api/kpi/dashboard').then((r: any) => r.data)
setData(d)
} catch { /* 오류 무시 */ }
setLoading(false)
}
async function applyTemplates() {
await guardiaApi.post('/api/kpi/apply-template', {
template_names: ['MTTR', 'FCR', 'SLA_COMPLIANCE', 'SR_BACKLOG', 'DEPLOY_SUCCESS_RATE'],
})
load()
}
async function recalc(id: number) {
await guardiaApi.post(`/api/kpi/${id}/calculate`)
load()
}
if (loading) return <div style={{ padding: 32, textAlign: 'center' }}> ...</div>
const overall = data?.overall_status || 'NO_DATA'
const summary = data?.summary || {}
return (
<div style={{ padding: '24px 28px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>
{STATUS_ICON[overall]} KPI
</h2>
<p style={{ margin: '4px 0 0', color: '#64748b', fontSize: 13 }}>
GREEN:{summary.GREEN||0} · YELLOW:{summary.YELLOW||0} · RED:{summary.RED||0}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={load} style={{ padding: '6px 14px', border: '1px solid #e2e8f0', borderRadius: 6, background: '#fff', cursor: 'pointer' }}>
🔄
</button>
{(!data?.kpis.length) && (
<button onClick={applyTemplates} style={{ padding: '6px 14px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>
📋 릿
</button>
)}
</div>
</div>
{/* KPI 카드 그리드 */}
{data?.kpis.length === 0 ? (
<div style={{ padding: 48, textAlign: 'center', border: '2px dashed #e2e8f0', borderRadius: 12 }}>
<p style={{ color: '#94a3b8' }}>KPI가 .</p>
<button onClick={applyTemplates} style={{ padding: '8px 20px', background: '#003366', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}>
KPI 5
</button>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 16 }}>
{data?.kpis.map(k => (
<div key={k.id} style={{ background: '#fff', border: `1px solid #e2e8f0`, borderLeft: `4px solid ${STATUS_COLOR[k.status]}`, borderRadius: 10, padding: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#1e293b' }}>{k.display_name}</span>
<span style={{ fontSize: 11, background: STATUS_COLOR[k.status], color: '#fff', padding: '2px 8px', borderRadius: 10 }}>{k.status}</span>
</div>
<div style={{ fontSize: 30, fontWeight: 700, color: STATUS_COLOR[k.status] }}>
{k.current_value !== null ? k.current_value : '—'}
<span style={{ fontSize: 14, marginLeft: 4, color: '#64748b' }}>{k.unit}</span>
</div>
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>
: {k.target}{k.unit} · : {k.achievement_pct !== null ? `${k.achievement_pct}%` : '—'}
</div>
<GaugeBar pct={k.achievement_pct} />
<button onClick={() => recalc(k.id)} style={{ marginTop: 10, width: '100%', padding: '5px', border: '1px solid #e2e8f0', borderRadius: 6, background: '#f8fafc', cursor: 'pointer', fontSize: 12 }}>
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/clients.ts","./src/api/types.ts","./src/components/common/btn.tsx","./src/components/common/datatable.tsx","./src/components/common/protectedroute.tsx","./src/components/common/slidepanel.tsx","./src/components/common/statcard.tsx","./src/components/common/statusbadge.tsx","./src/components/layout/applayout.tsx","./src/components/layout/gnb.tsx","./src/components/layout/sidebar.tsx","./src/config/env.ts","./src/hooks/useapi.ts","./src/hooks/useauth.ts","./src/pages/apikeys.tsx","./src/pages/auditlog.tsx","./src/pages/cmdb.tsx","./src/pages/configenv.tsx","./src/pages/confignginx.tsx","./src/pages/csapconsole.tsx","./src/pages/dashboard.tsx","./src/pages/deployments.tsx","./src/pages/drconsole.tsx","./src/pages/exportimport.tsx","./src/pages/institutions.tsx","./src/pages/llmmanager.tsx","./src/pages/licenses.tsx","./src/pages/login.tsx","./src/pages/networkconsole.tsx","./src/pages/notifications.tsx","./src/pages/repos.tsx","./src/pages/servers.tsx","./src/pages/users.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/api/clients.ts","./src/api/types.ts","./src/components/common/btn.tsx","./src/components/common/datatable.tsx","./src/components/common/protectedroute.tsx","./src/components/common/slidepanel.tsx","./src/components/common/statcard.tsx","./src/components/common/statusbadge.tsx","./src/components/layout/applayout.tsx","./src/components/layout/gnb.tsx","./src/components/layout/sidebar.tsx","./src/config/env.ts","./src/hooks/useapi.ts","./src/hooks/useauth.ts","./src/pages/aiplatform.tsx","./src/pages/apikeys.tsx","./src/pages/auditlog.tsx","./src/pages/bianalytics.tsx","./src/pages/billingmanage.tsx","./src/pages/cmdb.tsx","./src/pages/configenv.tsx","./src/pages/confignginx.tsx","./src/pages/csapconsole.tsx","./src/pages/dashboard.tsx","./src/pages/deployments.tsx","./src/pages/drconsole.tsx","./src/pages/exportimport.tsx","./src/pages/institutions.tsx","./src/pages/integrationhub.tsx","./src/pages/kpidashboard.tsx","./src/pages/llmmanager.tsx","./src/pages/licenses.tsx","./src/pages/login.tsx","./src/pages/networkconsole.tsx","./src/pages/notifications.tsx","./src/pages/repos.tsx","./src/pages/scrapingmanager.tsx","./src/pages/servers.tsx","./src/pages/users.tsx"],"version":"5.9.3"}