diff --git a/frontend/static/js/admin/scenario_detail.js b/frontend/static/js/admin/scenario_detail.js new file mode 100644 index 0000000..b846fa6 --- /dev/null +++ b/frontend/static/js/admin/scenario_detail.js @@ -0,0 +1,313 @@ +const API = '/api/admin/scenario' +const SCENARIO_ID = window.__SCENARIO_ID__ || '' + +document.addEventListener('DOMContentLoaded', () => { + loadDetail() +}) + +// ── 상세 조회 ────────────────────────────────────────────────────────────────── +async function loadDetail() { + const loading = document.getElementById('detail-loading') + const errorEl = document.getElementById('detail-error') + const content = document.getElementById('detail-content') + + try { + const res = await fetch(`${API}/detail/${SCENARIO_ID}`) + const data = await res.json() + if (!res.ok) throw new Error(data.detail || `${res.status} ${res.statusText}`) + + renderDetail(data) + loading.classList.add('hidden') + content.classList.remove('hidden') + } catch (err) { + loading.classList.add('hidden') + errorEl.classList.remove('hidden') + errorEl.textContent = `시나리오를 불러올 수 없습니다: ${err.message}` + } +} + +// ── 렌더링 ───────────────────────────────────────────────────────────────────── +function renderDetail(data) { + const scenario = data.scenario || {} + const fingerprint = + scenario.vector_fingerprint && typeof scenario.vector_fingerprint === 'object' + ? scenario.vector_fingerprint + : scenario + + // 헤더 + document.getElementById('d-pattern-name').textContent = + data.pattern_name || SCENARIO_ID + document.getElementById('d-scenario-id').textContent = data.id || SCENARIO_ID + + if (data.builtin) { + document.getElementById('d-builtin-badge').classList.remove('hidden') + } else { + const delBtn = document.getElementById('delete-btn') + delBtn.classList.remove('hidden') + delBtn.addEventListener('click', () => confirmDelete(data.id, data.pattern_name)) + } + + // 태그 + const tags = data.behavior_tags || fingerprint.behavior_tags || [] + document.getElementById('d-tags').innerHTML = tags + .map( + (t) => + `${escapeHtml(t)}`, + ) + .join('') + + // 핑거프린트 요약 + renderFingerprint(fingerprint) + + // 문서 + const docSection = document.getElementById('d-doc-section') + if (data.doc) { + document.getElementById('d-doc').innerHTML = renderMarkdown(data.doc) + } else { + docSection.querySelector('#d-doc').innerHTML = + '

등록된 문서가 없습니다.

' + } + + // 원본 JSON + document.getElementById('d-raw').textContent = JSON.stringify(scenario, null, 2) + + // ID 복사 + document.getElementById('copy-id-btn').addEventListener('click', () => { + navigator.clipboard + .writeText(data.id || SCENARIO_ID) + .then(() => showToast('시나리오 ID를 복사했습니다.', 'success')) + .catch(() => showToast('복사에 실패했습니다.', 'error')) + }) +} + +// ── 핑거프린트 카드들 ────────────────────────────────────────────────────────── +function renderFingerprint(fp) { + const container = document.getElementById('d-fingerprint') + const sections = [ + ['manifest_profile', '매니페스트 프로파일'], + ['capability_profile', '권한/기능 프로파일'], + ['static_code_signals', '정적 코드 시그널'], + ['predicted_flows', '예측 동작 흐름'], + ] + + container.innerHTML = sections + .filter(([key]) => fp[key] !== undefined) + .map(([key, label]) => { + return ` +
+
${label}
+
${renderValue(fp[key])}
+
` + }) + .join('') +} + +function renderValue(value) { + if (Array.isArray(value)) { + if (value.length === 0) return '(없음)' + if (value.every((v) => typeof v !== 'object')) { + return `
${value + .map( + (v) => + `${escapeHtml(String(v))}`, + ) + .join('')}
` + } + return `
${value + .map((v) => `
${renderValue(v)}
`) + .join('')}
` + } + + if (value && typeof value === 'object') { + return `
${Object.entries(value) + .map( + ([k, v]) => + `
+
${escapeHtml(k)}
+
${renderValue(v)}
+
`, + ) + .join('')}
` + } + + if (typeof value === 'boolean') { + return value + ? 'true' + : 'false' + } + + return `${escapeHtml(String(value))}` +} + +// ── 삭제 ─────────────────────────────────────────────────────────────────────── +async function confirmDelete(scenarioId, patternName) { + const confirmed = await showConfirm( + '시나리오 삭제', + `'${patternName || scenarioId}' 시나리오를 삭제합니다.\nJSON 파일과 MD 문서가 함께 삭제되며, vectorDB 재적재가 필요합니다.`, + ) + if (!confirmed) return + + try { + const res = await fetch(`${API}/delete/${scenarioId}`, { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) throw new Error(data.detail || '삭제 실패') + showToast('삭제되었습니다. 목록으로 이동합니다.', 'success') + setTimeout(() => { + window.location.href = '/scenario' + }, 900) + } catch (err) { + showToast(err.message, 'error') + } +} + +// ── 간단한 마크다운 렌더러 ───────────────────────────────────────────────────── +function renderMarkdown(md) { + const lines = md.replace(/\r\n/g, '\n').split('\n') + let html = '' + let inCode = false + let listType = null // 'ul' | 'ol' + + const closeList = () => { + if (listType) { + html += `` + listType = null + } + } + + for (const raw of lines) { + const line = raw + + // 코드 펜스 + if (line.trim().startsWith('```')) { + if (inCode) { + html += '' + inCode = false + } else { + closeList() + html += '
'
+        inCode = true
+      }
+      continue
+    }
+    if (inCode) {
+      html += escapeHtml(line) + '\n'
+      continue
+    }
+
+    // 빈 줄
+    if (line.trim() === '') {
+      closeList()
+      continue
+    }
+
+    // 헤딩
+    const h = line.match(/^(#{1,3})\s+(.*)$/)
+    if (h) {
+      closeList()
+      const level = h[1].length
+      html += `${inlineMd(h[2])}`
+      continue
+    }
+
+    // 순서 목록
+    const ol = line.match(/^\s*\d+\.\s+(.*)$/)
+    if (ol) {
+      if (listType !== 'ol') {
+        closeList()
+        html += '
    ' + listType = 'ol' + } + html += `
  1. ${inlineMd(ol[1])}
  2. ` + continue + } + + // 비순서 목록 + const ul = line.match(/^\s*[-*]\s+(.*)$/) + if (ul) { + if (listType !== 'ul') { + closeList() + html += '
' + closeList() + return html +} + +function inlineMd(text) { + let s = escapeHtml(text) + s = s.replace(/`([^`]+)`/g, '$1') + s = s.replace(/\*\*([^*]+)\*\*/g, '$1') + return s +} + +// ── 유틸 ─────────────────────────────────────────────────────────────────────── +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +// ── 공통 확인 다이얼로그 ─────────────────────────────────────────────────────── +function showConfirm(title, message) { + return new Promise((resolve) => { + document.getElementById('confirm-title').textContent = title + document.getElementById('confirm-message').textContent = message + const modal = document.getElementById('confirm-modal') + modal.classList.remove('hidden') + + const confirmBtn = document.getElementById('confirm-ok') + const cancelBtn = document.getElementById('confirm-cancel') + + function cleanup(result) { + modal.classList.add('hidden') + confirmBtn.removeEventListener('click', onOk) + cancelBtn.removeEventListener('click', onCancel) + resolve(result) + } + function onOk() { + cleanup(true) + } + function onCancel() { + cleanup(false) + } + confirmBtn.addEventListener('click', onOk) + cancelBtn.addEventListener('click', onCancel) + }) +} + +// ── 토스트 ───────────────────────────────────────────────────────────────────── +function showToast(message, type = 'info') { + const container = document.getElementById('toast-container') + const colors = { + success: 'bg-green-50 border-green-200 text-green-800', + error: 'bg-red-50 border-red-200 text-red-800', + warn: 'bg-amber-50 border-amber-200 text-amber-800', + info: 'bg-blue-50 border-blue-200 text-blue-800', + } + const icons = { + success: 'check_circle', + error: 'error', + warn: 'warning', + info: 'info', + } + const el = document.createElement('div') + el.className = `flex items-start gap-3 px-4 py-3 rounded-xl border shadow-md text-sm font-medium transition-all ${colors[type] || colors.info}` + el.innerHTML = ` + ${icons[type] || 'info'} + ${message} + ` + container.appendChild(el) + setTimeout(() => el.remove(), 4000) +} diff --git a/frontend/static/js/admin/scenario_id.js b/frontend/static/js/admin/scenario_id.js index 5e62940..0f7ae08 100644 --- a/frontend/static/js/admin/scenario_id.js +++ b/frontend/static/js/admin/scenario_id.js @@ -89,9 +89,21 @@ function renderTable(scenarios) { tbody.innerHTML = scenarios .map( (s) => ` - + - ${s.id} +
+ ${s.id} + ${ + s.builtin + ? `기본` + : '' + } +
+ ${ + s.pattern_name + ? `
${s.pattern_name}
` + : '' + }
@@ -122,17 +134,27 @@ function renderTable(scenarios) { - + ${ + s.builtin + ? `` + : `` + } `, @@ -140,6 +162,11 @@ function renderTable(scenarios) { .join('') } +// ── 상세 페이지로 이동 ───────────────────────────────────────────────────────── +function openDetail(scenarioId) { + window.location.href = `/scenario/${scenarioId}` +} + // ── 검색 필터 ────────────────────────────────────────────────────────────────── function filterScenarios(keyword) { const q = keyword.trim().toLowerCase() @@ -147,6 +174,7 @@ function filterScenarios(keyword) { return allScenarios.filter( (s) => s.id.toLowerCase().includes(q) || + (s.pattern_name || '').toLowerCase().includes(q) || (s.behavior_tags || []).some((t) => t.toLowerCase().includes(q)), ) } @@ -257,32 +285,6 @@ async function confirmDelete(scenarioId) { } } -// ── 상세 모달 ────────────────────────────────────────────────────────────────── -async function openDetailModal(scenarioId) { - const modal = document.getElementById('detail-modal') - const title = document.getElementById('modal-scenario-title') - const content = document.getElementById('modal-content-area') - - title.textContent = scenarioId - content.textContent = '불러오는 중...' - modal.classList.remove('hidden') - - try { - const res = await fetch(`${API}/detail/${scenarioId}`) - const data = await res.json() - if (!res.ok) throw new Error(data.detail) - - const json = JSON.stringify(data.scenario, null, 2) - content.textContent = json - } catch (err) { - content.textContent = `오류: ${err.message}` - } -} - -function closeDetailModal() { - document.getElementById('detail-modal').classList.add('hidden') -} - // ── 공통 확인 다이얼로그 ─────────────────────────────────────────────────────── function showConfirm(title, message) { return new Promise((resolve) => { diff --git a/frontend/templates/scenario/scenario_detail.html b/frontend/templates/scenario/scenario_detail.html new file mode 100644 index 0000000..5c17d3c --- /dev/null +++ b/frontend/templates/scenario/scenario_detail.html @@ -0,0 +1,233 @@ + + + + + + 시나리오 상세 - Suppressor + + + + + + + + + +
+ +
+ + + + + +
+
+ +
+ + + arrow_back + 목록으로 + +
+ + +
+ 불러오는 중... +
+ + + + +
+
+ + + + + +
+ + + + + diff --git a/frontend/templates/scenario/scenario_id.html b/frontend/templates/scenario/scenario_id.html index 402b44d..0409cdc 100644 --- a/frontend/templates/scenario/scenario_id.html +++ b/frontend/templates/scenario/scenario_id.html @@ -265,7 +265,7 @@

시나리오 추가

- JSON 파일명이 시나리오 ID가 됩니다.
+ 시나리오 ID는 업로드 시 고유값으로 자동 생성됩니다.
MD 문서는 선택 사항이지만 dynamic 분석에 필요합니다.

@@ -330,7 +330,7 @@

/>

- JSON 파일명과 동일한 이름이어야 합니다 + 업로드한 JSON 시나리오의 설명 문서

@@ -370,37 +370,6 @@

- - - - + diff --git a/main.py b/main.py index f3fc81e..f97abe9 100644 --- a/main.py +++ b/main.py @@ -248,6 +248,18 @@ async def admin_scenario_id(request: Request): return templates.TemplateResponse(request, "scenario/scenario_id.html", {"request": request}) +@app.get("/scenario/{scenario_id}", response_class=HTMLResponse) +async def admin_scenario_detail(request: Request, scenario_id: str): + blocked = require_admin_page(request) + if blocked: + return blocked + return templates.TemplateResponse( + request, + "scenario/scenario_detail.html", + {"request": request, "scenario_id": scenario_id}, + ) + + # 라이브러리 페이지 @app.get("/library", response_class=HTMLResponse) async def library(request: Request):