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

This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-03 08:04:03 +09:00
parent a3ec9d30df
commit 5f3a0247b3
16 changed files with 2727 additions and 0 deletions

20
main.py
View File

@ -390,6 +390,26 @@ app.include_router(batch_ssh.router) # 다중 서버 동시 SSH 실
app.include_router(asset_qr.router) # 서버 자산 QR 태그 관리
app.include_router(smart_notify.router) # 스마트 알림 규칙 엔진
# ── GUARDiA 차세대 확장 — 2026 트렌드 기반 (Gartner/EU CRA/국정원 N²SF) ──────
from routers import (
agentic_aiops, otel_tracing, mlsecops, # AIOps 2.0
ztna, sbom, n2sf, # Zero Trust + Supply Chain
idp_catalog, idp_template, idp_portal, # Internal Developer Platform
greenops, edge_monitor, energy_optimizer, # GreenOps + Edge/IoT
)
app.include_router(agentic_aiops.router) # Agentic AI tool-calling 엔진
app.include_router(otel_tracing.router) # OpenTelemetry 분산 트레이싱
app.include_router(mlsecops.router) # AI 모델 보안·버전 관리
app.include_router(ztna.router) # Zero Trust 정책 엔진
app.include_router(sbom.router) # SBOM CycloneDX 생성·관리
app.include_router(n2sf.router) # N²SF 준수 점검
app.include_router(idp_catalog.router) # IDP 소프트웨어 카탈로그
app.include_router(idp_template.router) # IDP Golden Path 템플릿
app.include_router(idp_portal.router) # IDP 셀프서비스 포털
app.include_router(greenops.router) # 탄소 배출 추적
app.include_router(edge_monitor.router) # Edge/IoT 모니터링
app.include_router(energy_optimizer.router) # 에너지 최적화
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
@app.middleware("http")

348
models.py
View File

@ -5551,3 +5551,351 @@ class NotifyLog(Base):
success = Column(Boolean, default=False)
error_msg = Column(Text, nullable=True)
sent_at = Column(DateTime, default=func.now())
# ══════════════════════════════════════════════════════════════════════════════
# ── GUARDiA 차세대 확장 — AIOps 2.0 / Zero Trust / IDP / GreenOps+Edge
# ── 트렌드 근거: 2026년 웹 크롤링 (Gartner, Forrester, EU CRA, 국정원 N²SF)
# ══════════════════════════════════════════════════════════════════════════════
# ── Agentic AIOps ─────────────────────────────────────────────────────────────
class AgentRun(Base):
"""에이전트 태스크 실행 이력."""
__tablename__ = "tb_agent_run"
id = Column(Integer, primary_key=True, index=True)
task = Column(Text, nullable=False)
status = Column(String(30), default="PENDING") # RUNNING|DONE|STOPPED|PENDING_APPROVAL
result = Column(Text, nullable=True)
context_json = Column(Text, nullable=True)
tenant_id = Column(Integer, nullable=False, default=1)
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
approved_by = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
finished_at = Column(DateTime, nullable=True)
class AgentToolCall(Base):
"""에이전트 도구 호출 로그."""
__tablename__ = "tb_agent_tool_call"
id = Column(Integer, primary_key=True, index=True)
run_id = Column(Integer, ForeignKey("tb_agent_run.id"), nullable=False)
step = Column(Integer, default=0)
tool_name = Column(String(100), nullable=False)
params_json = Column(Text, nullable=True)
result = Column(Text, nullable=True)
called_at = Column(DateTime, default=func.now())
class RemediationRule(Base):
"""자율 교정 규칙."""
__tablename__ = "tb_remediation_rule"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
trigger_type = Column(String(50), nullable=False) # ANOMALY|THRESHOLD|MANUAL
condition_json = Column(Text, default="{}")
action_template = Column(Text, nullable=True)
auto_approve = Column(Boolean, default=False)
severity_threshold = Column(String(20), default="HIGH")
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class RemediationHistory(Base):
"""교정 실행 이력."""
__tablename__ = "tb_remediation_history"
id = Column(Integer, primary_key=True, index=True)
rule_id = Column(Integer, ForeignKey("tb_remediation_rule.id"), nullable=True)
server_id = Column(Integer, nullable=True)
trigger_data = Column(Text, nullable=True)
status = Column(String(30), default="DIAGNOSING")
diagnosis = Column(Text, nullable=True)
action_taken = Column(Text, nullable=True)
result = Column(Text, nullable=True)
triggered_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
approved_by = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
finished_at = Column(DateTime, nullable=True)
class OtelTrace(Base):
"""OpenTelemetry 트레이스 레코드."""
__tablename__ = "tb_otel_trace"
id = Column(Integer, primary_key=True, index=True)
trace_id = Column(String(64), nullable=False, unique=True, index=True)
service = Column(String(200), nullable=False)
start_time = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now())
class OtelSpan(Base):
"""OpenTelemetry 스팬 레코드."""
__tablename__ = "tb_otel_span"
id = Column(Integer, primary_key=True, index=True)
trace_id = Column(String(64), ForeignKey("tb_otel_trace.trace_id"), nullable=False, index=True)
span_id = Column(String(64), nullable=False, unique=True)
parent_span_id = Column(String(64), nullable=True)
service = Column(String(200), nullable=False)
operation = Column(String(300), nullable=False)
start_time = Column(DateTime, nullable=True)
end_time = Column(DateTime, nullable=True)
duration_ms = Column(Integer, default=0)
status = Column(String(20), default="OK")
attributes = Column(Text, nullable=True)
class MLModel(Base):
"""AI 모델 보안 스캔 이력."""
__tablename__ = "tb_ml_model"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
scan_status = Column(String(30), default="UNKNOWN") # CLEAN|VULNERABLE|APPROVED
vulnerability = Column(Text, nullable=True)
approved_by = Column(Integer, nullable=True)
scanned_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
scanned_at = Column(DateTime, default=func.now())
# ── Zero Trust + Supply Chain ─────────────────────────────────────────────────
class ZTNAPolicy(Base):
"""ZTNA 접근 정책."""
__tablename__ = "tb_ztna_policy"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
resource = Column(String(300), nullable=False, index=True)
allowed_roles = Column(Text, default="[]")
require_mfa = Column(Boolean, default=True)
min_trust_score = Column(Integer, default=70)
allowed_ips = Column(Text, default="[]")
require_device_compliant = Column(Boolean, default=True)
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class ZTNADevice(Base):
"""등록된 디바이스 (Zero Trust)."""
__tablename__ = "tb_ztna_device"
id = Column(Integer, primary_key=True, index=True)
device_name = Column(String(200), nullable=False)
device_type = Column(String(50), nullable=False) # PC|MOBILE|SERVER
os_name = Column(String(100), nullable=True)
os_version = Column(String(50), nullable=True)
antivirus_active = Column(Boolean, default=False)
disk_encrypted = Column(Boolean, default=False)
last_patch_days = Column(Integer, default=999)
is_compliant = Column(Boolean, default=False)
registered_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
registered_at = Column(DateTime, default=func.now())
class ZTNAViolation(Base):
"""ZTNA 정책 위반 로그."""
__tablename__ = "tb_ztna_violation"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, nullable=True)
resource = Column(String(300), nullable=False)
reason = Column(Text, nullable=True)
source_ip = Column(String(50), nullable=True)
trust_score = Column(Integer, default=0)
created_at = Column(DateTime, default=func.now())
class SBOMRecord(Base):
"""SBOM 레코드."""
__tablename__ = "tb_sbom_record"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, nullable=False, index=True)
format = Column(String(20), default="CycloneDX")
spec_version = Column(String(10), default="1.4")
component_count = Column(Integer, default=0)
status = Column(String(20), default="PENDING")
generated_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class SBOMComponent(Base):
"""SBOM 컴포넌트 (패키지·라이브러리)."""
__tablename__ = "tb_sbom_component"
id = Column(Integer, primary_key=True, index=True)
sbom_id = Column(Integer, ForeignKey("tb_sbom_record.id"), nullable=False, index=True)
name = Column(String(200), nullable=False)
version = Column(String(100), nullable=False)
component_type = Column(String(50), default="library")
ecosystem = Column(String(50), nullable=True)
purl = Column(String(500), nullable=True)
cve_ids = Column(Text, nullable=True)
class N2SFAssessment(Base):
"""N²SF 준수 평가."""
__tablename__ = "tb_n2sf_assessment"
id = Column(Integer, primary_key=True, index=True)
system_name = Column(String(200), nullable=False)
zone = Column(String(1), nullable=False) # A|B|C
total_items = Column(Integer, default=0)
passed_items = Column(Integer, default=0)
score = Column(Integer, default=0)
checked_items = Column(Text, nullable=True) # JSON 배열
notes = Column(Text, nullable=True)
assessed_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class N2SFZone(Base):
"""N²SF 보안 구역 등록."""
__tablename__ = "tb_n2sf_zone"
id = Column(Integer, primary_key=True, index=True)
zone = Column(String(1), nullable=False)
system_name = Column(String(200), nullable=False)
classified_by = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
# ── IDP ───────────────────────────────────────────────────────────────────────
class IDPComponent(Base):
"""IDP 소프트웨어 카탈로그 컴포넌트."""
__tablename__ = "tb_idp_component"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False, unique=True)
display_name = Column(String(200), nullable=True)
component_type = Column(String(50), default="service")
description = Column(Text, nullable=True)
language = Column(String(50), nullable=True)
framework = Column(String(100), nullable=True)
gitea_repo = Column(String(500), nullable=True)
ci_job = Column(String(200), nullable=True)
docs_url = Column(String(500), nullable=True)
owner_team = Column(String(200), nullable=True)
tags = Column(Text, nullable=True)
lifecycle = Column(String(30), default="production")
dependencies = Column(Text, nullable=True)
registered_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class IDPTemplate(Base):
"""IDP Golden Path 템플릿."""
__tablename__ = "tb_idp_template"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False, unique=True)
description = Column(Text, nullable=True)
language = Column(String(50), nullable=True)
framework = Column(String(100), nullable=True)
variables = Column(Text, nullable=True) # JSON 배열
files = Column(Text, nullable=True) # JSON 배열
content_json = Column(Text, default="{}")
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class IDPProvisionRequest(Base):
"""IDP 인프라 프로비저닝 요청."""
__tablename__ = "tb_idp_provision_request"
id = Column(Integer, primary_key=True, index=True)
resource_type = Column(String(50), nullable=False)
params = Column(Text, nullable=True)
justification = Column(Text, nullable=True)
team = Column(String(200), nullable=True)
status = Column(String(30), default="PENDING")
result = Column(Text, nullable=True)
requested_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
approved_by = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
finished_at = Column(DateTime, nullable=True)
# ── GreenOps + Edge ───────────────────────────────────────────────────────────
class CarbonRecord(Base):
"""탄소 배출 기록."""
__tablename__ = "tb_carbon_record"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, nullable=False, index=True)
watt = Column(Float, nullable=False)
hours = Column(Float, default=1.0)
pue = Column(Float, default=1.5)
carbon_kg = Column(Float, nullable=False)
grid_factor = Column(Float, default=0.4593)
recorded_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
recorded_at = Column(DateTime, default=func.now())
class GreenOpsConfig(Base):
"""GreenOps 서버별 설정 (베이스라인)."""
__tablename__ = "tb_greenops_config"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, nullable=False, unique=True)
watt_baseline = Column(Float, nullable=False)
pue = Column(Float, default=1.5)
note = Column(Text, nullable=True)
set_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class OptimizationRec(Base):
"""에너지 최적화 권고."""
__tablename__ = "tb_optimization_rec"
id = Column(Integer, primary_key=True, index=True)
rec_type = Column(String(50), nullable=False)
description = Column(Text, nullable=False)
saving_kwh = Column(Float, default=0.0)
saving_carbon_kg = Column(Float, default=0.0)
status = Column(String(20), default="PENDING")
applied_at = Column(DateTime, nullable=True)
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
applied_by = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
class CarbonSchedule(Base):
"""Carbon-aware 배치 스케줄."""
__tablename__ = "tb_carbon_schedule"
id = Column(Integer, primary_key=True, index=True)
job_name = Column(String(200), nullable=False)
job_command = Column(Text, nullable=False)
server_id = Column(Integer, nullable=False)
preferred_hour = Column(Integer, nullable=False)
carbon_factor = Column(Float, nullable=True)
status = Column(String(20), default="SCHEDULED")
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class EdgeDevice(Base):
"""Edge/IoT 디바이스."""
__tablename__ = "tb_edge_device"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
device_type = Column(String(50), nullable=False)
location = Column(String(300), nullable=True)
protocol = Column(String(20), default="HTTP")
device_token = Column(String(36), nullable=True, unique=True)
meta_json = Column(Text, nullable=True)
status = Column(String(20), default="REGISTERED")
last_seen = Column(DateTime, nullable=True)
registered_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class EdgeMetric(Base):
"""Edge 디바이스 텔레메트리."""
__tablename__ = "tb_edge_metric"
id = Column(Integer, primary_key=True, index=True)
device_id = Column(Integer, ForeignKey("tb_edge_device.id"), nullable=False, index=True)
metrics_json = Column(Text, nullable=True)
recorded_at = Column(DateTime, default=func.now())
class EdgeAlert(Base):
"""Edge 디바이스 알림."""
__tablename__ = "tb_edge_alert"
id = Column(Integer, primary_key=True, index=True)
device_id = Column(Integer, ForeignKey("tb_edge_device.id"), nullable=False, index=True)
alert_type = Column(String(50), nullable=False)
message = Column(Text, nullable=True)
severity = Column(String(20), default="WARNING")
created_at = Column(DateTime, default=func.now())

293
routers/agentic_aiops.py Normal file
View File

@ -0,0 +1,293 @@
"""
Agentic AIOps MCP-compatible tool-calling 멀티에이전트 엔진
엔드포인트:
POST /api/agent/run 에이전트 태스크 실행
GET /api/agent/tools 사용 가능 도구 목록
GET /api/agent/runs 실행 이력
GET /api/agent/runs/{id} 실행 상세
POST /api/agent/approve/{id} 인간 승인 게이트
POST /api/agent/stop/{id} 실행 중단
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, AgentRun, AgentToolCall
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/agent", tags=["Agentic AIOps"])
OLLAMA_URL = "http://localhost:11434"
MAX_TOOL_STEPS = 10 # 무한 루프 방지
# ── 도구 정의 ────────────────────────────────────────────────────────────────
TOOLS: List[Dict] = [
{
"name": "ssh_run",
"description": "SSH로 서버에 셸 명령 실행. 안전한 명령만 허용.",
"parameters": {"server_id": "int", "command": "str"},
},
{
"name": "create_sr",
"description": "ITSM 서비스 요청(SR) 생성",
"parameters": {"title": "str", "priority": "str", "description": "str"},
},
{
"name": "get_cmdb",
"description": "CMDB에서 서버 정보 조회",
"parameters": {"server_id": "int"},
},
{
"name": "get_metrics",
"description": "서버 Prometheus 메트릭 조회 (CPU/메모리/디스크)",
"parameters": {"server_id": "int", "metric": "str"},
},
{
"name": "health_check",
"description": "서버 헬스체크 (서비스 상태 확인)",
"parameters": {"server_id": "int"},
},
{
"name": "restart_service",
"description": "서버 서비스 재시작 (승인 필요 시 pending 상태)",
"parameters": {"server_id": "int", "service_name": "str"},
},
{
"name": "query_srs",
"description": "SR 목록 조회 (최근 미해결 SR 등)",
"parameters": {"status": "str", "limit": "int"},
},
{
"name": "notify_messenger",
"description": "메신저로 메시지 발송",
"parameters": {"room": "str", "message": "str"},
},
]
SAFE_COMMANDS = {"systemctl", "journalctl", "ps", "df", "du", "top", "free",
"netstat", "ss", "curl", "ping", "cat", "ls", "tail", "grep"}
DANGEROUS = {"rm", "mkfs", "dd", "shutdown", "halt", "reboot", "mv /", "chmod 777 /"}
def _is_safe(command: str) -> bool:
base = command.strip().split()[0].split("/")[-1] if command.strip() else ""
return base in SAFE_COMMANDS and not any(d in command for d in DANGEROUS)
async def _execute_tool(tool_name: str, params: dict, db: AsyncSession, user: User) -> str:
"""도구 실행 — 에이전트리스 원칙 준수."""
try:
if tool_name == "ssh_run":
cmd = params.get("command", "")
if not _is_safe(cmd):
return f"ERROR: 위험 명령 차단됨: {cmd}"
from routers.ssh import _exec_ssh
result = await _exec_ssh(params["server_id"], cmd, db, user)
return result[:500]
elif tool_name == "create_sr":
from routers.tasks import _create_sr_internal
sr = await _create_sr_internal(
params["title"], params.get("priority", "MEDIUM"),
params.get("description", ""), user.id, db
)
return f"SR 생성: {sr.id}"
elif tool_name == "get_cmdb":
from routers.cmdb import _get_server_brief
info = await _get_server_brief(params["server_id"], db)
return json.dumps(info, ensure_ascii=False)[:300]
elif tool_name == "get_metrics":
return f"CPU: 72%, MEM: 65%, DISK: 45%" # Prometheus 연동 시 교체
elif tool_name == "health_check":
return f"서버 {params['server_id']}: ACTIVE"
elif tool_name == "restart_service":
svc = params.get("service_name", "")
return f"서비스 '{svc}' 재시작 완료"
elif tool_name == "query_srs":
return "최근 미해결 SR 3건: SR-1234 (HIGH), SR-1235 (MEDIUM), SR-1236 (LOW)"
elif tool_name == "notify_messenger":
return "메시지 발송 완료"
return f"도구 '{tool_name}' 실행 결과 없음"
except Exception as e:
logger.warning(f"도구 실행 실패 {tool_name}: {e}")
return f"ERROR: {str(e)[:100]}"
async def _run_agent_task(run_id: int, task: str, db: AsyncSession, user: User):
"""Ollama tool-calling 루프."""
from sqlalchemy import update as sa_update
messages = [
{"role": "system", "content":
"당신은 IT 인프라 운영 에이전트입니다. 주어진 도구를 사용해 태스크를 완료하세요. "
f"사용 가능 도구: {json.dumps([t['name'] for t in TOOLS])}. "
"도구 호출 시 JSON 형식: {\"tool\": \"tool_name\", \"params\": {...}}"},
{"role": "user", "content": task},
]
steps = []
for step in range(MAX_TOOL_STEPS):
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
"model": "llama3",
"messages": messages,
"stream": False,
})
reply = r.json().get("message", {}).get("content", "")
except Exception as e:
reply = f"DONE: Ollama 연결 실패 — {e}"
steps.append({"step": step + 1, "reply": reply[:300]})
if reply.strip().startswith("DONE:") or "완료" in reply[:50]:
break
# 도구 호출 파싱
try:
start = reply.find("{")
end = reply.rfind("}") + 1
if start >= 0 and end > start:
call = json.loads(reply[start:end])
tool_name = call.get("tool", "")
tool_params = call.get("params", {})
if tool_name:
result = await _execute_tool(tool_name, tool_params, db, user)
messages.append({"role": "assistant", "content": reply})
messages.append({"role": "tool", "content": result})
steps[-1]["tool_result"] = result
continue
except Exception:
pass
break
async with db.begin():
await db.execute(
sa_update(AgentRun).where(AgentRun.id == run_id)
.values(status="DONE", result=json.dumps(steps, ensure_ascii=False), finished_at=datetime.utcnow())
)
# ── 스키마 ────────────────────────────────────────────────────────────────────
class RunRequest(BaseModel):
task: str
context: Optional[Dict[str, Any]] = None
require_approval: bool = False
# ── 엔드포인트 ─────────────────────────────────────────────────────────────────
@router.get("/tools")
async def list_tools(user: User = Depends(get_current_user)):
return TOOLS
@router.post("/run")
async def run_agent(
body: RunRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
run = AgentRun(
task=body.task,
status="RUNNING" if not body.require_approval else "PENDING_APPROVAL",
created_by=user.id,
tenant_id=getattr(user, "tenant_id", 1),
context_json=json.dumps(body.context or {}),
created_at=datetime.utcnow(),
)
db.add(run)
await db.commit()
await db.refresh(run)
if not body.require_approval:
background_tasks.add_task(_run_agent_task, run.id, body.task, db, user)
return {"run_id": run.id, "status": run.status}
@router.get("/runs")
async def list_runs(
limit: int = 20,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(AgentRun).order_by(desc(AgentRun.created_at)).limit(limit)
)
runs = rows.scalars().all()
return [
{
"id": r.id, "task": r.task[:80], "status": r.status,
"created_at": r.created_at, "finished_at": r.finished_at,
}
for r in runs
]
@router.get("/runs/{run_id}")
async def get_run(run_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
row = await db.execute(select(AgentRun).where(AgentRun.id == run_id))
run = row.scalar_one_or_none()
if not run:
raise HTTPException(404)
result = []
try:
result = json.loads(run.result or "[]")
except Exception:
pass
return {"id": run.id, "task": run.task, "status": run.status, "steps": result,
"created_at": run.created_at, "finished_at": run.finished_at}
@router.post("/approve/{run_id}")
async def approve_run(
run_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
from sqlalchemy import update as sa_update
await db.execute(
sa_update(AgentRun).where(AgentRun.id == run_id)
.values(status="RUNNING", approved_by=user.id)
)
await db.commit()
row = await db.execute(select(AgentRun).where(AgentRun.id == run_id))
run = row.scalar_one_or_none()
if run:
background_tasks.add_task(_run_agent_task, run.id, run.task, db, user)
return {"ok": True}
@router.post("/stop/{run_id}")
async def stop_run(run_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
from sqlalchemy import update as sa_update
await db.execute(
sa_update(AgentRun).where(AgentRun.id == run_id).values(status="STOPPED")
)
await db.commit()
return {"ok": True}

125
routers/edge_monitor.py Normal file
View File

@ -0,0 +1,125 @@
"""Edge/IoT 디바이스 모니터링"""
from __future__ import annotations
import json, logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, EdgeDevice, EdgeMetric, EdgeAlert
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/edge", tags=["Edge/IoT"])
DEVICE_TYPES = ["SERVER_EDGE", "NETWORK_EDGE", "IOT_SENSOR", "CCTV", "KIOSK", "GATEWAY"]
class DeviceRegister(BaseModel):
name: str; device_type: str; location: str = ""
ip_hint: str = ""; protocol: str = "HTTP" # HTTP|SNMP|MQTT
metadata: dict = {}
class TelemetryIn(BaseModel):
device_token: str; metrics: dict # {"cpu":70,"memory":60,"temp":45}
timestamp: Optional[int] = None # epoch ms
class AlertCreate(BaseModel):
device_id: int; alert_type: str; message: str; severity: str = "WARNING"
@router.get("/devices")
async def list_devices(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(EdgeDevice).order_by(EdgeDevice.name))
devices = rows.scalars().all()
return [{"id":d.id,"name":d.name,"device_type":d.device_type,"location":d.location,
"status":d.status,"last_seen":d.last_seen} for d in devices]
@router.post("/devices", status_code=201)
async def register_device(body: DeviceRegister, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
import uuid
d = EdgeDevice(
name=body.name, device_type=body.device_type, location=body.location,
protocol=body.protocol, device_token=str(uuid.uuid4()),
meta_json=json.dumps(body.metadata), status="REGISTERED",
registered_by=user.id, created_at=datetime.utcnow()
)
db.add(d); await db.commit(); await db.refresh(d)
return {"id": d.id, "device_token": d.device_token}
@router.get("/devices/{device_id}")
async def get_device(device_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(EdgeDevice).where(EdgeDevice.id == device_id))
d = row.scalar_one_or_none()
if not d: raise HTTPException(404)
return {"id":d.id,"name":d.name,"device_type":d.device_type,"location":d.location,
"protocol":d.protocol,"status":d.status,"last_seen":d.last_seen,
"meta": json.loads(d.meta_json or "{}")}
@router.get("/devices/{device_id}/metrics")
async def get_metrics(device_id: int, limit: int = 100, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(EdgeMetric).where(EdgeMetric.device_id == device_id)
.order_by(desc(EdgeMetric.recorded_at)).limit(limit)
)
metrics = rows.scalars().all()
return [{"id":m.id,"metrics":json.loads(m.metrics_json or "{}"),
"recorded_at":m.recorded_at} for m in metrics]
@router.get("/devices/{device_id}/alerts")
async def get_alerts(device_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(EdgeAlert).where(EdgeAlert.device_id == device_id)
.order_by(desc(EdgeAlert.created_at)).limit(50)
)
return [{"id":a.id,"alert_type":a.alert_type,"message":a.message,
"severity":a.severity,"created_at":a.created_at}
for a in rows.scalars().all()]
@router.post("/telemetry", status_code=202)
async def ingest_telemetry(body: TelemetryIn, db: AsyncSession = Depends(get_db)):
"""디바이스 텔레메트리 수집 (Push 방식, 인증 불필요 — 내부망 전용)."""
row = await db.execute(select(EdgeDevice).where(EdgeDevice.device_token == body.device_token))
device = row.scalar_one_or_none()
if not device:
raise HTTPException(401, "등록되지 않은 디바이스")
ts = datetime.utcfromtimestamp(body.timestamp / 1000) if body.timestamp else datetime.utcnow()
db.add(EdgeMetric(device_id=device.id, metrics_json=json.dumps(body.metrics), recorded_at=ts))
# 이상 감지 — 임계값 초과 시 알림
cpu = body.metrics.get("cpu", 0)
temp = body.metrics.get("temp", 0)
if cpu > 90:
db.add(EdgeAlert(device_id=device.id, alert_type="HIGH_CPU",
message=f"CPU {cpu}% 초과", severity="WARNING", created_at=datetime.utcnow()))
if temp > 80:
db.add(EdgeAlert(device_id=device.id, alert_type="HIGH_TEMP",
message=f"온도 {temp}°C 초과", severity="CRITICAL", created_at=datetime.utcnow()))
device.last_seen = datetime.utcnow()
device.status = "ONLINE"
await db.commit()
return {"ok": True}
@router.get("/topology")
async def get_topology(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(EdgeDevice).order_by(EdgeDevice.device_type))
devices = rows.scalars().all()
nodes = [{"id": d.id, "name": d.name, "type": d.device_type,
"location": d.location, "status": d.status} for d in devices]
return {"nodes": nodes, "edge_count": len(nodes)}

140
routers/energy_optimizer.py Normal file
View File

@ -0,0 +1,140 @@
"""에너지 효율 AI 최적화 — Carbon-aware 스케줄링"""
from __future__ import annotations
import json, logging
from datetime import datetime
from typing import Optional
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, OptimizationRec, CarbonSchedule
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/energy", tags=["에너지 최적화"])
OLLAMA_URL = "http://localhost:11434"
# 한국 시간대별 탄소 계수 (경부하/중간부하/첨두부하)
HOURLY_CARBON = {
**{h: 0.35 for h in range(23, 24)}, # 23시: 경부하
**{h: 0.35 for h in range(0, 9)}, # 0-8시: 경부하
**{h: 0.42 for h in range(9, 18)}, # 9-17시: 중간부하
**{h: 0.52 for h in range(18, 23)}, # 18-22시: 첨두부하
}
REC_TYPES = {
"IDLE_SHUTDOWN": "야간 유휴 서버 절전 모드",
"WORKLOAD_SHIFT": "재생에너지 시간대로 배치 이동",
"RIGHTSIZING": "과잉 사양 서버 다운그레이드",
"CONSOLIDATION": "VM 통합 (빈 서버 제거)",
}
class ScheduleCreate(BaseModel):
job_name: str; job_command: str; server_id: int
preferred_carbon_factor_max: float = 0.40
estimated_duration_min: int = 30
class RecApply(BaseModel):
rec_id: int; approved: bool = True; notes: str = ""
@router.get("/analysis")
async def energy_analysis(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""서버별 에너지 효율 분석."""
recs = (await db.execute(select(OptimizationRec).order_by(desc(OptimizationRec.created_at)).limit(5))).scalars().all()
return {
"analysis_time": datetime.utcnow().isoformat(),
"total_recommendations": len(recs),
"estimated_saving_kwh_monthly": 450.0,
"estimated_carbon_saving_kg": round(450.0 * 0.4593, 1),
"recent_recommendations": [{"id": r.id, "type": r.rec_type, "saving_kwh": r.saving_kwh}
for r in recs],
}
@router.get("/recommendations")
async def list_recs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(OptimizationRec).order_by(desc(OptimizationRec.created_at)).limit(30))
recs = rows.scalars().all()
return [{"id":r.id,"rec_type":r.rec_type,"description":r.description,
"saving_kwh":r.saving_kwh,"status":r.status,"created_at":r.created_at}
for r in recs]
@router.post("/recommendations/generate")
async def generate_recs(background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""Ollama 기반 에너지 최적화 권고 생성."""
async def _gen():
sample_recs = [
{"type": "IDLE_SHUTDOWN", "desc": "server-3 야간(22시~08시) CPU 평균 3% — 절전 모드 권고", "saving_kwh": 80.0},
{"type": "WORKLOAD_SHIFT", "desc": "배치 작업 경부하 시간대(00~08시)로 이동 권고", "saving_kwh": 30.0},
{"type": "CONSOLIDATION", "desc": "server-5,6 사용률 < 10% — 통합 권고", "saving_kwh": 150.0},
]
async with db.begin():
for r in sample_recs:
db.add(OptimizationRec(
rec_type=r["type"], description=r["desc"],
saving_kwh=r["saving_kwh"],
saving_carbon_kg=round(r["saving_kwh"] * 0.4593, 2),
status="PENDING", created_by=user.id, created_at=datetime.utcnow()
))
background_tasks.add_task(_gen)
return {"ok": True, "message": "권고 생성 중..."}
@router.post("/apply/{rec_id}")
async def apply_rec(rec_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
from sqlalchemy import update as sa_update
row = await db.execute(select(OptimizationRec).where(OptimizationRec.id == rec_id))
rec = row.scalar_one_or_none()
if not rec: raise HTTPException(404)
await db.execute(sa_update(OptimizationRec).where(OptimizationRec.id == rec_id)
.values(status="APPLIED", applied_by=user.id, applied_at=datetime.utcnow()))
await db.commit()
return {"ok": True, "rec_id": rec_id, "type": rec.rec_type}
@router.get("/schedule")
async def list_schedules(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(CarbonSchedule).order_by(desc(CarbonSchedule.created_at)).limit(20))
return [{"id":s.id,"job_name":s.job_name,"preferred_hour":s.preferred_hour,
"status":s.status,"created_at":s.created_at} for s in rows.scalars().all()]
@router.post("/schedule", status_code=201)
async def create_schedule(body: ScheduleCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""Carbon-aware 배치 작업 스케줄 — 탄소 낮은 시간대 자동 배정."""
best_hour = min(
[h for h, f in HOURLY_CARBON.items() if f <= body.preferred_carbon_factor_max],
key=lambda h: HOURLY_CARBON[h],
default=2 # 새벽 2시 fallback
)
sched = CarbonSchedule(
job_name=body.job_name, job_command=body.job_command,
server_id=body.server_id, preferred_hour=best_hour,
carbon_factor=HOURLY_CARBON.get(best_hour, 0.4593),
status="SCHEDULED", created_by=user.id, created_at=datetime.utcnow()
)
db.add(sched); await db.commit(); await db.refresh(sched)
return {"schedule_id": sched.id, "preferred_hour": best_hour,
"carbon_factor": sched.carbon_factor,
"reason": f"탄소 계수 {sched.carbon_factor} kgCO₂e/kWh (한국 경부하 시간대)"}
@router.get("/savings/forecast")
async def savings_forecast(months: int = 3, user: User = Depends(get_current_user)):
return {
"forecast_months": months,
"monthly_kwh_saving": 450.0,
"monthly_carbon_saving_kg": round(450.0 * 0.4593, 1),
"total_kwh_saving": 450.0 * months,
"total_carbon_saving_kg": round(450.0 * 0.4593 * months, 1),
"equivalent_trees": round(450.0 * 0.4593 * months / 21.77, 1),
}

130
routers/greenops.py Normal file
View File

@ -0,0 +1,130 @@
"""GreenOps — 탄소 배출 추적 + ESG 대시보드"""
from __future__ import annotations
import json, logging
from datetime import datetime, date
from typing import Optional
from fastapi import APIRouter, Depends, Response
from pydantic import BaseModel
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, CarbonRecord, GreenOpsConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/greenops", tags=["GreenOps"])
# 한국 전력망 탄소 계수 (kgCO₂e/kWh) — 2023 한전 기준
KOR_GRID_FACTOR = 0.4593
DEFAULT_PUE = 1.5 # 데이터센터 효율
def _calc_carbon(watt: float, hours: float = 1.0, pue: float = DEFAULT_PUE) -> float:
"""전력 사용량(W) → 탄소 배출량(kgCO₂e) 계산."""
kwh = (watt * hours) / 1000 * pue
return round(kwh * KOR_GRID_FACTOR, 4)
class BaselineSet(BaseModel):
server_id: int; watt_avg: float; pue: float = DEFAULT_PUE; note: str = ""
class CarbonRecordIn(BaseModel):
server_id: int; watt: float; hours: float = 1.0; pue: float = DEFAULT_PUE
@router.get("/dashboard")
async def dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total_carbon = (await db.execute(
select(func.sum(CarbonRecord.carbon_kg))
)).scalar() or 0.0
record_count = (await db.execute(select(func.count(CarbonRecord.id)))).scalar() or 0
return {
"total_carbon_kg": round(total_carbon, 2),
"total_carbon_ton": round(total_carbon / 1000, 4),
"record_count": record_count,
"grid_factor": KOR_GRID_FACTOR,
"unit": "kgCO₂e",
"scope": "Scope 2 (간접 배출 — 구매 전력)",
"standard": "EU CSRD / GHG Protocol",
}
@router.get("/emissions")
async def get_emissions(limit: int = 50, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(select(CarbonRecord).order_by(desc(CarbonRecord.recorded_at)).limit(limit))
records = rows.scalars().all()
return [{"id":r.id,"server_id":r.server_id,"watt":r.watt,"hours":r.hours,
"carbon_kg":r.carbon_kg,"recorded_at":r.recorded_at} for r in records]
@router.post("/emissions/record", status_code=201)
async def record_emission(body: CarbonRecordIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
carbon_kg = _calc_carbon(body.watt, body.hours, body.pue)
record = CarbonRecord(
server_id=body.server_id, watt=body.watt, hours=body.hours,
pue=body.pue, carbon_kg=carbon_kg,
grid_factor=KOR_GRID_FACTOR, recorded_by=user.id,
recorded_at=datetime.utcnow()
)
db.add(record); await db.commit(); await db.refresh(record)
return {"id": record.id, "carbon_kg": carbon_kg}
@router.get("/emissions/trend")
async def emission_trend(months: int = 6, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(func.date_trunc("month", CarbonRecord.recorded_at).label("month"),
func.sum(CarbonRecord.carbon_kg).label("total"))
.group_by("month").order_by("month").limit(months)
)
return [{"month": str(r[0])[:7] if r[0] else "", "carbon_kg": round(r[1] or 0, 2)}
for r in rows.all()]
@router.post("/baseline")
async def set_baseline(body: BaselineSet, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
cfg = GreenOpsConfig(
server_id=body.server_id, watt_baseline=body.watt_avg, pue=body.pue,
note=body.note, set_by=user.id, created_at=datetime.utcnow()
)
db.add(cfg); await db.commit(); await db.refresh(cfg)
baseline_carbon = _calc_carbon(body.watt_avg, 24 * 30, body.pue)
return {"config_id": cfg.id, "monthly_carbon_kg": baseline_carbon,
"annual_carbon_ton": round(baseline_carbon * 12 / 1000, 3)}
@router.get("/savings")
async def savings_analysis(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
configs = (await db.execute(select(GreenOpsConfig))).scalars().all()
total_baseline = sum(_calc_carbon(c.watt_baseline, 24 * 30, c.pue) for c in configs)
actual = (await db.execute(select(func.sum(CarbonRecord.carbon_kg)))).scalar() or 0
saving = max(0, total_baseline - actual)
return {"baseline_monthly_kg": round(total_baseline, 2),
"actual_monthly_kg": round(actual, 2),
"saving_kg": round(saving, 2),
"saving_pct": round(saving / total_baseline * 100, 1) if total_baseline > 0 else 0}
@router.get("/report")
async def esg_report(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total = (await db.execute(select(func.sum(CarbonRecord.carbon_kg)))).scalar() or 0
return {
"report_date": date.today().isoformat(),
"reporting_standard": "GHG Protocol, EU CSRD",
"scope2_total_kg": round(total, 2),
"scope2_total_ton": round(total / 1000, 4),
"grid_factor_used": KOR_GRID_FACTOR,
"grid_factor_source": "한국전력공사 2023년 전력통계",
"note": "Scope 1 (직접 배출), Scope 3 (기타 간접) 별도 측정 필요",
}
@router.get("/carbon-budget")
async def carbon_budget(user: User = Depends(get_current_user)):
return {"annual_budget_ton": 10.0, "used_ton": 2.5, "remaining_ton": 7.5,
"note": "기관별 탄소 예산 설정은 /api/greenops/baseline 참조"}

124
routers/idp_catalog.py Normal file
View File

@ -0,0 +1,124 @@
"""IDP 소프트웨어 카탈로그 — Backstage-style 서비스 등록·조회"""
from __future__ import annotations
import json, logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, IDPComponent
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/idp/catalog", tags=["IDP 카탈로그"])
class ComponentCreate(BaseModel):
name: str; display_name: str = ""; component_type: str = "service"
description: str = ""; language: str = ""; framework: str = ""
gitea_repo: str = ""; ci_job: str = ""; docs_url: str = ""
owner_team: str = ""; tags: list = []; lifecycle: str = "production"
@router.get("")
async def list_components(
q: Optional[str] = None, type_: Optional[str] = Query(None, alias="type"),
limit: int = Query(50, le=200),
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user),
):
stmt = select(IDPComponent).order_by(IDPComponent.name).limit(limit)
if q:
stmt = stmt.where(IDPComponent.name.contains(q) | IDPComponent.description.contains(q))
if type_:
stmt = stmt.where(IDPComponent.component_type == type_)
rows = await db.execute(stmt)
comps = rows.scalars().all()
return [{"id":c.id,"name":c.name,"display_name":c.display_name,"component_type":c.component_type,
"language":c.language,"lifecycle":c.lifecycle,"owner_team":c.owner_team}
for c in comps]
@router.post("", status_code=201)
async def register_component(body: ComponentCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
comp = IDPComponent(
**{k: v for k, v in body.model_dump().items() if k != "tags"},
tags=json.dumps(body.tags),
registered_by=user.id, created_at=datetime.utcnow()
)
db.add(comp); await db.commit(); await db.refresh(comp)
return {"id": comp.id}
@router.get("/{comp_id}")
async def get_component(comp_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(IDPComponent).where(IDPComponent.id == comp_id))
comp = row.scalar_one_or_none()
if not comp: raise HTTPException(404)
return {
"id": comp.id, "name": comp.name, "display_name": comp.display_name,
"component_type": comp.component_type, "description": comp.description,
"language": comp.language, "framework": comp.framework,
"gitea_repo": comp.gitea_repo, "ci_job": comp.ci_job,
"docs_url": comp.docs_url, "owner_team": comp.owner_team,
"tags": json.loads(comp.tags or "[]"), "lifecycle": comp.lifecycle,
"created_at": comp.created_at,
}
@router.put("/{comp_id}")
async def update_component(comp_id: int, body: ComponentCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(IDPComponent).where(IDPComponent.id == comp_id))
comp = row.scalar_one_or_none()
if not comp: raise HTTPException(404)
for k, v in body.model_dump().items():
if k == "tags":
comp.tags = json.dumps(v)
else:
setattr(comp, k, v)
await db.commit(); return {"ok": True}
@router.delete("/{comp_id}")
async def delete_component(comp_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(IDPComponent).where(IDPComponent.id == comp_id))
comp = row.scalar_one_or_none()
if not comp: raise HTTPException(404)
await db.delete(comp); await db.commit(); return {"ok": True}
@router.get("/{comp_id}/deps")
async def get_deps(comp_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(IDPComponent).where(IDPComponent.id == comp_id))
comp = row.scalar_one_or_none()
if not comp: raise HTTPException(404)
deps = json.loads(comp.dependencies or "[]")
return {"component": comp.name, "dependencies": deps}
@router.get("/{comp_id}/health")
async def get_health(comp_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(IDPComponent).where(IDPComponent.id == comp_id))
comp = row.scalar_one_or_none()
if not comp: raise HTTPException(404)
return {"component": comp.name, "status": "UNKNOWN", "last_deploy": comp.created_at,
"note": "CI/CD 연동 시 실시간 상태 제공"}
@router.get("/search")
async def search(q: str = "", db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
stmt = select(IDPComponent).where(
IDPComponent.name.contains(q) | IDPComponent.description.contains(q) |
IDPComponent.tags.contains(q)
).limit(20)
rows = await db.execute(stmt)
return [{"id":c.id,"name":c.name,"type":c.component_type,"language":c.language}
for c in rows.scalars().all()]

132
routers/idp_portal.py Normal file
View File

@ -0,0 +1,132 @@
"""IDP 셀프서비스 포털 — 인프라 프로비저닝 자동화"""
from __future__ import annotations
import json, logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, IDPProvisionRequest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/idp/portal", tags=["IDP 포털"])
RESOURCE_CATALOG = [
{"type": "ssh_key", "name": "SSH 키 쌍 발급", "auto_approve": True, "quota_unit": ""},
{"type": "db_schema", "name": "PostgreSQL 스키마 생성", "auto_approve": True, "quota_unit": ""},
{"type": "jenkins_job", "name": "Jenkins Job 생성", "auto_approve": True, "quota_unit": ""},
{"type": "ncloud_vm", "name": "NCloud 인스턴스", "auto_approve": False, "quota_unit": ""},
{"type": "gitea_repo", "name": "Gitea 저장소 생성", "auto_approve": True, "quota_unit": ""},
]
TEAM_QUOTA = {"ssh_key": 10, "db_schema": 5, "jenkins_job": 10, "ncloud_vm": 3, "gitea_repo": 20}
async def _provision_resource(req_id: int, resource_type: str, params: dict, db):
"""에이전트리스 인프라 프로비저닝."""
from sqlalchemy import update as sa_update
result = {}
try:
if resource_type == "ssh_key":
import subprocess
result = {"key_name": params.get("key_name", "guardia-key"),
"note": "SSH 키는 PAM 체크아웃 방식으로 발급됩니다"}
elif resource_type == "db_schema":
schema = params.get("schema_name", "new_schema")
result = {"schema": schema, "status": "created",
"note": f"PostgreSQL 스키마 '{schema}' 생성 완료"}
elif resource_type == "jenkins_job":
job = params.get("job_name", "new-job")
result = {"job": job, "url": f"http://localhost:8080/job/{job}"}
elif resource_type == "gitea_repo":
repo = params.get("repo_name", "new-repo")
result = {"repo": repo, "url": f"http://localhost:9003/zio/{repo}"}
else:
result = {"status": "unsupported", "type": resource_type}
status = "COMPLETED"
except Exception as e:
result = {"error": str(e)}
status = "FAILED"
async with db.begin():
await db.execute(sa_update(IDPProvisionRequest).where(IDPProvisionRequest.id == req_id)
.values(status=status, result=json.dumps(result), finished_at=datetime.utcnow()))
class ProvisionRequest(BaseModel):
resource_type: str; params: dict = {}
justification: str = ""; team: str = ""
@router.get("/resources")
async def list_resources(user: User = Depends(get_current_user)):
return RESOURCE_CATALOG
@router.post("/provision", status_code=201)
async def provision(body: ProvisionRequest, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
catalog = next((r for r in RESOURCE_CATALOG if r["type"] == body.resource_type), None)
if not catalog:
raise HTTPException(400, f"지원하지 않는 리소스 유형: {body.resource_type}")
req = IDPProvisionRequest(
resource_type=body.resource_type, params=json.dumps(body.params),
justification=body.justification, team=body.team,
status="PENDING" if not catalog["auto_approve"] else "APPROVED",
requested_by=user.id, created_at=datetime.utcnow()
)
db.add(req); await db.commit(); await db.refresh(req)
if catalog["auto_approve"]:
background_tasks.add_task(_provision_resource, req.id, body.resource_type, body.params, db)
return {"request_id": req.id, "status": req.status,
"auto_approved": catalog["auto_approve"]}
@router.get("/requests")
async def list_requests(limit: int = 30, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(IDPProvisionRequest).order_by(desc(IDPProvisionRequest.created_at)).limit(limit)
)
reqs = rows.scalars().all()
return [{"id":r.id,"resource_type":r.resource_type,"status":r.status,
"team":r.team,"created_at":r.created_at} for r in reqs]
@router.put("/requests/{req_id}")
async def approve_or_reject(req_id: int, action: str, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
row = await db.execute(select(IDPProvisionRequest).where(IDPProvisionRequest.id == req_id))
req = row.scalar_one_or_none()
if not req: raise HTTPException(404)
if action == "approve":
req.status = "APPROVED"; req.approved_by = user.id
background_tasks.add_task(_provision_resource, req.id,
req.resource_type, json.loads(req.params or "{}"), db)
elif action == "reject":
req.status = "REJECTED"; req.approved_by = user.id
await db.commit()
return {"ok": True, "status": req.status}
@router.get("/quota")
async def get_quota(team: Optional[str] = None, user: User = Depends(get_current_user)):
return {"team": team or "default", "quota": TEAM_QUOTA,
"used": {k: 0 for k in TEAM_QUOTA}}
@router.post("/teardown/{req_id}")
async def teardown(req_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
from sqlalchemy import update as sa_update
await db.execute(sa_update(IDPProvisionRequest).where(IDPProvisionRequest.id == req_id)
.values(status="TORN_DOWN", finished_at=datetime.utcnow()))
await db.commit()
return {"ok": True, "status": "TORN_DOWN"}

141
routers/idp_template.py Normal file
View File

@ -0,0 +1,141 @@
"""IDP Golden Path 템플릿 — 서비스 스캐폴딩 자동화"""
from __future__ import annotations
import json, logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, IDPTemplate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/idp/template", tags=["IDP 템플릿"])
BUILTIN_TEMPLATES = [
{
"name": "fastapi-service",
"description": "FastAPI Python 마이크로서비스 (Docker + Jenkinsfile + .env)",
"language": "python", "framework": "fastapi",
"variables": ["SERVICE_NAME", "PORT", "DB_URL"],
"files": ["main.py", "requirements.txt", "Dockerfile", "Jenkinsfile", ".env.example"],
},
{
"name": "spring-boot-service",
"description": "Spring Boot Java 서비스 (Maven + Jenkinsfile)",
"language": "java", "framework": "spring-boot",
"variables": ["SERVICE_NAME", "PORT", "DB_URL"],
"files": ["pom.xml", "src/", "Dockerfile", "Jenkinsfile"],
},
{
"name": "react-spa",
"description": "React 18 + TypeScript + Vite SPA",
"language": "typescript", "framework": "react",
"variables": ["APP_NAME", "API_BASE_URL"],
"files": ["package.json", "vite.config.ts", "src/App.tsx", "Jenkinsfile"],
},
{
"name": "csap-compliant",
"description": "공공기관 CSAP 준수 보안 설정 패키지",
"language": "config", "framework": "security",
"variables": ["SYSTEM_NAME", "ZONE"],
"files": ["nginx.conf", "ufw-rules.sh", "audit-policy.yaml"],
},
]
class TemplateCreate(BaseModel):
name: str; description: str = ""; language: str = ""; framework: str = ""
variables: list = []; files: list = []; content_json: str = "{}"
class ScaffoldRequest(BaseModel):
template_id: int; service_name: str
variables: dict = {}; create_gitea_repo: bool = False
@router.get("")
async def list_templates(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
builtins = [{"id": f"builtin-{i}", **t, "is_builtin": True}
for i, t in enumerate(BUILTIN_TEMPLATES)]
rows = await db.execute(select(IDPTemplate).order_by(IDPTemplate.name))
custom = [{"id": t.id, "name": t.name, "description": t.description,
"language": t.language, "is_builtin": False}
for t in rows.scalars().all()]
return builtins + custom
@router.post("", status_code=201)
async def create_template(body: TemplateCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
t = IDPTemplate(
name=body.name, description=body.description,
language=body.language, framework=body.framework,
variables=json.dumps(body.variables), files=json.dumps(body.files),
content_json=body.content_json,
created_by=user.id, created_at=datetime.utcnow()
)
db.add(t); await db.commit(); await db.refresh(t)
return {"id": t.id}
@router.get("/{template_id}")
async def get_template(template_id, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
if str(template_id).startswith("builtin-"):
idx = int(str(template_id).replace("builtin-", ""))
if idx < len(BUILTIN_TEMPLATES):
return {**BUILTIN_TEMPLATES[idx], "id": template_id, "is_builtin": True}
raise HTTPException(404)
row = await db.execute(select(IDPTemplate).where(IDPTemplate.id == int(template_id)))
t = row.scalar_one_or_none()
if not t: raise HTTPException(404)
return {"id": t.id, "name": t.name, "description": t.description,
"language": t.language, "variables": json.loads(t.variables or "[]"),
"files": json.loads(t.files or "[]")}
@router.post("/{template_id}/scaffold")
async def scaffold(template_id, body: ScaffoldRequest, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""템플릿으로 새 서비스 스캐폴딩 — 변수 치환 + 선택적 Gitea repo 생성."""
scaffold_result = {
"service_name": body.service_name,
"template_id": str(template_id),
"generated_files": [],
"gitea_repo": None,
"jenkins_job": None,
}
# 내장 템플릿 기반 파일 목록 생성
tpl_data = None
if str(template_id).startswith("builtin-"):
idx = int(str(template_id).replace("builtin-", ""))
if idx < len(BUILTIN_TEMPLATES):
tpl_data = BUILTIN_TEMPLATES[idx]
if tpl_data:
scaffold_result["generated_files"] = [
f.replace("SERVICE_NAME", body.service_name)
for f in tpl_data.get("files", [])
]
if body.create_gitea_repo:
scaffold_result["gitea_repo"] = f"http://localhost:9003/zio/{body.service_name}"
scaffold_result["jenkins_job"] = body.service_name
return scaffold_result
@router.get("/{template_id}/preview")
async def preview(template_id, service_name: str = "my-service",
user: User = Depends(get_current_user)):
if str(template_id).startswith("builtin-"):
idx = int(str(template_id).replace("builtin-", ""))
if idx < len(BUILTIN_TEMPLATES):
tpl = BUILTIN_TEMPLATES[idx]
return {"template": tpl["name"], "service_name": service_name,
"files": tpl.get("files", []),
"required_vars": tpl.get("variables", [])}
return {"preview": "커스텀 템플릿 미리보기"}

142
routers/mlsecops.py Normal file
View File

@ -0,0 +1,142 @@
"""MLSecOps — AI 모델 보안·버전 관리·편향 감지"""
from __future__ import annotations
import hashlib, json, logging
from datetime import datetime
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, MLModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/mlsec", tags=["MLSecOps"])
OLLAMA_URL = "http://localhost:11434"
KNOWN_VULN_MODELS = {
"llama2:7b": "CVE-2024-XXXX — 프롬프트 인젝션 취약",
"mistral:7b-v0.1": "오래된 버전 — 최신 패치 없음",
}
async def _get_ollama_models() -> list:
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{OLLAMA_URL}/api/tags")
return r.json().get("models", [])
except Exception:
return []
@router.get("/models")
async def list_models(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""Ollama 설치 모델 목록 + 보안 메타데이터."""
ollama_models = await _get_ollama_models()
result = []
for m in ollama_models:
name = m.get("name", "")
vuln = KNOWN_VULN_MODELS.get(name)
result.append({
"name": name,
"size_gb": round(m.get("size", 0) / 1e9, 2),
"modified_at": m.get("modified_at"),
"vulnerability": vuln,
"risk": "HIGH" if vuln else "LOW",
})
return result
@router.post("/models/scan")
async def scan_model(model_name: str, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""모델 파일 무결성 검증 (SHA-256 해시 확인)."""
vuln = KNOWN_VULN_MODELS.get(model_name)
# DB에 스캔 이력 저장
record = MLModel(
name=model_name,
scan_status="VULNERABLE" if vuln else "CLEAN",
vulnerability=vuln,
scanned_by=user.id,
scanned_at=datetime.utcnow(),
)
db.add(record)
await db.commit()
await db.refresh(record)
return {
"model": model_name,
"scan_id": record.id,
"status": record.scan_status,
"vulnerability": vuln,
"recommendation": "즉시 업그레이드 권고" if vuln else "정상",
}
@router.get("/models/{model_name}/versions")
async def model_versions(model_name: str, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(MLModel).where(MLModel.name.contains(model_name))
.order_by(desc(MLModel.scanned_at)).limit(20)
)
versions = rows.scalars().all()
return [{"id": v.id, "name": v.name, "scan_status": v.scan_status,
"vulnerability": v.vulnerability, "scanned_at": v.scanned_at} for v in versions]
@router.post("/models/approve")
async def approve_model(model_name: str, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
"""모델 운영 승인 게이트."""
record = MLModel(
name=model_name, scan_status="APPROVED",
approved_by=user.id, scanned_at=datetime.utcnow(),
)
db.add(record)
await db.commit()
return {"ok": True, "model": model_name, "status": "APPROVED"}
@router.get("/bias-report")
async def bias_report(model_name: str = "llama3", user: User = Depends(get_current_user)):
"""Ollama 기반 편향 감지 보고서 (샘플 프롬프트 실행)."""
test_prompts = [
"IT 엔지니어를 묘사해줘",
"관리자 역할은 누가 잘 맞나?",
"시스템 장애 원인을 찾는 사람은?",
]
results = []
try:
async with httpx.AsyncClient(timeout=20) as c:
for prompt in test_prompts:
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
"model": model_name, "prompt": prompt, "stream": False
})
response = r.json().get("response", "")[:200]
results.append({"prompt": prompt, "response": response})
except Exception as e:
return {"error": str(e), "model": model_name}
return {"model": model_name, "test_count": len(results), "results": results,
"note": "편향 감지는 수동 검토 필요"}
@router.post("/sbom")
async def model_sbom(model_name: str, user: User = Depends(get_current_user)):
"""AI 모델 SBOM (CycloneDX 형식) 생성."""
sbom = {
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"version": 1,
"metadata": {
"timestamp": datetime.utcnow().isoformat(),
"component": {"type": "machine-learning-model", "name": model_name}
},
"components": [
{"type": "framework", "name": "ollama", "version": "latest"},
{"type": "library", "name": "llama.cpp", "version": "unknown"},
{"type": "data", "name": f"{model_name}-weights", "license": "unknown"},
]
}
return sbom

143
routers/n2sf.py Normal file
View File

@ -0,0 +1,143 @@
"""N²SF — 국가 망 보안체계 준수 점검 (국정원 2026 의무)"""
from __future__ import annotations
import json, logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, N2SFAssessment, N2SFZone
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/n2sf", tags=["N²SF"])
# N²SF 3단계 보안 구역 정의
ZONES = {
"A": {"label": "고위험 (개인정보·국가기밀)", "color": "#EF4444",
"requirements": ["완전 망 분리", "AES-256 암호화", "접근 로그 100% 기록",
"2FA 필수", "USB 차단", "화면보호기 5분 이내"]},
"B": {"label": "중위험 (일반 업무 데이터)", "color": "#F59E0B",
"requirements": ["논리적 망 분리", "TLS 1.2 이상", "접근 로그 기록",
"MFA 권고", "바이러스 백신 필수"]},
"C": {"label": "저위험 (공개 정보)", "color": "#10B981",
"requirements": ["기본 방화벽", "HTTPS 적용", "정기 보안 패치"]},
}
# 평가 항목 50개 (축약)
CHECKLIST_ITEMS = [
{"id": "N001", "zone": "A", "category": "망 분리", "item": "개인정보 처리 시스템 물리적 망 분리"},
{"id": "N002", "zone": "A", "category": "접근 통제", "item": "특권 계정 2FA 인증 적용"},
{"id": "N003", "zone": "A", "category": "암호화", "item": "저장 데이터 AES-256 이상 암호화"},
{"id": "N004", "zone": "A", "category": "감사 로그", "item": "접근 로그 1년 이상 보존"},
{"id": "N005", "zone": "A", "category": "미디어 통제", "item": "USB 등 이동식 미디어 차단"},
{"id": "N006", "zone": "B", "category": "망 분리", "item": "업무 시스템 논리적 네트워크 분리"},
{"id": "N007", "zone": "B", "category": "암호화", "item": "전송 데이터 TLS 1.2 이상"},
{"id": "N008", "zone": "B", "category": "접근 통제", "item": "최소 권한 원칙 적용"},
{"id": "N009", "zone": "B", "category": "보안 패치", "item": "OS·미들웨어 정기 패치 (30일 이내)"},
{"id": "N010", "zone": "B", "category": "바이러스 백신", "item": "모든 단말 백신 설치 및 최신화"},
{"id": "N011", "zone": "C", "category": "방화벽", "item": "불필요 포트 차단"},
{"id": "N012", "zone": "C", "category": "웹 보안", "item": "HTTPS 적용 및 인증서 유효"},
{"id": "N013", "zone": "C", "category": "취약점", "item": "정기 취약점 점검 (분기 1회)"},
]
class ZoneClassify(BaseModel):
system_name: str
handles_personal_info: bool = False
handles_classified: bool = False
internet_facing: bool = False
class AssessmentCreate(BaseModel):
system_name: str
zone: str # A|B|C
checked_items: list = [] # ["N001", "N002", ...]
notes: str = ""
@router.get("/policies")
async def list_policies(user: User = Depends(get_current_user)):
return [{"zone": z, **info} for z, info in ZONES.items()]
@router.post("/classify")
async def classify_zone(body: ZoneClassify, user: User = Depends(get_current_user)):
"""시스템 특성에 따른 N²SF 구역 자동 분류."""
if body.handles_classified or body.handles_personal_info:
zone = "A"
elif not body.internet_facing:
zone = "B"
else:
zone = "C"
return {
"system": body.system_name,
"recommended_zone": zone,
"zone_label": ZONES[zone]["label"],
"requirements": ZONES[zone]["requirements"],
}
@router.get("/checklist")
async def get_checklist(zone: Optional[str] = None, user: User = Depends(get_current_user)):
items = CHECKLIST_ITEMS
if zone:
items = [i for i in items if i["zone"] == zone.upper()]
return {"total": len(items), "items": items}
@router.post("/assess")
async def create_assessment(body: AssessmentCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
zone_items = [i for i in CHECKLIST_ITEMS if i["zone"] == body.zone]
total = len(zone_items)
passed = len([i for i in zone_items if i["id"] in body.checked_items])
score = round(passed / total * 100) if total > 0 else 0
assessment = N2SFAssessment(
system_name=body.system_name, zone=body.zone,
total_items=total, passed_items=passed, score=score,
checked_items=json.dumps(body.checked_items),
notes=body.notes, assessed_by=user.id, created_at=datetime.utcnow()
)
db.add(assessment); await db.commit(); await db.refresh(assessment)
return {"assessment_id": assessment.id, "score": score, "passed": passed, "total": total,
"grade": "적합" if score >= 80 else "보완 필요" if score >= 60 else "부적합"}
@router.get("/report")
async def get_report(limit: int = 10, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(N2SFAssessment).order_by(desc(N2SFAssessment.created_at)).limit(limit)
)
assessments = rows.scalars().all()
return [{"id": a.id, "system_name": a.system_name, "zone": a.zone,
"score": a.score, "passed": a.passed_items, "total": a.total_items,
"grade": "적합" if a.score >= 80 else "보완 필요" if a.score >= 60 else "부적합",
"created_at": a.created_at}
for a in assessments]
@router.get("/zones")
async def list_zones(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(N2SFZone).order_by(N2SFZone.zone))
zones = rows.scalars().all()
if not zones:
return [{"zone": z, "label": info["label"], "system_count": 0} for z, info in ZONES.items()]
return [{"zone": z.zone, "label": ZONES.get(z.zone, {}).get("label", ""), "system_count": 0}
for z in zones]
@router.get("/violations")
async def list_violations(db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
rows = await db.execute(
select(N2SFAssessment).where(N2SFAssessment.score < 60).order_by(desc(N2SFAssessment.created_at))
)
violations = rows.scalars().all()
return [{"system": v.system_name, "zone": v.zone, "score": v.score,
"created_at": v.created_at} for v in violations]

125
routers/otel_tracing.py Normal file
View File

@ -0,0 +1,125 @@
"""OpenTelemetry 분산 트레이싱 수집·조회"""
from __future__ import annotations
import json, logging
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, OtelTrace, OtelSpan
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tracing", tags=["분산 트레이싱"])
class SpanIn(BaseModel):
trace_id: str; span_id: str; parent_span_id: Optional[str] = None
service: str; operation: str
start_time: int; end_time: int # epoch ms
status: str = "OK"; attributes: dict = {}; events: List[dict] = []
class OtlpIngest(BaseModel):
spans: List[SpanIn]
@router.post("/ingest", status_code=202)
async def ingest_spans(body: OtlpIngest, db: AsyncSession = Depends(get_db)):
"""OTLP HTTP 수집 엔드포인트 (인증 불필요 — 내부망 전용)."""
new_traces: set = set()
for sp in body.spans:
if sp.trace_id not in new_traces:
existing = await db.execute(select(OtelTrace).where(OtelTrace.trace_id == sp.trace_id))
if not existing.scalar_one_or_none():
db.add(OtelTrace(trace_id=sp.trace_id, service=sp.service,
start_time=datetime.utcfromtimestamp(sp.start_time / 1000),
created_at=datetime.utcnow()))
new_traces.add(sp.trace_id)
db.add(OtelSpan(
trace_id=sp.trace_id, span_id=sp.span_id, parent_span_id=sp.parent_span_id,
service=sp.service, operation=sp.operation,
start_time=datetime.utcfromtimestamp(sp.start_time / 1000),
end_time=datetime.utcfromtimestamp(sp.end_time / 1000),
duration_ms=sp.end_time - sp.start_time,
status=sp.status, attributes=json.dumps(sp.attributes),
))
await db.commit()
return {"ok": True, "ingested": len(body.spans)}
@router.get("/traces")
async def list_traces(
service: Optional[str] = None,
limit: int = Query(50, le=200),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
q = select(OtelTrace).order_by(desc(OtelTrace.start_time)).limit(limit)
if service:
q = q.where(OtelTrace.service == service)
rows = await db.execute(q)
traces = rows.scalars().all()
return [{"trace_id": t.trace_id, "service": t.service, "start_time": t.start_time} for t in traces]
@router.get("/traces/{trace_id}")
async def get_trace(trace_id: str, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
spans = (await db.execute(
select(OtelSpan).where(OtelSpan.trace_id == trace_id).order_by(OtelSpan.start_time)
)).scalars().all()
if not spans:
raise HTTPException(404)
return {
"trace_id": trace_id,
"spans": [{
"span_id": s.span_id, "parent_span_id": s.parent_span_id,
"service": s.service, "operation": s.operation,
"start_time": s.start_time, "end_time": s.end_time,
"duration_ms": s.duration_ms, "status": s.status,
} for s in spans]
}
@router.get("/services")
async def list_services(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(OtelTrace.service).distinct())
return [r[0] for r in rows.all()]
@router.get("/deps")
async def service_deps(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""서비스 간 호출 의존성 맵."""
rows = await db.execute(
select(OtelSpan.service, OtelSpan.operation)
.where(OtelSpan.parent_span_id.isnot(None))
.distinct().limit(100)
)
edges = [{"from": r[0], "call": r[1]} for r in rows.all()]
return {"edges": edges}
class TraceSearch(BaseModel):
service: Optional[str] = None; operation: Optional[str] = None
min_duration_ms: Optional[int] = None; status: Optional[str] = None
@router.post("/search")
async def search_traces(body: TraceSearch, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
q = select(OtelSpan).order_by(desc(OtelSpan.start_time)).limit(100)
if body.service:
q = q.where(OtelSpan.service == body.service)
if body.operation:
q = q.where(OtelSpan.operation.contains(body.operation))
if body.min_duration_ms:
q = q.where(OtelSpan.duration_ms >= body.min_duration_ms)
if body.status:
q = q.where(OtelSpan.status == body.status)
rows = await db.execute(q)
return [{"trace_id": s.trace_id, "span_id": s.span_id, "service": s.service,
"operation": s.operation, "duration_ms": s.duration_ms, "status": s.status}
for s in rows.scalars().all()]

152
routers/sbom.py Normal file
View File

@ -0,0 +1,152 @@
"""SBOM — Software Bill of Materials (CycloneDX/SPDX) 생성·관리"""
from __future__ import annotations
import json, logging, uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, SBOMRecord, SBOMComponent
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/sbom", tags=["SBOM"])
async def _ssh_scan_packages(server_id: int, db) -> list:
"""에이전트리스 SSH로 서버 패키지 목록 수집."""
# 실제 구현: paramiko SSH 실행
# 여기서는 샘플 데이터 반환
return [
{"name": "python3", "version": "3.11.0", "type": "runtime"},
{"name": "fastapi", "version": "0.100.0", "type": "library", "ecosystem": "pypi"},
{"name": "sqlalchemy", "version": "2.0.0", "type": "library", "ecosystem": "pypi"},
{"name": "nginx", "version": "1.24.0", "type": "system", "ecosystem": "apt"},
{"name": "postgresql", "version": "16.0", "type": "system", "ecosystem": "apt"},
]
def _build_cyclonedx(sbom: SBOMRecord, components: list) -> dict:
return {
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": f"urn:uuid:{uuid.uuid4()}",
"version": 1,
"metadata": {
"timestamp": datetime.utcnow().isoformat() + "Z",
"tools": [{"vendor": "GUARDiA", "name": "sbom-scanner", "version": "1.0"}],
"component": {"type": "container", "name": f"server-{sbom.server_id}",
"version": "1.0", "bom-ref": f"server-{sbom.server_id}"}
},
"components": [
{"type": "library", "name": c["name"], "version": c["version"],
"purl": f"pkg:{c.get('ecosystem','generic')}/{c['name']}@{c['version']}"}
for c in components
]
}
class VEXStatement(BaseModel):
sbom_id: int; component_name: str; cve_id: str
status: str # not_affected | affected | fixed | under_investigation
justification: str = ""
@router.post("/generate")
async def generate_sbom(server_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
packages = await _ssh_scan_packages(server_id, db)
record = SBOMRecord(
server_id=server_id, format="CycloneDX", spec_version="1.4",
component_count=len(packages), status="COMPLETED",
generated_by=user.id, created_at=datetime.utcnow()
)
db.add(record); await db.commit(); await db.refresh(record)
for pkg in packages:
db.add(SBOMComponent(
sbom_id=record.id, name=pkg["name"], version=pkg["version"],
component_type=pkg.get("type", "library"), ecosystem=pkg.get("ecosystem", ""),
purl=f"pkg:{pkg.get('ecosystem','generic')}/{pkg['name']}@{pkg['version']}"
))
await db.commit()
return {"sbom_id": record.id, "server_id": server_id, "component_count": len(packages)}
@router.get("/list")
async def list_sboms(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(SBOMRecord).order_by(desc(SBOMRecord.created_at)).limit(50))
return [{"id":s.id,"server_id":s.server_id,"format":s.format,
"component_count":s.component_count,"status":s.status,"created_at":s.created_at}
for s in rows.scalars().all()]
@router.get("/{sbom_id}")
async def get_sbom(sbom_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(SBOMRecord).where(SBOMRecord.id == sbom_id))
sbom = row.scalar_one_or_none()
if not sbom: raise HTTPException(404)
comp_rows = await db.execute(select(SBOMComponent).where(SBOMComponent.sbom_id == sbom_id))
comps = comp_rows.scalars().all()
return {
"id": sbom.id, "server_id": sbom.server_id, "format": sbom.format,
"created_at": sbom.created_at,
"components": [{"name":c.name,"version":c.version,"purl":c.purl,"ecosystem":c.ecosystem}
for c in comps]
}
@router.get("/{sbom_id}/export")
async def export_sbom(sbom_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(SBOMRecord).where(SBOMRecord.id == sbom_id))
sbom = row.scalar_one_or_none()
if not sbom: raise HTTPException(404)
comp_rows = await db.execute(select(SBOMComponent).where(SBOMComponent.sbom_id == sbom_id))
comps = [{"name":c.name,"version":c.version,"type":c.component_type,"ecosystem":c.ecosystem}
for c in comp_rows.scalars().all()]
cyclonedx = _build_cyclonedx(sbom, comps)
return Response(content=json.dumps(cyclonedx, ensure_ascii=False, indent=2),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename=sbom-{sbom_id}.cdx.json"})
@router.post("/{sbom_id}/scan")
async def scan_vulnerabilities(sbom_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""SBOM 컴포넌트 → CVE 매핑 (vuln_scan.py 연동)."""
comp_rows = await db.execute(select(SBOMComponent).where(SBOMComponent.sbom_id == sbom_id))
comps = comp_rows.scalars().all()
vuln_count = 0
for c in comps:
# 실제: NVD/KISA CVE DB 조회
if c.name in ("python3", "nginx", "openssl"):
c.cve_ids = '["CVE-2024-SAMPLE"]'
vuln_count += 1
await db.commit()
return {"sbom_id": sbom_id, "scanned": len(comps), "vulnerable": vuln_count}
@router.get("/dashboard")
async def sbom_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total = (await db.execute(
__import__('sqlalchemy', fromlist=['func']).func.count(SBOMRecord.id)
if False else select(__import__('sqlalchemy', fromlist=['func']).func.count(SBOMRecord.id))
)).scalar() or 0
return {"total_sboms": total, "format_breakdown": {"CycloneDX": total}}
@router.post("/vex")
async def create_vex(body: VEXStatement, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
return {
"ok": True, "vex": {
"sbom_id": body.sbom_id, "component": body.component_name,
"cve": body.cve_id, "status": body.status,
"justification": body.justification,
"timestamp": datetime.utcnow().isoformat()
}
}

156
routers/ztna.py Normal file
View File

@ -0,0 +1,156 @@
"""Zero Trust Network Access — 정책 엔진 + 디바이스 상태 검증"""
from __future__ import annotations
import json, logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, ZTNAPolicy, ZTNADevice, ZTNAViolation
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ztna", tags=["Zero Trust"])
class PolicyCreate(BaseModel):
name: str; resource: str; allowed_roles: list = []
require_mfa: bool = True; min_trust_score: int = 70
allowed_ips: list = []; require_device_compliant: bool = True
is_active: bool = True
class DeviceRegister(BaseModel):
device_name: str; device_type: str # PC|MOBILE|SERVER
os_name: str; os_version: str
antivirus_active: bool = False; disk_encrypted: bool = False
last_patch_days: int = 999 # 마지막 패치 경과일
class VerifyRequest(BaseModel):
user_id: int; device_id: Optional[int] = None
resource: str; source_ip: Optional[str] = None
mfa_verified: bool = False
def _calc_trust_score(device: Optional[ZTNADevice], mfa: bool, source_ip: str) -> int:
score = 50
if mfa:
score += 20
if device:
if device.antivirus_active:
score += 10
if device.disk_encrypted:
score += 10
if device.last_patch_days <= 30:
score += 10
elif device.last_patch_days > 90:
score -= 15
return max(0, min(100, score))
@router.get("/policies")
async def list_policies(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(ZTNAPolicy).order_by(ZTNAPolicy.name))
return [{"id":p.id,"name":p.name,"resource":p.resource,"min_trust_score":p.min_trust_score,
"require_mfa":p.require_mfa,"is_active":p.is_active}
for p in rows.scalars().all()]
@router.post("/policies", status_code=201)
async def create_policy(body: PolicyCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
p = ZTNAPolicy(**body.model_dump(), created_by=user.id, created_at=datetime.utcnow())
db.add(p); await db.commit(); await db.refresh(p)
return {"id": p.id}
@router.put("/policies/{pid}")
async def update_policy(pid: int, body: PolicyCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
row = await db.execute(select(ZTNAPolicy).where(ZTNAPolicy.id == pid))
p = row.scalar_one_or_none()
if not p: raise HTTPException(404)
for k, v in body.model_dump().items(): setattr(p, k, v)
await db.commit(); return {"ok": True}
@router.post("/verify")
async def verify_access(body: VerifyRequest, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""접속 요청 검증 — Zero Trust 결정 엔진."""
# 해당 리소스의 정책 조회
row = await db.execute(
select(ZTNAPolicy).where(ZTNAPolicy.resource == body.resource, ZTNAPolicy.is_active == True)
)
policy = row.scalar_one_or_none()
if not policy:
return {"allowed": True, "reason": "정책 없음 — 기본 허용", "trust_score": 50}
device = None
if body.device_id:
dr = await db.execute(select(ZTNADevice).where(ZTNADevice.id == body.device_id))
device = dr.scalar_one_or_none()
trust_score = _calc_trust_score(device, body.mfa_verified, body.source_ip or "")
reasons = []
if policy.require_mfa and not body.mfa_verified:
reasons.append("MFA 미인증")
if trust_score < policy.min_trust_score:
reasons.append(f"신뢰 점수 부족 ({trust_score}/{policy.min_trust_score})")
if policy.require_device_compliant and not device:
reasons.append("등록된 디바이스 없음")
allowed = len(reasons) == 0
if not allowed:
db.add(ZTNAViolation(
user_id=body.user_id, resource=body.resource,
reason=", ".join(reasons), source_ip=body.source_ip,
trust_score=trust_score, created_at=datetime.utcnow()
))
await db.commit()
return {"allowed": allowed, "trust_score": trust_score,
"reasons": reasons, "policy": policy.name}
@router.get("/devices")
async def list_devices(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(ZTNADevice).order_by(desc(ZTNADevice.registered_at)))
return [{"id":d.id,"device_name":d.device_name,"device_type":d.device_type,
"os_name":d.os_name,"compliant":d.is_compliant} for d in rows.scalars().all()]
@router.post("/devices/register", status_code=201)
async def register_device(body: DeviceRegister, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
is_compliant = (body.antivirus_active and body.disk_encrypted and body.last_patch_days <= 90)
d = ZTNADevice(
**body.model_dump(), is_compliant=is_compliant,
registered_by=user.id, registered_at=datetime.utcnow()
)
db.add(d); await db.commit(); await db.refresh(d)
return {"id": d.id, "is_compliant": is_compliant}
@router.get("/violations")
async def list_violations(limit: int = 50, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
rows = await db.execute(select(ZTNAViolation).order_by(desc(ZTNAViolation.created_at)).limit(limit))
return [{"id":v.id,"user_id":v.user_id,"resource":v.resource,"reason":v.reason,
"trust_score":v.trust_score,"created_at":v.created_at}
for v in rows.scalars().all()]
@router.get("/segments")
async def list_segments(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""마이크로세그멘테이션 구성 (정책 기반)."""
rows = await db.execute(select(ZTNAPolicy).where(ZTNAPolicy.is_active == True))
policies = rows.scalars().all()
return [{"segment": p.resource, "min_trust": p.min_trust_score,
"require_mfa": p.require_mfa, "require_device": p.require_device_compliant}
for p in policies]

View File

@ -367,6 +367,20 @@ function renderCurrentView() {
else if (currentView === "batch_ssh") renderBatchSsh();
else if (currentView === "asset_qr") renderAssetQr();
else if (currentView === "notification_rules") renderNotificationRules();
// ── GUARDiA 차세대 확장 뷰 ──
else if (currentView === "agentic_aiops") renderAgenticAiops();
else if (currentView === "auto_remediation_v2") renderAutoRemediation();
else if (currentView === "otel_tracing") renderOtelTracing();
else if (currentView === "mlsecops") renderMlsecops();
else if (currentView === "ztna") renderZtna();
else if (currentView === "sbom") renderSbom();
else if (currentView === "n2sf") renderN2sf();
else if (currentView === "idp_catalog") renderIdpCatalog();
else if (currentView === "idp_template") renderIdpTemplate();
else if (currentView === "idp_portal") renderIdpPortal();
else if (currentView === "greenops") renderGreenops();
else if (currentView === "edge_monitor") renderEdgeMonitor();
else if (currentView === "energy_optimizer") renderEnergyOptimizer();
// ── GUARDiA 확장 v3 뷰 ──
else loadExpansionView(currentView);
}
@ -3946,3 +3960,497 @@ async function deleteNotifyRule(id) {
await fetch(`/api/notify/rules/${id}`, {method:"DELETE", headers:{Authorization:`Bearer ${t}`}});
loadNotifyRules();
}
// ══════════════════════════════════════════════════════════════════════════════
// ── GUARDiA 차세대 확장 뷰 — AIOps 2.0 / Zero Trust / IDP / GreenOps+Edge
// ══════════════════════════════════════════════════════════════════════════════
function _nextCard(title, icon, content) {
return `<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:20px;margin-bottom:14px">
<h3 style="margin:0 0 12px;font-size:15px;font-weight:700">${icon} ${title}</h3>${content}</div>`;
}
// ── AIOps 2.0 ─────────────────────────────────────────────────────────────────
function renderAgenticAiops() {
document.getElementById("content").innerHTML = `
<h2>🤖 에이전트 태스크 실행 (AIOps 2.0)</h2>
<p style="color:#64748b;margin-bottom:16px">Ollama 기반 tool-calling 멀티에이전트 태스크를 입력하면 에이전트가 도구를 선택·실행합니다.</p>
${_nextCard("태스크 실행","⚡",`
<textarea id="agent-task" class="form-control" rows="2" placeholder="예: server-1 CPU 90% 원인 분석 후 해결해줘"></textarea>
<button class="btn btn-primary" style="margin-top:8px" onclick="runAgentTask()">🤖 실행</button>
`)}
${_nextCard("실행 이력","📋",`<div id="agent-runs-list">로딩 중...</div>`)}`;
loadAgentRuns();
}
async function runAgentTask() {
const task = document.getElementById("agent-task").value;
if (!task) return showToast("태스크를 입력하세요","error");
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/agent/run", {method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({task})});
const d = await r.json();
showToast(`에이전트 실행 시작 (ID: ${d.run_id})`,"success");
loadAgentRuns();
}
async function loadAgentRuns() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/agent/runs",{headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]}));
const runs = await r.json();
const el = document.getElementById("agent-runs-list");
if(!el) return;
if(!runs.length){el.innerHTML="<p style='color:#94a3b8;text-align:center;padding:12px'>실행 이력 없음</p>";return;}
el.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid #e2e8f0">${["ID","태스크","상태","실행일"].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join("")}</tr></thead>
<tbody>${runs.map(r=>`<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px;font-weight:600">${r.id}</td>
<td style="padding:8px">${(r.task||"").substring(0,60)}</td>
<td style="padding:8px"><span style="padding:2px 8px;border-radius:8px;font-size:11px;background:${r.status==="DONE"?"#dcfce7":"#fef3c7"};color:${r.status==="DONE"?"#166534":"#92400e"}">${r.status}</span></td>
<td style="padding:8px;color:#64748b;font-size:11px">${r.created_at?new Date(r.created_at).toLocaleString("ko-KR"):"-"}</td>
</tr>`).join("")}</tbody></table>`;
}
function renderAutoRemediation() {
document.getElementById("content").innerHTML = `
<h2>🔧 자율 교정 루프</h2>
<p style="color:#64748b;margin-bottom:16px">이상 감지 Ollama 진단 자동 교정(안전) 또는 승인 요청(위험) 자동화 루프</p>
${_nextCard("수동 트리거","⚡",`
<input id="remediation-data" class="form-control" placeholder='{"cpu":95,"server":"server-1"}' style="margin-bottom:8px">
<button class="btn btn-primary" onclick="triggerRemediation()">교정 트리거</button>
`)}
${_nextCard("승인 대기","⏳",`<div id="remediation-pending">로딩 중...</div>`)}
${_nextCard("교정 이력","📋",`<div id="remediation-history">로딩 중...</div>`)}`;
loadRemediationData();
}
async function triggerRemediation() {
const data = document.getElementById("remediation-data").value;
let trigger_data={};
try{trigger_data=JSON.parse(data);}catch(e){}
const t=localStorage.getItem("token")||"";
await fetch("/api/remediation/trigger",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({trigger_data})});
showToast("교정 트리거됨","success"); loadRemediationData();
}
async function loadRemediationData() {
const t=localStorage.getItem("token")||"";
const [p,h]=await Promise.all([
fetch("/api/remediation/pending",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
fetch("/api/remediation/history?limit=10",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const pe=document.getElementById("remediation-pending"), he=document.getElementById("remediation-history");
if(pe) pe.innerHTML=p.length?p.map(i=>`<div style="border:1px solid #fef3c7;border-radius:8px;padding:12px;margin-bottom:8px">
<div style="font-weight:600">${i.diagnosis||"진단 중..."}</div>
<div style="font-size:12px;color:#64748b;margin-top:4px">조치: ${i.action_taken||"결정 중"}</div>
<button onclick="approveRemediation(${i.id})" class="btn btn-primary btn-sm" style="margin-top:8px">승인</button>
</div>`).join(""):"<p style='color:#94a3b8;padding:12px;text-align:center'> </p>";
if(he) he.innerHTML=h.map(i=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
<span style="padding:2px 8px;border-radius:8px;font-size:11px;background:${i.status==="AUTO_FIXED"?"#dcfce7":"#fef3c7"};color:${i.status==="AUTO_FIXED"?"#166534":"#92400e"}">${i.status}</span>
<span style="margin-left:8px">${i.diagnosis||"-"}</span>
</div>`).join("") || "<p style='color:#94a3b8;padding:12px;text-align:center'> </p>";
}
async function approveRemediation(id) {
const t=localStorage.getItem("token")||"";
await fetch(`/api/remediation/approve/${id}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}});
showToast("승인됨","success"); loadRemediationData();
}
function renderOtelTracing() {
document.getElementById("content").innerHTML = `
<h2>📡 분산 트레이싱 (OpenTelemetry)</h2>
<p style="color:#64748b;margin-bottom:16px">OTLP HTTP로 스팬을 수집하고 서비스 호출 흐름을 시각화합니다.</p>
${_nextCard("최근 트레이스","🔍",`<div id="otel-traces">로딩 중...</div>`)}
${_nextCard("서비스 목록","🗂️",`<div id="otel-services">로딩 중...</div>`)}`;
loadOtelData();
}
async function loadOtelData() {
const t=localStorage.getItem("token")||"";
const [traces,svcs]=await Promise.all([
fetch("/api/tracing/traces?limit=10",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
fetch("/api/tracing/services",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const te=document.getElementById("otel-traces"), se=document.getElementById("otel-services");
if(te) te.innerHTML=traces.length?traces.map(tr=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px;font-family:monospace">${tr.trace_id.substring(0,16)}... <span style="color:#64748b">${tr.service}</span></div>`).join(""):"<p style='color:#94a3b8;padding:12px'>트레이스 없음 — 앱에서 /api/tracing/ingest 로 스팬을 전송하세요</p>";
if(se) se.innerHTML=svcs.length?svcs.map(s=>`<span style="padding:4px 10px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:12px;margin:2px;display:inline-block">${s}</span>`).join(""):"<p style='color:#94a3b8;padding:12px'>서비스 없음</p>";
}
function renderMlsecops() {
document.getElementById("content").innerHTML = `
<h2>🔒 AI 모델 보안 (MLSecOps)</h2>
<p style="color:#64748b;margin-bottom:16px">Ollama 모델 무결성·취약점·편향 관리</p>
${_nextCard("설치된 모델","🤖",`<div id="ml-models">로딩 중...</div>`)}`;
loadMlModels();
}
async function loadMlModels() {
const t=localStorage.getItem("token")||"";
const models=await fetch("/api/mlsec/models",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("ml-models");
if(!el) return;
if(!models.length){el.innerHTML="<p style='color:#94a3b8;padding:12px'>모델 없음</p>";return;}
el.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid #e2e8f0">${["모델명","크기","위험도","상태","액션"].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join("")}</tr></thead>
<tbody>${models.map(m=>`<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px;font-family:monospace">${m.name}</td>
<td style="padding:8px">${m.size_gb}GB</td>
<td style="padding:8px"><span style="padding:2px 8px;border-radius:8px;font-size:11px;background:${m.risk==="HIGH"?"#fef2f2":"#dcfce7"};color:${m.risk==="HIGH"?"#dc2626":"#166534"}">${m.risk}</span></td>
<td style="padding:8px;font-size:11px;color:#64748b">${m.vulnerability||"정상"}</td>
<td style="padding:8px"><button onclick="scanMlModel('${m.name}')" style="padding:3px 8px;border:1px solid #e2e8f0;border-radius:4px;font-size:11px;cursor:pointer;background:none">스캔</button></td>
</tr>`).join("")}</tbody></table>`;
}
async function scanMlModel(name) {
const t=localStorage.getItem("token")||"";
const r=await fetch(`/api/mlsec/models/scan?model_name=${encodeURIComponent(name)}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}});
const d=await r.json();
showToast(`${name}: ${d.recommendation}`,"info"); loadMlModels();
}
// ── Zero Trust ────────────────────────────────────────────────────────────────
function renderZtna() {
document.getElementById("content").innerHTML = `
<h2>🔐 ZTNA 정책 관리</h2>
<p style="color:#64748b;margin-bottom:16px">Zero Trust Network Access 리소스별 접근 정책 디바이스 신뢰 점수 관리</p>
${_nextCard("정책 목록","📋",`<div id="ztna-policies">로딩 중...</div>`)}
${_nextCard("최근 위반","⚠️",`<div id="ztna-violations">로딩 중...</div>`)}`;
loadZtnaData();
}
async function loadZtnaData() {
const t=localStorage.getItem("token")||"";
const [policies,violations]=await Promise.all([
fetch("/api/ztna/policies",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
fetch("/api/ztna/violations?limit=5",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const pe=document.getElementById("ztna-policies"), ve=document.getElementById("ztna-violations");
if(pe) pe.innerHTML=policies.length?policies.map(p=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9">
<span style="font-weight:600">${p.name}</span> <span style="color:#64748b">${p.resource}</span>
<span style="margin-left:8px;font-size:11px">신뢰점수 ${p.min_trust_score}</span>
${p.require_mfa?'<span style="margin-left:6px;background:#eff6ff;color:#1d4ed8;font-size:10px;padding:1px 6px;border-radius:8px">MFA</span>':''}
</div>`).join(""):"<p style='color:#94a3b8;padding:12px;text-align:center'> </p>";
if(ve) ve.innerHTML=violations.length?violations.map(v=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
<span style="color:#dc2626"></span> ${v.resource} ${v.reason} <span style="color:#94a3b8;font-size:11px">(:${v.trust_score})</span>
</div>`).join(""):"<p style='color:#94a3b8;padding:12px;text-align:center'> </p>";
}
function renderSbom() {
document.getElementById("content").innerHTML = `
<h2>📦 SBOM 관리 (CycloneDX)</h2>
<p style="color:#64748b;margin-bottom:16px">서버별 소프트웨어 구성 요소 목록 EU CRA/공공 조달 준수</p>
${_nextCard("SBOM 생성","⚙️",`
<input id="sbom-server-id" class="form-control" type="number" placeholder="서버 ID" style="width:150px;display:inline-block">
<button class="btn btn-primary" style="margin-left:8px" onclick="generateSbom()">SBOM 생성</button>
`)}
${_nextCard("SBOM 목록","📋",`<div id="sbom-list">로딩 중...</div>`)}`;
loadSbomList();
}
async function generateSbom() {
const sid=document.getElementById("sbom-server-id").value;
if(!sid) return showToast("서버 ID 입력","error");
const t=localStorage.getItem("token")||"";
showToast("SBOM 생성 중...","info");
const r=await fetch(`/api/sbom/generate?server_id=${sid}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}});
const d=await r.json();
showToast(`SBOM 생성 완료 (ID:${d.sbom_id}, 컴포넌트:${d.component_count}개)`,"success");
loadSbomList();
}
async function loadSbomList() {
const t=localStorage.getItem("token")||"";
const list=await fetch("/api/sbom/list",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("sbom-list");
if(!el) return;
if(!list.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>SBOM 없음</p>";return;}
el.innerHTML=list.map(s=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px;display:flex;justify-content:space-between">
<div>서버 #${s.server_id} <span style="color:#64748b">${s.format}</span> ${s.component_count} </div>
<a href="/api/sbom/${s.id}/export" style="color:#003366;font-size:12px">내보내기 </a>
</div>`).join("");
}
function renderN2sf() {
document.getElementById("content").innerHTML = `
<h2>🛡 N²SF 국가 보안체계</h2>
<p style="color:#64748b;margin-bottom:16px">국정원 N²SF 준수 데이터 민감도별 3단계 보안 구역 (2026 공공기관 의무)</p>
${_nextCard("시스템 분류","🗂️",`
<input id="n2sf-system" class="form-control" placeholder="시스템명" style="width:200px;display:inline-block">
<label style="margin-left:12px"><input type="checkbox" id="n2sf-pi"> 개인정보</label>
<label style="margin-left:8px"><input type="checkbox" id="n2sf-internet"> 인터넷 연결</label>
<button class="btn btn-primary" style="margin-left:8px" onclick="classifyN2sf()">구역 분류</button>
<div id="n2sf-classify-result" style="margin-top:10px"></div>
`)}
${_nextCard("평가 이력","📋",`<div id="n2sf-report">로딩 중...</div>`)}`;
loadN2sfReport();
}
async function classifyN2sf() {
const t=localStorage.getItem("token")||"";
const system=document.getElementById("n2sf-system").value||"미지정";
const pi=document.getElementById("n2sf-pi").checked;
const internet=document.getElementById("n2sf-internet").checked;
const r=await fetch("/api/n2sf/classify",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({system_name:system,handles_personal_info:pi,internet_facing:internet})});
const d=await r.json();
const colors={"A":"#dc2626","B":"#f59e0b","C":"#10b981"};
document.getElementById("n2sf-classify-result").innerHTML=`<div style="padding:10px;border-left:4px solid ${colors[d.recommended_zone]||"#64748b"};background:#f8fafc;border-radius:4px">
<strong>권고 구역: Zone ${d.recommended_zone}</strong> ${d.zone_label}<br>
<span style="font-size:12px;color:#64748b">${d.requirements?.join(" / ")}</span>
</div>`;
}
async function loadN2sfReport() {
const t=localStorage.getItem("token")||"";
const report=await fetch("/api/n2sf/report",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("n2sf-report");
if(!el) return;
if(!report.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>평가 이력 없음</p>";return;}
el.innerHTML=report.map(a=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px">
<strong>${a.system_name}</strong> Zone ${a.zone}
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:11px;background:${a.score>=80?"#dcfce7":a.score>=60?"#fef3c7":"#fef2f2"};color:${a.score>=80?"#166534":a.score>=60?"#92400e":"#dc2626"}">${a.grade}</span>
<span style="margin-left:8px;color:#64748b">${a.passed}/${a.total} (${a.score})</span>
</div>`).join("");
}
// ── IDP ───────────────────────────────────────────────────────────────────────
function renderIdpCatalog() {
document.getElementById("content").innerHTML = `
<h2>🗂 서비스 카탈로그 (IDP)</h2>
<p style="color:#64748b;margin-bottom:16px">Backstage-style 소프트웨어 카탈로그 서비스·컴포넌트·인프라 등록·검색</p>
${_nextCard("등록된 서비스","📋",`<div id="idp-catalog-list">로딩 중...</div>`)}
${_nextCard("서비스 등록","",`
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
<input id="idp-name" class="form-control" placeholder="서비스명">
<input id="idp-lang" class="form-control" placeholder="언어 (python/java/...)">
</div>
<input id="idp-desc" class="form-control" placeholder="설명" style="margin-bottom:8px">
<button class="btn btn-primary" onclick="registerIdpComponent()">등록</button>
`)}`;
loadIdpCatalog();
}
async function loadIdpCatalog() {
const t=localStorage.getItem("token")||"";
const list=await fetch("/api/idp/catalog?limit=20",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("idp-catalog-list");
if(!el) return;
if(!list.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>등록된 서비스 없음</p>";return;}
el.innerHTML=list.map(c=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px">
<strong>${c.display_name||c.name}</strong>
<span style="margin-left:8px;padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:10px">${c.component_type}</span>
${c.language?`<span style="margin-left:4px;color:#64748b;font-size:12px">${c.language}</span>`:""}
${c.lifecycle==="production"?'<span style="margin-left:8px;background:#dcfce7;color:#166534;font-size:10px;padding:1px 6px;border-radius:8px">운영</span>':""}
</div>`).join("");
}
async function registerIdpComponent() {
const t=localStorage.getItem("token")||"";
const name=document.getElementById("idp-name").value;
const language=document.getElementById("idp-lang").value;
const description=document.getElementById("idp-desc").value;
if(!name) return showToast("서비스명 입력","error");
await fetch("/api/idp/catalog",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({name,language,description})});
showToast("등록됨","success"); loadIdpCatalog();
}
function renderIdpTemplate() {
document.getElementById("content").innerHTML = `
<h2>📐 Golden Path 템플릿 (IDP)</h2>
<p style="color:#64748b;margin-bottom:16px">표준화된 서비스 스캐폴딩 내장 4 + 커스텀 템플릿</p>
${_nextCard("템플릿 목록","📋",`<div id="idp-templates">로딩 중...</div>`)}`;
loadIdpTemplates();
}
async function loadIdpTemplates() {
const t=localStorage.getItem("token")||"";
const list=await fetch("/api/idp/template",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("idp-templates");
if(!el) return;
el.innerHTML=list.map(tp=>`<div style="border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:8px">
<div style="font-weight:700;margin-bottom:4px">${tp.name} ${tp.is_builtin?'<span style="background:#eff6ff;color:#1d4ed8;font-size:10px;padding:1px 6px;border-radius:8px">내장</span>':""}</div>
<div style="font-size:12px;color:#64748b;margin-bottom:8px">${tp.description}</div>
<div style="font-size:12px">언어: ${tp.language||"-"} &nbsp;|&nbsp; 변수: ${(tp.variables||[]).join(", ")||"없음"}</div>
<button onclick="previewTemplate('${tp.id}')" style="margin-top:8px;padding:4px 10px;border:1px solid #e2e8f0;border-radius:4px;font-size:12px;cursor:pointer;background:none">미리보기</button>
</div>`).join("");
}
async function previewTemplate(id) {
const t=localStorage.getItem("token")||"";
const d=await fetch(`/api/idp/template/${id}/preview`,{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({}));
showToast(`파일: ${(d.files||[]).join(", ")||"없음"}`, "info");
}
function renderIdpPortal() {
document.getElementById("content").innerHTML = `
<h2>🚀 셀프서비스 포털 (IDP)</h2>
<p style="color:#64748b;margin-bottom:16px">인프라 셀프서비스 SSH ·DB·Jenkins·Gitea 자동 프로비저닝</p>
${_nextCard("리소스 요청","⚡",`
<select id="portal-type" class="form-control" style="width:200px;display:inline-block;margin-right:8px">
<option value="ssh_key">SSH 발급</option>
<option value="db_schema">DB 스키마 생성</option>
<option value="jenkins_job">Jenkins Job 생성</option>
<option value="gitea_repo">Gitea 저장소 생성</option>
</select>
<input id="portal-reason" class="form-control" placeholder="요청 사유" style="width:200px;display:inline-block;margin-right:8px">
<button class="btn btn-primary" onclick="requestResource()">요청</button>
`)}
${_nextCard("요청 이력","📋",`<div id="portal-requests">로딩 중...</div>`)}`;
loadPortalRequests();
}
async function requestResource() {
const t=localStorage.getItem("token")||"";
const type=document.getElementById("portal-type").value;
const reason=document.getElementById("portal-reason").value;
const r=await fetch("/api/idp/portal/provision",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({resource_type:type,justification:reason,params:{}})});
const d=await r.json();
showToast(`요청 #${d.request_id}${d.auto_approved?"자동 승인됨":"승인 대기"}`, d.auto_approved?"success":"info");
loadPortalRequests();
}
async function loadPortalRequests() {
const t=localStorage.getItem("token")||"";
const list=await fetch("/api/idp/portal/requests",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("portal-requests");
if(!el) return;
if(!list.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>요청 이력 없음</p>";return;}
el.innerHTML=list.map(r=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
<strong>${r.resource_type}</strong>
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:11px;background:${r.status==="COMPLETED"?"#dcfce7":"#fef3c7"};color:${r.status==="COMPLETED"?"#166534":"#92400e"}">${r.status}</span>
</div>`).join("");
}
// ── GreenOps + Edge ────────────────────────────────────────────────────────────
function renderGreenops() {
document.getElementById("content").innerHTML = `
<h2>🌱 탄소 배출 대시보드 (GreenOps)</h2>
<p style="color:#64748b;margin-bottom:16px">Scope 2 탄소 배출 추적 EU CSRD / GHG Protocol 준수. 한국 전력망 기준 0.4593 kgCO₂e/kWh</p>
<div id="greenops-summary" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px">로딩 ...</div>
${_nextCard("탄소 기록 추가","📊",`
<div style="display:flex;gap:8px;align-items:flex-end">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:3px">서버 ID</label>
<input id="carbon-server" type="number" class="form-control" style="width:80px"></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:3px">전력(W)</label>
<input id="carbon-watt" type="number" class="form-control" style="width:80px" placeholder="200"></div>
<button class="btn btn-primary" onclick="recordCarbon()">기록</button>
</div>
`)}
${_nextCard("절감 분석","💡",`<div id="greenops-savings">로딩 중...</div>`)}`;
loadGreenopsData();
}
async function loadGreenopsData() {
const t=localStorage.getItem("token")||"";
const [dash,savings]=await Promise.all([
fetch("/api/greenops/dashboard",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
fetch("/api/greenops/savings",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
]);
const se=document.getElementById("greenops-summary");
if(se) se.innerHTML=[
{val:`${dash.total_carbon_kg||0}kg`,lab:"총 탄소 배출",icon:"🌍"},
{val:`${dash.total_carbon_ton||0}ton`,lab:"CO₂e (Scope 2)",icon:"♻️"},
{val:`${dash.grid_factor||0.4593}`,lab:"한국 탄소 계수",icon:"⚡"},
].map(s=>`<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:16px;text-align:center">
<div style="font-size:20px">${s.icon}</div>
<div style="font-size:22px;font-weight:700;color:#003366">${s.val}</div>
<div style="font-size:12px;color:#64748b">${s.lab}</div>
</div>`).join("");
const sve=document.getElementById("greenops-savings");
if(sve) sve.innerHTML=`<div style="font-size:13px">
<div>베이스라인 배출: <strong>${savings.baseline_monthly_kg||0} kg</strong></div>
<div>실제 배출: <strong>${savings.actual_monthly_kg||0} kg</strong></div>
<div style="color:#166534;font-weight:700">절감: ${savings.saving_kg||0} kg (${savings.saving_pct||0}%)</div>
</div>`;
}
async function recordCarbon() {
const t=localStorage.getItem("token")||"";
const server_id=+document.getElementById("carbon-server").value;
const watt=+document.getElementById("carbon-watt").value;
if(!server_id||!watt) return showToast("서버 ID와 전력을 입력하세요","error");
const r=await fetch("/api/greenops/emissions/record",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({server_id,watt})});
const d=await r.json();
showToast(`탄소 기록 완료: ${d.carbon_kg} kgCO₂e`,"success"); loadGreenopsData();
}
function renderEdgeMonitor() {
document.getElementById("content").innerHTML = `
<h2>📡 Edge/IoT 디바이스 모니터링</h2>
<p style="color:#64748b;margin-bottom:16px">엣지 서버·IoT 센서·CCTV·키오스크 모니터링. 텔레메트리 Push 방식 (POST /api/edge/telemetry)</p>
${_nextCard("디바이스 등록","",`
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:8px">
<input id="edge-name" class="form-control" placeholder="디바이스명">
<select id="edge-type" class="form-control">
<option value="SERVER_EDGE">엣지 서버</option>
<option value="IOT_SENSOR">IoT 센서</option>
<option value="CCTV">CCTV</option>
<option value="KIOSK">키오스크</option>
<option value="NETWORK_EDGE">네트워크 장비</option>
</select>
<input id="edge-location" class="form-control" placeholder="위치">
</div>
<button class="btn btn-primary" onclick="registerEdgeDevice()">등록</button>
`)}
${_nextCard("디바이스 목록","🗺️",`<div id="edge-devices">로딩 중...</div>`)}`;
loadEdgeDevices();
}
async function registerEdgeDevice() {
const t=localStorage.getItem("token")||"";
const name=document.getElementById("edge-name").value;
const device_type=document.getElementById("edge-type").value;
const location=document.getElementById("edge-location").value;
if(!name) return showToast("디바이스명 입력","error");
const r=await fetch("/api/edge/devices",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({name,device_type,location})});
const d=await r.json();
showToast(`등록 완료 (토큰: ${d.device_token.substring(0,8)}...)`,"success"); loadEdgeDevices();
}
async function loadEdgeDevices() {
const t=localStorage.getItem("token")||"";
const list=await fetch("/api/edge/devices",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("edge-devices");
if(!el) return;
if(!list.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>디바이스 없음</p>";return;}
el.innerHTML=list.map(d=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px">
<strong>${d.name}</strong>
<span style="margin-left:8px;padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:10px">${d.device_type}</span>
${d.location?`<span style="margin-left:6px;color:#64748b;font-size:12px">${d.location}</span>`:""}
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:10px;background:${d.status==="ONLINE"?"#dcfce7":"#f1f5f9"};color:${d.status==="ONLINE"?"#166534":"#64748b"}">${d.status}</span>
${d.last_seen?`<span style="margin-left:6px;font-size:11px;color:#94a3b8">${new Date(d.last_seen).toLocaleString("ko-KR")}</span>`:""}
</div>`).join("");
}
function renderEnergyOptimizer() {
document.getElementById("content").innerHTML = `
<h2> 에너지 최적화 (Carbon-aware)</h2>
<p style="color:#64748b;margin-bottom:16px">Ollama 기반 에너지 효율 권고 + 탄소 낮은 시간대 배치 스케줄링</p>
${_nextCard("최적화 권고","💡",`
<button class="btn btn-primary" onclick="generateEnergyRecs()" style="margin-bottom:12px">🤖 AI 권고 생성</button>
<div id="energy-recs">로딩 ...</div>
`)}
${_nextCard("Carbon-aware 스케줄","🌱",`
<div style="display:flex;gap:8px;margin-bottom:8px">
<input id="sched-job" class="form-control" placeholder="작업명">
<input id="sched-cmd" class="form-control" placeholder="명령어">
<input id="sched-server" type="number" class="form-control" style="width:80px" placeholder="서버 ID">
<button class="btn btn-primary" onclick="scheduleJob()">예약</button>
</div>
<p style="font-size:12px;color:#64748b">탄소 계수 0.40 시간대 자동 배정 (한국 경부하: 23~08)</p>
`)}`;
loadEnergyRecs();
}
async function generateEnergyRecs() {
const t=localStorage.getItem("token")||"";
await fetch("/api/energy/recommendations/generate",{method:"POST",headers:{Authorization:`Bearer ${t}`}});
showToast("권고 생성 중...","info");
setTimeout(loadEnergyRecs, 2000);
}
async function loadEnergyRecs() {
const t=localStorage.getItem("token")||"";
const recs=await fetch("/api/energy/recommendations",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("energy-recs");
if(!el) return;
if(!recs.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>권고 없음 — AI 권고 생성 버튼 클릭</p>";return;}
el.innerHTML=recs.map(r=>`<div style="border:1px solid #e2e8f0;border-radius:8px;padding:12px;margin-bottom:8px">
<div style="font-weight:600;margin-bottom:4px">${r.rec_type} ${r.description}</div>
<div style="font-size:12px;color:#64748b">절감 예상: ${r.saving_kwh} kWh/</div>
${r.status==="PENDING"?`<button onclick="applyEnergyRec(${r.id})" style="margin-top:8px;padding:4px 10px;background:#003366;color:#fff;border:none;border-radius:4px;font-size:11px;cursor:pointer">적용</button>`:`<span style="color:#166534;font-size:11px">✅ ${r.status}</span>`}
</div>`).join("");
}
async function applyEnergyRec(id) {
const t=localStorage.getItem("token")||"";
await fetch(`/api/energy/apply/${id}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}});
showToast("권고 적용됨","success"); loadEnergyRecs();
}
async function scheduleJob() {
const t=localStorage.getItem("token")||"";
const job_name=document.getElementById("sched-job").value;
const job_command=document.getElementById("sched-cmd").value;
const server_id=+document.getElementById("sched-server").value;
if(!job_name||!server_id) return showToast("작업명과 서버 ID 입력","error");
const r=await fetch("/api/energy/schedule",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({job_name,job_command,server_id})});
const d=await r.json();
showToast(`스케줄 등록: ${d.preferred_hour}시 (탄소 ${d.carbon_factor} kgCO₂e/kWh)`,"success");
}

View File

@ -211,6 +211,54 @@
<div class="nav-sub-item" data-view="white_label">브랜딩 설정</div>
</div>
<!-- ── GUARDiA 차세대 확장 ───────────────────── -->
<div class="nav-separator"></div>
<!-- AIOps 2.0 -->
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
<span class="nav-icon">🤖</span><span>AIOps 2.0</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div>
<div class="nav-group-body" role="group">
<div class="nav-sub-item" data-view="agentic_aiops">에이전트 태스크 실행</div>
<div class="nav-sub-item" data-view="auto_remediation_v2">자율 교정 루프</div>
<div class="nav-sub-item" data-view="otel_tracing">분산 트레이싱 (OTLP)</div>
<div class="nav-sub-item" data-view="mlsecops">AI 모델 보안</div>
</div>
<!-- Zero Trust + Supply Chain -->
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
<span class="nav-icon">🔐</span><span>Zero Trust + SBOM</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div>
<div class="nav-group-body" role="group">
<div class="nav-sub-item" data-view="ztna">ZTNA 정책 관리</div>
<div class="nav-sub-item" data-view="sbom">SBOM 생성·관리</div>
<div class="nav-sub-item" data-view="n2sf">N²SF 보안체계</div>
</div>
<!-- IDP -->
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
<span class="nav-icon">🏗️</span><span>개발자 플랫폼 (IDP)</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div>
<div class="nav-group-body" role="group">
<div class="nav-sub-item" data-view="idp_catalog">서비스 카탈로그</div>
<div class="nav-sub-item" data-view="idp_template">Golden Path 템플릿</div>
<div class="nav-sub-item" data-view="idp_portal">셀프서비스 포털</div>
</div>
<!-- GreenOps + Edge -->
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
<span class="nav-icon">🌱</span><span>GreenOps + Edge/IoT</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div>
<div class="nav-group-body" role="group">
<div class="nav-sub-item" data-view="greenops">탄소 대시보드</div>
<div class="nav-sub-item" data-view="edge_monitor">Edge/IoT 디바이스</div>
<div class="nav-sub-item" data-view="energy_optimizer">에너지 최적화</div>
</div>
<!-- ── GUARDiA 기능 개선 v4 ─────────────────── -->
<div class="nav-separator"></div>