+
+ 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 ` +'
+ 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 += `- ${inlineMd(ol[1])}
`
+ continue
+ }
+
+ // 비순서 목록
+ const ul = line.match(/^\s*[-*]\s+(.*)$/)
+ if (ul) {
+ if (listType !== 'ul') {
+ closeList()
+ html += ''
+ listType = 'ul'
+ }
+ html += `- ${inlineMd(ul[1])}
`
+ continue
+ }
+
+ // 일반 문단
+ closeList()
+ html += `${inlineMd(line)}
`
+ }
+
+ if (inCode) 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) => `
-
+
+
+
+
+
- JSON 파일명이 시나리오 ID가 됩니다.
+ 시나리오 ID는 업로드 시 고유값으로 자동 생성됩니다.
MD 문서는 선택 사항이지만 dynamic 분석에 필요합니다.
- JSON 파일명과 동일한 이름이어야 합니다 + 업로드한 JSON 시나리오의 설명 문서
@@ -370,37 +370,6 @@