feat(itsm): Jira-like ITSM 시스템 구현

- FastAPI + SQLAlchemy(aiosqlite) 기반 SR 상태 머신
  (RECEIVED → PARSED → PENDING_APPROVAL → APPROVED → IN_PROGRESS
   → PENDING_PM_VALIDATION → COMPLETED / FAILED_ROLLBACK)
- PM 승인 워크플로우 (ApprovalFlow 테이블)
- SHA-256 해시 체인 감사 로그 (위변조 방지)
- AES-256-GCM 서버 자격증명 암호화 (IP/PW API 미노출)
- CMDB: 기관(MOF/MOIS/MSS) + 서버 정보 관리
- 더미 데이터 자동 시딩 (6개 SR, 3개 기관, 6개 서버)
- Dark-theme SPA: 대시보드 / 칸반 보드 / SR 목록 / 감사 로그 / CMDB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPRython 2026-05-24 19:31:09 +09:00
commit bc85c5228a
14 changed files with 1808 additions and 0 deletions

0
core/__init__.py Normal file
View File

205
core/seed.py Normal file
View File

@ -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()

22
database.py Normal file
View File

@ -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)

33
main.py Normal file
View File

@ -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")

252
models.py Normal file
View File

@ -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()

9
requirements.txt Normal file
View File

@ -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

0
routers/__init__.py Normal file
View File

72
routers/approvals.py Normal file
View File

@ -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

55
routers/audit.py Normal file
View File

@ -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,
}

34
routers/cmdb.py Normal file
View File

@ -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()

156
routers/tasks.py Normal file
View File

@ -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

446
static/app.js Normal file
View File

@ -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 = `
<div class="stat-card accent">
<div class="stat-value">${s.total || 0}</div>
<div class="stat-label">전체 SR</div>
</div>
<div class="stat-card yellow">
<div class="stat-value">${pending}</div>
<div class="stat-label">승인 대기</div>
</div>
<div class="stat-card accent">
<div class="stat-value">${active}</div>
<div class="stat-label">진행 </div>
</div>
<div class="stat-card green">
<div class="stat-value">${completed}</div>
<div class="stat-label">완료</div>
</div>
<div class="stat-card red">
<div class="stat-value">${failed}</div>
<div class="stat-label">롤백 실패</div>
</div>
`;
// Recent SR list
const recent = [...srCache].slice(0, 10);
document.getElementById("recent-list").innerHTML = recent.map(sr => `
<div class="recent-row" onclick="openDetail('${sr.sr_id}')">
<span class="recent-sr-id">${sr.sr_id}</span>
<span class="recent-title">${esc(sr.title)}</span>
<span class="badge badge-${sr.status}">${STATUS_LABEL[sr.status] || sr.status}</span>
<span class="recent-time">${fmtDate(sr.created_at)}</span>
</div>
`).join("") || '<div style="padding:12px 16px;color:var(--text-muted)">SR이 없습니다.</div>';
// Status bar chart
const total = s.total || 1;
const items = Object.entries(bs)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `
<div class="status-bar-item">
<div class="status-bar-label">
<span>${STATUS_LABEL[k] || k}</span>
<span>${v}</span>
</div>
<div class="status-bar-track">
<div class="status-bar-fill" style="width:${Math.round(v/total*100)}%;background:${STATUS_COLORS[k]||'#8b949e'}"></div>
</div>
</div>`).join("");
document.getElementById("status-chart").innerHTML =
`<div class="status-bar-list">${items}</div>`;
}
/* ─── 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 = `
<div class="kanban-col-header">
<span class="badge badge-${col.key}">${col.label}</span>
<span class="col-count">${cards.length}</span>
</div>
<div class="kanban-cards" id="col-${col.key}"></div>`;
board.appendChild(colEl);
const cardsEl = colEl.querySelector(`#col-${col.key}`);
cards.forEach(sr => {
const card = document.createElement("div");
card.className = "kanban-card";
card.innerHTML = `
<div class="kanban-card-id">${sr.sr_id}</div>
<div class="kanban-card-title">${esc(sr.title)}</div>
<div class="kanban-card-meta">
<span class="badge badge-type-${sr.sr_type}">${TYPE_LABEL[sr.sr_type] || sr.sr_type}</span>
<span class="badge badge-priority-${sr.priority}">${PRIORITY_LABEL[sr.priority] || sr.priority}</span>
</div>`;
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 => `
<tr onclick="openDetail('${sr.sr_id}')">
<td><code style="font-size:11px">${sr.sr_id}</code></td>
<td><span class="badge badge-type-${sr.sr_type}">${TYPE_LABEL[sr.sr_type] || sr.sr_type}</span></td>
<td>${esc(sr.title)}</td>
<td><span class="badge badge-${sr.status}">${STATUS_LABEL[sr.status] || sr.status}</span></td>
<td><span class="badge badge-priority-${sr.priority}">${PRIORITY_LABEL[sr.priority] || sr.priority}</span></td>
<td>${esc(sr.requested_by || "")}</td>
<td style="font-size:12px;color:var(--text-muted)">${fmtDate(sr.created_at)}</td>
</tr>
`).join("") || `<tr><td colspan="7" style="color:var(--text-muted);text-align:center;padding:20px">결과 없음</td></tr>`;
}
/* ─── 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 => `
<div style="font-size:13px;padding:6px 0;border-bottom:1px solid var(--border)">
<strong>${esc(a.approver)}</strong>
<span class="badge badge-${a.result === "APPROVED" ? "COMPLETED" : a.result === "REJECTED" ? "REJECTED" : "PENDING_APPROVAL"}" style="margin-left:8px">
${a.result === "APPROVED" ? "승인" : a.result === "REJECTED" ? "반려" : "대기"}
</span>
${a.comment ? `<span style="color:var(--text-muted);margin-left:8px">${esc(a.comment)}</span>` : ""}
</div>`).join("")
: '<div style="color:var(--text-muted);font-size:13px">승인 기록 없음</div>';
const auditHTML = auditRes.slice(0, 10).map(log => `
<div class="timeline-item done">
<div class="timeline-action">${esc(log.action)} <span style="color:var(--text-muted);font-weight:400">by ${esc(log.actor || "system")}</span></div>
<div class="timeline-detail">${esc(log.detail || "")} &nbsp;<span class="hash-code">#${(log.log_hash || "").slice(0, 12)}</span></div>
</div>`).join("") || '<div style="color:var(--text-muted);font-size:13px"> </div>';
const canApprove = sr.status === "PENDING_APPROVAL";
document.getElementById("modal-body").innerHTML = `
<div class="modal-title">${esc(sr.title)}</div>
<div class="modal-meta">
<span class="badge badge-${sr.status}">${STATUS_LABEL[sr.status] || sr.status}</span>
<span class="badge badge-type-${sr.sr_type}">${TYPE_LABEL[sr.sr_type] || sr.sr_type}</span>
<span class="badge badge-priority-${sr.priority}">${PRIORITY_LABEL[sr.priority] || sr.priority}</span>
<code style="font-size:11px;color:var(--text-muted)">${sr.sr_id}</code>
</div>
<div class="modal-section">
<div class="modal-section-title">요약 정보</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:13px">
<div><span style="color:var(--text-muted)">요청자:</span> ${esc(sr.requested_by || "")}</div>
<div><span style="color:var(--text-muted)">담당자:</span> ${esc(sr.assigned_to || "")}</div>
<div><span style="color:var(--text-muted)">대상 서버:</span> ${esc(sr.target_server || "-")}</div>
<div><span style="color:var(--text-muted)">생성일:</span> ${fmtDate(sr.created_at)}</div>
</div>
</div>
${sr.description ? `
<div class="modal-section">
<div class="modal-section-title">설명</div>
<div class="modal-desc">${esc(sr.description)}</div>
</div>` : ""}
<div class="modal-section">
<div class="modal-section-title">승인 현황</div>
${approvalHTML}
</div>
<div class="modal-section">
<div class="modal-section-title">감사 로그</div>
<div class="timeline">${auditHTML}</div>
</div>
${canApprove ? `
<div class="modal-actions" id="approval-actions">
<input type="text" id="approver-name" placeholder="승인자 이름" class="search-box" style="max-width:160px">
<input type="text" id="approver-comment" placeholder="코멘트(선택)" class="search-box" style="max-width:200px">
<button class="btn btn-approve" onclick="doApproval('${srId}', 'APPROVED')"> 승인</button>
<button class="btn btn-reject" onclick="doApproval('${srId}', 'REJECTED')"> 반려</button>
</div>` : ""}
`;
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) => `
<tr>
<td style="color:var(--text-muted)">${i + 1}</td>
<td><code style="font-size:11px">${esc(log.sr_id || "—")}</code></td>
<td>${esc(log.actor || "system")}</td>
<td><strong>${esc(log.action)}</strong></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(log.detail || "")}</td>
<td class="hash-code">${(log.log_hash || "").slice(0, 12)}</td>
<td style="font-size:12px;color:var(--text-muted)">${fmtDate(log.created_at)}</td>
</tr>`).join("") || `<tr><td colspan="7" style="text-align:center;padding:20px;color:var(--text-muted)"> </td></tr>`;
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 = `
<div class="cmdb-card-header">
<span>${esc(inst.inst_name)}</span>
<span style="font-size:11px;color:var(--text-muted)">${esc(inst.inst_code)}</span>
</div>
<div class="cmdb-servers">
${servers.map(s => `
<div class="cmdb-server-row">
<span class="server-role-badge role-${s.server_role}">${s.server_role}</span>
<span>${esc(s.server_name)}</span>
<span style="font-size:11px;color:var(--text-muted)">${esc(s.os_type || "")}</span>
<span class="${s.is_active ? "server-active" : "server-inactive"}">${s.is_active ? "● 정상" : "● 비활성"}</span>
</div>`).join("") || '<div style="padding:8px 16px;color:var(--text-muted);font-size:12px"> </div>'}
</div>
${inst.contact_pm ? `<div style="padding:8px 16px;font-size:12px;color:var(--text-muted);border-top:1px solid var(--border)">PM: ${esc(inst.contact_pm)}</div>` : ""}
`;
grid.appendChild(card);
}));
}
/* ─── Helpers ───────────────────────────────────── */
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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; }
}

186
static/index.html Normal file
View File

@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUARDiA ITSM</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div id="app">
<!-- ── Sidebar ───────────────────────────────────── -->
<aside id="sidebar">
<div id="sidebar-logo">
<div class="logo-icon">G</div>
<div>
<div class="logo-title">GUARDiA ITSM</div>
<div class="logo-sub">인프라 자동화 플랫폼</div>
</div>
</div>
<nav id="sidebar-nav">
<div class="nav-item active" data-view="dashboard">
<span class="nav-icon">📊</span> 대시보드
</div>
<div class="nav-item" data-view="board">
<span class="nav-icon">🗂️</span> 칸반 보드
</div>
<div class="nav-item" data-view="list">
<span class="nav-icon">📋</span> SR 목록
</div>
<div class="nav-item" data-view="audit">
<span class="nav-icon">🔐</span> 감사 로그
</div>
<div class="nav-item" data-view="cmdb">
<span class="nav-icon">🖥️</span> CMDB
</div>
</nav>
<div id="sidebar-footer">
<div class="status-dot online"></div>
<span>시스템 정상</span>
</div>
</aside>
<!-- ── Main ──────────────────────────────────────── -->
<main id="main">
<!-- Top bar -->
<header id="topbar">
<h1 id="page-title">대시보드</h1>
<div id="topbar-actions">
<button class="btn btn-primary" id="btn-new-sr">+ 새 SR</button>
</div>
</header>
<!-- Views -->
<div id="view-dashboard" class="view active">
<div class="stats-row" id="stats-row">
<!-- filled by JS -->
</div>
<div class="dashboard-grid">
<div class="card" id="recent-list-card">
<div class="card-header">최근 SR</div>
<div class="card-body" id="recent-list"></div>
</div>
<div class="card" id="status-chart-card">
<div class="card-header">상태별 현황</div>
<div class="card-body" id="status-chart"></div>
</div>
</div>
</div>
<div id="view-board" class="view">
<div id="kanban-board">
<!-- columns rendered by JS -->
</div>
</div>
<div id="view-list" class="view">
<div class="list-toolbar">
<input type="text" id="search-input" placeholder="SR 제목 검색…" class="search-box">
<select id="filter-status" class="filter-select">
<option value="">전체 상태</option>
<option value="RECEIVED">접수</option>
<option value="PARSED">파싱 완료</option>
<option value="PENDING_APPROVAL">승인 대기</option>
<option value="APPROVED">승인됨</option>
<option value="IN_PROGRESS">진행 중</option>
<option value="PENDING_PM_VALIDATION">PM 검증 대기</option>
<option value="COMPLETED">완료</option>
<option value="FAILED_ROLLBACK">롤백 실패</option>
<option value="REJECTED">반려</option>
</select>
<select id="filter-type" class="filter-select">
<option value="">전체 유형</option>
<option value="DEPLOY">배포</option>
<option value="RESTART">재기동</option>
<option value="LOG">로그</option>
<option value="INQUIRY">문의</option>
<option value="OTHER">기타</option>
</select>
</div>
<table class="sr-table" id="sr-table">
<thead>
<tr>
<th>SR ID</th><th>유형</th><th>제목</th>
<th>상태</th><th>우선순위</th><th>요청자</th><th>생성일</th>
</tr>
</thead>
<tbody id="sr-tbody"></tbody>
</table>
</div>
<div id="view-audit" class="view">
<div class="audit-header-row">
<span class="audit-title">감사 로그 (SHA-256 해시 체인)</span>
<button class="btn btn-secondary" id="btn-verify">체인 무결성 검증</button>
<span id="verify-result"></span>
</div>
<table class="sr-table" id="audit-table">
<thead>
<tr>
<th>#</th><th>SR</th><th>행위자</th><th>액션</th><th>내용</th>
<th>해시 (앞 12자)</th><th>시각</th>
</tr>
</thead>
<tbody id="audit-tbody"></tbody>
</table>
</div>
<div id="view-cmdb" class="view">
<div class="cmdb-grid" id="cmdb-grid"><!-- filled by JS --></div>
</div>
</main>
</div>
<!-- ── SR Detail Modal ────────────────────────────── -->
<div id="modal-overlay" class="hidden">
<div id="modal">
<button class="modal-close" id="modal-close-btn">×</button>
<div id="modal-body"><!-- filled by JS --></div>
</div>
</div>
<!-- ── New SR Modal ───────────────────────────────── -->
<div id="new-sr-overlay" class="hidden">
<div id="new-sr-modal">
<button class="modal-close" id="new-sr-close">×</button>
<h2>새 SR 생성</h2>
<form id="new-sr-form">
<label>제목 <input type="text" name="title" required></label>
<label>설명 <textarea name="description" rows="3"></textarea></label>
<div class="form-row">
<label>유형
<select name="sr_type">
<option value="OTHER">기타</option>
<option value="DEPLOY">배포</option>
<option value="RESTART">재기동</option>
<option value="LOG">로그</option>
<option value="INQUIRY">문의</option>
</select>
</label>
<label>우선순위
<select name="priority">
<option value="MEDIUM">보통</option>
<option value="CRITICAL">긴급</option>
<option value="HIGH">높음</option>
<option value="LOW">낮음</option>
</select>
</label>
</div>
<div class="form-row">
<label>요청자 <input type="text" name="requested_by" required></label>
<label>기관코드 <input type="text" name="inst_code" placeholder="MOF / MOIS / MSS"></label>
</div>
<label>대상 서버 <input type="text" name="target_server" placeholder="예: MOF-WAS-01"></label>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:8px">생성</button>
</form>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

338
static/style.css Normal file
View File

@ -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; }