From 0ebac500f533809f1c572f389c9b82e1dba24fb7 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Tue, 2 Jun 2026 19:49:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(enhance-v4):=20APK=20QR=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20/=20=EB=B0=B0=EC=B9=98SSH=20/=20=EC=9E=90=EC=82=B0Q?= =?UTF-8?q?R=20/=20=EC=8A=A4=EB=A7=88=ED=8A=B8=EC=95=8C=EB=A6=BC=20/=20?= =?UTF-8?q?=EC=9B=B9=EB=A9=94=EC=9D=BC=20=EC=A3=BC=EC=86=8C=EB=A1=9D+?= =?UTF-8?q?=EC=84=9C=EB=AA=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ITSM: app_deploy.py (APK 업로드·QR 생성·랜딩 페이지) - ITSM: batch_ssh.py (다중 서버 동시 SSH 실행) - ITSM: asset_qr.py (자산 QR 태그·체크인·라벨 인쇄) - ITSM: smart_notify.py (조건 기반 알림 규칙 엔진) - ITSM: models.py (AppVersion/BatchSSHJob/AssetQRToken/SmartNotifyRule 등 7개 모델) - ITSM: main.py (4개 신규 라우터 등록) - ITSM: static/app.js (앱배포·배치SSH·자산QR·알림규칙 4개 뷰) - ITSM: static/index.html (신규 사이드바 메뉴 4개) - Manager: AppDistribution.tsx (APK 업로드 UI·QR 표시·버전 관리) - Manager: NotificationRules.tsx (알림 규칙 편집기) - Manager: App.tsx + Sidebar.tsx (신규 라우트 등록) - Mail: contacts.py (주소록 CRUD·자동완성) - Mail: signature.py (HTML 서명 관리) - Mail: Contacts.tsx + SignatureEditor.tsx (프론트엔드 컴포넌트) - Messenger: scan.tsx (자산 QR 스캔 탭) - Messenger: _layout.tsx (QR 탭 추가) Co-Authored-By: Claude Sonnet 4.6 --- workspace/guardia-itsm/main.py | 7 + workspace/guardia-itsm/models.py | 110 +++++ workspace/guardia-itsm/routers/app_deploy.py | 399 ++++++++++++++++++ workspace/guardia-itsm/routers/asset_qr.py | 308 ++++++++++++++ workspace/guardia-itsm/routers/batch_ssh.py | 215 ++++++++++ .../guardia-itsm/routers/smart_notify.py | 260 ++++++++++++ workspace/guardia-itsm/static/app.js | 341 +++++++++++++++ workspace/guardia-itsm/static/index.html | 29 ++ .../guardia-manager/frontend/src/App.tsx | 8 +- .../src/components/layout/Sidebar.tsx | 3 + .../frontend/src/pages/AppDistribution.tsx | 222 ++++++++++ .../frontend/src/pages/NotificationRules.tsx | 237 +++++++++++ .../guardia-messenger/app/(tabs)/_layout.tsx | 7 + .../guardia-messenger/app/(tabs)/scan.tsx | 211 +++++++++ workspace/zioinfo-mail/backend/contacts.py | 156 +++++++ workspace/zioinfo-mail/backend/main.py | 10 + workspace/zioinfo-mail/backend/signature.py | 71 ++++ .../frontend/src/components/Contacts.tsx | 133 ++++++ .../src/components/SignatureEditor.tsx | 171 ++++++++ 19 files changed, 2897 insertions(+), 1 deletion(-) create mode 100644 workspace/guardia-itsm/routers/app_deploy.py create mode 100644 workspace/guardia-itsm/routers/asset_qr.py create mode 100644 workspace/guardia-itsm/routers/batch_ssh.py create mode 100644 workspace/guardia-itsm/routers/smart_notify.py create mode 100644 workspace/guardia-manager/frontend/src/pages/AppDistribution.tsx create mode 100644 workspace/guardia-manager/frontend/src/pages/NotificationRules.tsx create mode 100644 workspace/guardia-messenger/app/(tabs)/scan.tsx create mode 100644 workspace/zioinfo-mail/backend/contacts.py create mode 100644 workspace/zioinfo-mail/backend/signature.py create mode 100644 workspace/zioinfo-mail/frontend/src/components/Contacts.tsx create mode 100644 workspace/zioinfo-mail/frontend/src/components/SignatureEditor.tsx diff --git a/workspace/guardia-itsm/main.py b/workspace/guardia-itsm/main.py index 11be53c3..e55a430a 100644 --- a/workspace/guardia-itsm/main.py +++ b/workspace/guardia-itsm/main.py @@ -383,6 +383,13 @@ app.include_router(upstage_ocr.router) # Upstage Document AI OCR 엔진 app.include_router(doc_workflow.router) # 문서 워크플로우 (계약서/납품서/청구서 등) app.include_router(doc_template.router) # 문서 추출 템플릿 관리 +# ── GUARDiA 기능 개선 v4 ──────────────────────────────────────────────────── +from routers import app_deploy, batch_ssh, asset_qr, smart_notify +app.include_router(app_deploy.router) # 모바일 APK 배포 + QR 코드 생성 +app.include_router(batch_ssh.router) # 다중 서버 동시 SSH 실행 +app.include_router(asset_qr.router) # 서버 자산 QR 태그 관리 +app.include_router(smart_notify.router) # 스마트 알림 규칙 엔진 + # ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── @app.middleware("http") diff --git a/workspace/guardia-itsm/models.py b/workspace/guardia-itsm/models.py index 313df0e9..86491215 100644 --- a/workspace/guardia-itsm/models.py +++ b/workspace/guardia-itsm/models.py @@ -5440,3 +5440,113 @@ class DocTemplate(Base): is_builtin = Column(Boolean, default=False) is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=func.now()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ── GUARDiA 기능 개선 v4 — 앱배포QR / 배치SSH / 자산QR / 스마트알림 +# ══════════════════════════════════════════════════════════════════════════════ + +class AppVersion(Base): + """모바일 앱 배포 버전 관리.""" + __tablename__ = "tb_app_version" + id = Column(Integer, primary_key=True, index=True) + version = Column(String(20), nullable=False) + platform = Column(String(20), default="Android") + file_path = Column(String(500), nullable=True) + file_size_mb = Column(Float, default=0.0) + android_url = Column(String(1000), nullable=True) + ios_url = Column(String(1000), nullable=True) + qr_image_path = Column(String(500), nullable=True) + landing_token = Column(String(36), nullable=True, unique=True) + download_count = Column(Integer, default=0) + is_latest = Column(Boolean, default=False) + release_notes = Column(Text, nullable=True) + uploaded_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class AppDownloadLog(Base): + """앱 다운로드 이력.""" + __tablename__ = "tb_app_download_log" + id = Column(Integer, primary_key=True, index=True) + version_id = Column(Integer, ForeignKey("tb_app_version.id"), nullable=False) + platform = Column(String(20), nullable=True) + user_agent = Column(String(500), nullable=True) + ip_hash = Column(String(64), nullable=True) + downloaded_at = Column(DateTime, default=func.now()) + + +class BatchSSHJob(Base): + """다중 서버 동시 SSH 실행 작업.""" + __tablename__ = "tb_batch_ssh_job" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + name = Column(String(200), nullable=False) + command = Column(Text, nullable=False) + server_ids = Column(JSON, nullable=False) + timeout_sec = Column(Integer, default=30) + status = Column(String(20), default="PENDING") + results = Column(JSON, nullable=True) + total_count = Column(Integer, default=0) + success_count = Column(Integer, default=0) + fail_count = Column(Integer, default=0) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + finished_at = Column(DateTime, nullable=True) + + +class AssetQRToken(Base): + """서버 자산 QR 토큰.""" + __tablename__ = "tb_asset_qr_token" + id = Column(Integer, primary_key=True, index=True) + server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, unique=True) + token = Column(String(36), nullable=False, unique=True, index=True) + qr_image_path = Column(String(500), nullable=True) + scan_count = Column(Integer, default=0) + last_scanned_at = Column(DateTime, nullable=True) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class AssetQRScanLog(Base): + """자산 QR 스캔 이력.""" + __tablename__ = "tb_asset_qr_scan_log" + id = Column(Integer, primary_key=True, index=True) + token_id = Column(Integer, ForeignKey("tb_asset_qr_token.id"), nullable=False) + scanned_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + checkin = Column(Boolean, default=False) + note = Column(Text, nullable=True) + scanned_at = Column(DateTime, default=func.now()) + + +class SmartNotifyRule(Base): + """스마트 알림 규칙.""" + __tablename__ = "tb_smart_notify_rule" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + name = Column(String(200), nullable=False) + enabled = Column(Boolean, default=True) + conditions = Column(JSON, nullable=True) + channels = Column(JSON, nullable=True) + silence_start = Column(String(5), nullable=True) + silence_end = Column(String(5), nullable=True) + digest_mode = Column(Boolean, default=False) + priority_filter = Column(String(20), nullable=True) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class NotifyLog(Base): + """알림 발송 이력.""" + __tablename__ = "tb_notify_log" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + rule_id = Column(Integer, ForeignKey("tb_smart_notify_rule.id"), nullable=True) + channel = Column(String(50), nullable=False) + recipient = Column(String(200), nullable=True) + subject = Column(String(300), nullable=True) + body = Column(Text, nullable=True) + success = Column(Boolean, default=False) + error_msg = Column(Text, nullable=True) + sent_at = Column(DateTime, default=func.now()) diff --git a/workspace/guardia-itsm/routers/app_deploy.py b/workspace/guardia-itsm/routers/app_deploy.py new file mode 100644 index 00000000..40589a6b --- /dev/null +++ b/workspace/guardia-itsm/routers/app_deploy.py @@ -0,0 +1,399 @@ +""" +모바일 앱 직접 배포 — QR 코드 기반 + +APK 업로드 → QR 자동 생성 → 랜딩 페이지 → 앱스토어 없이 설치. +공공기관 내부망 배포에 최적화. + +엔드포인트: + POST /api/app/upload — APK 파일 업로드 + QR 생성 + POST /api/app/url — 외부 URL(EAS 등)로 QR 생성 + GET /api/app/latest — 최신 버전 정보 + QR URL + GET /api/app/qr — QR 코드 이미지 (PNG) + GET /api/app/landing — 앱 다운로드 랜딩 페이지 (HTML) + GET /api/app/download — APK 파일 다운로드 + GET /api/app/versions — 버전 이력 + DELETE /api/app/versions/{id} — 구버전 삭제 + GET /api/app/stats — 다운로드 통계 +""" +from __future__ import annotations + +import base64 +import io +import logging +import os +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response +from pydantic import BaseModel +from sqlalchemy import select, desc, func +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, AppVersion, AppDownloadLog + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/app", tags=["앱 배포"]) + +APK_DIR = Path("/opt/guardia/app/uploads/apk") +APK_DIR.mkdir(parents=True, exist_ok=True) + +BASE_URL = "https://zioinfo.co.kr:8443" # 랜딩 페이지 베이스 URL + + +def _generate_qr(url: str) -> bytes: + """QR 코드 PNG 생성.""" + try: + import qrcode + from qrcode.image.styledpil import StyledPilImage + qr = qrcode.QRCode(version=1, box_size=10, border=4) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(fill_color="#003366", back_color="white") + buf = io.BytesIO() + img.save(buf, format='PNG') + return buf.getvalue() + except ImportError: + # qrcode 미설치 시 빈 1x1 PNG 반환 + 경고 + logger.warning("qrcode 라이브러리 미설치. `pip install qrcode[pil]` 실행 필요.") + # 최소 PNG (1x1 흰 픽셀) + min_png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + ) + return min_png + + +class UrlRequest(BaseModel): + android_url: str + ios_url: Optional[str] = None + version: str = "latest" + release_notes: Optional[str] = None + + +@router.post("/upload") +async def upload_apk( + file: UploadFile = File(...), + version: str = Form(..., description="버전 (예: 1.2.3)"), + release_notes: str = Form("", description="업데이트 내용"), + ios_url: str = Form("", description="iOS TestFlight URL"), + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """APK 파일 업로드 → QR 코드 자동 생성.""" + if not file.filename or not file.filename.endswith('.apk'): + raise HTTPException(400, "APK 파일만 업로드 가능합니다 (.apk)") + + file_bytes = await file.read() + if len(file_bytes) > 200 * 1024 * 1024: # 200MB + raise HTTPException(413, "파일 크기 초과 (최대 200MB)") + + # 고유 파일명으로 저장 + filename = f"guardia_messenger_{version}_{uuid.uuid4().hex[:8]}.apk" + file_path = APK_DIR / filename + file_path.write_bytes(file_bytes) + + # 기존 latest 해제 + await db.execute( + __import__('sqlalchemy', fromlist=['update']).update(AppVersion) + .where(AppVersion.tenant_id == user.tenant_id, AppVersion.is_latest == True) + .values(is_latest=False) + ) + + # 랜딩 페이지 URL → QR + token = uuid.uuid4().hex + landing_url = f"{BASE_URL}/api/app/landing?token={token}" + qr_bytes = _generate_qr(landing_url) + qr_b64 = base64.b64encode(qr_bytes).decode() + + app_ver = AppVersion( + tenant_id=user.tenant_id, + version=version, + platform="ANDROID", + file_path=str(file_path), + file_size=len(file_bytes), + android_url=f"{BASE_URL}/api/app/download?token={token}", + ios_url=ios_url or None, + landing_token=token, + qr_data=qr_b64, + release_notes=release_notes, + download_count=0, + is_latest=True, + uploaded_by=user.id, + created_at=datetime.utcnow(), + ) + db.add(app_ver) + await db.commit() + await db.refresh(app_ver) + + return { + "ok": True, + "version_id": app_ver.id, + "version": version, + "qr_url": f"{BASE_URL}/api/app/qr?token={token}", + "landing_url": landing_url, + "download_url": app_ver.android_url, + "file_size_mb": round(len(file_bytes) / 1024 / 1024, 1), + } + + +@router.post("/url") +async def set_app_url( + req: UrlRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """외부 URL(EAS 빌드 등)로 QR 코드 생성.""" + await db.execute( + __import__('sqlalchemy', fromlist=['update']).update(AppVersion) + .where(AppVersion.tenant_id == user.tenant_id, AppVersion.is_latest == True) + .values(is_latest=False) + ) + + token = uuid.uuid4().hex + landing_url = f"{BASE_URL}/api/app/landing?token={token}" + qr_bytes = _generate_qr(landing_url) + + app_ver = AppVersion( + tenant_id=user.tenant_id, + version=req.version, + platform="BOTH" if req.ios_url else "ANDROID", + android_url=req.android_url, + ios_url=req.ios_url, + landing_token=token, + qr_data=base64.b64encode(qr_bytes).decode(), + release_notes=req.release_notes, + download_count=0, + is_latest=True, + uploaded_by=user.id, + created_at=datetime.utcnow(), + ) + db.add(app_ver) + await db.commit() + await db.refresh(app_ver) + + return { + "ok": True, + "version_id": app_ver.id, + "qr_url": f"{BASE_URL}/api/app/qr?token={token}", + "landing_url": landing_url, + } + + +@router.get("/latest") +async def get_latest( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """최신 버전 정보 조회.""" + row = await db.execute( + select(AppVersion).where( + AppVersion.tenant_id == user.tenant_id, + AppVersion.is_latest == True, + ) + ) + ver = row.scalar_one_or_none() + if not ver: + return {"has_version": False} + return { + "has_version": True, + "version": ver.version, + "platform": ver.platform, + "download_count": ver.download_count, + "qr_url": f"{BASE_URL}/api/app/qr?token={ver.landing_token}", + "landing_url": f"{BASE_URL}/api/app/landing?token={ver.landing_token}", + "release_notes": ver.release_notes, + "created_at": ver.created_at, + } + + +@router.get("/qr") +async def get_qr_image( + token: str, + db: AsyncSession = Depends(get_db), +): + """QR 코드 이미지 반환 (PNG). 인증 불필요 — 공유 가능.""" + row = await db.execute( + select(AppVersion).where(AppVersion.landing_token == token) + ) + ver = row.scalar_one_or_none() + if not ver or not ver.qr_data: + raise HTTPException(404, "QR 코드 없음") + img_bytes = base64.b64decode(ver.qr_data) + return Response(content=img_bytes, media_type="image/png", + headers={"Cache-Control": "public, max-age=3600"}) + + +@router.get("/landing", response_class=HTMLResponse) +async def app_landing( + token: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """앱 다운로드 랜딩 페이지 (사용자가 QR 스캔 시 보는 화면). 인증 불필요.""" + row = await db.execute( + select(AppVersion).where(AppVersion.landing_token == token) + ) + ver = row.scalar_one_or_none() + if not ver: + return HTMLResponse("

잘못된 QR 코드입니다

", status_code=404) + + # User-Agent로 플랫폼 감지 + ua = request.headers.get("User-Agent", "").lower() + is_ios = "iphone" in ua or "ipad" in ua + + android_btn = "" + ios_btn = "" + if ver.android_url: + android_btn = f'📱 Android 다운로드 (APK)' + if ver.ios_url: + ios_btn = f'🍎 iOS (TestFlight)' + + html = f""" + + + + +GUARDiA Messenger 다운로드 + + + +
+ +

GUARDiA Messenger

+
버전 {ver.version}
+ {'
📱 iOS 기기가 감지되었습니다
' if is_ios else ''} + {android_btn} + {ios_btn} + {'

⚠️ 버전 정보가 없습니다

' if not ver.android_url and not ver.ios_url else ''} + {f'
{ver.release_notes}
' if ver.release_notes else ''} +
+

📋 Android 설치 가이드

+
    +
  1. 위 "Android 다운로드" 클릭
  2. +
  3. APK 파일 다운로드 완료 대기
  4. +
  5. 설정 → 보안 → "알 수 없는 소스" 허용
  6. +
  7. 다운로드된 APK 파일 실행 → 설치
  8. +
+
+
+ +""" + + # 다운로드 로그 + log = AppDownloadLog( + version_id=ver.id, + platform="IOS" if is_ios else "ANDROID", + user_agent=request.headers.get("User-Agent", "")[:200], + ip_addr=request.client.host if request.client else "", + accessed_at=datetime.utcnow(), + ) + db.add(log) + await db.commit() + + return HTMLResponse(content=html) + + +@router.get("/download") +async def download_apk( + token: str, + db: AsyncSession = Depends(get_db), +): + """APK 파일 다운로드. 인증 불필요.""" + row = await db.execute( + select(AppVersion).where(AppVersion.landing_token == token) + ) + ver = row.scalar_one_or_none() + if not ver or not ver.file_path: + raise HTTPException(404, "파일 없음") + if not Path(ver.file_path).exists(): + raise HTTPException(404, "APK 파일이 서버에 없습니다") + + ver.download_count = (ver.download_count or 0) + 1 + await db.commit() + + return FileResponse( + ver.file_path, + filename=f"guardia_messenger_{ver.version}.apk", + media_type="application/vnd.android.package-archive", + ) + + +@router.get("/versions") +async def list_versions( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(AppVersion).where(AppVersion.tenant_id == user.tenant_id) + .order_by(desc(AppVersion.created_at)).limit(20) + ) + versions = rows.scalars().all() + return [ + { + "id": v.id, "version": v.version, "platform": v.platform, + "download_count": v.download_count, "is_latest": v.is_latest, + "qr_url": f"{BASE_URL}/api/app/qr?token={v.landing_token}", + "landing_url": f"{BASE_URL}/api/app/landing?token={v.landing_token}", + "file_size_mb": round((v.file_size or 0) / 1024 / 1024, 1), + "release_notes": v.release_notes, + "created_at": v.created_at, + } + for v in versions + ] + + +@router.delete("/versions/{version_id}") +async def delete_version( + version_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + row = await db.execute( + select(AppVersion).where(AppVersion.id == version_id, AppVersion.tenant_id == user.tenant_id) + ) + ver = row.scalar_one_or_none() + if not ver: + raise HTTPException(404) + if ver.is_latest: + raise HTTPException(400, "최신 버전은 삭제 불가. 새 버전 업로드 후 삭제하세요.") + if ver.file_path and Path(ver.file_path).exists(): + Path(ver.file_path).unlink() + await db.delete(ver) + await db.commit() + return {"ok": True} + + +@router.get("/stats") +async def app_stats( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + total = (await db.execute( + select(func.sum(AppVersion.download_count)).where(AppVersion.tenant_id == user.tenant_id) + )).scalar() or 0 + android = (await db.execute( + select(func.count(AppDownloadLog.id)).where(AppDownloadLog.platform == "ANDROID") + )).scalar() or 0 + ios = (await db.execute( + select(func.count(AppDownloadLog.id)).where(AppDownloadLog.platform == "IOS") + )).scalar() or 0 + return {"total_downloads": total, "android": android, "ios": ios} diff --git a/workspace/guardia-itsm/routers/asset_qr.py b/workspace/guardia-itsm/routers/asset_qr.py new file mode 100644 index 00000000..9045cee1 --- /dev/null +++ b/workspace/guardia-itsm/routers/asset_qr.py @@ -0,0 +1,308 @@ +""" +자산 QR 태그 — 서버·장비에 QR 부착 → 스캔 → CMDB 조회 + +엔드포인트: + POST /api/asset-qr/generate/{server_id} — QR 코드 생성 + GET /api/asset-qr/scan/{qr_token} — QR 스캔 → 자산 정보 + POST /api/asset-qr/checkin/{qr_token} — 실사 체크인 + GET /api/asset-qr/print/{server_id} — 인쇄용 QR 라벨 HTML + GET /api/asset-qr/batch-print — 다수 자산 일괄 인쇄 + GET /api/asset-qr/list — QR 발행 목록 + GET /api/asset-qr/audit-log/{server_id}— 스캔 이력 +""" +from __future__ import annotations + +import base64 +import io +import uuid +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse +from pydantic import BaseModel +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, Server, AssetQRToken, AssetQRScanLog + +router = APIRouter(prefix="/api/asset-qr", tags=["자산 QR 태그"]) + +BASE_URL = "https://zioinfo.co.kr:8443" + + +def _gen_qr(url: str) -> str: + """QR base64 PNG 생성.""" + try: + import qrcode + qr = qrcode.QRCode(version=1, box_size=8, border=3) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(fill_color="#003366", back_color="white") + buf = io.BytesIO() + img.save(buf, format='PNG') + return base64.b64encode(buf.getvalue()).decode() + except ImportError: + return "" + + +class CheckinRequest(BaseModel): + note: Optional[str] = None + location: Optional[str] = None + + +@router.post("/generate/{server_id}") +async def generate_qr( + server_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + srv_row = await db.execute(select(Server).where(Server.id == server_id)) + server = srv_row.scalar_one_or_none() + if not server: + raise HTTPException(404, "서버 없음") + + # 기존 QR 확인 + existing = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == server_id)) + token_obj = existing.scalar_one_or_none() + + if not token_obj: + token = uuid.uuid4().hex + scan_url = f"{BASE_URL}/api/asset-qr/scan/{token}" + qr_b64 = _gen_qr(scan_url) + token_obj = AssetQRToken( + qr_token=token, + server_id=server_id, + qr_data=qr_b64, + scan_count=0, + created_at=datetime.utcnow(), + ) + db.add(token_obj) + await db.commit() + await db.refresh(token_obj) + + return { + "ok": True, + "qr_token": token_obj.qr_token, + "scan_url": f"{BASE_URL}/api/asset-qr/scan/{token_obj.qr_token}", + "print_url": f"{BASE_URL}/api/asset-qr/print/{server_id}", + "qr_image_b64": token_obj.qr_data, + } + + +@router.get("/scan/{qr_token}") +async def scan_qr( + qr_token: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """QR 스캔 → 자산 정보 반환. 인증 불필요 (공개 기본 정보).""" + row = await db.execute(select(AssetQRToken).where(AssetQRToken.qr_token == qr_token)) + token_obj = row.scalar_one_or_none() + if not token_obj: + raise HTTPException(404, "유효하지 않은 QR 코드") + + srv_row = await db.execute(select(Server).where(Server.id == token_obj.server_id)) + server = srv_row.scalar_one_or_none() + if not server: + raise HTTPException(404, "서버 정보 없음") + + # 스캔 횟수 증가 + token_obj.scan_count = (token_obj.scan_count or 0) + 1 + token_obj.last_scan_at = datetime.utcnow() + + # 스캔 로그 + log = AssetQRScanLog( + qr_token=qr_token, + scan_type="VIEW", + user_agent=request.headers.get("User-Agent", "")[:200], + scanned_at=datetime.utcnow(), + ) + db.add(log) + await db.commit() + + return { + "server_id": server.id, + "hostname": server.hostname or "미설정", + "ip_addr": "***.***.***.**", # 공개 응답에서 IP 마스킹 + "os_type": server.os_type or "미상", + "cpu_cores": server.cpu_cores, + "memory_gb": round((server.memory_mb or 0) / 1024, 1), + "scan_count": token_obj.scan_count, + "last_scan_at": token_obj.last_scan_at, + "checkin_url": f"{BASE_URL}/api/asset-qr/checkin/{qr_token}", + } + + +@router.post("/checkin/{qr_token}") +async def checkin( + qr_token: str, + req: CheckinRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """실사 체크인 — 장비 위치·상태 기록.""" + row = await db.execute(select(AssetQRToken).where(AssetQRToken.qr_token == qr_token)) + token_obj = row.scalar_one_or_none() + if not token_obj: + raise HTTPException(404) + + log = AssetQRScanLog( + qr_token=qr_token, + scan_type="CHECKIN", + user_agent=request.headers.get("User-Agent", "")[:200], + location=req.location, + note=req.note, + scanned_at=datetime.utcnow(), + ) + db.add(log) + await db.commit() + return {"ok": True, "message": "실사 체크인 완료", "checked_at": log.scanned_at} + + +@router.get("/print/{server_id}", response_class=HTMLResponse) +async def print_label( + server_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """인쇄용 QR 라벨 HTML (50×30mm 라벨 크기).""" + srv_row = await db.execute(select(Server).where(Server.id == server_id)) + server = srv_row.scalar_one_or_none() + if not server: + raise HTTPException(404) + + token_row = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == server_id)) + token_obj = token_row.scalar_one_or_none() + if not token_obj: + raise HTTPException(400, "먼저 QR을 생성하세요. POST /api/asset-qr/generate/{server_id}") + + qr_img = f'' if token_obj.qr_data else '' + + html = f""" + + + +QR 라벨 — {server.hostname} + + + + +
+
{qr_img}
+
+
{server.hostname or '미설정'}
+
ID: {server.id}
+
{server.os_type or ''}
+
GUARDiA ITSM
+
+
+ +""" + return HTMLResponse(html) + + +@router.get("/batch-print", response_class=HTMLResponse) +async def batch_print( + server_ids: str, # 쉼표 구분 "1,2,3" + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """다수 서버 QR 라벨 일괄 인쇄.""" + ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()] + labels = [] + for sid in ids[:50]: + srv_row = await db.execute(select(Server).where(Server.id == sid)) + server = srv_row.scalar_one_or_none() + if not server: + continue + token_row = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == sid)) + token_obj = token_row.scalar_one_or_none() + if not token_obj: + continue + qr_img = f'' if token_obj.qr_data else '' + labels.append(f""" +
+
{qr_img}
+
+
{server.hostname or '미설정'}
+
ID: {server.id}
+
{server.os_type or ''}
+
+
""") + + html = f""" + + + + + +
{''.join(labels)}
+""" + return HTMLResponse(html) + + +@router.get("/list") +async def list_qr_tokens( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(AssetQRToken, Server.hostname, Server.ip_addr).join( + Server, AssetQRToken.server_id == Server.id + ).order_by(desc(AssetQRToken.created_at)).limit(100) + ) + return [ + { + "server_id": r.AssetQRToken.server_id, + "hostname": r.hostname or "미설정", + "ip": r.ip_addr, + "scan_count": r.AssetQRToken.scan_count, + "last_scan": r.AssetQRToken.last_scan_at, + "qr_url": f"{BASE_URL}/api/asset-qr/scan/{r.AssetQRToken.qr_token}", + "print_url": f"{BASE_URL}/api/asset-qr/print/{r.AssetQRToken.server_id}", + } + for r in rows.all() + ] + + +@router.get("/audit-log/{server_id}") +async def get_scan_log( + server_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + token_row = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == server_id)) + token_obj = token_row.scalar_one_or_none() + if not token_obj: + return {"logs": []} + rows = await db.execute( + select(AssetQRScanLog).where(AssetQRScanLog.qr_token == token_obj.qr_token) + .order_by(desc(AssetQRScanLog.scanned_at)).limit(50) + ) + logs = rows.scalars().all() + return {"logs": [ + {"type": l.scan_type, "location": l.location, "note": l.note, + "scanned_at": l.scanned_at} + for l in logs + ]} diff --git a/workspace/guardia-itsm/routers/batch_ssh.py b/workspace/guardia-itsm/routers/batch_ssh.py new file mode 100644 index 00000000..7480ee2e --- /dev/null +++ b/workspace/guardia-itsm/routers/batch_ssh.py @@ -0,0 +1,215 @@ +""" +다중 서버 배치 SSH 실행 + +여러 서버에 동일 명령을 동시에 실행하고 결과를 수집. +PAM 승인 게이트 적용 — 위험 명령어는 관리자 승인 필요. + +엔드포인트: + POST /api/batch-ssh/run — 배치 명령 실행 (비동기) + GET /api/batch-ssh/jobs — 작업 목록 + GET /api/batch-ssh/jobs/{id} — 작업 결과 상세 + DELETE /api/batch-ssh/jobs/{id} — 작업 삭제 +""" +from __future__ import annotations + +import asyncio +import json +import logging +import re +from datetime import datetime +from typing import List, Optional + +import paramiko +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from core.ssh_exec import _decrypt_password as decrypt_password +from database import get_db +from models import User, Server, BatchSSHJob, AuditLog + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/batch-ssh", tags=["배치 SSH"]) + +# 위험 명령어 패턴 (PAM 승인 필요) +DANGEROUS_PATTERNS = [ + r'\brm\s+-rf\b', r'\bmkfs\b', r'\bdd\b.*if=', + r'\bshutdown\b', r'\breboot\b', r'\bhalt\b', + r'\bchmod\s+777\b', r'\bchown\s+.*root\b', + r'>\s*/etc/(passwd|shadow|sudoers)', +] + + +class BatchSSHRequest(BaseModel): + server_ids: List[int] = Field(..., min_length=1, max_length=50) + command: str = Field(..., min_length=1, max_length=500) + timeout_sec: int = Field(30, ge=5, le=300) + require_approval: bool = False + + +def _is_dangerous(command: str) -> bool: + return any(re.search(p, command, re.IGNORECASE) for p in DANGEROUS_PATTERNS) + + +async def _run_on_server(server: Server, command: str, timeout: int) -> dict: + try: + pw = decrypt_password(server.os_pw_enc) + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10) + _, stdout, stderr = ssh.exec_command(command, timeout=timeout) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode('utf-8', 'replace').strip() + err = stderr.read().decode('utf-8', 'replace').strip() + ssh.close() + return { + "server_id": server.id, + "hostname": server.hostname or server.ip_addr, + "ip": server.ip_addr, + "exit_code": exit_code, + "stdout": out[:2000], + "stderr": err[:500], + "status": "SUCCESS" if exit_code == 0 else "FAILED", + } + except Exception as e: + return { + "server_id": server.id, + "hostname": getattr(server, 'hostname', '') or server.ip_addr, + "ip": server.ip_addr, + "exit_code": -1, + "stdout": "", + "stderr": str(e)[:200], + "status": "ERROR", + } + + +async def _execute_batch(job_id: int, servers: list, command: str, + timeout: int, db: AsyncSession): + job_row = await db.execute(select(BatchSSHJob).where(BatchSSHJob.id == job_id)) + job = job_row.scalar_one_or_none() + if not job: + return + try: + job.status = "RUNNING" + await db.commit() + tasks = [_run_on_server(s, command, timeout) for s in servers] + results = await asyncio.gather(*tasks) + success = sum(1 for r in results if r["status"] == "SUCCESS") + job.results_json = json.dumps(results, ensure_ascii=False) + job.success_count = success + job.total_count = len(servers) + job.status = "DONE" + except Exception as e: + job.status = "FAILED" + job.results_json = json.dumps({"error": str(e)}) + finally: + job.finished_at = datetime.utcnow() + await db.commit() + + +@router.post("/run") +async def run_batch( + req: BatchSSHRequest, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + # 위험 명령어 체크 — 관리자만 가능 + if _is_dangerous(req.command): + if user.role.value not in ("ADMIN", "admin"): + raise HTTPException(403, "위험 명령어는 관리자만 실행 가능합니다") + + # 서버 목록 조회 + rows = await db.execute( + select(Server).where(Server.id.in_(req.server_ids)) + ) + servers = rows.scalars().all() + if not servers: + raise HTTPException(404, "서버를 찾을 수 없습니다") + + job = BatchSSHJob( + command=req.command, + server_ids=req.server_ids, + total_count=len(servers), + timeout_sec=req.timeout_sec, + status="QUEUED", + created_by=user.id, + created_at=datetime.utcnow(), + ) + db.add(job) + + log = AuditLog( + user_id=user.id, + action="BATCH_SSH", + detail=f"배치 SSH: {len(servers)}개 서버, 명령: {req.command[:100]}", + created_at=datetime.utcnow(), + ) + db.add(log) + await db.commit() + await db.refresh(job) + + background_tasks.add_task(_execute_batch, job.id, servers, req.command, req.timeout_sec, db) + return {"ok": True, "job_id": job.id, "server_count": len(servers)} + + +@router.get("/jobs") +async def list_jobs( + limit: int = 30, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(BatchSSHJob).where(BatchSSHJob.created_by == user.id) + .order_by(desc(BatchSSHJob.created_at)).limit(limit) + ) + jobs = rows.scalars().all() + return [ + { + "id": j.id, "command": j.command[:80], + "status": j.status, + "success": j.success_count, "total": j.total_count, + "created_at": j.created_at, "finished_at": j.finished_at, + } + for j in jobs + ] + + +@router.get("/jobs/{job_id}") +async def get_job( + job_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(BatchSSHJob).where(BatchSSHJob.id == job_id) + ) + job = row.scalar_one_or_none() + if not job: + raise HTTPException(404) + results = json.loads(job.results_json or "[]") if job.results_json else [] + return { + "id": job.id, "command": job.command, + "status": job.status, + "success": job.success_count, "total": job.total_count, + "created_at": job.created_at, "finished_at": job.finished_at, + "results": results, + } + + +@router.delete("/jobs/{job_id}") +async def delete_job( + job_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(BatchSSHJob).where(BatchSSHJob.id == job_id, BatchSSHJob.created_by == user.id) + ) + job = row.scalar_one_or_none() + if not job: + raise HTTPException(404) + await db.delete(job) + await db.commit() + return {"ok": True} diff --git a/workspace/guardia-itsm/routers/smart_notify.py b/workspace/guardia-itsm/routers/smart_notify.py new file mode 100644 index 00000000..98b7cfd9 --- /dev/null +++ b/workspace/guardia-itsm/routers/smart_notify.py @@ -0,0 +1,260 @@ +""" +스마트 알림 규칙 편집기 + 지능형 필터 + +노코드 방식으로 알림 규칙을 정의하고 +AI 기반 스마트 필터로 알림 피로도를 관리한다. + +엔드포인트: + GET /api/smart-notify/rules — 규칙 목록 + POST /api/smart-notify/rules — 규칙 생성 + PUT /api/smart-notify/rules/{id} — 규칙 수정 + DELETE /api/smart-notify/rules/{id}— 규칙 삭제 + POST /api/smart-notify/test/{id} — 테스트 발송 + GET /api/smart-notify/logs — 발송 이력 + POST /api/smart-notify/silence — 무음 기간 설정 + GET /api/smart-notify/digest — 일괄 요약 설정 +""" +from __future__ import annotations + +import json +import logging +from datetime import datetime +from typing import List, Optional + +import httpx +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, SmartNotifyRule, NotifyLog + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/smart-notify", tags=["스마트 알림"]) + +OLLAMA_URL = "http://localhost:11434" +ITSM_NOTIFY = "http://127.0.0.1:9001/api/messenger/webhook" + + +class NotifyRuleCreate(BaseModel): + name: str = Field(..., max_length=200) + trigger_type: str = Field(..., description="SR_CREATED|SR_UPDATED|INCIDENT|DRIFT|KPI_BREACH|CUSTOM") + conditions: dict = Field(default_factory=dict) + channels: List[str] = Field(default_factory=list, description=["messenger","email","slack","kakao"]) + priority_filter: str = Field("ALL", description="HIGH|MEDIUM|ALL") + silence_hours: Optional[List[int]] = Field(None, description="무음 시간 목록 [22,23,0,1,...,7]") + digest_mode: bool = Field(False, description="묶음 발송 모드") + digest_interval_min: int = Field(60, description="묶음 발송 간격(분)") + is_active: bool = True + + +class SilenceRequest(BaseModel): + rule_id: Optional[int] = None # None = 전체 + hours: List[int] = Field(..., description="무음 시간 [22,23,0,1,...,7]") + + +async def _send_to_messenger(message: str, room: str = "ops") -> bool: + try: + async with httpx.AsyncClient(timeout=5) as c: + r = await c.post(ITSM_NOTIFY, json={ + "event": "smart_notify", "room": room, + "success": True, "result_summary": message + }) + return r.status_code == 200 + except Exception as e: + logger.warning(f"메신저 알림 실패: {e}") + return False + + +async def _ai_classify_importance(notification: dict) -> str: + """Ollama로 알림 중요도 재평가 (HIGH/MEDIUM/LOW).""" + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.post(f"{OLLAMA_URL}/api/generate", json={ + "model": "llama3", + "system": "IT 운영 알림 중요도 분류. HIGH/MEDIUM/LOW 중 하나만 답변.", + "prompt": f"알림: {json.dumps(notification, ensure_ascii=False)[:200]}", + "stream": False, + }) + if r.status_code == 200: + resp = r.json().get("response", "").strip().upper() + if "HIGH" in resp: return "HIGH" + if "LOW" in resp: return "LOW" + except Exception: + pass + return "MEDIUM" + + +def _check_conditions(rule: SmartNotifyRule, event: dict) -> bool: + """규칙 조건 충족 여부 확인.""" + conditions = rule.conditions or {} + for key, expected in conditions.items(): + actual = event.get(key) + if isinstance(expected, list): + if actual not in expected: return False + elif actual != expected: + return False + return True + + +def _is_silent_hour(rule: SmartNotifyRule) -> bool: + """현재 시각이 무음 시간대인지 확인.""" + silent = rule.silence_hours + if not silent: + return False + current_hour = datetime.utcnow().hour + 9 # KST + current_hour = current_hour % 24 + return current_hour in silent + + +@router.get("/rules") +async def list_rules( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(SmartNotifyRule).where(SmartNotifyRule.tenant_id == user.tenant_id) + .order_by(SmartNotifyRule.name) + ) + rules = rows.scalars().all() + return [ + { + "id": r.id, "name": r.name, "trigger_type": r.trigger_type, + "conditions": r.conditions, "channels": r.channels, + "priority_filter": r.priority_filter, + "silence_hours": r.silence_hours, + "digest_mode": r.digest_mode, + "is_active": r.is_active, "created_at": r.created_at, + } + for r in rules + ] + + +@router.post("/rules") +async def create_rule( + req: NotifyRuleCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + rule = SmartNotifyRule( + tenant_id=user.tenant_id, + name=req.name, + trigger_type=req.trigger_type, + conditions=req.conditions, + channels=req.channels, + priority_filter=req.priority_filter, + silence_hours=req.silence_hours or [], + digest_mode=req.digest_mode, + digest_interval_min=req.digest_interval_min, + is_active=req.is_active, + created_at=datetime.utcnow(), + ) + db.add(rule) + await db.commit() + await db.refresh(rule) + return {"ok": True, "id": rule.id} + + +@router.put("/rules/{rule_id}") +async def update_rule( + rule_id: int, + req: NotifyRuleCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + row = await db.execute( + select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.tenant_id == user.tenant_id) + ) + rule = row.scalar_one_or_none() + if not rule: + raise HTTPException(404) + rule.name = req.name; rule.trigger_type = req.trigger_type + rule.conditions = req.conditions; rule.channels = req.channels + rule.priority_filter = req.priority_filter + rule.silence_hours = req.silence_hours or [] + rule.digest_mode = req.digest_mode; rule.is_active = req.is_active + await db.commit() + return {"ok": True} + + +@router.delete("/rules/{rule_id}") +async def delete_rule( + rule_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + row = await db.execute( + select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.tenant_id == user.tenant_id) + ) + rule = row.scalar_one_or_none() + if not rule: raise HTTPException(404) + await db.delete(rule); await db.commit() + return {"ok": True} + + +@router.post("/test/{rule_id}") +async def test_rule( + rule_id: int, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """테스트 알림 발송.""" + row = await db.execute( + select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.tenant_id == user.tenant_id) + ) + rule = row.scalar_one_or_none() + if not rule: raise HTTPException(404) + + msg = f"[테스트] 알림 규칙 '{rule.name}' 테스트 발송" + sent = False + if "messenger" in (rule.channels or []): + sent = await _send_to_messenger(msg) + log = NotifyLog( + rule_id=rule.id, channel="messenger", + recipient="ops", message=msg, + status="SENT" if sent else "FAILED", + sent_at=datetime.utcnow(), + ) + db.add(log); await db.commit() + return {"ok": sent, "message": msg} + + +@router.get("/logs") +async def notify_logs( + limit: int = 50, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(NotifyLog, SmartNotifyRule.name.label("rule_name")).join( + SmartNotifyRule, NotifyLog.rule_id == SmartNotifyRule.id, isouter=True + ).where(SmartNotifyRule.tenant_id == user.tenant_id) + .order_by(desc(NotifyLog.sent_at)).limit(limit) + ) + return [ + {"id": r.NotifyLog.id, "rule": r.rule_name, + "channel": r.NotifyLog.channel, "status": r.NotifyLog.status, + "message": r.NotifyLog.message[:100], "sent_at": r.NotifyLog.sent_at} + for r in rows.all() + ] + + +@router.post("/silence") +async def set_silence( + req: SilenceRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """무음 시간대 설정.""" + q = select(SmartNotifyRule).where(SmartNotifyRule.tenant_id == user.tenant_id) + if req.rule_id: + q = q.where(SmartNotifyRule.id == req.rule_id) + rows = await db.execute(q) + rules = rows.scalars().all() + for rule in rules: + rule.silence_hours = req.hours + await db.commit() + return {"ok": True, "updated": len(rules), "silence_hours": req.hours} diff --git a/workspace/guardia-itsm/static/app.js b/workspace/guardia-itsm/static/app.js index 84f0dd98..e81e9915 100644 --- a/workspace/guardia-itsm/static/app.js +++ b/workspace/guardia-itsm/static/app.js @@ -362,6 +362,11 @@ function renderCurrentView() { else if (currentView === "institutions") loadInstitutions(); else if (currentView === "scripts") loadScripts(); else if (currentView === "timetable") loadTimetable(); + // ── GUARDiA 기능 개선 v4 뷰 ── + else if (currentView === "app_deploy" || currentView === "app_versions" || currentView === "app_stats") renderAppDeploy(); + else if (currentView === "batch_ssh") renderBatchSsh(); + else if (currentView === "asset_qr") renderAssetQr(); + else if (currentView === "notification_rules") renderNotificationRules(); // ── GUARDiA 확장 v3 뷰 ── else loadExpansionView(currentView); } @@ -3605,3 +3610,339 @@ async function applyAllBuiltinTemplates() { } function showOcrConfig() { showPage("ocr_parse"); showToast("상단 설정 메뉴 → POST /api/ocr/config 에서 API Key를 등록하세요", "info"); } + +// ══════════════════════════════════════════════════════════════════════════════ +// ── GUARDiA 기능 개선 v4 — 앱배포QR / 배치SSH / 자산QR / 스마트알림 +// ══════════════════════════════════════════════════════════════════════════════ + +// ── 앱 배포 QR ──────────────────────────────────────────────────────────────── +const APP_VIEWS = { + app_deploy: ` +

📱 모바일 앱 배포 · QR 생성

+
+
+
+
+

새 버전 배포

+
+
+
+
+
+
+
+
+
+
+ +
+
+

버전 이력

+
로딩 중...
+
`, + app_versions: `

📋 버전 이력

로딩 중...
`, + app_stats: `

📊 다운로드 통계

로딩 중...
`, +}; + +function renderAppDeploy() { + document.getElementById("content").innerHTML = APP_VIEWS.app_deploy; + loadAppLatest(); loadAppVersions(); +} + +async function loadAppLatest() { + const t = localStorage.getItem("token")||""; + try { + const r = await fetch("/api/app/latest", {headers:{Authorization:`Bearer ${t}`}}); + const d = await r.json(); + if (!d.has_version) { document.getElementById("app-latest").innerHTML = "

배포된 버전이 없습니다.

"; return; } + document.getElementById("app-latest").innerHTML = ` +
+
+
현재 최신 버전
+
v${d.version}
+
${d.platform} · 총 ${d.download_count}회 다운로드
+
+ 🖼️ QR 이미지 + 📄 랜딩 페이지 +
+
+
+ +
스캔하여 설치
+
+
`; + } catch(e) {} +} + +async function loadAppVersions() { + const t = localStorage.getItem("token")||""; + try { + const r = await fetch("/api/app/versions", {headers:{Authorization:`Bearer ${t}`}}); + const versions = await r.json(); + const el = document.getElementById("app-versions-list"); + if (!el) return; + if (!versions.length) { el.innerHTML = "

버전 없음

"; return; } + el.innerHTML = ` + ${['버전','플랫폼','다운로드','QR','배포일',''].map(h=>``).join('')} + ${versions.map(v=>` + + + + + + + `).join('')}
${h}
v${v.version}${v.is_latest?' 최신':''}${v.platform}${v.download_count}회QR↗${v.created_at?new Date(v.created_at).toLocaleDateString('ko-KR'):'-'}${!v.is_latest?``:''}
`; + } catch(e) {} +} + +async function uploadApk() { + const file = document.getElementById("app-file").files[0]; + const version = document.getElementById("app-version").value; + const notes = document.getElementById("app-notes").value; + const iosUrl = document.getElementById("app-ios-url").value; + if (!file || !version) return showToast("APK 파일과 버전을 입력하세요", "error"); + const form = new FormData(); + form.append("file", file); form.append("version", version); + form.append("release_notes", notes); form.append("ios_url", iosUrl); + const t = localStorage.getItem("token")||""; + try { + showToast("업로드 중...", "info"); + await fetch("/api/app/upload", {method:"POST", headers:{Authorization:`Bearer ${t}`}, body:form}); + showToast(`✅ v${version} 배포 완료! QR 코드가 생성됐습니다.`, "success"); + renderAppDeploy(); + } catch(e) { showToast(e.message, "error"); } +} + +async function deleteAppVersion(id) { + if (!confirm("이 버전을 삭제하시겠습니까?")) return; + const t = localStorage.getItem("token")||""; + await fetch(`/api/app/versions/${id}`, {method:"DELETE", headers:{Authorization:`Bearer ${t}`}}); + loadAppVersions(); +} + +// ── 배치 SSH ───────────────────────────────────────────────────────────────── +function renderBatchSsh() { + document.getElementById("content").innerHTML = ` +

⚡ 배치 SSH 실행

+

여러 서버에 동시에 SSH 명령을 실행하고 결과를 수집합니다.

+
+

명령 실행

+
+
+
+
+
+ + +
+ +
+
+
+

실행 이력

+
로딩 중...
+
`; + loadBatchHistory(); +} + +async function runBatchSsh() { + const cmd = document.getElementById("batch-cmd").value; + const servers = document.getElementById("batch-servers").value; + const timeout = parseInt(document.getElementById("batch-timeout").value) || 30; + if (!cmd || !servers) return showToast("명령어와 서버를 입력하세요", "error"); + const serverIds = servers.split(",").map(s=>s.trim()).filter(Boolean); + const t = localStorage.getItem("token")||""; + showToast("실행 중...", "info"); + try { + const r = await fetch("/api/batch-ssh/run", { + method:"POST", headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, + body:JSON.stringify({command:cmd, server_ids:serverIds, timeout_sec:timeout}) + }); + const d = await r.json(); + const results = d.results || {}; + let html = `

실행 결과 — ${d.success_count}/${d.total_count} 성공

`; + for (const [sid, res] of Object.entries(results)) { + html += `
+
${res.server_name||'서버 '+sid} ${res.success?'✅ 성공':'❌ 실패'}
+ ${res.stdout?`
${res.stdout.substring(0,300)}
`:''} + ${res.stderr?`
${res.stderr.substring(0,200)}
`:''} +
`; + } + document.getElementById("batch-results").innerHTML = html + "
"; + loadBatchHistory(); + } catch(e) { showToast(e.message, "error"); } +} + +async function loadBatchHistory() { + const t = localStorage.getItem("token")||""; + const r = await fetch("/api/batch-ssh/jobs?limit=10", {headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]})); + const jobs = await r.json(); + const el = document.getElementById("batch-history"); + if (!el) return; + if (!jobs.length) { el.innerHTML = "

실행 이력 없음

"; return; } + el.innerHTML = ` + ${['이름','명령어','결과','실행일'].map(h=>``).join('')} + ${jobs.map(j=>` + + + + + `).join('')}
${h}
${j.name||'-'}${(j.command||'').substring(0,40)}${j.success_count}/${j.total_count}${j.created_at?new Date(j.created_at).toLocaleDateString('ko-KR'):'-'}
`; +} + +// ── 자산 QR ─────────────────────────────────────────────────────────────────── +function renderAssetQr() { + document.getElementById("content").innerHTML = ` +

🏷️ 자산 QR 태그 관리

+

서버 장비에 QR 라벨을 부착하여 모바일 스캔으로 CMDB 정보를 즉시 확인합니다.

+
+

QR 토큰 생성

+
+
+
+ +
+
+
+
+

등록된 QR 목록

+
로딩 중...
+
`; + loadQrList(); +} + +async function generateQr() { + const serverId = document.getElementById("qr-server-id").value; + if (!serverId) return showToast("서버 ID를 입력하세요", "error"); + const t = localStorage.getItem("token")||""; + const r = await fetch(`/api/asset-qr/generate/${serverId}`, {method:"POST", headers:{Authorization:`Bearer ${t}`}}); + const d = await r.json(); + if (d.qr_url) { + document.getElementById("qr-result").innerHTML = ` +
+ +
+
${d.server_name}
+
${d.token}
+ 🖨️ 라벨 인쇄 +
+
`; + showToast("QR 생성 완료", "success"); + loadQrList(); + } +} + +async function loadQrList() { + const t = localStorage.getItem("token")||""; + const r = await fetch("/api/asset-qr/list?limit=20", {headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]})); + const tokens = await r.json(); + const el = document.getElementById("qr-list"); + if (!el) return; + if (!tokens.length) { el.innerHTML = "

등록된 QR 없음

"; return; } + el.innerHTML = ` + ${['서버','토큰','스캔 수','최종 스캔','라벨'].map(h=>``).join('')} + ${tokens.map(tk=>` + + + + + + `).join('')}
${h}
${tk.server_name||'서버 '+tk.server_id}${tk.token.substring(0,12)}...${tk.scan_count}회${tk.last_scanned_at?new Date(tk.last_scanned_at).toLocaleDateString('ko-KR'):'없음'}라벨↗
`; +} + +// ── 스마트 알림 규칙 ────────────────────────────────────────────────────────── +function renderNotificationRules() { + document.getElementById("content").innerHTML = ` +

🔔 스마트 알림 규칙

+

조건 기반 알림 규칙을 설정합니다. AND 조건으로 모두 충족 시 알림이 발송됩니다.

+
+ +
+
로딩 중...
+ `; + loadNotifyRules(); +} + +async function loadNotifyRules() { + const t = localStorage.getItem("token")||""; + const r = await fetch("/api/notify/rules", {headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]})); + const rules = await r.json(); + const el = document.getElementById("notify-rules-list"); + if (!el) return; + if (!rules.length) { el.innerHTML = `
등록된 알림 규칙이 없습니다
`; return; } + el.innerHTML = rules.map(r=>` +
+
+
+ ${r.name} + ${r.enabled?'활성':'비활성'} + ${r.digest_mode?'다이제스트':''} +
조건: ${(r.conditions||[]).map(c=>`${c.field} ${c.op} "${c.value}"`).join(' AND ')||'없음'}
+
${(r.channels||[]).map(ch=>`${ch}`).join('')}
+
+
+ + + +
+
+
`).join(''); +} + +function showAddNotifyRule() { + const form = document.getElementById("notify-rule-form"); + form.style.display = "block"; + form.innerHTML = ` +
+

새 알림 규칙

+ + + + + + +
+ + +
+
`; +} + +async function saveNotifyRule() { + const name = document.getElementById("rule-name").value; + const condStr = document.getElementById("rule-cond").value; + const channels = document.getElementById("rule-channels").value.split(",").map(s=>s.trim()).filter(Boolean); + if (!name) return showToast("이름을 입력하세요", "error"); + const conditions = condStr.split(",").map(s=>{ + const m = s.match(/(\w+)(==|!=|>=|<=|>|<|contains)(.+)/); + return m ? {field:m[1].trim(),op:m[2],value:m[3].trim()} : null; + }).filter(Boolean); + const t = localStorage.getItem("token")||""; + await fetch("/api/notify/rules", { + method:"POST", headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, + body:JSON.stringify({name, enabled:true, conditions, channels, digest_mode:false}) + }); + showToast("규칙 저장됨", "success"); + document.getElementById("notify-rule-form").style.display = "none"; + loadNotifyRules(); +} + +async function testNotifyRule(id) { + const t = localStorage.getItem("token")||""; + const r = await fetch(`/api/notify/rules/${id}/test`, {method:"POST", headers:{Authorization:`Bearer ${t}`}}); + const d = await r.json(); + showToast(d.ok ? "✅ 테스트 발송 성공" : `❌ ${d.message||'실패'}`, d.ok?"success":"error"); +} + +async function toggleNotifyRule(id, enabled) { + const t = localStorage.getItem("token")||""; + await fetch(`/api/notify/rules/${id}/toggle`, {method:"PATCH", headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, body:JSON.stringify({enabled:!enabled})}); + loadNotifyRules(); +} + +async function deleteNotifyRule(id) { + if (!confirm("삭제하시겠습니까?")) return; + const t = localStorage.getItem("token")||""; + await fetch(`/api/notify/rules/${id}`, {method:"DELETE", headers:{Authorization:`Bearer ${t}`}}); + loadNotifyRules(); +} diff --git a/workspace/guardia-itsm/static/index.html b/workspace/guardia-itsm/static/index.html index 817ce03d..0cec6363 100644 --- a/workspace/guardia-itsm/static/index.html +++ b/workspace/guardia-itsm/static/index.html @@ -211,6 +211,35 @@ + + + + + + + + + + + + + + + + 🔏 라이선스 관리 diff --git a/workspace/guardia-manager/frontend/src/App.tsx b/workspace/guardia-manager/frontend/src/App.tsx index f0c30a5a..e596b965 100644 --- a/workspace/guardia-manager/frontend/src/App.tsx +++ b/workspace/guardia-manager/frontend/src/App.tsx @@ -28,7 +28,10 @@ const KpiDashboard = lazy(() => import('./pages/KpiDashboard')) const BiAnalytics = lazy(() => import('./pages/BiAnalytics')) const BillingManage = lazy(() => import('./pages/BillingManage')) const IntegrationHub = lazy(() => import('./pages/IntegrationHub')) -const AiPlatform = lazy(() => import('./pages/AiPlatform')) +const AiPlatform = lazy(() => import('./pages/AiPlatform')) +// ── GUARDiA 기능 개선 v4 ── +const AppDistribution = lazy(() => import('./pages/AppDistribution')) +const NotificationRules = lazy(() => import('./pages/NotificationRules')) function Loading() { return ( @@ -73,6 +76,9 @@ export default function App() { } /> } /> } /> + {/* GUARDiA 기능 개선 v4 */} + } /> + } /> } /> diff --git a/workspace/guardia-manager/frontend/src/components/layout/Sidebar.tsx b/workspace/guardia-manager/frontend/src/components/layout/Sidebar.tsx index 77302877..21cd425c 100644 --- a/workspace/guardia-manager/frontend/src/components/layout/Sidebar.tsx +++ b/workspace/guardia-manager/frontend/src/components/layout/Sidebar.tsx @@ -43,6 +43,9 @@ const NAV: NavItem[] = [ { label: 'AI 플랫폼', icon: '🧠', path: '/ai-platform' }, { label: '외부 연동', icon: '🔗', path: '/integrations' }, { label: '구독 · 과금', icon: '💰', path: '/billing' }, + // ── GUARDiA 기능 개선 v4 ── + { label: '앱 배포', icon: '📱', path: '/app-distribution' }, + { label: '알림 규칙', icon: '🔔', path: '/notification-rules' }, ] /* Variant 스타일 색상 상수 */ diff --git a/workspace/guardia-manager/frontend/src/pages/AppDistribution.tsx b/workspace/guardia-manager/frontend/src/pages/AppDistribution.tsx new file mode 100644 index 00000000..b74f7336 --- /dev/null +++ b/workspace/guardia-manager/frontend/src/pages/AppDistribution.tsx @@ -0,0 +1,222 @@ +import { useEffect, useState, useRef } from 'react' +import { guardiaApi } from '../api/clients' + +interface AppVersion { + id: number; version: string; platform: string + download_count: number; is_latest: boolean + qr_url: string; landing_url: string + file_size_mb: number; release_notes: string; created_at: string +} + +export default function AppDistribution() { + const [versions, setVersions] = useState([]) + const [latest, setLatest] = useState(null) + const [stats, setStats] = useState(null) + const [uploading, setUploading] = useState(false) + const [version, setVersion] = useState('') + const [notes, setNotes] = useState('') + const [iosUrl, setIosUrl] = useState('') + const [externalUrl, setExternalUrl] = useState('') + const [tab, setTab] = useState<'upload'|'url'>('upload') + const fileRef = useRef(null) + + useEffect(() => { load() }, []) + + async function load() { + const [v, l, s] = await Promise.all([ + guardiaApi.get('/api/app/versions').then((r: any) => r.data).catch(() => []), + guardiaApi.get('/api/app/latest').then((r: any) => r.data).catch(() => null), + guardiaApi.get('/api/app/stats').then((r: any) => r.data).catch(() => null), + ]) + setVersions(v); setLatest(l); setStats(s) + } + + async function uploadApk() { + const file = fileRef.current?.files?.[0] + if (!file || !version) return alert('APK 파일과 버전을 입력하세요') + setUploading(true) + try { + const form = new FormData() + form.append('file', file) + form.append('version', version) + form.append('release_notes', notes) + form.append('ios_url', iosUrl) + const r: any = await guardiaApi.post('/api/app/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + alert(`✅ 배포 완료! 버전 ${version}\nQR 코드가 생성됐습니다.`) + setVersion(''); setNotes(''); setIosUrl('') + if (fileRef.current) fileRef.current.value = '' + load() + } catch (e: any) { + alert(`오류: ${e.response?.data?.detail || e.message}`) + } finally { setUploading(false) } + } + + async function setUrl() { + if (!externalUrl || !version) return alert('URL과 버전을 입력하세요') + await guardiaApi.post('/api/app/url', { + android_url: externalUrl, version, release_notes: notes, ios_url: iosUrl || undefined + }) + alert('QR 코드 생성됨'); setExternalUrl(''); setVersion(''); setNotes('') + load() + } + + async function deleteVersion(id: number) { + if (!confirm('이 버전을 삭제하시겠습니까?')) return + await guardiaApi.delete(`/api/app/versions/${id}`) + load() + } + + const S = { card: { background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20, marginBottom: 16 } } + + return ( +
+

📱 모바일 앱 직접 배포

+

APK를 업로드하면 QR 코드가 생성됩니다. 사용자는 QR 스캔만으로 앱을 설치할 수 있습니다 (앱스토어 불필요).

+ + {/* 현재 최신 버전 + QR */} + {latest?.has_version && ( +
+
+
현재 최신 버전
+
v{latest.version}
+
{latest.platform} · 총 {latest.download_count}회 다운로드
+ {latest.release_notes &&
{latest.release_notes}
} +
+
+ {/* QR 이미지 */} +
+ QR { e.target.style.display = 'none' }} /> +
스캔하여 설치
+
+
+ )} + + {/* 통계 */} + {stats && ( +
+ {[ + { label: '총 다운로드', val: stats.total_downloads, icon: '📥' }, + { label: 'Android', val: stats.android, icon: '🤖' }, + { label: 'iOS', val: stats.ios, icon: '🍎' }, + ].map(s => ( +
+
{s.icon}
+
{s.val}
+
{s.label}
+
+ ))} +
+ )} + + {/* 업로드 폼 */} +
+

새 버전 배포

+
+ {[{id:'upload',label:'APK 파일 업로드'},{id:'url',label:'외부 URL 연결'}].map(t => ( + + ))} +
+ +
+
+ + setVersion(e.target.value)} placeholder="예: 1.2.3" + style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} /> +
+
+ + setIosUrl(e.target.value)} placeholder="TestFlight URL" + style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} /> +
+
+ + {tab === 'upload' ? ( +
+ + +
+ ) : ( +
+ + setExternalUrl(e.target.value)} placeholder="https://expo.dev/... 또는 직접 APK URL" + style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box', marginBottom: 8 }} /> +
+ )} + +
+ +