diff --git a/itsm/core/__init__.py b/itsm/core/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/itsm/core/seed.py b/itsm/core/seed.py
new file mode 100644
index 00000000..2b9afa48
--- /dev/null
+++ b/itsm/core/seed.py
@@ -0,0 +1,205 @@
+"""Seed dummy data for GUARDiA ITSM demo."""
+import base64
+import os
+from datetime import datetime, timedelta
+from uuid import uuid4
+
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from models import (
+ ApprovalFlow, ApprovalResult, AuditLog, Institution, OpsTask,
+ Priority, SRRequest, SRStatus, SRType, Server, compute_log_hash
+)
+
+
+def _encrypt_pw(plain: str) -> str:
+ """AES-256-GCM encrypt. Key from env (demo: fixed 32-byte key)."""
+ key = os.environ.get("GUARDIA_ENC_KEY", "guardia-demo-key-32bytes-padding!").encode()[:32]
+ key = key.ljust(32, b"\x00")
+ aesgcm = AESGCM(key)
+ nonce = os.urandom(12)
+ ct = aesgcm.encrypt(nonce, plain.encode(), None)
+ return base64.b64encode(nonce + ct).decode()
+
+
+def _new_sr() -> str:
+ return f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
+
+
+async def seed_all(db: AsyncSession) -> None:
+ from sqlalchemy import select
+ result = await db.execute(select(Institution))
+ if result.scalars().first():
+ return # already seeded
+
+ # ── Institutions ────────────────────────────────────────────
+ inst_data = [
+ {"inst_code": "MOF", "inst_name": "기획재정부", "org_type": "중앙행정기관", "contact_pm": "김PM"},
+ {"inst_code": "MOIS", "inst_name": "행정안전부", "org_type": "중앙행정기관", "contact_pm": "이PM"},
+ {"inst_code": "MSS", "inst_name": "중소벤처기업부", "org_type": "중앙행정기관", "contact_pm": "박PM"},
+ ]
+ institutions = []
+ for d in inst_data:
+ inst = Institution(**d)
+ db.add(inst)
+ institutions.append(inst)
+ await db.flush()
+
+ # ── Servers ─────────────────────────────────────────────────
+ server_data = [
+ # MOF
+ {"inst": institutions[0], "server_name": "MOF-WEB-01", "server_role": "WEB",
+ "os_type": "RHEL", "os_version": "8.9", "ip_addr": "10.10.1.11",
+ "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22},
+ {"inst": institutions[0], "server_name": "MOF-WAS-01", "server_role": "WAS",
+ "os_type": "RHEL", "os_version": "8.9", "ip_addr": "10.10.1.21",
+ "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22},
+ {"inst": institutions[0], "server_name": "MOF-DB-01", "server_role": "DB",
+ "os_type": "RHEL", "os_version": "8.9", "ip_addr": "10.10.1.31",
+ "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22},
+ # MOIS
+ {"inst": institutions[1], "server_name": "MOIS-WEB-01", "server_role": "WEB",
+ "os_type": "CentOS", "os_version": "7.9", "ip_addr": "10.20.1.11",
+ "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22},
+ {"inst": institutions[1], "server_name": "MOIS-WAS-01", "server_role": "WAS",
+ "os_type": "CentOS", "os_version": "7.9", "ip_addr": "10.20.1.21",
+ "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22},
+ # MSS
+ {"inst": institutions[2], "server_name": "MSS-WAS-01", "server_role": "WAS",
+ "os_type": "Ubuntu", "os_version": "22.04", "ip_addr": "10.30.1.21",
+ "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22},
+ ]
+ for sd in server_data:
+ inst = sd.pop("inst")
+ srv = Server(inst_id=inst.id, **sd)
+ db.add(srv)
+ await db.flush()
+
+ # ── SR Requests ─────────────────────────────────────────────
+ now = datetime.now()
+ sr_data = [
+ {
+ "sr_id": "SR-20260524-AA1B2C", "inst": institutions[0],
+ "sr_type": SRType.DEPLOY, "title": "기재부 예산시스템 WAS 배포",
+ "description": "2026년 2차 추경 예산시스템 class 파일 배포",
+ "status": SRStatus.COMPLETED, "priority": Priority.HIGH,
+ "requested_by": "홍길동", "assigned_to": "운영팀",
+ "target_server": "MOF-WAS-01",
+ "created_at": now - timedelta(days=3),
+ "updated_at": now - timedelta(days=2),
+ },
+ {
+ "sr_id": "SR-20260524-BB3C4D", "inst": institutions[0],
+ "sr_type": SRType.RESTART, "title": "기재부 WAS-02 재기동 요청",
+ "description": "OutOfMemoryError 발생으로 WAS 재기동 필요",
+ "status": SRStatus.PENDING_APPROVAL, "priority": Priority.CRITICAL,
+ "requested_by": "김운영", "assigned_to": "운영팀",
+ "target_server": "MOF-WAS-01",
+ "created_at": now - timedelta(hours=2),
+ "updated_at": now - timedelta(hours=1),
+ },
+ {
+ "sr_id": "SR-20260524-CC5D6E", "inst": institutions[1],
+ "sr_type": SRType.DEPLOY, "title": "행안부 민원포털 정적파일 배포",
+ "description": "UI 개선 HTML/JS/CSS 정적 파일 배포",
+ "status": SRStatus.IN_PROGRESS, "priority": Priority.MEDIUM,
+ "requested_by": "이배포", "assigned_to": "운영팀",
+ "target_server": "MOIS-WEB-01",
+ "created_at": now - timedelta(hours=5),
+ "updated_at": now - timedelta(minutes=30),
+ },
+ {
+ "sr_id": "SR-20260524-DD7E8F", "inst": institutions[2],
+ "sr_type": SRType.LOG, "title": "중기부 WAS 에러 로그 분석",
+ "description": "Connection pool exhausted 오류 원인 분석 요청",
+ "status": SRStatus.RECEIVED, "priority": Priority.MEDIUM,
+ "requested_by": "박운영", "assigned_to": None,
+ "target_server": "MSS-WAS-01",
+ "created_at": now - timedelta(minutes=20),
+ "updated_at": now - timedelta(minutes=20),
+ },
+ {
+ "sr_id": "SR-20260524-EE9F0A", "inst": institutions[0],
+ "sr_type": SRType.INQUIRY, "title": "기재부 SSL 인증서 만료 갱신",
+ "description": "portal.mof.go.kr SSL 인증서 D-14 갱신 요청",
+ "status": SRStatus.APPROVED, "priority": Priority.HIGH,
+ "requested_by": "최보안", "assigned_to": "보안팀",
+ "target_server": "MOF-WEB-01",
+ "created_at": now - timedelta(days=1),
+ "updated_at": now - timedelta(hours=3),
+ },
+ {
+ "sr_id": "SR-20260524-FF1A2B", "inst": institutions[1],
+ "sr_type": SRType.RESTART, "title": "행안부 WAS 롤링 재기동",
+ "description": "주간 정기 점검 롤링 재기동",
+ "status": SRStatus.FAILED_ROLLBACK, "priority": Priority.LOW,
+ "requested_by": "이운영", "assigned_to": "운영팀",
+ "target_server": "MOIS-WAS-01",
+ "created_at": now - timedelta(days=2),
+ "updated_at": now - timedelta(days=1),
+ },
+ ]
+
+ sr_objs = []
+ for sd in sr_data:
+ inst = sd.pop("inst")
+ sr = SRRequest(inst_id=inst.id, **sd)
+ db.add(sr)
+ sr_objs.append(sr)
+ await db.flush()
+
+ # ── Approvals ────────────────────────────────────────────────
+ approval_data = [
+ {"sr": sr_objs[0], "approver": "김PM", "result": ApprovalResult.APPROVED,
+ "comment": "정상 배포 승인", "decided_at": now - timedelta(days=2, hours=22)},
+ {"sr": sr_objs[1], "approver": "김PM", "result": ApprovalResult.PENDING,
+ "comment": None, "decided_at": None},
+ {"sr": sr_objs[4], "approver": "김PM", "result": ApprovalResult.APPROVED,
+ "comment": "긴급 갱신 승인", "decided_at": now - timedelta(hours=4)},
+ {"sr": sr_objs[5], "approver": "이PM", "result": ApprovalResult.APPROVED,
+ "comment": "정기 점검 승인", "decided_at": now - timedelta(days=1, hours=20)},
+ ]
+ for ad in approval_data:
+ sr = ad.pop("sr")
+ apv = ApprovalFlow(sr_id=sr.sr_id, **ad)
+ db.add(apv)
+ await db.flush()
+
+ # ── Audit Logs with hash chain ───────────────────────────────
+ prev_hash = None
+ audit_entries = [
+ {"sr": sr_objs[0], "actor": "홍길동", "action": "SR_CREATED", "detail": "배포 SR 생성"},
+ {"sr": sr_objs[0], "actor": "김PM", "action": "SR_APPROVED", "detail": "PM 승인"},
+ {"sr": sr_objs[0], "actor": "system", "action": "SR_COMPLETED","detail": "배포 완료"},
+ {"sr": sr_objs[1], "actor": "김운영", "action": "SR_CREATED", "detail": "재기동 SR 생성"},
+ {"sr": sr_objs[2], "actor": "이배포", "action": "SR_CREATED", "detail": "정적파일 배포 SR 생성"},
+ {"sr": sr_objs[2], "actor": "system", "action": "SR_STARTED", "detail": "배포 작업 시작"},
+ ]
+ ts_base = now - timedelta(days=3)
+ for i, ae in enumerate(audit_entries):
+ sr = ae.pop("sr")
+ ts = (ts_base + timedelta(hours=i * 4)).isoformat()
+ log_hash = compute_log_hash(prev_hash, ae["actor"], ae["action"], ae["detail"], ts)
+ log = AuditLog(
+ sr_id=sr.sr_id, prev_hash=prev_hash, log_hash=log_hash,
+ created_at=ts_base + timedelta(hours=i * 4), **ae
+ )
+ db.add(log)
+ prev_hash = log_hash
+ await db.flush()
+
+ # ── OPS Tasks ────────────────────────────────────────────────
+ task_data = [
+ {"sr": sr_objs[0], "task_name": "파일 전송 (SFTP)", "task_order": 1, "status": "COMPLETED"},
+ {"sr": sr_objs[0], "task_name": "WAS 롤링 재기동", "task_order": 2, "status": "COMPLETED"},
+ {"sr": sr_objs[0], "task_name": "헬스체크 확인", "task_order": 3, "status": "COMPLETED"},
+ {"sr": sr_objs[2], "task_name": "파일 전송 (SFTP)", "task_order": 1, "status": "COMPLETED"},
+ {"sr": sr_objs[2], "task_name": "Nginx 설정 리로드", "task_order": 2, "status": "IN_PROGRESS"},
+ ]
+ for td in task_data:
+ sr = td.pop("sr")
+ task = OpsTask(sr_id=sr.sr_id, **td)
+ db.add(task)
+
+ await db.commit()
diff --git a/itsm/database.py b/itsm/database.py
new file mode 100644
index 00000000..ce780a72
--- /dev/null
+++ b/itsm/database.py
@@ -0,0 +1,22 @@
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
+from sqlalchemy.orm import DeclarativeBase
+
+DATABASE_URL = "sqlite+aiosqlite:///./guardia_itsm.db"
+
+engine = create_async_engine(DATABASE_URL, echo=False)
+SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+async def get_db():
+ async with SessionLocal() as session:
+ yield session
+
+
+async def init_db():
+ from models import Base as ModelBase # noqa: F401
+ async with engine.begin() as conn:
+ await conn.run_sync(ModelBase.metadata.create_all)
diff --git a/itsm/main.py b/itsm/main.py
new file mode 100644
index 00000000..91da7e4d
--- /dev/null
+++ b/itsm/main.py
@@ -0,0 +1,33 @@
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+
+from database import init_db
+from routers import approvals, audit, cmdb, tasks
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await init_db()
+ from database import SessionLocal
+ from core.seed import seed_all
+ async with SessionLocal() as db:
+ await seed_all(db)
+ yield
+
+
+app = FastAPI(title="GUARDiA ITSM", version="1.0.0", lifespan=lifespan)
+
+app.include_router(tasks.router)
+app.include_router(approvals.router)
+app.include_router(cmdb.router)
+app.include_router(audit.router)
+
+app.mount("/static", StaticFiles(directory="static"), name="static")
+
+
+@app.get("/")
+async def index():
+ return FileResponse("static/index.html")
diff --git a/itsm/models.py b/itsm/models.py
new file mode 100644
index 00000000..86111c13
--- /dev/null
+++ b/itsm/models.py
@@ -0,0 +1,252 @@
+"""
+ORM models + Pydantic schemas for GUARDiA ITSM
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+from datetime import datetime
+from enum import Enum
+from typing import Any, Optional
+
+from pydantic import BaseModel, ConfigDict
+from sqlalchemy import (
+ Boolean, Column, DateTime, ForeignKey, Integer, String, Text, func
+)
+from sqlalchemy.orm import relationship
+
+from database import Base
+
+
+# ── Enums ──────────────────────────────────────────────────────────────────────
+
+class SRStatus(str, Enum):
+ RECEIVED = "RECEIVED"
+ PARSED = "PARSED"
+ PENDING_APPROVAL = "PENDING_APPROVAL"
+ APPROVED = "APPROVED"
+ IN_PROGRESS = "IN_PROGRESS"
+ PENDING_PM_VALIDATION = "PENDING_PM_VALIDATION"
+ COMPLETED = "COMPLETED"
+ FAILED_ROLLBACK = "FAILED_ROLLBACK"
+ REJECTED = "REJECTED"
+
+
+class SRType(str, Enum):
+ DEPLOY = "DEPLOY"
+ RESTART = "RESTART"
+ LOG = "LOG"
+ INQUIRY = "INQUIRY"
+ OTHER = "OTHER"
+
+
+class Priority(str, Enum):
+ CRITICAL = "CRITICAL"
+ HIGH = "HIGH"
+ MEDIUM = "MEDIUM"
+ LOW = "LOW"
+
+
+class ApprovalResult(str, Enum):
+ PENDING = "PENDING"
+ APPROVED = "APPROVED"
+ REJECTED = "REJECTED"
+
+
+# ── ORM Models ─────────────────────────────────────────────────────────────────
+
+class Institution(Base):
+ __tablename__ = "tb_inst_meta"
+
+ id = Column(Integer, primary_key=True, index=True)
+ inst_code = Column(String(20), unique=True, nullable=False, index=True)
+ inst_name = Column(String(100), nullable=False)
+ org_type = Column(String(50))
+ contact_pm = Column(String(100))
+ created_at = Column(DateTime, default=func.now())
+
+ servers = relationship("Server", back_populates="institution")
+ sr_requests = relationship("SRRequest", back_populates="institution")
+
+
+class Server(Base):
+ __tablename__ = "tb_server_info"
+
+ id = Column(Integer, primary_key=True, index=True)
+ inst_id = Column(Integer, ForeignKey("tb_inst_meta.id"), nullable=False)
+ server_name = Column(String(100), nullable=False)
+ server_role = Column(String(20)) # WEB / WAS / DB
+ os_type = Column(String(50))
+ os_version = Column(String(50))
+ ip_addr = Column(String(45)) # NOT exposed in API responses
+ ssh_user = Column(String(50)) # NOT exposed
+ os_pw_enc = Column(Text) # AES-256 encrypted, NEVER exposed
+ port = Column(Integer, default=22)
+ is_active = Column(Boolean, default=True)
+ created_at = Column(DateTime, default=func.now())
+
+ institution = relationship("Institution", back_populates="servers")
+
+
+class SRRequest(Base):
+ __tablename__ = "tb_sr_request"
+
+ id = Column(Integer, primary_key=True, index=True)
+ sr_id = Column(String(30), unique=True, nullable=False, index=True)
+ inst_id = Column(Integer, ForeignKey("tb_inst_meta.id"))
+ sr_type = Column(String(20), default=SRType.OTHER)
+ title = Column(String(200), nullable=False)
+ description = Column(Text)
+ status = Column(String(30), default=SRStatus.RECEIVED)
+ priority = Column(String(20), default=Priority.MEDIUM)
+ requested_by = Column(String(100))
+ assigned_to = Column(String(100))
+ target_server = Column(String(100))
+ created_at = Column(DateTime, default=func.now())
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+
+ institution = relationship("Institution", back_populates="sr_requests")
+ approvals = relationship("ApprovalFlow", back_populates="sr_request")
+ audit_logs = relationship("AuditLog", back_populates="sr_request")
+
+
+class ApprovalFlow(Base):
+ __tablename__ = "tb_approval_flow"
+
+ id = Column(Integer, primary_key=True, index=True)
+ sr_id = Column(String(30), ForeignKey("tb_sr_request.sr_id"), nullable=False)
+ approver = Column(String(100), nullable=False)
+ result = Column(String(20), default=ApprovalResult.PENDING)
+ comment = Column(Text)
+ decided_at = Column(DateTime)
+ created_at = Column(DateTime, default=func.now())
+
+ sr_request = relationship("SRRequest", back_populates="approvals")
+
+
+class AuditLog(Base):
+ __tablename__ = "tb_audit_log"
+
+ id = Column(Integer, primary_key=True, index=True)
+ sr_id = Column(String(30), ForeignKey("tb_sr_request.sr_id"))
+ actor = Column(String(100))
+ action = Column(String(100), nullable=False)
+ detail = Column(Text)
+ prev_hash = Column(String(64))
+ log_hash = Column(String(64))
+ created_at = Column(DateTime, default=func.now())
+
+ sr_request = relationship("SRRequest", back_populates="audit_logs")
+
+
+class OpsTask(Base):
+ __tablename__ = "tb_ops_task"
+
+ id = Column(Integer, primary_key=True, index=True)
+ sr_id = Column(String(30), ForeignKey("tb_sr_request.sr_id"))
+ task_name = Column(String(200), nullable=False)
+ task_order = Column(Integer, default=0)
+ status = Column(String(30), default="PENDING")
+ result_msg = Column(Text)
+ started_at = Column(DateTime)
+ finished_at = Column(DateTime)
+ created_at = Column(DateTime, default=func.now())
+
+
+# ── Pydantic Schemas ───────────────────────────────────────────────────────────
+
+class InstitutionOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ inst_code: str
+ inst_name: str
+ org_type: Optional[str]
+ contact_pm: Optional[str]
+
+
+class ServerOut(BaseModel):
+ """Server info — sensitive fields (ip_addr, ssh_user, os_pw_enc) intentionally omitted."""
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ server_name: str
+ server_role: Optional[str]
+ os_type: Optional[str]
+ is_active: bool
+
+
+class SRCreate(BaseModel):
+ title: str
+ description: Optional[str] = None
+ sr_type: SRType = SRType.OTHER
+ priority: Priority = Priority.MEDIUM
+ requested_by: str
+ assigned_to: Optional[str] = None
+ target_server: Optional[str] = None
+ inst_code: Optional[str] = None
+
+
+class SROut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ sr_id: str
+ sr_type: str
+ title: str
+ description: Optional[str]
+ status: str
+ priority: str
+ requested_by: str
+ assigned_to: Optional[str]
+ target_server: Optional[str]
+ created_at: datetime
+ updated_at: datetime
+
+
+class SRStatusUpdate(BaseModel):
+ status: SRStatus
+ actor: str
+ comment: Optional[str] = None
+
+
+class ApprovalCreate(BaseModel):
+ approver: str
+ result: ApprovalResult
+ comment: Optional[str] = None
+
+
+class ApprovalOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ sr_id: str
+ approver: str
+ result: str
+ comment: Optional[str]
+ decided_at: Optional[datetime]
+ created_at: datetime
+
+
+class AuditLogOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ sr_id: Optional[str]
+ actor: Optional[str]
+ action: str
+ detail: Optional[str]
+ log_hash: Optional[str]
+ created_at: datetime
+
+
+# ── Audit hash helper ──────────────────────────────────────────────────────────
+
+def compute_log_hash(prev_hash: Optional[str], actor: str, action: str,
+ detail: str, ts: str) -> str:
+ payload = json.dumps(
+ {"prev": prev_hash or "", "actor": actor, "action": action,
+ "detail": detail, "ts": ts},
+ ensure_ascii=False, sort_keys=True
+ )
+ return hashlib.sha256(payload.encode()).hexdigest()
diff --git a/itsm/requirements.txt b/itsm/requirements.txt
new file mode 100644
index 00000000..43fce8ac
--- /dev/null
+++ b/itsm/requirements.txt
@@ -0,0 +1,9 @@
+fastapi>=0.115.0
+uvicorn[standard]>=0.32.0
+sqlalchemy>=2.0.0
+aiosqlite>=0.20.0
+pydantic>=2.10.0
+python-dotenv>=1.0.1
+python-multipart>=0.0.12
+aiofiles>=24.1.0
+cryptography>=42.0.0
diff --git a/itsm/routers/__init__.py b/itsm/routers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/itsm/routers/approvals.py b/itsm/routers/approvals.py
new file mode 100644
index 00000000..c4054761
--- /dev/null
+++ b/itsm/routers/approvals.py
@@ -0,0 +1,72 @@
+"""PM Approval workflow endpoints."""
+from datetime import datetime
+from typing import List
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from database import get_db
+from models import (
+ ApprovalCreate, ApprovalFlow, ApprovalOut, ApprovalResult,
+ SRRequest, SRStatus, AuditLog, compute_log_hash
+)
+
+router = APIRouter(prefix="/api/approvals", tags=["approvals"])
+
+
+async def _write_audit(db: AsyncSession, sr_id: str, actor: str, action: str, detail: str) -> None:
+ result = await db.execute(
+ select(AuditLog).where(AuditLog.sr_id == sr_id)
+ .order_by(AuditLog.id.desc()).limit(1)
+ )
+ last = result.scalars().first()
+ prev_hash = last.log_hash if last else None
+ ts = datetime.now().isoformat()
+ log_hash = compute_log_hash(prev_hash, actor, action, detail, ts)
+ db.add(AuditLog(
+ sr_id=sr_id, actor=actor, action=action, detail=detail,
+ prev_hash=prev_hash, log_hash=log_hash
+ ))
+
+
+@router.get("/{sr_id}", response_model=List[ApprovalOut])
+async def list_approvals(sr_id: str, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(
+ select(ApprovalFlow).where(ApprovalFlow.sr_id == sr_id)
+ .order_by(ApprovalFlow.created_at)
+ )
+ return result.scalars().all()
+
+
+@router.post("/{sr_id}", response_model=ApprovalOut, status_code=201)
+async def decide_approval(sr_id: str, payload: ApprovalCreate,
+ db: AsyncSession = Depends(get_db)):
+ r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
+ sr = r.scalars().first()
+ if not sr:
+ raise HTTPException(404, detail="SR을 찾을 수 없습니다.")
+ if sr.status != SRStatus.PENDING_APPROVAL:
+ raise HTTPException(400, detail="승인 대기 상태의 SR만 처리할 수 있습니다.")
+
+ apv = ApprovalFlow(
+ sr_id=sr_id,
+ approver=payload.approver,
+ result=payload.result,
+ comment=payload.comment,
+ decided_at=datetime.now(),
+ )
+ db.add(apv)
+
+ if payload.result == ApprovalResult.APPROVED:
+ sr.status = SRStatus.APPROVED
+ action, detail = "SR_APPROVED", f"{payload.approver} 승인"
+ else:
+ sr.status = SRStatus.REJECTED
+ action, detail = "SR_REJECTED", f"{payload.approver} 반려: {payload.comment or ''}"
+
+ sr.updated_at = datetime.now()
+ await _write_audit(db, sr_id, payload.approver, action, detail)
+ await db.commit()
+ await db.refresh(apv)
+ return apv
diff --git a/itsm/routers/audit.py b/itsm/routers/audit.py
new file mode 100644
index 00000000..bdd1a168
--- /dev/null
+++ b/itsm/routers/audit.py
@@ -0,0 +1,55 @@
+"""Audit log endpoints with hash-chain verification."""
+import hashlib
+import json
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from database import get_db
+from models import AuditLog, AuditLogOut
+
+router = APIRouter(prefix="/api/audit", tags=["audit"])
+
+
+@router.get("", response_model=List[AuditLogOut])
+async def list_audit_logs(
+ sr_id: Optional[str] = Query(None),
+ skip: int = 0,
+ limit: int = 100,
+ db: AsyncSession = Depends(get_db),
+):
+ q = select(AuditLog).order_by(AuditLog.created_at.desc())
+ if sr_id:
+ q = q.where(AuditLog.sr_id == sr_id)
+ q = q.offset(skip).limit(limit)
+ result = await db.execute(q)
+ return result.scalars().all()
+
+
+@router.get("/verify")
+async def verify_chain(db: AsyncSession = Depends(get_db)):
+ """Verify SHA-256 hash chain integrity."""
+ result = await db.execute(select(AuditLog).order_by(AuditLog.id))
+ logs = result.scalars().all()
+
+ broken_at: Optional[int] = None
+ for log in logs:
+ payload = json.dumps(
+ {"prev": log.prev_hash or "", "actor": log.actor or "",
+ "action": log.action,
+ "detail": log.detail or "",
+ "ts": log.created_at.isoformat() if log.created_at else ""},
+ ensure_ascii=False, sort_keys=True
+ )
+ expected = hashlib.sha256(payload.encode()).hexdigest()
+ if expected != log.log_hash:
+ broken_at = log.id
+ break
+
+ return {
+ "total": len(logs),
+ "intact": broken_at is None,
+ "broken_at_id": broken_at,
+ }
diff --git a/itsm/routers/cmdb.py b/itsm/routers/cmdb.py
new file mode 100644
index 00000000..7bc6c6e7
--- /dev/null
+++ b/itsm/routers/cmdb.py
@@ -0,0 +1,34 @@
+"""CMDB: Institution + Server management endpoints."""
+from typing import List
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from database import get_db
+from models import Institution, InstitutionOut, Server, ServerOut
+
+router = APIRouter(prefix="/api/cmdb", tags=["cmdb"])
+
+
+@router.get("/institutions", response_model=List[InstitutionOut])
+async def list_institutions(db: AsyncSession = Depends(get_db)):
+ result = await db.execute(select(Institution).order_by(Institution.inst_name))
+ return result.scalars().all()
+
+
+@router.get("/institutions/{inst_code}/servers", response_model=List[ServerOut])
+async def list_servers(inst_code: str, db: AsyncSession = Depends(get_db)):
+ r = await db.execute(
+ select(Institution).where(Institution.inst_code == inst_code)
+ )
+ inst = r.scalars().first()
+ if not inst:
+ from fastapi import HTTPException
+ raise HTTPException(404, detail="기관을 찾을 수 없습니다.")
+
+ result = await db.execute(
+ select(Server).where(Server.inst_id == inst.id, Server.is_active == True)
+ .order_by(Server.server_name)
+ )
+ return result.scalars().all()
diff --git a/itsm/routers/tasks.py b/itsm/routers/tasks.py
new file mode 100644
index 00000000..8aee58ec
--- /dev/null
+++ b/itsm/routers/tasks.py
@@ -0,0 +1,156 @@
+"""SR / Task CRUD + status transition endpoints."""
+from datetime import datetime
+from typing import List, Optional
+from uuid import uuid4
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from database import get_db
+from models import (
+ AuditLog, Institution, SRCreate, SROut, SRRequest, SRStatus,
+ SRStatusUpdate, SRType, compute_log_hash
+)
+
+router = APIRouter(prefix="/api/tasks", tags=["tasks"])
+
+# Valid state transitions
+_TRANSITIONS: dict[str, list[str]] = {
+ SRStatus.RECEIVED: [SRStatus.PARSED, SRStatus.REJECTED],
+ SRStatus.PARSED: [SRStatus.PENDING_APPROVAL, SRStatus.REJECTED],
+ SRStatus.PENDING_APPROVAL: [SRStatus.APPROVED, SRStatus.REJECTED],
+ SRStatus.APPROVED: [SRStatus.IN_PROGRESS, SRStatus.REJECTED],
+ SRStatus.IN_PROGRESS: [SRStatus.PENDING_PM_VALIDATION, SRStatus.FAILED_ROLLBACK],
+ SRStatus.PENDING_PM_VALIDATION:[SRStatus.COMPLETED, SRStatus.FAILED_ROLLBACK],
+ SRStatus.COMPLETED: [],
+ SRStatus.FAILED_ROLLBACK: [],
+ SRStatus.REJECTED: [],
+}
+
+
+def _new_sr_id() -> str:
+ return f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
+
+
+async def _write_audit(db: AsyncSession, sr_id: str, actor: str,
+ action: str, detail: str) -> None:
+ from sqlalchemy import select as sel
+ result = await db.execute(
+ sel(AuditLog).where(AuditLog.sr_id == sr_id)
+ .order_by(AuditLog.id.desc()).limit(1)
+ )
+ last = result.scalars().first()
+ prev_hash = last.log_hash if last else None
+ ts = datetime.now().isoformat()
+ log_hash = compute_log_hash(prev_hash, actor, action, detail, ts)
+ db.add(AuditLog(
+ sr_id=sr_id, actor=actor, action=action, detail=detail,
+ prev_hash=prev_hash, log_hash=log_hash
+ ))
+
+
+@router.get("", response_model=List[SROut])
+async def list_tasks(
+ status: Optional[str] = Query(None),
+ sr_type: Optional[str] = Query(None),
+ priority: Optional[str]= Query(None),
+ keyword: Optional[str] = Query(None),
+ skip: int = 0,
+ limit: int = 50,
+ db: AsyncSession = Depends(get_db),
+):
+ q = select(SRRequest).order_by(SRRequest.created_at.desc())
+ if status:
+ q = q.where(SRRequest.status == status)
+ if sr_type:
+ q = q.where(SRRequest.sr_type == sr_type)
+ if priority:
+ q = q.where(SRRequest.priority == priority)
+ if keyword:
+ q = q.where(SRRequest.title.contains(keyword))
+ q = q.offset(skip).limit(limit)
+ result = await db.execute(q)
+ return result.scalars().all()
+
+
+@router.get("/stats")
+async def get_stats(db: AsyncSession = Depends(get_db)):
+ total = (await db.execute(select(func.count(SRRequest.id)))).scalar()
+ by_status: dict[str, int] = {}
+ for s in SRStatus:
+ cnt = (await db.execute(
+ select(func.count(SRRequest.id)).where(SRRequest.status == s)
+ )).scalar()
+ if cnt:
+ by_status[s.value] = cnt
+ by_type: dict[str, int] = {}
+ for t in SRType:
+ cnt = (await db.execute(
+ select(func.count(SRRequest.id)).where(SRRequest.sr_type == t)
+ )).scalar()
+ if cnt:
+ by_type[t.value] = cnt
+ return {"total": total, "by_status": by_status, "by_type": by_type}
+
+
+@router.get("/{sr_id}", response_model=SROut)
+async def get_task(sr_id: str, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
+ sr = result.scalars().first()
+ if not sr:
+ raise HTTPException(404, detail="SR을 찾을 수 없습니다.")
+ return sr
+
+
+@router.post("", response_model=SROut, status_code=201)
+async def create_task(payload: SRCreate, db: AsyncSession = Depends(get_db)):
+ inst_id = None
+ if payload.inst_code:
+ r = await db.execute(
+ select(Institution).where(Institution.inst_code == payload.inst_code)
+ )
+ inst = r.scalars().first()
+ if inst:
+ inst_id = inst.id
+
+ sr = SRRequest(
+ sr_id=_new_sr_id(),
+ inst_id=inst_id,
+ sr_type=payload.sr_type,
+ title=payload.title,
+ description=payload.description,
+ status=SRStatus.RECEIVED,
+ priority=payload.priority,
+ requested_by=payload.requested_by,
+ assigned_to=payload.assigned_to,
+ target_server=payload.target_server,
+ )
+ db.add(sr)
+ await db.flush()
+ await _write_audit(db, sr.sr_id, payload.requested_by, "SR_CREATED", f"SR 생성: {payload.title}")
+ await db.commit()
+ await db.refresh(sr)
+ return sr
+
+
+@router.patch("/{sr_id}/status", response_model=SROut)
+async def update_status(sr_id: str, payload: SRStatusUpdate,
+ db: AsyncSession = Depends(get_db)):
+ result = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
+ sr = result.scalars().first()
+ if not sr:
+ raise HTTPException(404, detail="SR을 찾을 수 없습니다.")
+
+ allowed = _TRANSITIONS.get(SRStatus(sr.status), [])
+ if payload.status not in allowed:
+ raise HTTPException(400, detail=f"'{sr.status}' → '{payload.status}' 전이는 허용되지 않습니다.")
+
+ old_status = sr.status
+ sr.status = payload.status
+ sr.updated_at = datetime.now()
+ detail = payload.comment or f"상태 변경: {old_status} → {payload.status}"
+ await _write_audit(db, sr_id, payload.actor, "STATUS_CHANGED", detail)
+ await db.commit()
+ await db.refresh(sr)
+ return sr
diff --git a/itsm/static/app.js b/itsm/static/app.js
new file mode 100644
index 00000000..c9d4bc37
--- /dev/null
+++ b/itsm/static/app.js
@@ -0,0 +1,446 @@
+/* ─── State ─────────────────────────────────────── */
+let currentView = "dashboard";
+let srCache = [];
+let statsCache = {};
+
+/* ─── Status labels ─────────────────────────────── */
+const STATUS_LABEL = {
+ RECEIVED: "접수",
+ PARSED: "파싱 완료",
+ PENDING_APPROVAL: "승인 대기",
+ APPROVED: "승인됨",
+ IN_PROGRESS: "진행 중",
+ PENDING_PM_VALIDATION: "PM 검증 대기",
+ COMPLETED: "완료",
+ FAILED_ROLLBACK: "롤백 실패",
+ REJECTED: "반려",
+};
+
+const TYPE_LABEL = {
+ DEPLOY: "배포", RESTART: "재기동", LOG: "로그",
+ INQUIRY: "문의", OTHER: "기타",
+};
+
+const PRIORITY_LABEL = { CRITICAL: "긴급", HIGH: "높음", MEDIUM: "보통", LOW: "낮음" };
+
+const KANBAN_COLS = [
+ { key: "RECEIVED", label: "접수" },
+ { key: "PENDING_APPROVAL", label: "승인 대기" },
+ { key: "APPROVED", label: "승인됨" },
+ { key: "IN_PROGRESS", label: "진행 중" },
+ { key: "PENDING_PM_VALIDATION", label: "PM 검증" },
+ { key: "COMPLETED", label: "완료" },
+ { key: "FAILED_ROLLBACK", label: "롤백 실패" },
+ { key: "REJECTED", label: "반려" },
+];
+
+const STATUS_COLORS = {
+ RECEIVED: "#8b949e",
+ PARSED: "#79c0ff",
+ PENDING_APPROVAL: "#e3b341",
+ APPROVED: "#56d364",
+ IN_PROGRESS: "#58a6ff",
+ PENDING_PM_VALIDATION: "#bc8cff",
+ COMPLETED: "#3fb950",
+ FAILED_ROLLBACK: "#f85149",
+ REJECTED: "#da3633",
+};
+
+/* ─── Init ──────────────────────────────────────── */
+window.addEventListener("DOMContentLoaded", async () => {
+ setupNav();
+ setupNewSR();
+ setupListFilters();
+ await loadAll();
+});
+
+async function loadAll() {
+ await Promise.all([loadStats(), loadSRs()]);
+ renderCurrentView();
+}
+
+/* ─── Nav ───────────────────────────────────────── */
+function setupNav() {
+ document.querySelectorAll(".nav-item").forEach(el => {
+ el.addEventListener("click", () => {
+ const view = el.dataset.view;
+ switchView(view);
+ });
+ });
+}
+
+function switchView(view) {
+ currentView = view;
+ document.querySelectorAll(".nav-item").forEach(el =>
+ el.classList.toggle("active", el.dataset.view === view)
+ );
+ document.querySelectorAll(".view").forEach(el =>
+ el.classList.toggle("active", el.id === `view-${view}`)
+ );
+ const titles = {
+ dashboard: "대시보드", board: "칸반 보드",
+ list: "SR 목록", audit: "감사 로그", cmdb: "CMDB",
+ };
+ document.getElementById("page-title").textContent = titles[view] || view;
+ renderCurrentView();
+}
+
+function renderCurrentView() {
+ if (currentView === "dashboard") renderDashboard();
+ else if (currentView === "board") renderKanban();
+ else if (currentView === "list") renderList();
+ else if (currentView === "audit") loadAudit();
+ else if (currentView === "cmdb") loadCmdb();
+}
+
+/* ─── Data loading ──────────────────────────────── */
+async function loadStats() {
+ try {
+ const r = await fetch("/api/tasks/stats");
+ statsCache = await r.json();
+ } catch { statsCache = {}; }
+}
+
+async function loadSRs(params = {}) {
+ const qs = new URLSearchParams(params).toString();
+ const r = await fetch(`/api/tasks?limit=200${qs ? "&" + qs : ""}`);
+ srCache = await r.json();
+}
+
+/* ─── Dashboard ─────────────────────────────────── */
+function renderDashboard() {
+ const s = statsCache;
+ const bs = s.by_status || {};
+ const bt = s.by_type || {};
+ const pending = (bs.PENDING_APPROVAL || 0) + (bs.PENDING_PM_VALIDATION || 0);
+ const active = bs.IN_PROGRESS || 0;
+ const completed = bs.COMPLETED || 0;
+ const failed = bs.FAILED_ROLLBACK || 0;
+
+ document.getElementById("stats-row").innerHTML = `
+
+
${s.total || 0}
+
전체 SR
+
+
+
+
+
+ `;
+
+ // Recent SR list
+ const recent = [...srCache].slice(0, 10);
+ document.getElementById("recent-list").innerHTML = recent.map(sr => `
+
+ ${sr.sr_id}
+ ${esc(sr.title)}
+ ${STATUS_LABEL[sr.status] || sr.status}
+ ${fmtDate(sr.created_at)}
+
+ `).join("") || 'SR이 없습니다.
';
+
+ // Status bar chart
+ const total = s.total || 1;
+ const items = Object.entries(bs)
+ .sort((a, b) => b[1] - a[1])
+ .map(([k, v]) => `
+
+
+ ${STATUS_LABEL[k] || k}
+ ${v}
+
+
+
`).join("");
+ document.getElementById("status-chart").innerHTML =
+ `${items}
`;
+}
+
+/* ─── Kanban ────────────────────────────────────── */
+function renderKanban() {
+ const board = document.getElementById("kanban-board");
+ board.innerHTML = "";
+ KANBAN_COLS.forEach(col => {
+ const cards = srCache.filter(sr => sr.status === col.key);
+ const colEl = document.createElement("div");
+ colEl.className = "kanban-col";
+ colEl.innerHTML = `
+
+ `;
+ board.appendChild(colEl);
+
+ const cardsEl = colEl.querySelector(`#col-${col.key}`);
+ cards.forEach(sr => {
+ const card = document.createElement("div");
+ card.className = "kanban-card";
+ card.innerHTML = `
+ ${sr.sr_id}
+ ${esc(sr.title)}
+
+ ${TYPE_LABEL[sr.sr_type] || sr.sr_type}
+ ${PRIORITY_LABEL[sr.priority] || sr.priority}
+
`;
+ card.addEventListener("click", () => openDetail(sr.sr_id));
+ cardsEl.appendChild(card);
+ });
+ });
+}
+
+/* ─── SR List ───────────────────────────────────── */
+function setupListFilters() {
+ document.getElementById("search-input").addEventListener("input", renderList);
+ document.getElementById("filter-status").addEventListener("change", renderList);
+ document.getElementById("filter-type").addEventListener("change", renderList);
+}
+
+function renderList() {
+ const keyword = document.getElementById("search-input").value.toLowerCase();
+ const fStatus = document.getElementById("filter-status").value;
+ const fType = document.getElementById("filter-type").value;
+
+ let rows = srCache;
+ if (keyword) rows = rows.filter(r => r.title.toLowerCase().includes(keyword) || r.sr_id.toLowerCase().includes(keyword));
+ if (fStatus) rows = rows.filter(r => r.status === fStatus);
+ if (fType) rows = rows.filter(r => r.sr_type === fType);
+
+ document.getElementById("sr-tbody").innerHTML = rows.map(sr => `
+
+ ${sr.sr_id} |
+ ${TYPE_LABEL[sr.sr_type] || sr.sr_type} |
+ ${esc(sr.title)} |
+ ${STATUS_LABEL[sr.status] || sr.status} |
+ ${PRIORITY_LABEL[sr.priority] || sr.priority} |
+ ${esc(sr.requested_by || "")} |
+ ${fmtDate(sr.created_at)} |
+
+ `).join("") || `| 결과 없음 |
`;
+}
+
+/* ─── SR Detail Modal ───────────────────────────── */
+async function openDetail(srId) {
+ const sr = srCache.find(s => s.sr_id === srId);
+ if (!sr) return;
+
+ const [approvalsRes, auditRes] = await Promise.all([
+ fetch(`/api/approvals/${srId}`).then(r => r.json()).catch(() => []),
+ fetch(`/api/audit?sr_id=${srId}`).then(r => r.json()).catch(() => []),
+ ]);
+
+ const approvalHTML = approvalsRes.length
+ ? approvalsRes.map(a => `
+
+ ${esc(a.approver)}
+
+ ${a.result === "APPROVED" ? "승인" : a.result === "REJECTED" ? "반려" : "대기"}
+
+ ${a.comment ? `${esc(a.comment)}` : ""}
+
`).join("")
+ : '승인 기록 없음
';
+
+ const auditHTML = auditRes.slice(0, 10).map(log => `
+
+
${esc(log.action)} by ${esc(log.actor || "system")}
+
${esc(log.detail || "")} #${(log.log_hash || "").slice(0, 12)}
+
`).join("") || '기록 없음
';
+
+ const canApprove = sr.status === "PENDING_APPROVAL";
+
+ document.getElementById("modal-body").innerHTML = `
+ ${esc(sr.title)}
+
+ ${STATUS_LABEL[sr.status] || sr.status}
+ ${TYPE_LABEL[sr.sr_type] || sr.sr_type}
+ ${PRIORITY_LABEL[sr.priority] || sr.priority}
+ ${sr.sr_id}
+
+
+
+
요약 정보
+
+
요청자: ${esc(sr.requested_by || "")}
+
담당자: ${esc(sr.assigned_to || "미지정")}
+
대상 서버: ${esc(sr.target_server || "-")}
+
생성일: ${fmtDate(sr.created_at)}
+
+
+
+ ${sr.description ? `
+
+
설명
+
${esc(sr.description)}
+
` : ""}
+
+
+
승인 현황
+ ${approvalHTML}
+
+
+
+
+ ${canApprove ? `
+
+
+
+
+
+
` : ""}
+ `;
+
+ document.getElementById("modal-overlay").classList.remove("hidden");
+}
+
+async function doApproval(srId, result) {
+ const approver = document.getElementById("approver-name").value.trim();
+ const comment = document.getElementById("approver-comment").value.trim();
+ if (!approver) { alert("승인자 이름을 입력하세요."); return; }
+
+ const r = await fetch(`/api/approvals/${srId}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ approver, result, comment: comment || null }),
+ });
+ if (r.ok) {
+ document.getElementById("modal-overlay").classList.add("hidden");
+ await loadAll();
+ } else {
+ const err = await r.json().catch(() => ({}));
+ alert(err.detail || "처리 중 오류 발생");
+ }
+}
+
+document.getElementById("modal-close-btn").addEventListener("click", () =>
+ document.getElementById("modal-overlay").classList.add("hidden")
+);
+document.getElementById("modal-overlay").addEventListener("click", e => {
+ if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden");
+});
+
+/* ─── New SR Modal ──────────────────────────────── */
+function setupNewSR() {
+ document.getElementById("btn-new-sr").addEventListener("click", () =>
+ document.getElementById("new-sr-overlay").classList.remove("hidden")
+ );
+ document.getElementById("new-sr-close").addEventListener("click", () =>
+ document.getElementById("new-sr-overlay").classList.add("hidden")
+ );
+ document.getElementById("new-sr-overlay").addEventListener("click", e => {
+ if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden");
+ });
+ document.getElementById("new-sr-form").addEventListener("submit", async e => {
+ e.preventDefault();
+ const fd = new FormData(e.target);
+ const payload = Object.fromEntries(fd.entries());
+ // remove empty optional fields
+ if (!payload.description) delete payload.description;
+ if (!payload.target_server) delete payload.target_server;
+ if (!payload.inst_code) delete payload.inst_code;
+
+ const r = await fetch("/api/tasks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (r.ok) {
+ document.getElementById("new-sr-overlay").classList.add("hidden");
+ e.target.reset();
+ await loadAll();
+ } else {
+ const err = await r.json().catch(() => ({}));
+ alert(err.detail || "SR 생성 실패");
+ }
+ });
+}
+
+/* ─── Audit ─────────────────────────────────────── */
+async function loadAudit() {
+ const data = await fetch("/api/audit?limit=100").then(r => r.json()).catch(() => []);
+ document.getElementById("audit-tbody").innerHTML = data.map((log, i) => `
+
+ | ${i + 1} |
+ ${esc(log.sr_id || "—")} |
+ ${esc(log.actor || "system")} |
+ ${esc(log.action)} |
+ ${esc(log.detail || "")} |
+ ${(log.log_hash || "").slice(0, 12)} |
+ ${fmtDate(log.created_at)} |
+
`).join("") || `| 기록 없음 |
`;
+
+ document.getElementById("btn-verify").addEventListener("click", async () => {
+ const res = await fetch("/api/audit/verify").then(r => r.json());
+ const el = document.getElementById("verify-result");
+ if (res.intact) {
+ el.textContent = "✅ 체인 무결성 확인됨";
+ el.className = "ok";
+ } else {
+ el.textContent = `❌ 변조 감지 (ID: ${res.broken_at_id})`;
+ el.className = "fail";
+ }
+ });
+}
+
+/* ─── CMDB ───────────────────────────────────────── */
+async function loadCmdb() {
+ const institutions = await fetch("/api/cmdb/institutions").then(r => r.json()).catch(() => []);
+ const grid = document.getElementById("cmdb-grid");
+ grid.innerHTML = "";
+
+ await Promise.all(institutions.map(async inst => {
+ const servers = await fetch(`/api/cmdb/institutions/${inst.inst_code}/servers`)
+ .then(r => r.json()).catch(() => []);
+ const card = document.createElement("div");
+ card.className = "cmdb-card";
+ card.innerHTML = `
+
+
+ ${servers.map(s => `
+
+ ${s.server_role}
+ ${esc(s.server_name)}
+ ${esc(s.os_type || "")}
+ ${s.is_active ? "● 정상" : "● 비활성"}
+
`).join("") || '
서버 없음
'}
+
+ ${inst.contact_pm ? `PM: ${esc(inst.contact_pm)}
` : ""}
+ `;
+ grid.appendChild(card);
+ }));
+}
+
+/* ─── Helpers ───────────────────────────────────── */
+function esc(s) {
+ return String(s ?? "")
+ .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+
+function fmtDate(iso) {
+ if (!iso) return "";
+ try {
+ return new Date(iso).toLocaleString("ko-KR", {
+ month: "2-digit", day: "2-digit",
+ hour: "2-digit", minute: "2-digit",
+ });
+ } catch { return iso; }
+}
diff --git a/itsm/static/index.html b/itsm/static/index.html
new file mode 100644
index 00000000..8246a685
--- /dev/null
+++ b/itsm/static/index.html
@@ -0,0 +1,186 @@
+
+
+
+
+
+ GUARDiA ITSM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | SR ID | 유형 | 제목 |
+ 상태 | 우선순위 | 요청자 | 생성일 |
+
+
+
+
+
+
+
+
+
+
+
+ | # | SR | 행위자 | 액션 | 내용 |
+ 해시 (앞 12자) | 시각 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/itsm/static/style.css b/itsm/static/style.css
new file mode 100644
index 00000000..ec686d51
--- /dev/null
+++ b/itsm/static/style.css
@@ -0,0 +1,338 @@
+/* ─── Reset & Base ─────────────────────────────── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --sidebar-bg: #0d1117;
+ --sidebar-hover: #161b22;
+ --sidebar-active:#1f6feb;
+ --main-bg: #0d1117;
+ --card-bg: #161b22;
+ --border: #30363d;
+ --text-primary: #c9d1d9;
+ --text-muted: #8b949e;
+ --text-bright: #f0f6fc;
+ --accent: #1f6feb;
+ --green: #2ea043;
+ --yellow: #d29922;
+ --red: #da3633;
+ --orange: #e3b341;
+ --purple: #8957e5;
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --radius: 6px;
+ --radius-lg: 8px;
+}
+
+html, body { height: 100%; font-family: var(--font); background: var(--main-bg); color: var(--text-primary); font-size: 14px; }
+
+/* ─── Layout ────────────────────────────────────── */
+#app { display: flex; height: 100vh; overflow: hidden; }
+
+/* ─── Sidebar ───────────────────────────────────── */
+#sidebar {
+ width: 220px; min-width: 180px; flex-shrink: 0;
+ background: var(--sidebar-bg);
+ border-right: 1px solid var(--border);
+ display: flex; flex-direction: column;
+}
+
+#sidebar-logo {
+ padding: 18px 16px 14px;
+ border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; gap: 10px;
+}
+.logo-icon {
+ width: 32px; height: 32px; border-radius: var(--radius);
+ background: var(--accent); color: #fff;
+ font-size: 18px; font-weight: 900;
+ display: flex; align-items: center; justify-content: center;
+}
+.logo-title { font-size: 15px; font-weight: 700; color: var(--text-bright); }
+.logo-sub { font-size: 11px; color: var(--text-muted); }
+
+#sidebar-nav { flex: 1; padding: 10px 8px; }
+.nav-item {
+ padding: 8px 10px; border-radius: var(--radius);
+ cursor: pointer; color: var(--text-muted);
+ display: flex; align-items: center; gap: 8px;
+ transition: background .12s, color .12s;
+ margin-bottom: 2px; font-size: 13px;
+}
+.nav-item:hover { background: var(--sidebar-hover); color: var(--text-primary); }
+.nav-item.active { background: var(--sidebar-active); color: #fff; font-weight: 500; }
+.nav-icon { font-size: 15px; }
+
+#sidebar-footer {
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+ display: flex; align-items: center; gap: 6px;
+ font-size: 12px; color: var(--text-muted);
+}
+.status-dot { width: 8px; height: 8px; border-radius: 50%; }
+.status-dot.online { background: var(--green); }
+
+/* ─── Main ──────────────────────────────────────── */
+#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
+
+#topbar {
+ padding: 12px 24px;
+ border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; justify-content: space-between;
+ background: var(--main-bg);
+}
+#page-title { font-size: 18px; font-weight: 700; color: var(--text-bright); }
+
+/* ─── Views ─────────────────────────────────────── */
+.view { display: none; flex: 1; overflow-y: auto; padding: 20px 24px; }
+.view.active { display: block; }
+
+/* ─── Buttons ───────────────────────────────────── */
+.btn {
+ padding: 7px 14px; border-radius: var(--radius);
+ font-size: 13px; font-weight: 500; cursor: pointer;
+ border: 1px solid transparent; transition: opacity .15s;
+}
+.btn:hover { opacity: .85; }
+.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
+.btn-secondary { background: transparent; border-color: var(--border); color: var(--text-primary); }
+.btn-approve { background: var(--green); color: #fff; }
+.btn-reject { background: var(--red); color: #fff; }
+
+/* ─── Stats row ─────────────────────────────────── */
+.stats-row { display: flex; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
+.stat-card {
+ background: var(--card-bg); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); padding: 16px 20px;
+ min-width: 140px; flex: 1;
+}
+.stat-value { font-size: 28px; font-weight: 700; color: var(--text-bright); }
+.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
+.stat-card.accent .stat-value { color: var(--accent); }
+.stat-card.green .stat-value { color: var(--green); }
+.stat-card.yellow .stat-value { color: var(--yellow); }
+.stat-card.red .stat-value { color: var(--red); }
+
+/* ─── Dashboard grid ────────────────────────────── */
+.dashboard-grid { display: grid; grid-template-columns: 1fr 340px; gap: 16px; }
+@media (max-width: 900px) { .dashboard-grid { grid-template-columns: 1fr; } }
+
+.card {
+ background: var(--card-bg); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); overflow: hidden;
+}
+.card-header {
+ padding: 12px 16px; font-size: 13px; font-weight: 600;
+ color: var(--text-bright); border-bottom: 1px solid var(--border);
+}
+.card-body { padding: 8px 0; }
+
+/* Recent SR list in dashboard */
+.recent-row {
+ display: flex; align-items: center; gap: 10px;
+ padding: 8px 16px; border-bottom: 1px solid var(--border);
+ cursor: pointer; transition: background .1s;
+}
+.recent-row:last-child { border-bottom: none; }
+.recent-row:hover { background: rgba(255,255,255,.04); }
+.recent-sr-id { font-size: 12px; color: var(--text-muted); min-width: 150px; }
+.recent-title { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.recent-time { font-size: 11px; color: var(--text-muted); }
+
+/* Status donut chart (pure CSS) */
+.status-bar-list { padding: 12px 16px; }
+.status-bar-item { margin-bottom: 10px; }
+.status-bar-label {
+ display: flex; justify-content: space-between;
+ font-size: 12px; margin-bottom: 4px;
+}
+.status-bar-track {
+ height: 8px; background: var(--border); border-radius: 4px; overflow: hidden;
+}
+.status-bar-fill { height: 100%; border-radius: 4px; }
+
+/* ─── Status badges ─────────────────────────────── */
+.badge {
+ display: inline-block; padding: 2px 8px; border-radius: 20px;
+ font-size: 11px; font-weight: 600; white-space: nowrap;
+}
+.badge-RECEIVED { background: rgba(139,148,158,.2); color: #8b949e; }
+.badge-PARSED { background: rgba(31,111,235,.2); color: #79c0ff; }
+.badge-PENDING_APPROVAL { background: rgba(210,153,34,.2); color: #e3b341; }
+.badge-APPROVED { background: rgba(46,160,67,.2); color: #56d364; }
+.badge-IN_PROGRESS { background: rgba(31,111,235,.3); color: #58a6ff; }
+.badge-PENDING_PM_VALIDATION { background: rgba(137,87,229,.2); color: #bc8cff; }
+.badge-COMPLETED { background: rgba(46,160,67,.3); color: #3fb950; }
+.badge-FAILED_ROLLBACK { background: rgba(218,54,51,.2); color: #f85149; }
+.badge-REJECTED { background: rgba(218,54,51,.15); color: #da3633; }
+
+.badge-priority-CRITICAL { background: rgba(218,54,51,.25); color: #f85149; }
+.badge-priority-HIGH { background: rgba(227,179,65,.2); color: #e3b341; }
+.badge-priority-MEDIUM { background: rgba(31,111,235,.2); color: #79c0ff; }
+.badge-priority-LOW { background: rgba(139,148,158,.2); color: #8b949e; }
+
+.badge-type-DEPLOY { background: rgba(31,111,235,.2); color: #79c0ff; }
+.badge-type-RESTART { background: rgba(218,54,51,.18); color: #f85149; }
+.badge-type-LOG { background: rgba(137,87,229,.2); color: #bc8cff; }
+.badge-type-INQUIRY { background: rgba(139,148,158,.15); color: #8b949e; }
+.badge-type-OTHER { background: rgba(139,148,158,.1); color: #8b949e; }
+
+/* ─── Kanban ────────────────────────────────────── */
+#kanban-board {
+ display: flex; gap: 14px; overflow-x: auto;
+ padding-bottom: 16px; min-height: calc(100vh - 100px);
+}
+#kanban-board::-webkit-scrollbar { height: 5px; }
+#kanban-board::-webkit-scrollbar-thumb { background: var(--border); }
+
+.kanban-col {
+ min-width: 240px; flex-shrink: 0;
+ background: var(--card-bg); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); display: flex; flex-direction: column;
+ max-height: calc(100vh - 100px);
+}
+.kanban-col-header {
+ padding: 10px 14px; font-size: 12px; font-weight: 600;
+ color: var(--text-muted); border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; gap: 6px; flex-shrink: 0;
+}
+.kanban-col-header .col-count {
+ background: var(--border); border-radius: 10px;
+ padding: 1px 6px; font-size: 11px;
+}
+.kanban-cards { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
+
+.kanban-card {
+ background: var(--main-bg); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 10px 12px;
+ cursor: pointer; transition: border-color .15s;
+}
+.kanban-card:hover { border-color: var(--accent); }
+.kanban-card-id { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }
+.kanban-card-title { font-size: 13px; color: var(--text-bright); line-height: 1.4; margin-bottom: 6px; }
+.kanban-card-meta { display: flex; gap: 4px; flex-wrap: wrap; }
+
+/* ─── SR Table ──────────────────────────────────── */
+.list-toolbar { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
+.search-box {
+ flex: 1; min-width: 200px;
+ background: var(--card-bg); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 7px 12px;
+ color: var(--text-primary); font-size: 13px; outline: none;
+}
+.search-box:focus { border-color: var(--accent); }
+.filter-select {
+ background: var(--card-bg); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 7px 10px;
+ color: var(--text-primary); font-size: 13px; outline: none; cursor: pointer;
+}
+.filter-select:focus { border-color: var(--accent); }
+
+.sr-table { width: 100%; border-collapse: collapse; }
+.sr-table th {
+ text-align: left; padding: 10px 12px; font-size: 11px;
+ font-weight: 600; color: var(--text-muted); text-transform: uppercase;
+ letter-spacing: .05em; border-bottom: 1px solid var(--border);
+}
+.sr-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
+.sr-table tbody tr { cursor: pointer; transition: background .1s; }
+.sr-table tbody tr:hover { background: var(--card-bg); }
+
+/* ─── Audit ─────────────────────────────────────── */
+.audit-header-row {
+ display: flex; align-items: center; gap: 12px;
+ margin-bottom: 14px; flex-wrap: wrap;
+}
+.audit-title { font-size: 15px; font-weight: 600; color: var(--text-bright); flex: 1; }
+#verify-result { font-size: 13px; font-weight: 600; }
+#verify-result.ok { color: var(--green); }
+#verify-result.fail { color: var(--red); }
+
+.hash-code { font-family: "Consolas", monospace; font-size: 11px; color: var(--text-muted); }
+
+/* ─── CMDB ───────────────────────────────────────── */
+.cmdb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
+.cmdb-card {
+ background: var(--card-bg); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); overflow: hidden;
+}
+.cmdb-card-header {
+ padding: 12px 16px; border-bottom: 1px solid var(--border);
+ font-weight: 700; color: var(--text-bright); display: flex; justify-content: space-between;
+}
+.cmdb-servers { padding: 8px 0; }
+.cmdb-server-row {
+ display: flex; align-items: center; gap: 10px;
+ padding: 7px 16px; font-size: 13px;
+}
+.cmdb-server-row:hover { background: rgba(255,255,255,.04); }
+.server-role-badge {
+ padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 700;
+}
+.role-WEB { background: rgba(31,111,235,.25); color: #79c0ff; }
+.role-WAS { background: rgba(46,160,67,.25); color: #56d364; }
+.role-DB { background: rgba(137,87,229,.25); color: #bc8cff; }
+.server-active { color: var(--green); font-size: 12px; margin-left: auto; }
+.server-inactive { color: var(--text-muted); font-size: 12px; margin-left: auto; }
+
+/* ─── Modal ─────────────────────────────────────── */
+#modal-overlay, #new-sr-overlay {
+ position: fixed; inset: 0; background: rgba(0,0,0,.6);
+ z-index: 100; display: flex; align-items: center; justify-content: center;
+}
+#modal-overlay.hidden, #new-sr-overlay.hidden { display: none; }
+
+#modal {
+ background: var(--card-bg); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); width: 660px; max-width: 95vw;
+ max-height: 85vh; overflow-y: auto; padding: 24px; position: relative;
+}
+#new-sr-modal {
+ background: var(--card-bg); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); width: 500px; max-width: 95vw;
+ padding: 24px; position: relative;
+}
+#new-sr-modal h2 { font-size: 16px; margin-bottom: 16px; color: var(--text-bright); }
+
+.modal-close {
+ position: absolute; top: 14px; right: 14px;
+ background: none; border: none; color: var(--text-muted);
+ font-size: 20px; cursor: pointer; line-height: 1;
+}
+.modal-close:hover { color: var(--text-bright); }
+
+/* Modal detail sections */
+.modal-title { font-size: 18px; font-weight: 700; color: var(--text-bright); margin-bottom: 12px; }
+.modal-meta { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
+.modal-section { margin-bottom: 16px; }
+.modal-section-title { font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 8px; }
+.modal-desc { font-size: 13px; color: var(--text-primary); line-height: 1.6; }
+.modal-actions { display: flex; gap: 8px; margin-top: 16px; }
+
+/* Status timeline */
+.timeline { border-left: 2px solid var(--border); padding-left: 16px; }
+.timeline-item { position: relative; margin-bottom: 12px; }
+.timeline-item::before {
+ content: ""; position: absolute; left: -20px; top: 4px;
+ width: 8px; height: 8px; border-radius: 50%;
+ background: var(--border); border: 2px solid var(--card-bg);
+}
+.timeline-item.done::before { background: var(--green); }
+.timeline-item.active::before { background: var(--accent); }
+.timeline-action { font-size: 12px; font-weight: 600; color: var(--text-primary); }
+.timeline-detail { font-size: 11px; color: var(--text-muted); }
+
+/* Form */
+label { display: block; margin-bottom: 10px; font-size: 13px; color: var(--text-muted); }
+label input, label select, label textarea {
+ display: block; width: 100%; margin-top: 4px;
+ background: var(--main-bg); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 8px 10px;
+ color: var(--text-primary); font-size: 13px; outline: none;
+ font-family: var(--font);
+}
+label input:focus, label select:focus, label textarea:focus { border-color: var(--accent); }
+.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+
+/* ─── Scrollbar ─────────────────────────────────── */
+* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
+*::-webkit-scrollbar { width: 5px; height: 5px; }
+*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }