diff --git a/backend/scenario/scenario_id.py b/backend/scenario/scenario_id.py
new file mode 100644
index 0000000..4ff39ca
--- /dev/null
+++ b/backend/scenario/scenario_id.py
@@ -0,0 +1,102 @@
+import os
+
+import httpx
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi.responses import JSONResponse
+
+from backend.auth.security import require_admin
+
+router = APIRouter(prefix="/api/admin/scenario", tags=["scenario-admin"])
+
+SUPPRESSOR_BASE = (
+ f"http://{os.getenv('SUPPRESSOR_PRIVATE_IP', 'localhost')}:{os.getenv('SUPPRESSOR_PORT', '8001')}"
+)
+
+
+def _suppressor_url(path: str) -> str:
+ return f"{SUPPRESSOR_BASE}/api/scenario/{path}"
+
+
+async def _get(path: str) -> dict:
+ async with httpx.AsyncClient(timeout=30) as client:
+ try:
+ res = await client.get(_suppressor_url(path))
+ res.raise_for_status()
+ return res.json()
+ except httpx.ConnectError:
+ raise HTTPException(status_code=503, detail="Suppressor 서버에 연결할 수 없습니다.")
+ except httpx.HTTPStatusError as e:
+ raise HTTPException(status_code=e.response.status_code, detail=e.response.text)
+
+
+async def _delete(path: str) -> dict:
+ async with httpx.AsyncClient(timeout=30) as client:
+ try:
+ res = await client.delete(_suppressor_url(path))
+ res.raise_for_status()
+ return res.json()
+ except httpx.ConnectError:
+ raise HTTPException(status_code=503, detail="Suppressor 서버에 연결할 수 없습니다.")
+ except httpx.HTTPStatusError as e:
+ raise HTTPException(status_code=e.response.status_code, detail=e.response.text)
+
+
+# ── 목록 조회 ──────────────────────────────────────────────────────────────────
+@router.get("/list", dependencies=[Depends(require_admin)])
+async def list_scenarios():
+ return await _get("list")
+
+
+# ── 단일 조회 ──────────────────────────────────────────────────────────────────
+@router.get("/detail/{scenario_id}", dependencies=[Depends(require_admin)])
+async def get_scenario(scenario_id: str):
+ return await _get(f"detail/{scenario_id}")
+
+
+# ── 업로드 ────────────────────────────────────────────────────────────────────
+@router.post("/upload", dependencies=[Depends(require_admin)])
+async def upload_scenario(
+ json_file: UploadFile = File(...),
+ md_file: UploadFile = File(None),
+):
+ async with httpx.AsyncClient(timeout=60) as client:
+ try:
+ files: dict = {
+ "json_file": (json_file.filename, await json_file.read(), "application/json"),
+ }
+ if md_file and md_file.filename:
+ files["md_file"] = (md_file.filename, await md_file.read(), "text/markdown")
+
+ res = await client.post(_suppressor_url("upload"), files=files)
+ res.raise_for_status()
+ return JSONResponse(status_code=res.status_code, content=res.json())
+ except httpx.ConnectError:
+ raise HTTPException(status_code=503, detail="Suppressor 서버에 연결할 수 없습니다.")
+ except httpx.HTTPStatusError as e:
+ raise HTTPException(status_code=e.response.status_code, detail=e.response.text)
+
+
+# ── 삭제 ──────────────────────────────────────────────────────────────────────
+@router.delete("/delete/{scenario_id}", dependencies=[Depends(require_admin)])
+async def delete_scenario(scenario_id: str):
+ return await _delete(f"delete/{scenario_id}")
+
+
+# ── vectorDB 재적재 ────────────────────────────────────────────────────────────
+@router.post("/reload", dependencies=[Depends(require_admin)])
+async def reload_scenarios():
+ async with httpx.AsyncClient(timeout=300) as client:
+ try:
+ res = await client.post(_suppressor_url("reload"))
+ res.raise_for_status()
+ return res.json()
+ except httpx.ConnectError:
+ raise HTTPException(status_code=503, detail="Suppressor 서버에 연결할 수 없습니다.")
+ except httpx.HTTPStatusError as e:
+ raise HTTPException(status_code=e.response.status_code, detail=e.response.text)
+
+
+# ── vectorDB 상태 ──────────────────────────────────────────────────────────────
+@router.get("/db-status", dependencies=[Depends(require_admin)])
+async def db_status():
+ return await _get("db-status")
\ No newline at end of file
diff --git a/frontend/static/js/admin/scenario_id.js b/frontend/static/js/admin/scenario_id.js
new file mode 100644
index 0000000..5e62940
--- /dev/null
+++ b/frontend/static/js/admin/scenario_id.js
@@ -0,0 +1,338 @@
+const API = '/api/admin/scenario'
+
+// ── 상태 ───────────────────────────────────────────────────────────────────────
+let allScenarios = []
+
+// ── 초기화 ─────────────────────────────────────────────────────────────────────
+document.addEventListener('DOMContentLoaded', () => {
+ fetchDbStatus()
+ fetchScenarios()
+
+ document
+ .getElementById('reload-btn')
+ .addEventListener('click', reloadVectorDB)
+ document
+ .getElementById('upload-form')
+ .addEventListener('submit', handleUpload)
+ document
+ .getElementById('json-file-input')
+ .addEventListener('change', onJsonFileChange)
+ document.getElementById('search-input').addEventListener('input', (e) => {
+ renderTable(filterScenarios(e.target.value))
+ })
+})
+
+// ── vectorDB 상태 조회 ─────────────────────────────────────────────────────────
+async function fetchDbStatus() {
+ const el = document.getElementById('db-status')
+ try {
+ const res = await fetch(`${API}/db-status`)
+ const data = await res.json()
+ if (data.status === 'ok') {
+ el.textContent = `vectorDB: ${data.vector_count}개 벡터 적재됨`
+ el.className =
+ 'text-sm font-medium text-green-600 bg-green-50 px-3 py-1 rounded-full'
+ } else {
+ el.textContent = `vectorDB 연결 오류`
+ el.className =
+ 'text-sm font-medium text-red-600 bg-red-50 px-3 py-1 rounded-full'
+ }
+ } catch {
+ el.textContent = 'Suppressor 연결 불가'
+ el.className =
+ 'text-sm font-medium text-red-600 bg-red-50 px-3 py-1 rounded-full'
+ }
+}
+
+// ── 시나리오 목록 조회 ─────────────────────────────────────────────────────────
+async function fetchScenarios() {
+ const tbody = document.getElementById('scenario-tbody')
+ tbody.innerHTML = `
+
+ |
+ 불러오는 중...
+ |
+
`
+
+ try {
+ const res = await fetch(`${API}/list`)
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
+ const data = await res.json()
+ allScenarios = data.scenarios || []
+ document.getElementById('total-count').textContent =
+ `총 ${allScenarios.length}개`
+ renderTable(allScenarios)
+ } catch (err) {
+ tbody.innerHTML = `
+
+ |
+ ${err.message}
+ |
+
`
+ }
+}
+
+// ── 테이블 렌더링 ──────────────────────────────────────────────────────────────
+function renderTable(scenarios) {
+ const tbody = document.getElementById('scenario-tbody')
+
+ if (scenarios.length === 0) {
+ tbody.innerHTML = `
+
+ |
+ 시나리오가 없습니다.
+ |
+
`
+ return
+ }
+
+ tbody.innerHTML = scenarios
+ .map(
+ (s) => `
+
+ |
+ ${s.id}
+ |
+
+
+ ${(s.behavior_tags || [])
+ .slice(0, 3)
+ .map(
+ (tag) =>
+ `${tag}`,
+ )
+ .join('')}
+ ${
+ (s.behavior_tags || []).length > 3
+ ? `+${s.behavior_tags.length - 3}`
+ : ''
+ }
+
+ |
+
+ ${
+ s.has_doc
+ ? `
+ check_circle 있음
+ `
+ : `
+ warning 없음
+ `
+ }
+ |
+
+
+
+ |
+
+ `,
+ )
+ .join('')
+}
+
+// ── 검색 필터 ──────────────────────────────────────────────────────────────────
+function filterScenarios(keyword) {
+ const q = keyword.trim().toLowerCase()
+ if (!q) return allScenarios
+ return allScenarios.filter(
+ (s) =>
+ s.id.toLowerCase().includes(q) ||
+ (s.behavior_tags || []).some((t) => t.toLowerCase().includes(q)),
+ )
+}
+
+// ── 파일 입력 변경 시 파일명 표시 ─────────────────────────────────────────────
+function onJsonFileChange(e) {
+ const file = e.target.files[0]
+ const label = document.getElementById('json-file-label')
+ label.textContent = file ? file.name : 'JSON 파일 선택'
+}
+
+// ── 시나리오 업로드 ────────────────────────────────────────────────────────────
+async function handleUpload(e) {
+ e.preventDefault()
+
+ const jsonInput = document.getElementById('json-file-input')
+ const mdInput = document.getElementById('md-file-input')
+ const btn = document.getElementById('upload-btn')
+
+ if (!jsonInput.files[0]) {
+ showToast('JSON 파일을 선택해주세요.', 'error')
+ return
+ }
+
+ const formData = new FormData()
+ formData.append('json_file', jsonInput.files[0])
+ if (mdInput.files[0]) {
+ formData.append('md_file', mdInput.files[0])
+ }
+
+ btn.disabled = true
+ btn.textContent = '업로드 중...'
+
+ try {
+ const res = await fetch(`${API}/upload`, {
+ method: 'POST',
+ body: formData,
+ })
+ const data = await res.json()
+
+ if (!res.ok) {
+ throw new Error(data.detail || '업로드 실패')
+ }
+
+ showToast(data.message, 'success')
+ if (data.reload_required) {
+ showToast('변경사항을 반영하려면 vectorDB 재적재가 필요합니다.', 'warn')
+ }
+ jsonInput.value = ''
+ mdInput.value = ''
+ document.getElementById('json-file-label').textContent = 'JSON 파일 선택'
+ fetchScenarios()
+ fetchDbStatus()
+ } catch (err) {
+ showToast(err.message, 'error')
+ } finally {
+ btn.disabled = false
+ btn.textContent = '업로드'
+ }
+}
+
+// ── vectorDB 재적재 ────────────────────────────────────────────────────────────
+async function reloadVectorDB() {
+ const btn = document.getElementById('reload-btn')
+ const confirmed = await showConfirm(
+ 'vectorDB 재적재',
+ '기존 벡터를 모두 삭제하고 현재 시나리오 파일 전체를 다시 임베딩합니다.\n시간이 걸릴 수 있습니다. 진행할까요?',
+ )
+ if (!confirmed) return
+
+ btn.disabled = true
+ btn.innerHTML = `refresh 재적재 중...`
+
+ try {
+ const res = await fetch(`${API}/reload`, { method: 'POST' })
+ const data = await res.json()
+ if (!res.ok) throw new Error(data.detail || '재적재 실패')
+ showToast(`재적재 완료 — ${data.vector_count}개 벡터`, 'success')
+ fetchDbStatus()
+ } catch (err) {
+ showToast(err.message, 'error')
+ } finally {
+ btn.disabled = false
+ btn.innerHTML = `refresh vectorDB 재적재`
+ }
+}
+
+// ── 삭제 확인 ──────────────────────────────────────────────────────────────────
+async function confirmDelete(scenarioId) {
+ const confirmed = await showConfirm(
+ '시나리오 삭제',
+ `'${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(data.message, 'success')
+ if (data.reload_required) {
+ showToast('변경사항을 반영하려면 vectorDB 재적재가 필요합니다.', 'warn')
+ }
+ fetchScenarios()
+ fetchDbStatus()
+ } catch (err) {
+ showToast(err.message, 'error')
+ }
+}
+
+// ── 상세 모달 ──────────────────────────────────────────────────────────────────
+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) => {
+ 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/common.js b/frontend/static/js/common.js
index d070a7e..9739c5d 100644
--- a/frontend/static/js/common.js
+++ b/frontend/static/js/common.js
@@ -82,6 +82,12 @@ window.exts3SessionPromise = window.exts3SessionPromise || (async () => {
}
});
+ document.querySelectorAll('a[href="/scenario"]').forEach(link => {
+ if (!isAdmin) {
+ link.style.display = 'none';
+ }
+ });
+
const protectedLinks = ['/search', '/library', '/build', '/user_set'];
document.querySelectorAll('a').forEach(link => {
const href = link.getAttribute('href') || '#';
diff --git a/frontend/templates/admin/admin_dash.html b/frontend/templates/admin/admin_dash.html
index 0a6b278..78b1d52 100644
--- a/frontend/templates/admin/admin_dash.html
+++ b/frontend/templates/admin/admin_dash.html
@@ -96,6 +96,7 @@
대시보드
앱 탐색
라이브러리
+
@@ -88,6 +89,7 @@
home홈
explore앱 탐색
inventory_2라이브러리
+dataset시나리오 관리
dashboard_customize관리자 대시보드
diff --git a/frontend/templates/index.html b/frontend/templates/index.html
index 9588bb4..647b3e1 100644
--- a/frontend/templates/index.html
+++ b/frontend/templates/index.html
@@ -96,6 +96,7 @@
대시보드
앱 탐색
라이브러리
+
@@ -159,6 +160,10 @@
Supp
inventory_2
라이브러리
+
+dataset
+시나리오 관리
+
dashboard_customize
관리자 대시보드
diff --git a/main.py b/main.py
index acbc207..f3fc81e 100644
--- a/main.py
+++ b/main.py
@@ -240,6 +240,12 @@ async def admin_policy_catalog(request: Request):
if blocked is not None:
return blocked
return templates.TemplateResponse(request, "admin/policy_catalog.html", {"request": request})
+@app.get("/scenario", response_class=HTMLResponse)
+async def admin_scenario_id(request: Request):
+ blocked = require_admin_page(request)
+ if blocked:
+ return blocked
+ return templates.TemplateResponse(request, "scenario/scenario_id.html", {"request": request})
# 라이브러리 페이지
@@ -286,6 +292,8 @@ async def build(request: Request):
app.include_router(policy_catalog_router)
from backend.admin.permissions import router as admin_permissions_router
app.include_router(admin_permissions_router)
+from backend.scenario.scenario_id import router as scenario_id_router
+app.include_router(scenario_id_router)
# 업로드 파일 스캔 서버로 전송-----------------------
from backend.security_scan.file_save import router as file_save_router