From 79061ee89c4e68955997c068cc5c318e394cecad Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Sun, 24 May 2026 19:31:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(itsm):=20Jira-like=20ITSM=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- itsm/core/__init__.py | 0 itsm/core/seed.py | 205 ++++++++++++++++++ itsm/database.py | 22 ++ itsm/main.py | 33 +++ itsm/models.py | 252 +++++++++++++++++++++ itsm/requirements.txt | 9 + itsm/routers/__init__.py | 0 itsm/routers/approvals.py | 72 ++++++ itsm/routers/audit.py | 55 +++++ itsm/routers/cmdb.py | 34 +++ itsm/routers/tasks.py | 156 +++++++++++++ itsm/static/app.js | 446 ++++++++++++++++++++++++++++++++++++++ itsm/static/index.html | 186 ++++++++++++++++ itsm/static/style.css | 338 +++++++++++++++++++++++++++++ 14 files changed, 1808 insertions(+) create mode 100644 itsm/core/__init__.py create mode 100644 itsm/core/seed.py create mode 100644 itsm/database.py create mode 100644 itsm/main.py create mode 100644 itsm/models.py create mode 100644 itsm/requirements.txt create mode 100644 itsm/routers/__init__.py create mode 100644 itsm/routers/approvals.py create mode 100644 itsm/routers/audit.py create mode 100644 itsm/routers/cmdb.py create mode 100644 itsm/routers/tasks.py create mode 100644 itsm/static/app.js create mode 100644 itsm/static/index.html create mode 100644 itsm/static/style.css diff --git a/itsm/core/__init__.py b/itsm/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/itsm/core/seed.py b/itsm/core/seed.py new file mode 100644 index 00000000..2b9afa48 --- /dev/null +++ b/itsm/core/seed.py @@ -0,0 +1,205 @@ +"""Seed dummy data for GUARDiA ITSM demo.""" +import base64 +import os +from datetime import datetime, timedelta +from uuid import uuid4 + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from sqlalchemy.ext.asyncio import AsyncSession + +from models import ( + ApprovalFlow, ApprovalResult, AuditLog, Institution, OpsTask, + Priority, SRRequest, SRStatus, SRType, Server, compute_log_hash +) + + +def _encrypt_pw(plain: str) -> str: + """AES-256-GCM encrypt. Key from env (demo: fixed 32-byte key).""" + key = os.environ.get("GUARDIA_ENC_KEY", "guardia-demo-key-32bytes-padding!").encode()[:32] + key = key.ljust(32, b"\x00") + aesgcm = AESGCM(key) + nonce = os.urandom(12) + ct = aesgcm.encrypt(nonce, plain.encode(), None) + return base64.b64encode(nonce + ct).decode() + + +def _new_sr() -> str: + return f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}" + + +async def seed_all(db: AsyncSession) -> None: + from sqlalchemy import select + result = await db.execute(select(Institution)) + if result.scalars().first(): + return # already seeded + + # ── Institutions ──────────────────────────────────────────── + inst_data = [ + {"inst_code": "MOF", "inst_name": "기획재정부", "org_type": "중앙행정기관", "contact_pm": "김PM"}, + {"inst_code": "MOIS", "inst_name": "행정안전부", "org_type": "중앙행정기관", "contact_pm": "이PM"}, + {"inst_code": "MSS", "inst_name": "중소벤처기업부", "org_type": "중앙행정기관", "contact_pm": "박PM"}, + ] + institutions = [] + for d in inst_data: + inst = Institution(**d) + db.add(inst) + institutions.append(inst) + await db.flush() + + # ── Servers ───────────────────────────────────────────────── + server_data = [ + # MOF + {"inst": institutions[0], "server_name": "MOF-WEB-01", "server_role": "WEB", + "os_type": "RHEL", "os_version": "8.9", "ip_addr": "10.10.1.11", + "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22}, + {"inst": institutions[0], "server_name": "MOF-WAS-01", "server_role": "WAS", + "os_type": "RHEL", "os_version": "8.9", "ip_addr": "10.10.1.21", + "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22}, + {"inst": institutions[0], "server_name": "MOF-DB-01", "server_role": "DB", + "os_type": "RHEL", "os_version": "8.9", "ip_addr": "10.10.1.31", + "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22}, + # MOIS + {"inst": institutions[1], "server_name": "MOIS-WEB-01", "server_role": "WEB", + "os_type": "CentOS", "os_version": "7.9", "ip_addr": "10.20.1.11", + "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22}, + {"inst": institutions[1], "server_name": "MOIS-WAS-01", "server_role": "WAS", + "os_type": "CentOS", "os_version": "7.9", "ip_addr": "10.20.1.21", + "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22}, + # MSS + {"inst": institutions[2], "server_name": "MSS-WAS-01", "server_role": "WAS", + "os_type": "Ubuntu", "os_version": "22.04", "ip_addr": "10.30.1.21", + "ssh_user": "opsagent", "os_pw_enc": _encrypt_pw("DEMO_MASKED"), "port": 22}, + ] + for sd in server_data: + inst = sd.pop("inst") + srv = Server(inst_id=inst.id, **sd) + db.add(srv) + await db.flush() + + # ── SR Requests ───────────────────────────────────────────── + now = datetime.now() + sr_data = [ + { + "sr_id": "SR-20260524-AA1B2C", "inst": institutions[0], + "sr_type": SRType.DEPLOY, "title": "기재부 예산시스템 WAS 배포", + "description": "2026년 2차 추경 예산시스템 class 파일 배포", + "status": SRStatus.COMPLETED, "priority": Priority.HIGH, + "requested_by": "홍길동", "assigned_to": "운영팀", + "target_server": "MOF-WAS-01", + "created_at": now - timedelta(days=3), + "updated_at": now - timedelta(days=2), + }, + { + "sr_id": "SR-20260524-BB3C4D", "inst": institutions[0], + "sr_type": SRType.RESTART, "title": "기재부 WAS-02 재기동 요청", + "description": "OutOfMemoryError 발생으로 WAS 재기동 필요", + "status": SRStatus.PENDING_APPROVAL, "priority": Priority.CRITICAL, + "requested_by": "김운영", "assigned_to": "운영팀", + "target_server": "MOF-WAS-01", + "created_at": now - timedelta(hours=2), + "updated_at": now - timedelta(hours=1), + }, + { + "sr_id": "SR-20260524-CC5D6E", "inst": institutions[1], + "sr_type": SRType.DEPLOY, "title": "행안부 민원포털 정적파일 배포", + "description": "UI 개선 HTML/JS/CSS 정적 파일 배포", + "status": SRStatus.IN_PROGRESS, "priority": Priority.MEDIUM, + "requested_by": "이배포", "assigned_to": "운영팀", + "target_server": "MOIS-WEB-01", + "created_at": now - timedelta(hours=5), + "updated_at": now - timedelta(minutes=30), + }, + { + "sr_id": "SR-20260524-DD7E8F", "inst": institutions[2], + "sr_type": SRType.LOG, "title": "중기부 WAS 에러 로그 분석", + "description": "Connection pool exhausted 오류 원인 분석 요청", + "status": SRStatus.RECEIVED, "priority": Priority.MEDIUM, + "requested_by": "박운영", "assigned_to": None, + "target_server": "MSS-WAS-01", + "created_at": now - timedelta(minutes=20), + "updated_at": now - timedelta(minutes=20), + }, + { + "sr_id": "SR-20260524-EE9F0A", "inst": institutions[0], + "sr_type": SRType.INQUIRY, "title": "기재부 SSL 인증서 만료 갱신", + "description": "portal.mof.go.kr SSL 인증서 D-14 갱신 요청", + "status": SRStatus.APPROVED, "priority": Priority.HIGH, + "requested_by": "최보안", "assigned_to": "보안팀", + "target_server": "MOF-WEB-01", + "created_at": now - timedelta(days=1), + "updated_at": now - timedelta(hours=3), + }, + { + "sr_id": "SR-20260524-FF1A2B", "inst": institutions[1], + "sr_type": SRType.RESTART, "title": "행안부 WAS 롤링 재기동", + "description": "주간 정기 점검 롤링 재기동", + "status": SRStatus.FAILED_ROLLBACK, "priority": Priority.LOW, + "requested_by": "이운영", "assigned_to": "운영팀", + "target_server": "MOIS-WAS-01", + "created_at": now - timedelta(days=2), + "updated_at": now - timedelta(days=1), + }, + ] + + sr_objs = [] + for sd in sr_data: + inst = sd.pop("inst") + sr = SRRequest(inst_id=inst.id, **sd) + db.add(sr) + sr_objs.append(sr) + await db.flush() + + # ── Approvals ──────────────────────────────────────────────── + approval_data = [ + {"sr": sr_objs[0], "approver": "김PM", "result": ApprovalResult.APPROVED, + "comment": "정상 배포 승인", "decided_at": now - timedelta(days=2, hours=22)}, + {"sr": sr_objs[1], "approver": "김PM", "result": ApprovalResult.PENDING, + "comment": None, "decided_at": None}, + {"sr": sr_objs[4], "approver": "김PM", "result": ApprovalResult.APPROVED, + "comment": "긴급 갱신 승인", "decided_at": now - timedelta(hours=4)}, + {"sr": sr_objs[5], "approver": "이PM", "result": ApprovalResult.APPROVED, + "comment": "정기 점검 승인", "decided_at": now - timedelta(days=1, hours=20)}, + ] + for ad in approval_data: + sr = ad.pop("sr") + apv = ApprovalFlow(sr_id=sr.sr_id, **ad) + db.add(apv) + await db.flush() + + # ── Audit Logs with hash chain ─────────────────────────────── + prev_hash = None + audit_entries = [ + {"sr": sr_objs[0], "actor": "홍길동", "action": "SR_CREATED", "detail": "배포 SR 생성"}, + {"sr": sr_objs[0], "actor": "김PM", "action": "SR_APPROVED", "detail": "PM 승인"}, + {"sr": sr_objs[0], "actor": "system", "action": "SR_COMPLETED","detail": "배포 완료"}, + {"sr": sr_objs[1], "actor": "김운영", "action": "SR_CREATED", "detail": "재기동 SR 생성"}, + {"sr": sr_objs[2], "actor": "이배포", "action": "SR_CREATED", "detail": "정적파일 배포 SR 생성"}, + {"sr": sr_objs[2], "actor": "system", "action": "SR_STARTED", "detail": "배포 작업 시작"}, + ] + ts_base = now - timedelta(days=3) + for i, ae in enumerate(audit_entries): + sr = ae.pop("sr") + ts = (ts_base + timedelta(hours=i * 4)).isoformat() + log_hash = compute_log_hash(prev_hash, ae["actor"], ae["action"], ae["detail"], ts) + log = AuditLog( + sr_id=sr.sr_id, prev_hash=prev_hash, log_hash=log_hash, + created_at=ts_base + timedelta(hours=i * 4), **ae + ) + db.add(log) + prev_hash = log_hash + await db.flush() + + # ── OPS Tasks ──────────────────────────────────────────────── + task_data = [ + {"sr": sr_objs[0], "task_name": "파일 전송 (SFTP)", "task_order": 1, "status": "COMPLETED"}, + {"sr": sr_objs[0], "task_name": "WAS 롤링 재기동", "task_order": 2, "status": "COMPLETED"}, + {"sr": sr_objs[0], "task_name": "헬스체크 확인", "task_order": 3, "status": "COMPLETED"}, + {"sr": sr_objs[2], "task_name": "파일 전송 (SFTP)", "task_order": 1, "status": "COMPLETED"}, + {"sr": sr_objs[2], "task_name": "Nginx 설정 리로드", "task_order": 2, "status": "IN_PROGRESS"}, + ] + for td in task_data: + sr = td.pop("sr") + task = OpsTask(sr_id=sr.sr_id, **td) + db.add(task) + + await db.commit() diff --git a/itsm/database.py b/itsm/database.py new file mode 100644 index 00000000..ce780a72 --- /dev/null +++ b/itsm/database.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +DATABASE_URL = "sqlite+aiosqlite:///./guardia_itsm.db" + +engine = create_async_engine(DATABASE_URL, echo=False) +SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with SessionLocal() as session: + yield session + + +async def init_db(): + from models import Base as ModelBase # noqa: F401 + async with engine.begin() as conn: + await conn.run_sync(ModelBase.metadata.create_all) diff --git a/itsm/main.py b/itsm/main.py new file mode 100644 index 00000000..91da7e4d --- /dev/null +++ b/itsm/main.py @@ -0,0 +1,33 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from database import init_db +from routers import approvals, audit, cmdb, tasks + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + from database import SessionLocal + from core.seed import seed_all + async with SessionLocal() as db: + await seed_all(db) + yield + + +app = FastAPI(title="GUARDiA ITSM", version="1.0.0", lifespan=lifespan) + +app.include_router(tasks.router) +app.include_router(approvals.router) +app.include_router(cmdb.router) +app.include_router(audit.router) + +app.mount("/static", StaticFiles(directory="static"), name="static") + + +@app.get("/") +async def index(): + return FileResponse("static/index.html") diff --git a/itsm/models.py b/itsm/models.py new file mode 100644 index 00000000..86111c13 --- /dev/null +++ b/itsm/models.py @@ -0,0 +1,252 @@ +""" +ORM models + Pydantic schemas for GUARDiA ITSM +""" +from __future__ import annotations + +import hashlib +import json +from datetime import datetime +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import ( + Boolean, Column, DateTime, ForeignKey, Integer, String, Text, func +) +from sqlalchemy.orm import relationship + +from database import Base + + +# ── Enums ────────────────────────────────────────────────────────────────────── + +class SRStatus(str, Enum): + RECEIVED = "RECEIVED" + PARSED = "PARSED" + PENDING_APPROVAL = "PENDING_APPROVAL" + APPROVED = "APPROVED" + IN_PROGRESS = "IN_PROGRESS" + PENDING_PM_VALIDATION = "PENDING_PM_VALIDATION" + COMPLETED = "COMPLETED" + FAILED_ROLLBACK = "FAILED_ROLLBACK" + REJECTED = "REJECTED" + + +class SRType(str, Enum): + DEPLOY = "DEPLOY" + RESTART = "RESTART" + LOG = "LOG" + INQUIRY = "INQUIRY" + OTHER = "OTHER" + + +class Priority(str, Enum): + CRITICAL = "CRITICAL" + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" + + +class ApprovalResult(str, Enum): + PENDING = "PENDING" + APPROVED = "APPROVED" + REJECTED = "REJECTED" + + +# ── ORM Models ───────────────────────────────────────────────────────────────── + +class Institution(Base): + __tablename__ = "tb_inst_meta" + + id = Column(Integer, primary_key=True, index=True) + inst_code = Column(String(20), unique=True, nullable=False, index=True) + inst_name = Column(String(100), nullable=False) + org_type = Column(String(50)) + contact_pm = Column(String(100)) + created_at = Column(DateTime, default=func.now()) + + servers = relationship("Server", back_populates="institution") + sr_requests = relationship("SRRequest", back_populates="institution") + + +class Server(Base): + __tablename__ = "tb_server_info" + + id = Column(Integer, primary_key=True, index=True) + inst_id = Column(Integer, ForeignKey("tb_inst_meta.id"), nullable=False) + server_name = Column(String(100), nullable=False) + server_role = Column(String(20)) # WEB / WAS / DB + os_type = Column(String(50)) + os_version = Column(String(50)) + ip_addr = Column(String(45)) # NOT exposed in API responses + ssh_user = Column(String(50)) # NOT exposed + os_pw_enc = Column(Text) # AES-256 encrypted, NEVER exposed + port = Column(Integer, default=22) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + + institution = relationship("Institution", back_populates="servers") + + +class SRRequest(Base): + __tablename__ = "tb_sr_request" + + id = Column(Integer, primary_key=True, index=True) + sr_id = Column(String(30), unique=True, nullable=False, index=True) + inst_id = Column(Integer, ForeignKey("tb_inst_meta.id")) + sr_type = Column(String(20), default=SRType.OTHER) + title = Column(String(200), nullable=False) + description = Column(Text) + status = Column(String(30), default=SRStatus.RECEIVED) + priority = Column(String(20), default=Priority.MEDIUM) + requested_by = Column(String(100)) + assigned_to = Column(String(100)) + target_server = Column(String(100)) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + institution = relationship("Institution", back_populates="sr_requests") + approvals = relationship("ApprovalFlow", back_populates="sr_request") + audit_logs = relationship("AuditLog", back_populates="sr_request") + + +class ApprovalFlow(Base): + __tablename__ = "tb_approval_flow" + + id = Column(Integer, primary_key=True, index=True) + sr_id = Column(String(30), ForeignKey("tb_sr_request.sr_id"), nullable=False) + approver = Column(String(100), nullable=False) + result = Column(String(20), default=ApprovalResult.PENDING) + comment = Column(Text) + decided_at = Column(DateTime) + created_at = Column(DateTime, default=func.now()) + + sr_request = relationship("SRRequest", back_populates="approvals") + + +class AuditLog(Base): + __tablename__ = "tb_audit_log" + + id = Column(Integer, primary_key=True, index=True) + sr_id = Column(String(30), ForeignKey("tb_sr_request.sr_id")) + actor = Column(String(100)) + action = Column(String(100), nullable=False) + detail = Column(Text) + prev_hash = Column(String(64)) + log_hash = Column(String(64)) + created_at = Column(DateTime, default=func.now()) + + sr_request = relationship("SRRequest", back_populates="audit_logs") + + +class OpsTask(Base): + __tablename__ = "tb_ops_task" + + id = Column(Integer, primary_key=True, index=True) + sr_id = Column(String(30), ForeignKey("tb_sr_request.sr_id")) + task_name = Column(String(200), nullable=False) + task_order = Column(Integer, default=0) + status = Column(String(30), default="PENDING") + result_msg = Column(Text) + started_at = Column(DateTime) + finished_at = Column(DateTime) + created_at = Column(DateTime, default=func.now()) + + +# ── Pydantic Schemas ─────────────────────────────────────────────────────────── + +class InstitutionOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + inst_code: str + inst_name: str + org_type: Optional[str] + contact_pm: Optional[str] + + +class ServerOut(BaseModel): + """Server info — sensitive fields (ip_addr, ssh_user, os_pw_enc) intentionally omitted.""" + model_config = ConfigDict(from_attributes=True) + + id: int + server_name: str + server_role: Optional[str] + os_type: Optional[str] + is_active: bool + + +class SRCreate(BaseModel): + title: str + description: Optional[str] = None + sr_type: SRType = SRType.OTHER + priority: Priority = Priority.MEDIUM + requested_by: str + assigned_to: Optional[str] = None + target_server: Optional[str] = None + inst_code: Optional[str] = None + + +class SROut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + sr_id: str + sr_type: str + title: str + description: Optional[str] + status: str + priority: str + requested_by: str + assigned_to: Optional[str] + target_server: Optional[str] + created_at: datetime + updated_at: datetime + + +class SRStatusUpdate(BaseModel): + status: SRStatus + actor: str + comment: Optional[str] = None + + +class ApprovalCreate(BaseModel): + approver: str + result: ApprovalResult + comment: Optional[str] = None + + +class ApprovalOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + sr_id: str + approver: str + result: str + comment: Optional[str] + decided_at: Optional[datetime] + created_at: datetime + + +class AuditLogOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + sr_id: Optional[str] + actor: Optional[str] + action: str + detail: Optional[str] + log_hash: Optional[str] + created_at: datetime + + +# ── Audit hash helper ────────────────────────────────────────────────────────── + +def compute_log_hash(prev_hash: Optional[str], actor: str, action: str, + detail: str, ts: str) -> str: + payload = json.dumps( + {"prev": prev_hash or "", "actor": actor, "action": action, + "detail": detail, "ts": ts}, + ensure_ascii=False, sort_keys=True + ) + return hashlib.sha256(payload.encode()).hexdigest() diff --git a/itsm/requirements.txt b/itsm/requirements.txt new file mode 100644 index 00000000..43fce8ac --- /dev/null +++ b/itsm/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +sqlalchemy>=2.0.0 +aiosqlite>=0.20.0 +pydantic>=2.10.0 +python-dotenv>=1.0.1 +python-multipart>=0.0.12 +aiofiles>=24.1.0 +cryptography>=42.0.0 diff --git a/itsm/routers/__init__.py b/itsm/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/itsm/routers/approvals.py b/itsm/routers/approvals.py new file mode 100644 index 00000000..c4054761 --- /dev/null +++ b/itsm/routers/approvals.py @@ -0,0 +1,72 @@ +"""PM Approval workflow endpoints.""" +from datetime import datetime +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from models import ( + ApprovalCreate, ApprovalFlow, ApprovalOut, ApprovalResult, + SRRequest, SRStatus, AuditLog, compute_log_hash +) + +router = APIRouter(prefix="/api/approvals", tags=["approvals"]) + + +async def _write_audit(db: AsyncSession, sr_id: str, actor: str, action: str, detail: str) -> None: + result = await db.execute( + select(AuditLog).where(AuditLog.sr_id == sr_id) + .order_by(AuditLog.id.desc()).limit(1) + ) + last = result.scalars().first() + prev_hash = last.log_hash if last else None + ts = datetime.now().isoformat() + log_hash = compute_log_hash(prev_hash, actor, action, detail, ts) + db.add(AuditLog( + sr_id=sr_id, actor=actor, action=action, detail=detail, + prev_hash=prev_hash, log_hash=log_hash + )) + + +@router.get("/{sr_id}", response_model=List[ApprovalOut]) +async def list_approvals(sr_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(ApprovalFlow).where(ApprovalFlow.sr_id == sr_id) + .order_by(ApprovalFlow.created_at) + ) + return result.scalars().all() + + +@router.post("/{sr_id}", response_model=ApprovalOut, status_code=201) +async def decide_approval(sr_id: str, payload: ApprovalCreate, + db: AsyncSession = Depends(get_db)): + r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) + sr = r.scalars().first() + if not sr: + raise HTTPException(404, detail="SR을 찾을 수 없습니다.") + if sr.status != SRStatus.PENDING_APPROVAL: + raise HTTPException(400, detail="승인 대기 상태의 SR만 처리할 수 있습니다.") + + apv = ApprovalFlow( + sr_id=sr_id, + approver=payload.approver, + result=payload.result, + comment=payload.comment, + decided_at=datetime.now(), + ) + db.add(apv) + + if payload.result == ApprovalResult.APPROVED: + sr.status = SRStatus.APPROVED + action, detail = "SR_APPROVED", f"{payload.approver} 승인" + else: + sr.status = SRStatus.REJECTED + action, detail = "SR_REJECTED", f"{payload.approver} 반려: {payload.comment or ''}" + + sr.updated_at = datetime.now() + await _write_audit(db, sr_id, payload.approver, action, detail) + await db.commit() + await db.refresh(apv) + return apv diff --git a/itsm/routers/audit.py b/itsm/routers/audit.py new file mode 100644 index 00000000..bdd1a168 --- /dev/null +++ b/itsm/routers/audit.py @@ -0,0 +1,55 @@ +"""Audit log endpoints with hash-chain verification.""" +import hashlib +import json +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from models import AuditLog, AuditLogOut + +router = APIRouter(prefix="/api/audit", tags=["audit"]) + + +@router.get("", response_model=List[AuditLogOut]) +async def list_audit_logs( + sr_id: Optional[str] = Query(None), + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), +): + q = select(AuditLog).order_by(AuditLog.created_at.desc()) + if sr_id: + q = q.where(AuditLog.sr_id == sr_id) + q = q.offset(skip).limit(limit) + result = await db.execute(q) + return result.scalars().all() + + +@router.get("/verify") +async def verify_chain(db: AsyncSession = Depends(get_db)): + """Verify SHA-256 hash chain integrity.""" + result = await db.execute(select(AuditLog).order_by(AuditLog.id)) + logs = result.scalars().all() + + broken_at: Optional[int] = None + for log in logs: + payload = json.dumps( + {"prev": log.prev_hash or "", "actor": log.actor or "", + "action": log.action, + "detail": log.detail or "", + "ts": log.created_at.isoformat() if log.created_at else ""}, + ensure_ascii=False, sort_keys=True + ) + expected = hashlib.sha256(payload.encode()).hexdigest() + if expected != log.log_hash: + broken_at = log.id + break + + return { + "total": len(logs), + "intact": broken_at is None, + "broken_at_id": broken_at, + } diff --git a/itsm/routers/cmdb.py b/itsm/routers/cmdb.py new file mode 100644 index 00000000..7bc6c6e7 --- /dev/null +++ b/itsm/routers/cmdb.py @@ -0,0 +1,34 @@ +"""CMDB: Institution + Server management endpoints.""" +from typing import List + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from models import Institution, InstitutionOut, Server, ServerOut + +router = APIRouter(prefix="/api/cmdb", tags=["cmdb"]) + + +@router.get("/institutions", response_model=List[InstitutionOut]) +async def list_institutions(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Institution).order_by(Institution.inst_name)) + return result.scalars().all() + + +@router.get("/institutions/{inst_code}/servers", response_model=List[ServerOut]) +async def list_servers(inst_code: str, db: AsyncSession = Depends(get_db)): + r = await db.execute( + select(Institution).where(Institution.inst_code == inst_code) + ) + inst = r.scalars().first() + if not inst: + from fastapi import HTTPException + raise HTTPException(404, detail="기관을 찾을 수 없습니다.") + + result = await db.execute( + select(Server).where(Server.inst_id == inst.id, Server.is_active == True) + .order_by(Server.server_name) + ) + return result.scalars().all() diff --git a/itsm/routers/tasks.py b/itsm/routers/tasks.py new file mode 100644 index 00000000..8aee58ec --- /dev/null +++ b/itsm/routers/tasks.py @@ -0,0 +1,156 @@ +"""SR / Task CRUD + status transition endpoints.""" +from datetime import datetime +from typing import List, Optional +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from models import ( + AuditLog, Institution, SRCreate, SROut, SRRequest, SRStatus, + SRStatusUpdate, SRType, compute_log_hash +) + +router = APIRouter(prefix="/api/tasks", tags=["tasks"]) + +# Valid state transitions +_TRANSITIONS: dict[str, list[str]] = { + SRStatus.RECEIVED: [SRStatus.PARSED, SRStatus.REJECTED], + SRStatus.PARSED: [SRStatus.PENDING_APPROVAL, SRStatus.REJECTED], + SRStatus.PENDING_APPROVAL: [SRStatus.APPROVED, SRStatus.REJECTED], + SRStatus.APPROVED: [SRStatus.IN_PROGRESS, SRStatus.REJECTED], + SRStatus.IN_PROGRESS: [SRStatus.PENDING_PM_VALIDATION, SRStatus.FAILED_ROLLBACK], + SRStatus.PENDING_PM_VALIDATION:[SRStatus.COMPLETED, SRStatus.FAILED_ROLLBACK], + SRStatus.COMPLETED: [], + SRStatus.FAILED_ROLLBACK: [], + SRStatus.REJECTED: [], +} + + +def _new_sr_id() -> str: + return f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}" + + +async def _write_audit(db: AsyncSession, sr_id: str, actor: str, + action: str, detail: str) -> None: + from sqlalchemy import select as sel + result = await db.execute( + sel(AuditLog).where(AuditLog.sr_id == sr_id) + .order_by(AuditLog.id.desc()).limit(1) + ) + last = result.scalars().first() + prev_hash = last.log_hash if last else None + ts = datetime.now().isoformat() + log_hash = compute_log_hash(prev_hash, actor, action, detail, ts) + db.add(AuditLog( + sr_id=sr_id, actor=actor, action=action, detail=detail, + prev_hash=prev_hash, log_hash=log_hash + )) + + +@router.get("", response_model=List[SROut]) +async def list_tasks( + status: Optional[str] = Query(None), + sr_type: Optional[str] = Query(None), + priority: Optional[str]= Query(None), + keyword: Optional[str] = Query(None), + skip: int = 0, + limit: int = 50, + db: AsyncSession = Depends(get_db), +): + q = select(SRRequest).order_by(SRRequest.created_at.desc()) + if status: + q = q.where(SRRequest.status == status) + if sr_type: + q = q.where(SRRequest.sr_type == sr_type) + if priority: + q = q.where(SRRequest.priority == priority) + if keyword: + q = q.where(SRRequest.title.contains(keyword)) + q = q.offset(skip).limit(limit) + result = await db.execute(q) + return result.scalars().all() + + +@router.get("/stats") +async def get_stats(db: AsyncSession = Depends(get_db)): + total = (await db.execute(select(func.count(SRRequest.id)))).scalar() + by_status: dict[str, int] = {} + for s in SRStatus: + cnt = (await db.execute( + select(func.count(SRRequest.id)).where(SRRequest.status == s) + )).scalar() + if cnt: + by_status[s.value] = cnt + by_type: dict[str, int] = {} + for t in SRType: + cnt = (await db.execute( + select(func.count(SRRequest.id)).where(SRRequest.sr_type == t) + )).scalar() + if cnt: + by_type[t.value] = cnt + return {"total": total, "by_status": by_status, "by_type": by_type} + + +@router.get("/{sr_id}", response_model=SROut) +async def get_task(sr_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) + sr = result.scalars().first() + if not sr: + raise HTTPException(404, detail="SR을 찾을 수 없습니다.") + return sr + + +@router.post("", response_model=SROut, status_code=201) +async def create_task(payload: SRCreate, db: AsyncSession = Depends(get_db)): + inst_id = None + if payload.inst_code: + r = await db.execute( + select(Institution).where(Institution.inst_code == payload.inst_code) + ) + inst = r.scalars().first() + if inst: + inst_id = inst.id + + sr = SRRequest( + sr_id=_new_sr_id(), + inst_id=inst_id, + sr_type=payload.sr_type, + title=payload.title, + description=payload.description, + status=SRStatus.RECEIVED, + priority=payload.priority, + requested_by=payload.requested_by, + assigned_to=payload.assigned_to, + target_server=payload.target_server, + ) + db.add(sr) + await db.flush() + await _write_audit(db, sr.sr_id, payload.requested_by, "SR_CREATED", f"SR 생성: {payload.title}") + await db.commit() + await db.refresh(sr) + return sr + + +@router.patch("/{sr_id}/status", response_model=SROut) +async def update_status(sr_id: str, payload: SRStatusUpdate, + db: AsyncSession = Depends(get_db)): + result = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) + sr = result.scalars().first() + if not sr: + raise HTTPException(404, detail="SR을 찾을 수 없습니다.") + + allowed = _TRANSITIONS.get(SRStatus(sr.status), []) + if payload.status not in allowed: + raise HTTPException(400, detail=f"'{sr.status}' → '{payload.status}' 전이는 허용되지 않습니다.") + + old_status = sr.status + sr.status = payload.status + sr.updated_at = datetime.now() + detail = payload.comment or f"상태 변경: {old_status} → {payload.status}" + await _write_audit(db, sr_id, payload.actor, "STATUS_CHANGED", detail) + await db.commit() + await db.refresh(sr) + return sr diff --git a/itsm/static/app.js b/itsm/static/app.js new file mode 100644 index 00000000..c9d4bc37 --- /dev/null +++ b/itsm/static/app.js @@ -0,0 +1,446 @@ +/* ─── State ─────────────────────────────────────── */ +let currentView = "dashboard"; +let srCache = []; +let statsCache = {}; + +/* ─── Status labels ─────────────────────────────── */ +const STATUS_LABEL = { + RECEIVED: "접수", + PARSED: "파싱 완료", + PENDING_APPROVAL: "승인 대기", + APPROVED: "승인됨", + IN_PROGRESS: "진행 중", + PENDING_PM_VALIDATION: "PM 검증 대기", + COMPLETED: "완료", + FAILED_ROLLBACK: "롤백 실패", + REJECTED: "반려", +}; + +const TYPE_LABEL = { + DEPLOY: "배포", RESTART: "재기동", LOG: "로그", + INQUIRY: "문의", OTHER: "기타", +}; + +const PRIORITY_LABEL = { CRITICAL: "긴급", HIGH: "높음", MEDIUM: "보통", LOW: "낮음" }; + +const KANBAN_COLS = [ + { key: "RECEIVED", label: "접수" }, + { key: "PENDING_APPROVAL", label: "승인 대기" }, + { key: "APPROVED", label: "승인됨" }, + { key: "IN_PROGRESS", label: "진행 중" }, + { key: "PENDING_PM_VALIDATION", label: "PM 검증" }, + { key: "COMPLETED", label: "완료" }, + { key: "FAILED_ROLLBACK", label: "롤백 실패" }, + { key: "REJECTED", label: "반려" }, +]; + +const STATUS_COLORS = { + RECEIVED: "#8b949e", + PARSED: "#79c0ff", + PENDING_APPROVAL: "#e3b341", + APPROVED: "#56d364", + IN_PROGRESS: "#58a6ff", + PENDING_PM_VALIDATION: "#bc8cff", + COMPLETED: "#3fb950", + FAILED_ROLLBACK: "#f85149", + REJECTED: "#da3633", +}; + +/* ─── Init ──────────────────────────────────────── */ +window.addEventListener("DOMContentLoaded", async () => { + setupNav(); + setupNewSR(); + setupListFilters(); + await loadAll(); +}); + +async function loadAll() { + await Promise.all([loadStats(), loadSRs()]); + renderCurrentView(); +} + +/* ─── Nav ───────────────────────────────────────── */ +function setupNav() { + document.querySelectorAll(".nav-item").forEach(el => { + el.addEventListener("click", () => { + const view = el.dataset.view; + switchView(view); + }); + }); +} + +function switchView(view) { + currentView = view; + document.querySelectorAll(".nav-item").forEach(el => + el.classList.toggle("active", el.dataset.view === view) + ); + document.querySelectorAll(".view").forEach(el => + el.classList.toggle("active", el.id === `view-${view}`) + ); + const titles = { + dashboard: "대시보드", board: "칸반 보드", + list: "SR 목록", audit: "감사 로그", cmdb: "CMDB", + }; + document.getElementById("page-title").textContent = titles[view] || view; + renderCurrentView(); +} + +function renderCurrentView() { + if (currentView === "dashboard") renderDashboard(); + else if (currentView === "board") renderKanban(); + else if (currentView === "list") renderList(); + else if (currentView === "audit") loadAudit(); + else if (currentView === "cmdb") loadCmdb(); +} + +/* ─── Data loading ──────────────────────────────── */ +async function loadStats() { + try { + const r = await fetch("/api/tasks/stats"); + statsCache = await r.json(); + } catch { statsCache = {}; } +} + +async function loadSRs(params = {}) { + const qs = new URLSearchParams(params).toString(); + const r = await fetch(`/api/tasks?limit=200${qs ? "&" + qs : ""}`); + srCache = await r.json(); +} + +/* ─── Dashboard ─────────────────────────────────── */ +function renderDashboard() { + const s = statsCache; + const bs = s.by_status || {}; + const bt = s.by_type || {}; + const pending = (bs.PENDING_APPROVAL || 0) + (bs.PENDING_PM_VALIDATION || 0); + const active = bs.IN_PROGRESS || 0; + const completed = bs.COMPLETED || 0; + const failed = bs.FAILED_ROLLBACK || 0; + + document.getElementById("stats-row").innerHTML = ` +
+
${s.total || 0}
+
전체 SR
+
+
+
${pending}
+
승인 대기
+
+
+
${active}
+
진행 중
+
+
+
${completed}
+
완료
+
+
+
${failed}
+
롤백 실패
+
+ `; + + // Recent SR list + const recent = [...srCache].slice(0, 10); + document.getElementById("recent-list").innerHTML = recent.map(sr => ` +
+ ${sr.sr_id} + ${esc(sr.title)} + ${STATUS_LABEL[sr.status] || sr.status} + ${fmtDate(sr.created_at)} +
+ `).join("") || '
SR이 없습니다.
'; + + // Status bar chart + const total = s.total || 1; + const items = Object.entries(bs) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => ` +
+
+ ${STATUS_LABEL[k] || k} + ${v} +
+
+
+
+
`).join(""); + document.getElementById("status-chart").innerHTML = + `
${items}
`; +} + +/* ─── Kanban ────────────────────────────────────── */ +function renderKanban() { + const board = document.getElementById("kanban-board"); + board.innerHTML = ""; + KANBAN_COLS.forEach(col => { + const cards = srCache.filter(sr => sr.status === col.key); + const colEl = document.createElement("div"); + colEl.className = "kanban-col"; + colEl.innerHTML = ` +
+ ${col.label} + ${cards.length} +
+
`; + board.appendChild(colEl); + + const cardsEl = colEl.querySelector(`#col-${col.key}`); + cards.forEach(sr => { + const card = document.createElement("div"); + card.className = "kanban-card"; + card.innerHTML = ` +
${sr.sr_id}
+
${esc(sr.title)}
+
+ ${TYPE_LABEL[sr.sr_type] || sr.sr_type} + ${PRIORITY_LABEL[sr.priority] || sr.priority} +
`; + card.addEventListener("click", () => openDetail(sr.sr_id)); + cardsEl.appendChild(card); + }); + }); +} + +/* ─── SR List ───────────────────────────────────── */ +function setupListFilters() { + document.getElementById("search-input").addEventListener("input", renderList); + document.getElementById("filter-status").addEventListener("change", renderList); + document.getElementById("filter-type").addEventListener("change", renderList); +} + +function renderList() { + const keyword = document.getElementById("search-input").value.toLowerCase(); + const fStatus = document.getElementById("filter-status").value; + const fType = document.getElementById("filter-type").value; + + let rows = srCache; + if (keyword) rows = rows.filter(r => r.title.toLowerCase().includes(keyword) || r.sr_id.toLowerCase().includes(keyword)); + if (fStatus) rows = rows.filter(r => r.status === fStatus); + if (fType) rows = rows.filter(r => r.sr_type === fType); + + document.getElementById("sr-tbody").innerHTML = rows.map(sr => ` + + ${sr.sr_id} + ${TYPE_LABEL[sr.sr_type] || sr.sr_type} + ${esc(sr.title)} + ${STATUS_LABEL[sr.status] || sr.status} + ${PRIORITY_LABEL[sr.priority] || sr.priority} + ${esc(sr.requested_by || "")} + ${fmtDate(sr.created_at)} + + `).join("") || `결과 없음`; +} + +/* ─── SR Detail Modal ───────────────────────────── */ +async function openDetail(srId) { + const sr = srCache.find(s => s.sr_id === srId); + if (!sr) return; + + const [approvalsRes, auditRes] = await Promise.all([ + fetch(`/api/approvals/${srId}`).then(r => r.json()).catch(() => []), + fetch(`/api/audit?sr_id=${srId}`).then(r => r.json()).catch(() => []), + ]); + + const approvalHTML = approvalsRes.length + ? approvalsRes.map(a => ` +
+ ${esc(a.approver)} + + ${a.result === "APPROVED" ? "승인" : a.result === "REJECTED" ? "반려" : "대기"} + + ${a.comment ? `${esc(a.comment)}` : ""} +
`).join("") + : '
승인 기록 없음
'; + + const auditHTML = auditRes.slice(0, 10).map(log => ` +
+
${esc(log.action)} by ${esc(log.actor || "system")}
+
${esc(log.detail || "")}  #${(log.log_hash || "").slice(0, 12)}
+
`).join("") || '
기록 없음
'; + + const canApprove = sr.status === "PENDING_APPROVAL"; + + document.getElementById("modal-body").innerHTML = ` + + + + + + ${sr.description ? ` + ` : ""} + + + + + + ${canApprove ? ` + ` : ""} + `; + + document.getElementById("modal-overlay").classList.remove("hidden"); +} + +async function doApproval(srId, result) { + const approver = document.getElementById("approver-name").value.trim(); + const comment = document.getElementById("approver-comment").value.trim(); + if (!approver) { alert("승인자 이름을 입력하세요."); return; } + + const r = await fetch(`/api/approvals/${srId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approver, result, comment: comment || null }), + }); + if (r.ok) { + document.getElementById("modal-overlay").classList.add("hidden"); + await loadAll(); + } else { + const err = await r.json().catch(() => ({})); + alert(err.detail || "처리 중 오류 발생"); + } +} + +document.getElementById("modal-close-btn").addEventListener("click", () => + document.getElementById("modal-overlay").classList.add("hidden") +); +document.getElementById("modal-overlay").addEventListener("click", e => { + if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden"); +}); + +/* ─── New SR Modal ──────────────────────────────── */ +function setupNewSR() { + document.getElementById("btn-new-sr").addEventListener("click", () => + document.getElementById("new-sr-overlay").classList.remove("hidden") + ); + document.getElementById("new-sr-close").addEventListener("click", () => + document.getElementById("new-sr-overlay").classList.add("hidden") + ); + document.getElementById("new-sr-overlay").addEventListener("click", e => { + if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden"); + }); + document.getElementById("new-sr-form").addEventListener("submit", async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const payload = Object.fromEntries(fd.entries()); + // remove empty optional fields + if (!payload.description) delete payload.description; + if (!payload.target_server) delete payload.target_server; + if (!payload.inst_code) delete payload.inst_code; + + const r = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (r.ok) { + document.getElementById("new-sr-overlay").classList.add("hidden"); + e.target.reset(); + await loadAll(); + } else { + const err = await r.json().catch(() => ({})); + alert(err.detail || "SR 생성 실패"); + } + }); +} + +/* ─── Audit ─────────────────────────────────────── */ +async function loadAudit() { + const data = await fetch("/api/audit?limit=100").then(r => r.json()).catch(() => []); + document.getElementById("audit-tbody").innerHTML = data.map((log, i) => ` + + ${i + 1} + ${esc(log.sr_id || "—")} + ${esc(log.actor || "system")} + ${esc(log.action)} + ${esc(log.detail || "")} + ${(log.log_hash || "").slice(0, 12)} + ${fmtDate(log.created_at)} + `).join("") || `기록 없음`; + + document.getElementById("btn-verify").addEventListener("click", async () => { + const res = await fetch("/api/audit/verify").then(r => r.json()); + const el = document.getElementById("verify-result"); + if (res.intact) { + el.textContent = "✅ 체인 무결성 확인됨"; + el.className = "ok"; + } else { + el.textContent = `❌ 변조 감지 (ID: ${res.broken_at_id})`; + el.className = "fail"; + } + }); +} + +/* ─── CMDB ───────────────────────────────────────── */ +async function loadCmdb() { + const institutions = await fetch("/api/cmdb/institutions").then(r => r.json()).catch(() => []); + const grid = document.getElementById("cmdb-grid"); + grid.innerHTML = ""; + + await Promise.all(institutions.map(async inst => { + const servers = await fetch(`/api/cmdb/institutions/${inst.inst_code}/servers`) + .then(r => r.json()).catch(() => []); + const card = document.createElement("div"); + card.className = "cmdb-card"; + card.innerHTML = ` +
+ ${esc(inst.inst_name)} + ${esc(inst.inst_code)} +
+
+ ${servers.map(s => ` +
+ ${s.server_role} + ${esc(s.server_name)} + ${esc(s.os_type || "")} + ${s.is_active ? "● 정상" : "● 비활성"} +
`).join("") || '
서버 없음
'} +
+ ${inst.contact_pm ? `
PM: ${esc(inst.contact_pm)}
` : ""} + `; + grid.appendChild(card); + })); +} + +/* ─── Helpers ───────────────────────────────────── */ +function esc(s) { + return String(s ?? "") + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function fmtDate(iso) { + if (!iso) return ""; + try { + return new Date(iso).toLocaleString("ko-KR", { + month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", + }); + } catch { return iso; } +} diff --git a/itsm/static/index.html b/itsm/static/index.html new file mode 100644 index 00000000..8246a685 --- /dev/null +++ b/itsm/static/index.html @@ -0,0 +1,186 @@ + + + + + + GUARDiA ITSM + + + +
+ + + + + +
+ + +
+

대시보드

+
+ +
+
+ + +
+
+ +
+
+
+
최근 SR
+
+
+
+
상태별 현황
+
+
+
+
+ +
+
+ +
+
+ +
+
+ + + +
+ + + + + + + + +
SR ID유형제목상태우선순위요청자생성일
+
+ +
+
+ 감사 로그 (SHA-256 해시 체인) + + +
+ + + + + + + + +
#SR행위자액션내용해시 (앞 12자)시각
+
+ +
+
+
+ +
+
+ + + + + + + + + + diff --git a/itsm/static/style.css b/itsm/static/style.css new file mode 100644 index 00000000..ec686d51 --- /dev/null +++ b/itsm/static/style.css @@ -0,0 +1,338 @@ +/* ─── Reset & Base ─────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --sidebar-bg: #0d1117; + --sidebar-hover: #161b22; + --sidebar-active:#1f6feb; + --main-bg: #0d1117; + --card-bg: #161b22; + --border: #30363d; + --text-primary: #c9d1d9; + --text-muted: #8b949e; + --text-bright: #f0f6fc; + --accent: #1f6feb; + --green: #2ea043; + --yellow: #d29922; + --red: #da3633; + --orange: #e3b341; + --purple: #8957e5; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --radius: 6px; + --radius-lg: 8px; +} + +html, body { height: 100%; font-family: var(--font); background: var(--main-bg); color: var(--text-primary); font-size: 14px; } + +/* ─── Layout ────────────────────────────────────── */ +#app { display: flex; height: 100vh; overflow: hidden; } + +/* ─── Sidebar ───────────────────────────────────── */ +#sidebar { + width: 220px; min-width: 180px; flex-shrink: 0; + background: var(--sidebar-bg); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; +} + +#sidebar-logo { + padding: 18px 16px 14px; + border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 10px; +} +.logo-icon { + width: 32px; height: 32px; border-radius: var(--radius); + background: var(--accent); color: #fff; + font-size: 18px; font-weight: 900; + display: flex; align-items: center; justify-content: center; +} +.logo-title { font-size: 15px; font-weight: 700; color: var(--text-bright); } +.logo-sub { font-size: 11px; color: var(--text-muted); } + +#sidebar-nav { flex: 1; padding: 10px 8px; } +.nav-item { + padding: 8px 10px; border-radius: var(--radius); + cursor: pointer; color: var(--text-muted); + display: flex; align-items: center; gap: 8px; + transition: background .12s, color .12s; + margin-bottom: 2px; font-size: 13px; +} +.nav-item:hover { background: var(--sidebar-hover); color: var(--text-primary); } +.nav-item.active { background: var(--sidebar-active); color: #fff; font-weight: 500; } +.nav-icon { font-size: 15px; } + +#sidebar-footer { + padding: 12px 16px; + border-top: 1px solid var(--border); + display: flex; align-items: center; gap: 6px; + font-size: 12px; color: var(--text-muted); +} +.status-dot { width: 8px; height: 8px; border-radius: 50%; } +.status-dot.online { background: var(--green); } + +/* ─── Main ──────────────────────────────────────── */ +#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; } + +#topbar { + padding: 12px 24px; + border-bottom: 1px solid var(--border); + display: flex; align-items: center; justify-content: space-between; + background: var(--main-bg); +} +#page-title { font-size: 18px; font-weight: 700; color: var(--text-bright); } + +/* ─── Views ─────────────────────────────────────── */ +.view { display: none; flex: 1; overflow-y: auto; padding: 20px 24px; } +.view.active { display: block; } + +/* ─── Buttons ───────────────────────────────────── */ +.btn { + padding: 7px 14px; border-radius: var(--radius); + font-size: 13px; font-weight: 500; cursor: pointer; + border: 1px solid transparent; transition: opacity .15s; +} +.btn:hover { opacity: .85; } +.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); } +.btn-secondary { background: transparent; border-color: var(--border); color: var(--text-primary); } +.btn-approve { background: var(--green); color: #fff; } +.btn-reject { background: var(--red); color: #fff; } + +/* ─── Stats row ─────────────────────────────────── */ +.stats-row { display: flex; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; } +.stat-card { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 16px 20px; + min-width: 140px; flex: 1; +} +.stat-value { font-size: 28px; font-weight: 700; color: var(--text-bright); } +.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.stat-card.accent .stat-value { color: var(--accent); } +.stat-card.green .stat-value { color: var(--green); } +.stat-card.yellow .stat-value { color: var(--yellow); } +.stat-card.red .stat-value { color: var(--red); } + +/* ─── Dashboard grid ────────────────────────────── */ +.dashboard-grid { display: grid; grid-template-columns: 1fr 340px; gap: 16px; } +@media (max-width: 900px) { .dashboard-grid { grid-template-columns: 1fr; } } + +.card { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); overflow: hidden; +} +.card-header { + padding: 12px 16px; font-size: 13px; font-weight: 600; + color: var(--text-bright); border-bottom: 1px solid var(--border); +} +.card-body { padding: 8px 0; } + +/* Recent SR list in dashboard */ +.recent-row { + display: flex; align-items: center; gap: 10px; + padding: 8px 16px; border-bottom: 1px solid var(--border); + cursor: pointer; transition: background .1s; +} +.recent-row:last-child { border-bottom: none; } +.recent-row:hover { background: rgba(255,255,255,.04); } +.recent-sr-id { font-size: 12px; color: var(--text-muted); min-width: 150px; } +.recent-title { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.recent-time { font-size: 11px; color: var(--text-muted); } + +/* Status donut chart (pure CSS) */ +.status-bar-list { padding: 12px 16px; } +.status-bar-item { margin-bottom: 10px; } +.status-bar-label { + display: flex; justify-content: space-between; + font-size: 12px; margin-bottom: 4px; +} +.status-bar-track { + height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; +} +.status-bar-fill { height: 100%; border-radius: 4px; } + +/* ─── Status badges ─────────────────────────────── */ +.badge { + display: inline-block; padding: 2px 8px; border-radius: 20px; + font-size: 11px; font-weight: 600; white-space: nowrap; +} +.badge-RECEIVED { background: rgba(139,148,158,.2); color: #8b949e; } +.badge-PARSED { background: rgba(31,111,235,.2); color: #79c0ff; } +.badge-PENDING_APPROVAL { background: rgba(210,153,34,.2); color: #e3b341; } +.badge-APPROVED { background: rgba(46,160,67,.2); color: #56d364; } +.badge-IN_PROGRESS { background: rgba(31,111,235,.3); color: #58a6ff; } +.badge-PENDING_PM_VALIDATION { background: rgba(137,87,229,.2); color: #bc8cff; } +.badge-COMPLETED { background: rgba(46,160,67,.3); color: #3fb950; } +.badge-FAILED_ROLLBACK { background: rgba(218,54,51,.2); color: #f85149; } +.badge-REJECTED { background: rgba(218,54,51,.15); color: #da3633; } + +.badge-priority-CRITICAL { background: rgba(218,54,51,.25); color: #f85149; } +.badge-priority-HIGH { background: rgba(227,179,65,.2); color: #e3b341; } +.badge-priority-MEDIUM { background: rgba(31,111,235,.2); color: #79c0ff; } +.badge-priority-LOW { background: rgba(139,148,158,.2); color: #8b949e; } + +.badge-type-DEPLOY { background: rgba(31,111,235,.2); color: #79c0ff; } +.badge-type-RESTART { background: rgba(218,54,51,.18); color: #f85149; } +.badge-type-LOG { background: rgba(137,87,229,.2); color: #bc8cff; } +.badge-type-INQUIRY { background: rgba(139,148,158,.15); color: #8b949e; } +.badge-type-OTHER { background: rgba(139,148,158,.1); color: #8b949e; } + +/* ─── Kanban ────────────────────────────────────── */ +#kanban-board { + display: flex; gap: 14px; overflow-x: auto; + padding-bottom: 16px; min-height: calc(100vh - 100px); +} +#kanban-board::-webkit-scrollbar { height: 5px; } +#kanban-board::-webkit-scrollbar-thumb { background: var(--border); } + +.kanban-col { + min-width: 240px; flex-shrink: 0; + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); display: flex; flex-direction: column; + max-height: calc(100vh - 100px); +} +.kanban-col-header { + padding: 10px 14px; font-size: 12px; font-weight: 600; + color: var(--text-muted); border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 6px; flex-shrink: 0; +} +.kanban-col-header .col-count { + background: var(--border); border-radius: 10px; + padding: 1px 6px; font-size: 11px; +} +.kanban-cards { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; } + +.kanban-card { + background: var(--main-bg); border: 1px solid var(--border); + border-radius: var(--radius); padding: 10px 12px; + cursor: pointer; transition: border-color .15s; +} +.kanban-card:hover { border-color: var(--accent); } +.kanban-card-id { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; } +.kanban-card-title { font-size: 13px; color: var(--text-bright); line-height: 1.4; margin-bottom: 6px; } +.kanban-card-meta { display: flex; gap: 4px; flex-wrap: wrap; } + +/* ─── SR Table ──────────────────────────────────── */ +.list-toolbar { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; } +.search-box { + flex: 1; min-width: 200px; + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius); padding: 7px 12px; + color: var(--text-primary); font-size: 13px; outline: none; +} +.search-box:focus { border-color: var(--accent); } +.filter-select { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius); padding: 7px 10px; + color: var(--text-primary); font-size: 13px; outline: none; cursor: pointer; +} +.filter-select:focus { border-color: var(--accent); } + +.sr-table { width: 100%; border-collapse: collapse; } +.sr-table th { + text-align: left; padding: 10px 12px; font-size: 11px; + font-weight: 600; color: var(--text-muted); text-transform: uppercase; + letter-spacing: .05em; border-bottom: 1px solid var(--border); +} +.sr-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; } +.sr-table tbody tr { cursor: pointer; transition: background .1s; } +.sr-table tbody tr:hover { background: var(--card-bg); } + +/* ─── Audit ─────────────────────────────────────── */ +.audit-header-row { + display: flex; align-items: center; gap: 12px; + margin-bottom: 14px; flex-wrap: wrap; +} +.audit-title { font-size: 15px; font-weight: 600; color: var(--text-bright); flex: 1; } +#verify-result { font-size: 13px; font-weight: 600; } +#verify-result.ok { color: var(--green); } +#verify-result.fail { color: var(--red); } + +.hash-code { font-family: "Consolas", monospace; font-size: 11px; color: var(--text-muted); } + +/* ─── CMDB ───────────────────────────────────────── */ +.cmdb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; } +.cmdb-card { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); overflow: hidden; +} +.cmdb-card-header { + padding: 12px 16px; border-bottom: 1px solid var(--border); + font-weight: 700; color: var(--text-bright); display: flex; justify-content: space-between; +} +.cmdb-servers { padding: 8px 0; } +.cmdb-server-row { + display: flex; align-items: center; gap: 10px; + padding: 7px 16px; font-size: 13px; +} +.cmdb-server-row:hover { background: rgba(255,255,255,.04); } +.server-role-badge { + padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; +} +.role-WEB { background: rgba(31,111,235,.25); color: #79c0ff; } +.role-WAS { background: rgba(46,160,67,.25); color: #56d364; } +.role-DB { background: rgba(137,87,229,.25); color: #bc8cff; } +.server-active { color: var(--green); font-size: 12px; margin-left: auto; } +.server-inactive { color: var(--text-muted); font-size: 12px; margin-left: auto; } + +/* ─── Modal ─────────────────────────────────────── */ +#modal-overlay, #new-sr-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.6); + z-index: 100; display: flex; align-items: center; justify-content: center; +} +#modal-overlay.hidden, #new-sr-overlay.hidden { display: none; } + +#modal { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); width: 660px; max-width: 95vw; + max-height: 85vh; overflow-y: auto; padding: 24px; position: relative; +} +#new-sr-modal { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); width: 500px; max-width: 95vw; + padding: 24px; position: relative; +} +#new-sr-modal h2 { font-size: 16px; margin-bottom: 16px; color: var(--text-bright); } + +.modal-close { + position: absolute; top: 14px; right: 14px; + background: none; border: none; color: var(--text-muted); + font-size: 20px; cursor: pointer; line-height: 1; +} +.modal-close:hover { color: var(--text-bright); } + +/* Modal detail sections */ +.modal-title { font-size: 18px; font-weight: 700; color: var(--text-bright); margin-bottom: 12px; } +.modal-meta { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } +.modal-section { margin-bottom: 16px; } +.modal-section-title { font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 8px; } +.modal-desc { font-size: 13px; color: var(--text-primary); line-height: 1.6; } +.modal-actions { display: flex; gap: 8px; margin-top: 16px; } + +/* Status timeline */ +.timeline { border-left: 2px solid var(--border); padding-left: 16px; } +.timeline-item { position: relative; margin-bottom: 12px; } +.timeline-item::before { + content: ""; position: absolute; left: -20px; top: 4px; + width: 8px; height: 8px; border-radius: 50%; + background: var(--border); border: 2px solid var(--card-bg); +} +.timeline-item.done::before { background: var(--green); } +.timeline-item.active::before { background: var(--accent); } +.timeline-action { font-size: 12px; font-weight: 600; color: var(--text-primary); } +.timeline-detail { font-size: 11px; color: var(--text-muted); } + +/* Form */ +label { display: block; margin-bottom: 10px; font-size: 13px; color: var(--text-muted); } +label input, label select, label textarea { + display: block; width: 100%; margin-top: 4px; + background: var(--main-bg); border: 1px solid var(--border); + border-radius: var(--radius); padding: 8px 10px; + color: var(--text-primary); font-size: 13px; outline: none; + font-family: var(--font); +} +label input:focus, label select:focus, label textarea:focus { border-color: var(--accent); } +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } + +/* ─── Scrollbar ─────────────────────────────────── */ +* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; } +*::-webkit-scrollbar { width: 5px; height: 5px; } +*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }