sync: update from workspace (latest ITSM/CICD/DR changes)

This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-02 19:49:59 +09:00
parent 7eece4e49e
commit 1057915729
8 changed files with 1669 additions and 0 deletions

View File

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

110
models.py
View File

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

399
routers/app_deploy.py Normal file
View File

@ -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("<h1>잘못된 QR 코드입니다</h1>", 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'<a href="{ver.android_url}" class="btn android-btn">📱 Android 다운로드 (APK)</a>'
if ver.ios_url:
ios_btn = f'<a href="{ver.ios_url}" class="btn ios-btn">🍎 iOS (TestFlight)</a>'
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUARDiA Messenger 다운로드</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0 }}
body {{ font-family: 'Pretendard', -apple-system, sans-serif; background: #f0f4ff; min-height: 100vh; display: flex; align-items: center; justify-content: center }}
.card {{ background: #fff; border-radius: 20px; padding: 40px 32px; max-width: 400px; width: 90%; text-align: center; box-shadow: 0 8px 32px rgba(0,51,102,.12) }}
.logo {{ font-size: 48px; margin-bottom: 16px }}
h1 {{ color: #003366; font-size: 24px; font-weight: 800; margin-bottom: 8px }}
.version {{ color: #64748b; font-size: 14px; margin-bottom: 24px }}
.btn {{ display: block; padding: 14px 24px; border-radius: 12px; font-size: 16px; font-weight: 700; text-decoration: none; margin-bottom: 12px; transition: transform .1s }}
.btn:active {{ transform: scale(0.98) }}
.android-btn {{ background: #003366; color: #fff }}
.ios-btn {{ background: #1d1d1f; color: #fff }}
.guide {{ background: #f8fafc; border-radius: 10px; padding: 16px; margin-top: 20px; text-align: left; font-size: 13px; color: #475569 }}
.guide h3 {{ font-size: 13px; font-weight: 700; margin-bottom: 8px; color: #003366 }}
.guide li {{ margin-left: 16px; margin-bottom: 4px }}
.notes {{ margin-top: 16px; font-size: 13px; color: #64748b; line-height: 1.6 }}
</style>
</head>
<body>
<div class="card">
<div class="logo">🛡</div>
<h1>GUARDiA Messenger</h1>
<div class="version">버전 {ver.version}</div>
{'<div style="background:#fef3c7;border-radius:8px;padding:10px;margin-bottom:16px;font-size:13px;color:#92400e">📱 iOS 기기가 감지되었습니다</div>' if is_ios else ''}
{android_btn}
{ios_btn}
{'<p style="color:#ef4444;font-size:13px;margin-top:8px">⚠️ 버전 정보가 없습니다</p>' if not ver.android_url and not ver.ios_url else ''}
{f'<div class="notes">{ver.release_notes}</div>' if ver.release_notes else ''}
<div class="guide">
<h3>📋 Android 설치 가이드</h3>
<ol>
<li> "Android 다운로드" 클릭</li>
<li>APK 파일 다운로드 완료 대기</li>
<li>설정 보안 "알 수 없는 소스" 허용</li>
<li>다운로드된 APK 파일 실행 설치</li>
</ol>
</div>
</div>
</body>
</html>"""
# 다운로드 로그
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}

308
routers/asset_qr.py Normal file
View File

@ -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'<img src="data:image/png;base64,{token_obj.qr_data}" width="80" height="80">' if token_obj.qr_data else ''
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>QR 라벨 {server.hostname}</title>
<style>
@page {{ size: 50mm 30mm; margin: 1mm }}
body {{ font-family: sans-serif; margin: 0; padding: 2mm }}
.label {{ display: flex; align-items: center; gap: 2mm; width: 46mm }}
.qr {{ flex-shrink: 0 }}
.info {{ font-size: 7pt; line-height: 1.4 }}
.host {{ font-weight: bold; font-size: 8pt; color: #003366 }}
.btn {{ display: inline-block; margin: 4mm 0; padding: 4px 12px; background: #003366; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px }}
@media print {{ .btn {{ display: none }} }}
</style>
</head>
<body>
<button class="btn" onclick="window.print()">🖨 인쇄</button>
<div class="label">
<div class="qr">{qr_img}</div>
<div class="info">
<div class="host">{server.hostname or '미설정'}</div>
<div>ID: {server.id}</div>
<div>{server.os_type or ''}</div>
<div>GUARDiA ITSM</div>
</div>
</div>
</body>
</html>"""
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'<img src="data:image/png;base64,{token_obj.qr_data}" width="70" height="70">' if token_obj.qr_data else ''
labels.append(f"""
<div class="label">
<div class="qr">{qr_img}</div>
<div class="info">
<div class="host">{server.hostname or '미설정'}</div>
<div>ID: {server.id}</div>
<div>{server.os_type or ''}</div>
</div>
</div>""")
html = f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8">
<style>
@page {{ margin: 5mm }}
body {{ font-family: sans-serif }}
.grid {{ display: flex; flex-wrap: wrap; gap: 2mm }}
.label {{ display: flex; align-items: center; gap: 2mm; width: 50mm; height: 30mm; border: 0.3mm solid #ccc; padding: 1mm; page-break-inside: avoid }}
.info {{ font-size: 7pt; line-height: 1.4 }}
.host {{ font-weight: bold; font-size: 8pt; color: #003366 }}
.btn {{ padding: 4px 12px; background: #003366; color: white; border: none; border-radius: 4px; cursor: pointer; margin-bottom: 4mm }}
@media print {{ .btn {{ display: none }} }}
</style>
</head>
<body>
<button class="btn" onclick="window.print()">🖨 전체 인쇄 ({len(labels)})</button>
<div class="grid">{''.join(labels)}</div>
</body></html>"""
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
]}

215
routers/batch_ssh.py Normal file
View File

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

260
routers/smart_notify.py Normal file
View File

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

View File

@ -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: `
<h2>📱 모바일 배포 · QR 생성</h2>
<div class="card" style="margin-bottom:16px">
<div id="app-latest"></div>
</div>
<div class="card">
<h3> 버전 배포</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<div><label class="form-label">버전 *</label>
<input id="app-version" class="form-control" placeholder="예: 1.2.3"></div>
<div><label class="form-label">iOS URL (선택)</label>
<input id="app-ios-url" class="form-control" placeholder="TestFlight URL"></div>
</div>
<div style="margin-bottom:12px"><label class="form-label">APK 파일</label>
<input type="file" id="app-file" accept=".apk" class="form-control"></div>
<div style="margin-bottom:12px"><label class="form-label">업데이트 내용</label>
<textarea id="app-notes" class="form-control" rows="2" placeholder="변경사항..."></textarea></div>
<button class="btn btn-primary" onclick="uploadApk()">🚀 배포 + QR 생성</button>
</div>
<div class="card" style="margin-top:16px">
<h3>버전 이력</h3>
<div id="app-versions-list">로딩 ...</div>
</div>`,
app_versions: `<h2>📋 버전 이력</h2><div id="app-versions-list2">로딩 중...</div>`,
app_stats: `<h2>📊 다운로드 통계</h2><div id="app-stats-div">로딩 중...</div>`,
};
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 = "<p style='color:#64748b'>배포된 버전이 없습니다.</p>"; return; }
document.getElementById("app-latest").innerHTML = `
<div style="display:flex;gap:20px;align-items:flex-start">
<div style="flex:1">
<div style="font-size:11px;color:#64748b">현재 최신 버전</div>
<div style="font-size:28px;font-weight:800;color:#003366">v${d.version}</div>
<div style="font-size:13px;color:#64748b;margin-top:4px">${d.platform} · ${d.download_count} 다운로드</div>
<div style="margin-top:10px;display:flex;gap:8px">
<a href="${d.qr_url}" target="_blank" class="btn btn-primary btn-sm">🖼 QR 이미지</a>
<a href="${d.landing_url}" target="_blank" class="btn btn-sm">📄 랜딩 페이지</a>
</div>
</div>
<div style="text-align:center">
<img src="${d.qr_url}" width="100" height="100" style="border:2px solid #e2e8f0;border-radius:8px" onerror="this.style.display='none'">
<div style="font-size:11px;color:#64748b;margin-top:4px">스캔하여 설치</div>
</div>
</div>`;
} 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 = "<p style='color:#94a3b8;text-align:center;padding:20px'>버전 없음</p>"; return; }
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid #e2e8f0">${['버전','플랫폼','다운로드','QR','배포일',''].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join('')}</tr></thead>
<tbody>${versions.map(v=>`<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:10px 8px;font-weight:${v.is_latest?700:400}">v${v.version}${v.is_latest?' <span style="background:#003366;color:#fff;font-size:10px;padding:1px 6px;border-radius:8px;margin-left:4px">최신</span>':''}</td>
<td style="padding:10px 8px">${v.platform}</td>
<td style="padding:10px 8px">${v.download_count}</td>
<td style="padding:10px 8px"><a href="${v.qr_url}" target="_blank" style="color:#003366;font-size:12px">QR</a></td>
<td style="padding:10px 8px;color:#64748b;font-size:11px">${v.created_at?new Date(v.created_at).toLocaleDateString('ko-KR'):'-'}</td>
<td style="padding:10px 8px">${!v.is_latest?`<button onclick="deleteAppVersion(${v.id})" style="padding:3px 8px;border:1px solid #fca5a5;color:#dc2626;border-radius:4px;background:none;cursor:pointer;font-size:11px">삭제</button>`:''}</td>
</tr>`).join('')}</tbody></table>`;
} 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 = `
<h2> 배치 SSH 실행</h2>
<p style="color:#64748b;margin-bottom:16px">여러 서버에 동시에 SSH 명령을 실행하고 결과를 수집합니다.</p>
<div class="card" style="margin-bottom:16px">
<h3>명령 실행</h3>
<div style="margin-bottom:12px"><label class="form-label">명령어</label>
<input id="batch-cmd" class="form-control" placeholder="예: df -h /"></div>
<div style="margin-bottom:12px"><label class="form-label">서버 목록 (콤마 구분 서버 ID 또는 태그)</label>
<input id="batch-servers" class="form-control" placeholder="예: 1,2,3 또는 web,db"></div>
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
<label class="form-label" style="margin:0;white-space:nowrap">타임아웃()</label>
<input id="batch-timeout" class="form-control" type="number" value="30" style="width:80px">
</div>
<button class="btn btn-primary" onclick="runBatchSsh()"> 실행</button>
</div>
<div id="batch-results"></div>
<div class="card" style="margin-top:16px">
<h3>실행 이력</h3>
<div id="batch-history">로딩 ...</div>
</div>`;
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 = `<div class="card"><h3>실행 결과 — ${d.success_count}/${d.total_count} 성공</h3>`;
for (const [sid, res] of Object.entries(results)) {
html += `<div style="margin-bottom:12px;padding:12px;border:1px solid ${res.success?'#bbf7d0':'#fca5a5'};border-radius:8px;background:${res.success?'#f0fdf4':'#fff5f5'}">
<div style="font-weight:700;margin-bottom:4px">${res.server_name||'서버 '+sid} <span style="color:${res.success?'#166534':'#dc2626'};font-size:12px">${res.success?'✅ 성공':'❌ 실패'}</span></div>
${res.stdout?`<pre style="font-size:12px;margin:0;white-space:pre-wrap;color:#374151">${res.stdout.substring(0,300)}</pre>`:''}
${res.stderr?`<pre style="font-size:12px;margin:0;color:#dc2626">${res.stderr.substring(0,200)}</pre>`:''}
</div>`;
}
document.getElementById("batch-results").innerHTML = html + "</div>";
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 = "<p style='color:#94a3b8;text-align:center;padding:12px'>실행 이력 없음</p>"; return; }
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid #e2e8f0">${['이름','명령어','결과','실행일'].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join('')}</tr></thead>
<tbody>${jobs.map(j=>`<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px">${j.name||'-'}</td>
<td style="padding:8px;font-family:monospace;font-size:12px">${(j.command||'').substring(0,40)}</td>
<td style="padding:8px;color:${j.fail_count>0?'#dc2626':'#166534'}">${j.success_count}/${j.total_count}</td>
<td style="padding:8px;color:#64748b;font-size:11px">${j.created_at?new Date(j.created_at).toLocaleDateString('ko-KR'):'-'}</td>
</tr>`).join('')}</tbody></table>`;
}
// ── 자산 QR ───────────────────────────────────────────────────────────────────
function renderAssetQr() {
document.getElementById("content").innerHTML = `
<h2>🏷 자산 QR 태그 관리</h2>
<p style="color:#64748b;margin-bottom:16px">서버 장비에 QR 라벨을 부착하여 모바일 스캔으로 CMDB 정보를 즉시 확인합니다.</p>
<div class="card" style="margin-bottom:16px">
<h3>QR 토큰 생성</h3>
<div style="display:flex;gap:12px;align-items:flex-end">
<div style="flex:1"><label class="form-label">서버 ID</label>
<input id="qr-server-id" class="form-control" type="number" placeholder="서버 ID"></div>
<button class="btn btn-primary" onclick="generateQr()">🏷 QR 생성</button>
</div>
<div id="qr-result" style="margin-top:12px"></div>
</div>
<div class="card">
<h3>등록된 QR 목록</h3>
<div id="qr-list">로딩 ...</div>
</div>`;
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 = `
<div style="display:flex;gap:16px;align-items:center;padding:12px;border:1px solid #e2e8f0;border-radius:8px">
<img src="${d.qr_url}" width="80" height="80" style="border:1px solid #e2e8f0;border-radius:4px">
<div>
<div style="font-weight:700">${d.server_name}</div>
<div style="font-size:12px;color:#64748b;font-family:monospace;margin-top:4px">${d.token}</div>
<a href="/api/asset-qr/label/${d.token}" target="_blank" class="btn btn-sm" style="margin-top:8px">🖨 라벨 인쇄</a>
</div>
</div>`;
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 = "<p style='color:#94a3b8;text-align:center;padding:12px'>등록된 QR 없음</p>"; return; }
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid #e2e8f0">${['서버','토큰','스캔 수','최종 스캔','라벨'].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join('')}</tr></thead>
<tbody>${tokens.map(tk=>`<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px;font-weight:600">${tk.server_name||'서버 '+tk.server_id}</td>
<td style="padding:8px;font-family:monospace;font-size:11px;color:#64748b">${tk.token.substring(0,12)}...</td>
<td style="padding:8px">${tk.scan_count}</td>
<td style="padding:8px;color:#64748b;font-size:11px">${tk.last_scanned_at?new Date(tk.last_scanned_at).toLocaleDateString('ko-KR'):'없음'}</td>
<td style="padding:8px"><a href="/api/asset-qr/label/${tk.token}" target="_blank" style="font-size:12px;color:#003366">라벨</a></td>
</tr>`).join('')}</tbody></table>`;
}
// ── 스마트 알림 규칙 ──────────────────────────────────────────────────────────
function renderNotificationRules() {
document.getElementById("content").innerHTML = `
<h2>🔔 스마트 알림 규칙</h2>
<p style="color:#64748b;margin-bottom:16px">조건 기반 알림 규칙을 설정합니다. AND 조건으로 모두 충족 알림이 발송됩니다.</p>
<div style="margin-bottom:12px;text-align:right">
<button class="btn btn-primary" onclick="showAddNotifyRule()">+ 규칙 추가</button>
</div>
<div id="notify-rules-list">로딩 ...</div>
<div id="notify-rule-form" style="display:none;margin-top:16px"></div>`;
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 = `<div style="text-align:center;padding:30px;border:2px dashed #e2e8f0;border-radius:10px;color:#94a3b8">등록된 알림 규칙이 없습니다</div>`; return; }
el.innerHTML = rules.map(r=>`
<div style="border:1px solid #e2e8f0;border-radius:10px;padding:16px;margin-bottom:10px;opacity:${r.enabled?1:0.6}">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<span style="font-weight:700;font-size:15px">${r.name}</span>
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:11px;background:${r.enabled?'#dcfce7':'#f1f5f9'};color:${r.enabled?'#166534':'#64748b'}">${r.enabled?'활성':'비활성'}</span>
${r.digest_mode?'<span style="margin-left:4px;padding:2px 8px;border-radius:8px;font-size:11px;background:#fef3c7;color:#92400e">다이제스트</span>':''}
<div style="font-size:12px;color:#64748b;margin-top:4px">조건: ${(r.conditions||[]).map(c=>`${c.field} ${c.op} "${c.value}"`).join(' AND ')||'없음'}</div>
<div style="margin-top:4px">${(r.channels||[]).map(ch=>`<span style="padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:11px;margin-right:4px">${ch}</span>`).join('')}</div>
</div>
<div style="display:flex;gap:6px">
<button onclick="testNotifyRule(${r.id})" style="padding:5px 10px;border:1px solid #e2e8f0;border-radius:6px;background:none;cursor:pointer;font-size:12px">테스트</button>
<button onclick="toggleNotifyRule(${r.id},${r.enabled})" style="padding:5px 10px;border:1px solid #e2e8f0;border-radius:6px;background:none;cursor:pointer;font-size:12px">${r.enabled?'비활성화':'활성화'}</button>
<button onclick="deleteNotifyRule(${r.id})" style="padding:5px 10px;border:1px solid #fca5a5;color:#dc2626;border-radius:6px;background:none;cursor:pointer;font-size:12px">삭제</button>
</div>
</div>
</div>`).join('');
}
function showAddNotifyRule() {
const form = document.getElementById("notify-rule-form");
form.style.display = "block";
form.innerHTML = `
<div class="card">
<h3> 알림 규칙</h3>
<label class="form-label">규칙 이름</label>
<input id="rule-name" class="form-control" placeholder="예: CRITICAL SR 즉시 알림" style="margin-bottom:10px">
<label class="form-label">조건 (field==value 형식, 쉼표 구분)</label>
<input id="rule-cond" class="form-control" placeholder="sr_priority==CRITICAL" style="margin-bottom:10px">
<label class="form-label">알림 채널 (쉼표 구분)</label>
<input id="rule-channels" class="form-control" placeholder="messenger,email" style="margin-bottom:10px">
<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn btn-primary" onclick="saveNotifyRule()">저장</button>
<button class="btn" onclick="document.getElementById('notify-rule-form').style.display='none'">취소</button>
</div>
</div>`;
}
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();
}

View File

@ -211,6 +211,35 @@
<div class="nav-sub-item" data-view="white_label">브랜딩 설정</div>
</div>
<!-- ── GUARDiA 기능 개선 v4 ─────────────────── -->
<div class="nav-separator"></div>
<!-- 앱 배포 -->
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
<span class="nav-icon">📱</span><span>모바일 앱 배포</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div>
<div class="nav-group-body" role="group">
<div class="nav-sub-item" data-view="app_deploy">APK 업로드 · QR</div>
<div class="nav-sub-item" data-view="app_versions">버전 이력</div>
<div class="nav-sub-item" data-view="app_stats">다운로드 통계</div>
</div>
<!-- 배치 SSH -->
<div class="nav-item" data-view="batch_ssh" onclick="showPage('batch_ssh')">
<span class="nav-icon"></span> 배치 SSH 실행
</div>
<!-- 자산 QR -->
<div class="nav-item" data-view="asset_qr" onclick="showPage('asset_qr')">
<span class="nav-icon">🏷️</span> 자산 QR 태그
</div>
<!-- 스마트 알림 -->
<div class="nav-item" data-view="notification_rules" onclick="showPage('notification_rules')">
<span class="nav-icon">🔔</span> 스마트 알림 규칙
</div>
<div class="nav-separator"></div>
<a class="nav-item nav-link-ext" href="/license" id="nav-license">
<span class="nav-icon">🔏</span> 라이선스 관리