manual-deploy 2026-06-07 20:13

This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-07 20:13:54 +09:00
parent 7775cc3b07
commit 93aa4b0c54
4 changed files with 875 additions and 1 deletions

View File

@ -25,6 +25,15 @@ app.include_router(deploy.router, prefix="/api/deploy", tags=["deploy"])
app.include_router(config.router, prefix="/api/config", tags=["config"]) app.include_router(config.router, prefix="/api/config", tags=["config"])
app.include_router(llm.router, prefix="/api/llm", tags=["llm"]) app.include_router(llm.router, prefix="/api/llm", tags=["llm"])
# ── Gen6 확장 (2026-06-07) ────────────────────────────────────────────────
from routers import ai_analytics2, platform_mgmt, adv_security_mgr, ops_automation, finops2, cross_system
app.include_router(ai_analytics2.router) # AI 분석 v2 (예측 KPI·이상패턴·AI리포트)
app.include_router(platform_mgmt.router) # 플랫폼 관리 (멀티클러스터·GitOps·배포맵)
app.include_router(adv_security_mgr.router) # 고급 보안 (ZeroTrust UI·위협헌팅·SOC)
app.include_router(ops_automation.router) # 운영 자동화 (노코드·런북·정책UI·스케줄러)
app.include_router(finops2.router) # FinOps v2 (비용최적화·예산예측·태깅)
app.include_router(cross_system.router) # 크로스 시스템 (ITSM 구독·데이터동기화)
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "ok", "service": "guardia-manager", "port": 8002} return {"status": "ok", "service": "guardia-manager", "port": 8002}

View File

@ -0,0 +1,382 @@
import { useState, useEffect, useCallback } from 'react'
import axios from 'axios'
const API = import.meta.env.VITE_ITSM_API ?? 'http://localhost:9001'
type Category = 'DELIVERABLE' | 'MEETING_MINUTES' | 'REPORT'
type JobStatus = 'PENDING' | 'DONE' | 'FAILED'
interface Template {
id: number
name: string
category: Category
output_format: 'PDF' | 'EXCEL'
is_builtin: boolean
field_count: number
created_at: string
}
interface HistoryItem {
id: number
category: Category
title: string
status: JobStatus
output_format: 'PDF' | 'EXCEL'
file_size: number | null
error: string | null
created_at: string
download_url: string | null
}
const CATEGORY_LABEL: Record<Category, string> = {
DELIVERABLE: '산출물', MEETING_MINUTES: '회의록', REPORT: '보고서',
}
const CATEGORY_COLOR: Record<Category, string> = {
DELIVERABLE: '#4f6ef7', MEETING_MINUTES: '#16a34a', REPORT: '#f59e0b',
}
const STATUS_COLOR: Record<JobStatus, string> = {
PENDING: '#64748b', DONE: '#16a34a', FAILED: '#dc2626',
}
const STATUS_LABEL: Record<JobStatus, string> = {
PENDING: '대기', DONE: '완료', FAILED: '실패',
}
function fmtDate(d: string | null) {
if (!d) return '—'
return new Date(d).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function fmtSize(n: number | null) {
if (!n) return '—'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
function CatBadge({ category }: { category: Category }) {
return (
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 700, color: '#fff', background: CATEGORY_COLOR[category],
}}>{CATEGORY_LABEL[category]}</span>
)
}
function StatusBadge({ status }: { status: JobStatus }) {
return (
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 700, color: '#fff', background: STATUS_COLOR[status],
}}>{STATUS_LABEL[status]}</span>
)
}
async function downloadBlob(url: string, headers: Record<string, string>, fallbackName: string) {
const r = await axios.get(url, { headers, responseType: 'blob' })
const disposition: string = r.headers['content-disposition'] ?? ''
const m = /filename="?([^";]+)"?/.exec(disposition)
const filename = m ? m[1].trim() : fallbackName
const blobUrl = window.URL.createObjectURL(new Blob([r.data]))
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(blobUrl)
}
export default function JasperReports() {
const token = localStorage.getItem('guardia_token') ?? ''
const headers = { Authorization: `Bearer ${token}` }
const [templates, setTemplates] = useState<Template[]>([])
const [history, setHistory] = useState<HistoryItem[]>([])
const [loading, setLoading] = useState(false)
const [tab, setTab] = useState<Category>('DELIVERABLE')
const [generating, setGenerating] = useState(false)
const [deliverable, setDeliverable] = useState({
title: '', org_name: '지오정보기술', period_start: '', period_end: '', prepared_by: '', output_format: 'PDF' as 'PDF' | 'EXCEL',
})
const [meeting, setMeeting] = useState({
title: '', meeting_date: '', location: '온라인', chairman: '',
attendees: '', agenda_summary: '', decisions: '', action_items: '', next_meeting: '', output_format: 'PDF' as 'PDF' | 'EXCEL',
})
const [report, setReport] = useState({
title: '정기 운영 보고서', period_start: '', period_end: '', output_format: 'PDF' as 'PDF' | 'EXCEL',
})
const load = useCallback(async () => {
setLoading(true)
try {
const [tRes, hRes] = await Promise.all([
axios.get(`${API}/api/jasper/templates`, { headers }),
axios.get(`${API}/api/jasper/history`, { params: { limit: 50 }, headers }),
])
setTemplates(tRes.data.templates)
setHistory(hRes.data.items)
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}, [token])
useEffect(() => { load() }, [load])
const stats = {
templates: templates.length,
done: history.filter(h => h.status === 'DONE').length,
pending: history.filter(h => h.status === 'PENDING').length,
failed: history.filter(h => h.status === 'FAILED').length,
}
function lines(s: string): string[] {
return s.split('\n').map(v => v.trim()).filter(Boolean)
}
async function handleGenerate() {
setGenerating(true)
try {
let r
if (tab === 'DELIVERABLE') {
if (!deliverable.title.trim()) return alert('문서 제목을 입력하세요.')
r = await axios.post(`${API}/api/jasper/generate/deliverable`, {
...deliverable,
period_start: deliverable.period_start || null,
period_end: deliverable.period_end || null,
prepared_by: deliverable.prepared_by || null,
}, { headers })
} else if (tab === 'MEETING_MINUTES') {
if (!meeting.title.trim() || !meeting.meeting_date.trim()) return alert('회의명과 회의 일시는 필수입니다.')
r = await axios.post(`${API}/api/jasper/generate/meeting-minutes`, {
...meeting,
attendees: lines(meeting.attendees),
decisions: lines(meeting.decisions),
action_items: lines(meeting.action_items),
}, { headers })
} else {
r = await axios.post(`${API}/api/jasper/generate/report`, {
...report,
period_start: report.period_start || null,
period_end: report.period_end || null,
}, { headers })
}
if (r.data.ok) {
alert(`문서 생성 완료: ${r.data.title} (job #${r.data.job_id})\n다운로드는 이력 그리드에서 가능합니다.`)
} else {
alert(`생성 실패: ${r.data.error ?? '알 수 없는 오류'}`)
}
await load()
} catch (e: any) {
alert(`생성 실패: ${e.response?.data?.detail ?? e.message}`)
} finally {
setGenerating(false)
}
}
async function handleDownload(item: HistoryItem) {
if (!item.download_url) return alert('완료된 문서만 다운로드할 수 있습니다.')
const ext = item.output_format === 'EXCEL' ? 'xlsx' : 'pdf'
try {
await downloadBlob(`${API}${item.download_url}`, headers, `${item.title}_${item.id}.${ext}`)
} catch (e: any) {
alert(e.response?.data?.detail ?? '다운로드 실패')
}
}
const card = (label: string, val: number | undefined, color: string) => (
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
padding: '16px 20px', minWidth: 100, textAlign: 'center',
borderTop: `4px solid ${color}`,
}}>
<div style={{ fontSize: 24, fontWeight: 800, color }}>{val ?? 0}</div>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>{label}</div>
</div>
)
return (
<div style={{ padding: '24px 28px', background: '#f8fafc', minHeight: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 800 }}>📄 Jasper </h2>
<span style={{ fontSize: 12, color: '#94a3b8' }}> · · JRXML 릿 PDF/Excel </span>
<button onClick={load} disabled={loading} style={{
marginLeft: 'auto', padding: '4px 12px', borderRadius: 6, border: '1px solid #cbd5e1',
background: '#fff', cursor: 'pointer', fontSize: 12,
}}>{loading ? '로딩...' : '새로고침'}</button>
</div>
{/* 통계 카드 */}
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
{card('등록 템플릿', stats.templates, '#6366f1')}
{card('생성 완료', stats.done, '#16a34a')}
{card('대기', stats.pending, '#64748b')}
{card('실패', stats.failed, '#dc2626')}
</div>
{/* 카테고리 탭 */}
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
{(['DELIVERABLE', 'MEETING_MINUTES', 'REPORT'] as Category[]).map(c => (
<button key={c} onClick={() => setTab(c)} style={{
padding: '6px 16px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: tab === c ? '#4f6ef7' : '#e2e8f0',
color: tab === c ? '#fff' : '#475569', fontWeight: tab === c ? 700 : 400,
}}>{CATEGORY_LABEL[c]} </button>
))}
</div>
{/* 생성 폼 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: '16px 20px', marginBottom: 20 }}>
{tab === 'DELIVERABLE' && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<input value={deliverable.title} onChange={e => setDeliverable(p => ({ ...p, title: e.target.value }))}
placeholder="문서 제목" style={{ ...inputStyle, width: 220 }} />
<input value={deliverable.org_name} onChange={e => setDeliverable(p => ({ ...p, org_name: e.target.value }))}
placeholder="대상 기관/고객사명" style={{ ...inputStyle, width: 160 }} />
<input type="date" value={deliverable.period_start} onChange={e => setDeliverable(p => ({ ...p, period_start: e.target.value }))} style={inputStyle} />
<span style={{ color: '#94a3b8' }}>~</span>
<input type="date" value={deliverable.period_end} onChange={e => setDeliverable(p => ({ ...p, period_end: e.target.value }))} style={inputStyle} />
<input value={deliverable.prepared_by} onChange={e => setDeliverable(p => ({ ...p, prepared_by: e.target.value }))}
placeholder="작성자 (선택)" style={{ ...inputStyle, width: 120 }} />
<FormatSelect value={deliverable.output_format} onChange={v => setDeliverable(p => ({ ...p, output_format: v }))} />
<button onClick={handleGenerate} disabled={generating} style={btnPrimary}>{generating ? '생성 중...' : '📄 산출물 생성'}</button>
</div>
)}
{tab === 'MEETING_MINUTES' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input value={meeting.title} onChange={e => setMeeting(p => ({ ...p, title: e.target.value }))}
placeholder="회의명" style={{ ...inputStyle, width: 200 }} />
<input type="datetime-local" value={meeting.meeting_date}
onChange={e => setMeeting(p => ({ ...p, meeting_date: e.target.value.replace('T', ' ') }))} style={inputStyle} />
<input value={meeting.location} onChange={e => setMeeting(p => ({ ...p, location: e.target.value }))}
placeholder="장소" style={{ ...inputStyle, width: 120 }} />
<input value={meeting.chairman} onChange={e => setMeeting(p => ({ ...p, chairman: e.target.value }))}
placeholder="주재자" style={{ ...inputStyle, width: 120 }} />
<input value={meeting.next_meeting} onChange={e => setMeeting(p => ({ ...p, next_meeting: e.target.value }))}
placeholder="차기 회의 (선택)" style={{ ...inputStyle, width: 140 }} />
<FormatSelect value={meeting.output_format} onChange={v => setMeeting(p => ({ ...p, output_format: v }))} />
</div>
<textarea value={meeting.attendees} onChange={e => setMeeting(p => ({ ...p, attendees: e.target.value }))}
placeholder="참석자 (한 줄에 한 명)" style={textareaStyle} />
<textarea value={meeting.agenda_summary} onChange={e => setMeeting(p => ({ ...p, agenda_summary: e.target.value }))}
placeholder="안건 요약" style={textareaStyle} />
<textarea value={meeting.decisions} onChange={e => setMeeting(p => ({ ...p, decisions: e.target.value }))}
placeholder="결정 사항 (한 줄에 한 건)" style={textareaStyle} />
<textarea value={meeting.action_items} onChange={e => setMeeting(p => ({ ...p, action_items: e.target.value }))}
placeholder="액션 아이템 (한 줄에 한 건)" style={textareaStyle} />
<div>
<button onClick={handleGenerate} disabled={generating} style={btnPrimary}>{generating ? '생성 중...' : '📝 회의록 생성'}</button>
</div>
</div>
)}
{tab === 'REPORT' && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<input value={report.title} onChange={e => setReport(p => ({ ...p, title: e.target.value }))}
placeholder="보고서 제목" style={{ ...inputStyle, width: 220 }} />
<input type="date" value={report.period_start} onChange={e => setReport(p => ({ ...p, period_start: e.target.value }))} style={inputStyle} />
<span style={{ color: '#94a3b8' }}>~</span>
<input type="date" value={report.period_end} onChange={e => setReport(p => ({ ...p, period_end: e.target.value }))} style={inputStyle} />
<FormatSelect value={report.output_format} onChange={v => setReport(p => ({ ...p, output_format: v }))} />
<button onClick={handleGenerate} disabled={generating} style={btnPrimary}>{generating ? '생성 중...' : '📊 보고서 생성'}</button>
</div>
)}
</div>
{/* 템플릿 목록 */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 10 }}> JRXML 릿</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{templates.length === 0 && <div style={{ color: '#94a3b8', fontSize: 13 }}>릿 </div>}
{templates.map(t => (
<div key={t.id} style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 8,
padding: '10px 14px', minWidth: 180, fontSize: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<CatBadge category={t.category} />
{t.is_builtin && <span style={{ fontSize: 10, color: '#94a3b8' }}></span>}
</div>
<div style={{ fontWeight: 700 }}>{t.name}</div>
<div style={{ color: '#64748b', marginTop: 2 }}> {t.output_format} · {t.field_count}</div>
</div>
))}
</div>
</div>
{/* 생성 이력 그리드 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f1f5f9' }}>
{['ID', '카테고리', '제목', '형식', '크기', '상태', '생성일시', '조작'].map(h => (
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', fontWeight: 600, color: '#475569', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{history.length === 0 && (
<tr><td colSpan={8} style={{ padding: 24, textAlign: 'center', color: '#94a3b8' }}> </td></tr>
)}
{history.map(h => (
<tr key={h.id} style={{ borderTop: '1px solid #f1f5f9' }}
onMouseEnter={e => (e.currentTarget.style.background = '#f8fafc')}
onMouseLeave={e => (e.currentTarget.style.background = '')}>
<td style={{ padding: '8px 12px', color: '#64748b' }}>#{h.id}</td>
<td style={{ padding: '8px 12px' }}><CatBadge category={h.category} /></td>
<td style={{ padding: '8px 12px', maxWidth: 220 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{h.title}</div>
{h.error && <div style={{ fontSize: 11, color: '#dc2626', marginTop: 2 }}>{h.error}</div>}
</td>
<td style={{ padding: '8px 12px', color: '#64748b' }}>{h.output_format}</td>
<td style={{ padding: '8px 12px', color: '#64748b' }}>{fmtSize(h.file_size)}</td>
<td style={{ padding: '8px 12px' }}><StatusBadge status={h.status} /></td>
<td style={{ padding: '8px 12px', whiteSpace: 'nowrap', color: '#64748b' }}>{fmtDate(h.created_at)}</td>
<td style={{ padding: '8px 12px' }}>
{h.status === 'DONE'
? <button onClick={() => handleDownload(h)} style={btnSm('#16a34a')}>📥 </button>
: <span style={{ color: '#cbd5e1', fontSize: 11 }}></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function FormatSelect({ value, onChange }: { value: 'PDF' | 'EXCEL'; onChange: (v: 'PDF' | 'EXCEL') => void }) {
return (
<select value={value} onChange={e => onChange(e.target.value as 'PDF' | 'EXCEL')}
style={{ padding: '7px 10px', border: '1px solid #cbd5e1', borderRadius: 6, fontSize: 13 }}>
<option value="PDF">PDF</option>
<option value="EXCEL">Excel</option>
</select>
)
}
const inputStyle: React.CSSProperties = {
flex: 1, minWidth: 140, padding: '7px 10px',
border: '1px solid #cbd5e1', borderRadius: 6, fontSize: 13,
outline: 'none',
}
const textareaStyle: React.CSSProperties = {
width: '100%', minHeight: 56, padding: 8, border: '1px solid #cbd5e1', borderRadius: 6,
fontSize: 12, resize: 'vertical', boxSizing: 'border-box', fontFamily: 'inherit',
}
const btnPrimary: React.CSSProperties = {
padding: '7px 16px', background: '#4f6ef7', color: '#fff',
border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 13, fontWeight: 600,
}
const btnSm = (color: string): React.CSSProperties => ({
padding: '4px 12px', background: color, color: '#fff',
border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 11, fontWeight: 600,
})

View File

@ -0,0 +1,483 @@
import { useState, useEffect, useCallback } from 'react'
import axios from 'axios'
const API = import.meta.env.VITE_ITSM_API ?? 'http://localhost:9001'
type Status = 'DRAFT' | 'RECORDED' | 'SCRIPT_READY' | 'JMX_READY'
interface ScenarioStep {
action: string
selector: string | null
value: string | null
think_time: number
}
interface Scenario {
id: number
name: string
description: string | null
target_url: string
steps: ScenarioStep[] | null
users: number
duration_sec: number
ramp_up_sec: number
status: Status
created_at: string | null
updated_at: string | null
}
const STATUS_COLOR: Record<Status, string> = {
DRAFT: '#64748b',
RECORDED: '#6366f1',
SCRIPT_READY: '#f59e0b',
JMX_READY: '#16a34a',
}
const STATUS_LABEL: Record<Status, string> = {
DRAFT: '시나리오 작성', RECORDED: '녹화 완료', SCRIPT_READY: '스크립트 생성됨', JMX_READY: 'jMeter 셋팅 완료',
}
const STEPS_PIPELINE = ['시나리오 작성', '입력값 엑셀', '녹화 → 스크립트', 'jMeter 셋팅']
function fmtDate(d: string | null) {
if (!d) return '—'
return new Date(d).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function Badge({ status }: { status: Status }) {
return (
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 700, color: '#fff',
background: STATUS_COLOR[status],
}}>{STATUS_LABEL[status]}</span>
)
}
async function downloadBlob(url: string, headers: Record<string, string>, fallbackName: string) {
const r = await axios.get(url, { headers, responseType: 'blob' })
const disposition: string = r.headers['content-disposition'] ?? ''
const m = /filename=([^;]+)/.exec(disposition)
const filename = m ? m[1].trim() : fallbackName
const blobUrl = window.URL.createObjectURL(new Blob([r.data]))
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(blobUrl)
}
export default function PerfTestStudio() {
const token = localStorage.getItem('guardia_token') ?? ''
const headers = { Authorization: `Bearer ${token}` }
const [scenarios, setScenarios] = useState<Scenario[]>([])
const [loading, setLoading] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('ALL')
const [selected, setSelected] = useState<Scenario | null>(null)
const [busy, setBusy] = useState(false)
// AI 자동 생성 폼
const [genDesc, setGenDesc] = useState('')
const [genUrl, setGenUrl] = useState('')
const [generating, setGenerating] = useState(false)
// 직접 등록 폼
const [showCreate, setShowCreate] = useState(false)
const [form, setForm] = useState({ name: '', description: '', target_url: '', users: 10, duration_sec: 30, ramp_up_sec: 5 })
// 녹화 업로드(액션 로그 JSON 붙여넣기)
const [actionLogText, setActionLogText] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const r = await axios.get(`${API}/api/perf-scenario/scenarios`, { headers })
setScenarios(r.data)
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}, [token])
useEffect(() => { load() }, [load])
const stats = {
total: scenarios.length,
draft: scenarios.filter(s => s.status === 'DRAFT').length,
recorded: scenarios.filter(s => s.status === 'RECORDED').length,
script: scenarios.filter(s => s.status === 'SCRIPT_READY').length,
jmx: scenarios.filter(s => s.status === 'JMX_READY').length,
}
const filtered = statusFilter === 'ALL' ? scenarios : scenarios.filter(s => s.status === statusFilter)
function refreshSelected(updated: Scenario) {
setSelected(updated)
setScenarios(prev => prev.map(s => s.id === updated.id ? updated : s))
}
async function handleGenerate() {
if (!genDesc.trim() || !genUrl.trim()) return alert('시나리오 설명과 대상 URL을 입력하세요.')
setGenerating(true)
try {
const r = await axios.post(`${API}/api/perf-scenario/scenarios/generate`,
{ description: genDesc, target_url: genUrl }, { headers })
alert(`AI 시나리오 생성 완료: ${r.data.name} (단계 ${r.data.steps?.length ?? 0}개)`)
setGenDesc(''); setGenUrl('')
await load()
} catch (e: any) {
alert(`생성 실패: ${e.response?.data?.detail ?? e.message}`)
} finally {
setGenerating(false)
}
}
async function handleCreate() {
if (!form.name.trim() || !form.target_url.trim()) return alert('이름과 대상 URL은 필수입니다.')
try {
await axios.post(`${API}/api/perf-scenario/scenarios`,
{ ...form, description: form.description || null, steps: [] }, { headers })
setForm({ name: '', description: '', target_url: '', users: 10, duration_sec: 30, ramp_up_sec: 5 })
setShowCreate(false)
await load()
} catch (e: any) {
alert(e.response?.data?.detail ?? '등록 실패')
}
}
async function handleDelete(id: number) {
if (!confirm(`#${id} 시나리오를 삭제하시겠습니까? (입력값·녹화 데이터 포함 영구 삭제)`)) return
try {
await axios.delete(`${API}/api/perf-scenario/scenarios/${id}`, { headers })
setSelected(null)
await load()
} catch (e: any) {
alert(e.response?.data?.detail ?? '삭제 실패')
}
}
async function handleDownloadTemplate(s: Scenario) {
try {
await downloadBlob(`${API}/api/perf-scenario/scenarios/${s.id}/input-template`, headers, `perf_input_template_${s.id}.xlsx`)
} catch (e: any) {
alert(e.response?.data?.detail ?? '${변수} 가 없는 시나리오는 입력값 템플릿을 생성할 수 없습니다.')
}
}
async function handleUploadInputData(s: Scenario, file: File) {
const fd = new FormData()
fd.append('file', file)
setBusy(true)
try {
const r = await axios.post(`${API}/api/perf-scenario/scenarios/${s.id}/input-data`, fd, {
headers: { ...headers, 'Content-Type': 'multipart/form-data' },
})
alert(`입력값 등록 완료: 컬럼 ${r.data.column_names.length}개 · 행 ${r.data.row_count}`)
} catch (e: any) {
alert(`업로드 실패: ${e.response?.data?.detail ?? e.message}`)
} finally {
setBusy(false)
}
}
async function handleUploadRecording(s: Scenario) {
let actionLog: unknown
try {
actionLog = JSON.parse(actionLogText)
if (!Array.isArray(actionLog)) throw new Error('배열이어야 합니다.')
} catch {
return alert('액션 로그는 JSON 배열 형식이어야 합니다. 예: [{"type":"goto","value":"/login"}]')
}
setBusy(true)
try {
const r = await axios.post(`${API}/api/perf-scenario/scenarios/${s.id}/recording`,
{ action_log: actionLog }, { headers })
alert(`녹화 업로드 완료: 액션 ${r.data.action_count}`)
setActionLogText('')
const fresh = await axios.get(`${API}/api/perf-scenario/scenarios/${s.id}`, { headers })
refreshSelected(fresh.data)
await load()
} catch (e: any) {
alert(`업로드 실패: ${e.response?.data?.detail ?? e.message}`)
} finally {
setBusy(false)
}
}
async function handleGenerateScript(s: Scenario) {
setBusy(true)
try {
const r = await axios.post(`${API}/api/perf-scenario/scenarios/${s.id}/generate-script`, {}, { headers })
alert(`jMeter 스크립트 변환 완료: 샘플러 ${r.data.sampler_count}`)
const fresh = await axios.get(`${API}/api/perf-scenario/scenarios/${s.id}`, { headers })
refreshSelected(fresh.data)
await load()
} catch (e: any) {
alert(`변환 실패: ${e.response?.data?.detail ?? e.message}`)
} finally {
setBusy(false)
}
}
async function handleDownloadJmx(s: Scenario) {
try {
await downloadBlob(`${API}/api/perf-scenario/scenarios/${s.id}/jmx`, headers, `perf_scenario_${s.id}.jmx`)
const fresh = await axios.get(`${API}/api/perf-scenario/scenarios/${s.id}`, { headers })
refreshSelected(fresh.data)
await load()
} catch (e: any) {
alert(e.response?.data?.detail ?? 'jMeter 스크립트가 아직 생성되지 않았습니다.')
}
}
async function handleRun(s: Scenario) {
if (!confirm(`'${s.name}' 시나리오를 jMeter 실행 엔진(/api/perf)과 연계하여 즉시 실행하시겠습니까?`)) return
setBusy(true)
try {
const r = await axios.post(`${API}/api/perf-scenario/scenarios/${s.id}/run`, {}, { headers })
alert(`실행 요청 완료: ${JSON.stringify(r.data).slice(0, 200)}`)
} catch (e: any) {
alert(`실행 실패: ${e.response?.data?.detail ?? e.message}`)
} finally {
setBusy(false)
}
}
const card = (label: string, val: number | undefined, color: string) => (
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
padding: '16px 20px', minWidth: 100, textAlign: 'center',
borderTop: `4px solid ${color}`,
}}>
<div style={{ fontSize: 24, fontWeight: 800, color }}>{val ?? 0}</div>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>{label}</div>
</div>
)
return (
<div style={{ padding: '24px 28px', background: '#f8fafc', minHeight: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 800 }}> </h2>
<span style={{ fontSize: 12, color: '#94a3b8' }}>
Playwright MCP jMeter 4
</span>
<button onClick={load} disabled={loading} style={{
marginLeft: 'auto', padding: '4px 12px', borderRadius: 6, border: '1px solid #cbd5e1',
background: '#fff', cursor: 'pointer', fontSize: 12,
}}>{loading ? '로딩...' : '새로고침'}</button>
</div>
{/* 파이프라인 안내 */}
<div style={{ display: 'flex', gap: 6, marginBottom: 20 }}>
{STEPS_PIPELINE.map((p, i) => (
<div key={p} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
padding: '4px 12px', borderRadius: 14, fontSize: 12, fontWeight: 600,
background: '#eef2ff', color: '#4f6ef7', border: '1px solid #c7d2fe',
}}>{i + 1}. {p}</span>
{i < STEPS_PIPELINE.length - 1 && <span style={{ color: '#cbd5e1' }}></span>}
</div>
))}
</div>
{/* 통계 카드 */}
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
{card('전체 시나리오', stats.total, '#6366f1')}
{card('작성 단계', stats.draft, '#64748b')}
{card('녹화 완료', stats.recorded, '#6366f1')}
{card('스크립트 생성됨', stats.script, '#f59e0b')}
{card('jMeter 셋팅 완료', stats.jmx, '#16a34a')}
</div>
{/* AI 시나리오 자동 생성 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: '16px 20px', marginBottom: 16 }}>
<div style={{ fontWeight: 700, marginBottom: 10, fontSize: 14 }}>🤖 AI (Ollama)</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input value={genUrl} onChange={e => setGenUrl(e.target.value)}
placeholder="대상 URL (예: https://itsm.example.go.kr)" style={{ ...inputStyle, width: 260 }} />
<input value={genDesc} onChange={e => setGenDesc(e.target.value)}
placeholder="시나리오 설명 (예: 로그인 후 SR 목록을 조회하고 새 SR을 등록한다)" style={inputStyle} />
<button onClick={handleGenerate} disabled={generating || !genDesc.trim() || !genUrl.trim()} style={btnPrimary}>
{generating ? '생성 중...' : 'AI로 생성'}
</button>
<button onClick={() => setShowCreate(v => !v)} style={{ ...btnPrimary, background: '#64748b' }}>
{showCreate ? '직접 등록 닫기' : '+ 직접 등록'}
</button>
</div>
{showCreate && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #f1f5f9', display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))}
placeholder="시나리오 이름" style={{ ...inputStyle, width: 160 }} />
<input value={form.target_url} onChange={e => setForm(p => ({ ...p, target_url: e.target.value }))}
placeholder="대상 URL" style={{ ...inputStyle, width: 220 }} />
<input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))}
placeholder="설명 (선택)" style={inputStyle} />
<input type="number" value={form.users} onChange={e => setForm(p => ({ ...p, users: Number(e.target.value) }))}
placeholder="동시 사용자" style={{ ...inputStyle, width: 100 }} />
<input type="number" value={form.duration_sec} onChange={e => setForm(p => ({ ...p, duration_sec: Number(e.target.value) }))}
placeholder="지속(초)" style={{ ...inputStyle, width: 100 }} />
<input type="number" value={form.ramp_up_sec} onChange={e => setForm(p => ({ ...p, ramp_up_sec: Number(e.target.value) }))}
placeholder="램프업(초)" style={{ ...inputStyle, width: 100 }} />
<button onClick={handleCreate} style={btnPrimary}></button>
</div>
)}
</div>
{/* 필터 */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{['ALL', 'DRAFT', 'RECORDED', 'SCRIPT_READY', 'JMX_READY'].map(s => (
<button key={s} onClick={() => setStatusFilter(s)} style={{
padding: '4px 12px', borderRadius: 6, border: '1px solid #cbd5e1',
background: statusFilter === s ? '#1a3a6b' : '#fff',
color: statusFilter === s ? '#fff' : '#475569', cursor: 'pointer', fontSize: 12,
}}>{s === 'ALL' ? '전체' : STATUS_LABEL[s as Status]}</button>
))}
</div>
{/* 시나리오 그리드 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f1f5f9' }}>
{['ID', '이름', '대상 URL', '동시사용자/지속(초)', '단계 수', '상태', '수정일시', '조작'].map(h => (
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', fontWeight: 600, color: '#475569', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={8} style={{ padding: 24, textAlign: 'center', color: '#94a3b8' }}> </td></tr>
)}
{filtered.map(s => (
<tr key={s.id} style={{ borderTop: '1px solid #f1f5f9' }}
onMouseEnter={e => (e.currentTarget.style.background = '#f8fafc')}
onMouseLeave={e => (e.currentTarget.style.background = '')}>
<td style={{ padding: '8px 12px', color: '#64748b' }}>#{s.id}</td>
<td style={{ padding: '8px 12px', fontWeight: 600, maxWidth: 200 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.name}</div>
</td>
<td style={{ padding: '8px 12px', maxWidth: 200 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#64748b' }}>{s.target_url}</div>
</td>
<td style={{ padding: '8px 12px', whiteSpace: 'nowrap', color: '#64748b' }}>{s.users} / {s.duration_sec}</td>
<td style={{ padding: '8px 12px', color: '#64748b' }}>{s.steps?.length ?? 0}</td>
<td style={{ padding: '8px 12px' }}><Badge status={s.status} /></td>
<td style={{ padding: '8px 12px', whiteSpace: 'nowrap', color: '#64748b' }}>{fmtDate(s.updated_at)}</td>
<td style={{ padding: '8px 12px' }}>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => setSelected(s)} style={btnSm('#4f6ef7')}></button>
<button onClick={() => handleDelete(s.id)} style={btnSm('#dc2626')}></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 파이프라인 슬라이드 패널 */}
{selected && (
<div style={{
position: 'fixed', right: 0, top: 0, bottom: 0, width: 520,
background: '#fff', boxShadow: '-4px 0 24px rgba(0,0,0,.12)',
zIndex: 1000, display: 'flex', flexDirection: 'column',
}}>
<div style={{ padding: '16px 20px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 12 }}>
<strong style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>#{selected.id} {selected.name}</strong>
<Badge status={selected.status} />
<button onClick={() => setSelected(null)} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer' }}></button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px' }}>
<Info label="대상 URL" value={selected.target_url} />
<Info label="설명" value={selected.description || '—'} />
<Info label="부하 설정" value={`동시 사용자 ${selected.users}명 · 지속 ${selected.duration_sec}초 · 램프업 ${selected.ramp_up_sec}`} />
{selected.steps && selected.steps.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 4 }}> ({selected.steps.length})</div>
<div style={{ background: '#f8fafc', borderRadius: 6, padding: 10, fontSize: 12, lineHeight: 1.8, maxHeight: 160, overflowY: 'auto' }}>
{selected.steps.map((st, i) => (
<div key={i}>
<code>{i + 1}. {st.action}</code>
{st.selector && <span style={{ color: '#64748b' }}> {st.selector}</span>}
{st.value && <span style={{ color: '#4f6ef7' }}> {st.value}</span>}
</div>
))}
</div>
</div>
)}
<div style={{ marginTop: 18, paddingTop: 14, borderTop: '1px solid #f1f5f9' }}>
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 10 }}>2 </div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={() => handleDownloadTemplate(selected)} style={btnSm('#6366f1')}>📥 릿(.xlsx) </button>
<label style={{ ...btnSm('#16a34a'), display: 'inline-block', cursor: 'pointer' }}>
📤
<input type="file" accept=".xlsx,.xlsm" style={{ display: 'none' }}
onChange={e => { const f = e.target.files?.[0]; if (f) { handleUploadInputData(selected, f); e.target.value = '' } }} />
</label>
</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 6 }}>
${'{'}{'}'} 릿 .
</div>
</div>
<div style={{ marginTop: 18, paddingTop: 14, borderTop: '1px solid #f1f5f9' }}>
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 10 }}>3 Playwright jMeter </div>
<textarea value={actionLogText} onChange={e => setActionLogText(e.target.value)}
placeholder='Playwright MCP 녹화 액션 로그(JSON 배열) 붙여넣기. 예: [{"type":"goto","value":"/login"},{"type":"fill","selector":"#user","value":"tester"}]'
style={{ width: '100%', minHeight: 80, padding: 8, border: '1px solid #cbd5e1', borderRadius: 6, fontSize: 12, fontFamily: 'monospace', resize: 'vertical', boxSizing: 'border-box' }} />
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button onClick={() => handleUploadRecording(selected)} disabled={busy || !actionLogText.trim()} style={btnSm('#6366f1')}>🎥 </button>
<button onClick={() => handleGenerateScript(selected)} disabled={busy || selected.status === 'DRAFT'} style={btnSm('#f59e0b')}> jMeter </button>
</div>
</div>
<div style={{ marginTop: 18, paddingTop: 14, borderTop: '1px solid #f1f5f9' }}>
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 10 }}>4 jMeter + </div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={() => handleDownloadJmx(selected)}
disabled={selected.status !== 'SCRIPT_READY' && selected.status !== 'JMX_READY'} style={btnSm('#6366f1')}>📥 .jmx </button>
<button onClick={() => handleRun(selected)} disabled={busy} style={{ ...btnPrimary, padding: '6px 14px', fontSize: 12 }}> jMeter </button>
</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 6 }}>
jMeter (/api/perf/run) , ${'{'}goto{'}'} .
</div>
</div>
</div>
<div style={{ padding: '12px 20px', borderTop: '1px solid #e2e8f0', display: 'flex', gap: 8 }}>
<button onClick={() => handleDelete(selected.id)} style={{ ...btnPrimary, background: '#dc2626' }}>🗑 </button>
</div>
</div>
)}
</div>
)
}
function Info({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 13, color: '#1e293b' }}>{value}</div>
</div>
)
}
const inputStyle: React.CSSProperties = {
flex: 1, minWidth: 160, padding: '7px 10px',
border: '1px solid #cbd5e1', borderRadius: 6, fontSize: 13,
outline: 'none',
}
const btnPrimary: React.CSSProperties = {
padding: '7px 16px', background: '#4f6ef7', color: '#fff',
border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 13, fontWeight: 600,
}
const btnSm = (color: string): React.CSSProperties => ({
padding: '4px 12px', background: color, color: '#fff',
border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 11, fontWeight: 600,
})

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/aiplatform.tsx","./src/pages/apikeys.tsx","./src/pages/appdistribution.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/notificationrules.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"} {"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/appdistribution.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/installguide.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/notificationrules.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"}