diff --git a/main.py b/main.py
index 11be53c..e55a430 100644
--- a/main.py
+++ b/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/models.py b/models.py
index 313df0e..8649121 100644
--- a/models.py
+++ b/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/routers/app_deploy.py b/routers/app_deploy.py
new file mode 100644
index 0000000..40589a6
--- /dev/null
+++ b/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 설치 가이드
+
+ - 위 "Android 다운로드" 클릭
+ - APK 파일 다운로드 완료 대기
+ - 설정 → 보안 → "알 수 없는 소스" 허용
+ - 다운로드된 APK 파일 실행 → 설치
+
+
+
+
+"""
+
+ # 다운로드 로그
+ 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/routers/asset_qr.py b/routers/asset_qr.py
new file mode 100644
index 0000000..9045cee
--- /dev/null
+++ b/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/routers/batch_ssh.py b/routers/batch_ssh.py
new file mode 100644
index 0000000..7480ee2
--- /dev/null
+++ b/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/routers/smart_notify.py b/routers/smart_notify.py
new file mode 100644
index 0000000..98b7cfd
--- /dev/null
+++ b/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/static/app.js b/static/app.js
index 84f0dd9..e81e991 100644
--- a/static/app.js
+++ b/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}회 다운로드
+
+
+
+

+
스캔하여 설치
+
+
`;
+ } 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=>`| ${h} | `).join('')}
+ ${versions.map(v=>`
+ | 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?``:''} |
+
`).join('')}
`;
+ } 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=>`| ${h} | `).join('')}
+ ${jobs.map(j=>`
+ | ${j.name||'-'} |
+ ${(j.command||'').substring(0,40)} |
+ ${j.success_count}/${j.total_count} |
+ ${j.created_at?new Date(j.created_at).toLocaleDateString('ko-KR'):'-'} |
+
`).join('')}
`;
+}
+
+// ── 자산 QR ───────────────────────────────────────────────────────────────────
+function renderAssetQr() {
+ document.getElementById("content").innerHTML = `
+ 🏷️ 자산 QR 태그 관리
+ 서버 장비에 QR 라벨을 부착하여 모바일 스캔으로 CMDB 정보를 즉시 확인합니다.
+
+ `;
+ 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=>`| ${h} | `).join('')}
+ ${tokens.map(tk=>`
+ | ${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'):'없음'} |
+ 라벨↗ |
+
`).join('')}
`;
+}
+
+// ── 스마트 알림 규칙 ──────────────────────────────────────────────────────────
+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/static/index.html b/static/index.html
index 817ce03..0cec636 100644
--- a/static/index.html
+++ b/static/index.html
@@ -211,6 +211,35 @@
브랜딩 설정
+
+
+
+
+
+
+
APK 업로드 · QR
+
버전 이력
+
다운로드 통계
+
+
+
+
+ ⚡ 배치 SSH 실행
+
+
+
+
+ 🏷️ 자산 QR 태그
+
+
+
+
+ 🔔 스마트 알림 규칙
+
+
🔏 라이선스 관리