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:
parent
a3b3aaf29e
commit
79061ee89c
0
itsm/core/__init__.py
Normal file
0
itsm/core/__init__.py
Normal file
205
itsm/core/seed.py
Normal file
205
itsm/core/seed.py
Normal 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
itsm/database.py
Normal file
22
itsm/database.py
Normal 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
itsm/main.py
Normal file
33
itsm/main.py
Normal 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
itsm/models.py
Normal file
252
itsm/models.py
Normal 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
itsm/requirements.txt
Normal file
9
itsm/requirements.txt
Normal 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
itsm/routers/__init__.py
Normal file
0
itsm/routers/__init__.py
Normal file
72
itsm/routers/approvals.py
Normal file
72
itsm/routers/approvals.py
Normal 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
itsm/routers/audit.py
Normal file
55
itsm/routers/audit.py
Normal 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
itsm/routers/cmdb.py
Normal file
34
itsm/routers/cmdb.py
Normal 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
itsm/routers/tasks.py
Normal file
156
itsm/routers/tasks.py
Normal 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
itsm/static/app.js
Normal file
446
itsm/static/app.js
Normal 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 || "")} <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, "&").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; }
|
||||
}
|
||||
186
itsm/static/index.html
Normal file
186
itsm/static/index.html
Normal 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
itsm/static/style.css
Normal file
338
itsm/static/style.css
Normal 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; }
|
||||
Loading…
Reference in New Issue
Block a user